detached 672ba53

#12 feat(state): list workspaces with column and window geometry

state
merged · 672ba53 · fast-forward
branch
feat/state-geometry → main
opened
bnema ·
updated

What

The state file (neferwl state, $NEFERWL_STATE) lists every workspace with its tiled columns and the windows in each column, with their rectangles.

"workspaces": [
  {"id": "…-1", "number": 1, "name": "", "output": "HEADLESS-1", "visible": true, "overflow": "fixed",
   "columns": [
     {"x": 10, "y": 10, "width": 945, "height": 1060,
      "windows": [{"id": 1, "x": 10, "y": 10, "width": 945, "height": 1060}]},
     {"x": 965, "y": 10, "width": 945, "height": 1060,
      "windows": [{"id": 2, "x": 965, "y": 10, "width": 945, "height": 1060}]}]},
  {"id": "…-2", "number": 2, "visible": false, "overflow": "fixed", "columns": []}
]
  • Every workspace is listed, hidden ones too; visible is true for the one on screen on its output.
  • Rects are logical pixels in the output's coordinates, from the settled layout: animation frames do not change them.
  • Floats stay in windows[] only.
  • The file is written at most once per second: a change after a quiet second is written at once, later ones are coalesced and the latest is written when the second ends.

How

  • ports.State.Workspaces (WorkspaceState → ColumnState → TileState), built in the core's publish scratch (no new allocations per frame; publish allocation tests pass), compared deeply in sameState.
  • columnRectsAt(…, shift) gives the geometry without the view slide's presentation offset.
  • statefile.Run coalesces writes with one timer.

Checks

  • make check, make race, make mocks-check, staticcheck ./... pass.
  • Tests: TestStateWorkspaceGeometry, TestStateWorkspaceChange, TestWorkspaceGeometryFields, TestRunRateLimit (synctest).
  • Headless e2e: two foot terminals, fixed overflow, 1920×1080 → output above.
CommitSubjectAuthorDateSignatureStatus
672ba53docs(desktop): describe workspace geometry in the state filebriceGPG Verified
b996bb5feat(statefile): write the state file at most once per secondbriceGPG Verified
f8e0ac9feat(state): list workspaces with column and window geometrybriceGPG Verified

Checks 2

11 files · +377 -12
1/2
M docs/desktop.md +3 -0
@@ -133,8 +133,11 @@ Workspace IDs are `name:<name>` for named workspaces, stable across launches. Nu
133133
134134 NeferWL writes its state as JSON to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json`, also given to started programs as `$NEFERWL_STATE`. It lists outputs, workspaces, the focused window, and every window with its app ID, PID, workspace, column and row.
135135
136+The `workspaces` list holds every workspace with its `visible` flag, its columns and the windows in them, each with `x`, `y`, `width` and `height` in logical pixels of its output. The file is written at most once per second.
137+
136138 ```sh
137139 neferwl state # the whole state
138140 neferwl state output-of "$PID" # the output showing that process's window
139141 neferwl state | jq '[.windows[] | select(.hidden)] | length' # windows in hidden stashes
142+neferwl state | jq '.workspaces[] | select(.visible) | .columns[] | .width' # column widths on screen
140143 ```
@@ -133,8 +133,11 @@ Workspace IDs are `name:<name>` for named workspaces, stable across launches. Nu
133 133
134 NeferWL writes its state as JSON to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json`, also given to started programs as `$NEFERWL_STATE`. It lists outputs, workspaces, the focused window, and every window with its app ID, PID, workspace, column and row.134 NeferWL writes its state as JSON to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json`, also given to started programs as `$NEFERWL_STATE`. It lists outputs, workspaces, the focused window, and every window with its app ID, PID, workspace, column and row.
135 135
136+The `workspaces` list holds every workspace with its `visible` flag, its columns and the windows in them, each with `x`, `y`, `width` and `height` in logical pixels of its output. The file is written at most once per second.
137+
136 ```sh138 ```sh
137 neferwl state # the whole state139 neferwl state # the whole state
138 neferwl state output-of "$PID" # the output showing that process's window140 neferwl state output-of "$PID" # the output showing that process's window
139 neferwl state | jq '[.windows[] | select(.hidden)] | length' # windows in hidden stashes141 neferwl state | jq '[.windows[] | select(.hidden)] | length' # windows in hidden stashes
142+neferwl state | jq '.workspaces[] | select(.visible) | .columns[] | .width' # column widths on screen
140 ```143 ```
M internal/adapters/statefile/statefile.go +86 -6
@@ -12,6 +12,7 @@ import (
1212 "path/filepath"
1313 "strconv"
1414 "strings"
15+ "time"
1516
1617 "github.com/bnema/neferwl/internal/adapters/workspaceid"
1718 "github.com/bnema/neferwl/internal/ports"
@@ -37,6 +38,40 @@ type State struct {
3738 Outputs []Output `json:"outputs"`
3839 Window *Window `json:"window"`
3940 Windows []Window `json:"windows"`
41+ // Workspaces lists every workspace of every output.
42+ Workspaces []Workspace `json:"workspaces"`
43+}
44+
45+// Workspace is one workspace and its tiled columns, left to right. ID is
46+// the workspace_id string of Output. Number is 0 for a hidden workspace.
47+// Visible means on screen on its output.
48+type Workspace struct {
49+ ID string `json:"id"`
50+ Number int `json:"number"`
51+ Name string `json:"name"`
52+ Output string `json:"output"`
53+ Visible bool `json:"visible"`
54+ Overflow string `json:"overflow"`
55+ Columns []Column `json:"columns"`
56+}
57+
58+// Column is the area of one column in logical pixels of its output, view
59+// offset applied (it may be off-screen), and its windows top to bottom.
60+type Column struct {
61+ X int `json:"x"`
62+ Y int `json:"y"`
63+ Width int `json:"width"`
64+ Height int `json:"height"`
65+ Windows []Tile `json:"windows"`
66+}
67+
68+// Tile is one tiled window and its rectangle.
69+type Tile struct {
70+ ID uint64 `json:"id"`
71+ X int `json:"x"`
72+ Y int `json:"y"`
73+ Width int `json:"width"`
74+ Height int `json:"height"`
4075 }
4176
4277 // Output is one output: its active numbered workspace (0 while a hidden one
@@ -78,13 +113,24 @@ type Window struct {
78113 }
79114
80115 func fromPorts(st ports.State, ids *workspaceid.IDs) State {
81- out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}
116+ out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows)), Workspaces: make([]Workspace, 0, len(st.Workspaces))}
82117 for _, o := range st.Outputs {
83118 out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})
84119 }
85120 for _, w := range st.Windows {
86121 out.Windows = append(out.Windows, window(w, ids))
87122 }
123+ for _, w := range st.Workspaces {
124+ ws := Workspace{ID: ids.ID(w.ID, w.Name), Number: w.Number, Name: w.Name, Output: w.Output, Visible: w.Visible, Overflow: w.Overflow, Columns: make([]Column, 0, len(w.Columns))}
125+ for _, c := range w.Columns {
126+ col := Column{X: c.Rect.X, Y: c.Rect.Y, Width: c.Rect.W, Height: c.Rect.H, Windows: make([]Tile, 0, len(c.Windows))}
127+ for _, t := range c.Windows {
128+ col.Windows = append(col.Windows, Tile{ID: uint64(t.ID), X: t.Rect.X, Y: t.Rect.Y, Width: t.Rect.W, Height: t.Rect.H})
129+ }
130+ ws.Columns = append(ws.Columns, col)
131+ }
132+ out.Workspaces = append(out.Workspaces, ws)
133+ }
88134 if st.Window != nil {
89135 w := window(*st.Window, ids)
90136 out.Window = &w
@@ -96,8 +142,13 @@ func window(w ports.WindowState, ids *workspaceid.IDs) Window {
96142 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
97143 }
98144
99-// Run writes every state it receives to path until ctx ends, then removes
100-// the file: a stale state must not outlive the session.
145+// writeInterval is the least time between two writes of the file.
146+const writeInterval = time.Second
147+
148+// Run writes the states it receives to path until ctx ends, then removes
149+// the file: a stale state must not outlive the session. It writes at most
150+// once per writeInterval: a state after a quiet interval is written at once,
151+// others are coalesced and the latest is written when the interval ends.
101152 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
102153 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
103154 return err
@@ -105,15 +156,44 @@ func Run(ctx context.Context, path string, states <-chan ports.State, ids *works
105156 // A file left by a crashed session on this socket is not ours.
106157 _ = os.Remove(path)
107158 defer os.Remove(path)
159+ flush := func(st ports.State) {
160+ if err := write(path, fromPorts(st, ids)); err != nil {
161+ // Scripts lose their state; the session goes on.
162+ log.Warn().Err(err).Str("path", path).Msg("write state")
163+ }
164+ }
165+ timer := time.NewTimer(0)
166+ defer timer.Stop()
167+ <-timer.C
168+ var (
169+ last time.Time
170+ pending ports.State
171+ waiting bool
172+ )
108173 for {
174+ var due <-chan time.Time
175+ if waiting {
176+ due = timer.C
177+ }
109178 select {
110179 case <-ctx.Done():
111180 return nil
112181 case st := <-states:
113- if err := write(path, fromPorts(st, ids)); err != nil {
114- // Scripts lose their state; the session goes on.
115- log.Warn().Err(err).Str("path", path).Msg("write state")
182+ if waiting {
183+ pending = st
184+ continue
185+ }
186+ if wait := writeInterval - time.Since(last); !last.IsZero() && wait > 0 {
187+ pending, waiting = st, true
188+ timer.Reset(wait)
189+ continue
116190 }
191+ flush(st)
192+ last = time.Now()
193+ case <-due:
194+ waiting = false
195+ flush(pending)
196+ last = time.Now()
117197 }
118198 }
119199 }
@@ -12,6 +12,7 @@ import (
12 "path/filepath"12 "path/filepath"
13 "strconv"13 "strconv"
14 "strings"14 "strings"
15+ "time"
15 16
16 "github.com/bnema/neferwl/internal/adapters/workspaceid"17 "github.com/bnema/neferwl/internal/adapters/workspaceid"
17 "github.com/bnema/neferwl/internal/ports"18 "github.com/bnema/neferwl/internal/ports"
@@ -37,6 +38,40 @@ type State struct {
37 Outputs []Output `json:"outputs"`38 Outputs []Output `json:"outputs"`
38 Window *Window `json:"window"`39 Window *Window `json:"window"`
39 Windows []Window `json:"windows"`40 Windows []Window `json:"windows"`
41+ // Workspaces lists every workspace of every output.
42+ Workspaces []Workspace `json:"workspaces"`
43+}
44+
45+// Workspace is one workspace and its tiled columns, left to right. ID is
46+// the workspace_id string of Output. Number is 0 for a hidden workspace.
47+// Visible means on screen on its output.
48+type Workspace struct {
49+ ID string `json:"id"`
50+ Number int `json:"number"`
51+ Name string `json:"name"`
52+ Output string `json:"output"`
53+ Visible bool `json:"visible"`
54+ Overflow string `json:"overflow"`
55+ Columns []Column `json:"columns"`
56+}
57+
58+// Column is the area of one column in logical pixels of its output, view
59+// offset applied (it may be off-screen), and its windows top to bottom.
60+type Column struct {
61+ X int `json:"x"`
62+ Y int `json:"y"`
63+ Width int `json:"width"`
64+ Height int `json:"height"`
65+ Windows []Tile `json:"windows"`
66+}
67+
68+// Tile is one tiled window and its rectangle.
69+type Tile struct {
70+ ID uint64 `json:"id"`
71+ X int `json:"x"`
72+ Y int `json:"y"`
73+ Width int `json:"width"`
74+ Height int `json:"height"`
40 }75 }
41 76
42 // Output is one output: its active numbered workspace (0 while a hidden one77 // Output is one output: its active numbered workspace (0 while a hidden one
@@ -78,13 +113,24 @@ type Window struct {
78 }113 }
79 114
80 func fromPorts(st ports.State, ids *workspaceid.IDs) State {115 func fromPorts(st ports.State, ids *workspaceid.IDs) State {
81- out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}116+ out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows)), Workspaces: make([]Workspace, 0, len(st.Workspaces))}
82 for _, o := range st.Outputs {117 for _, o := range st.Outputs {
83 out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})118 out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})
84 }119 }
85 for _, w := range st.Windows {120 for _, w := range st.Windows {
86 out.Windows = append(out.Windows, window(w, ids))121 out.Windows = append(out.Windows, window(w, ids))
87 }122 }
123+ for _, w := range st.Workspaces {
124+ ws := Workspace{ID: ids.ID(w.ID, w.Name), Number: w.Number, Name: w.Name, Output: w.Output, Visible: w.Visible, Overflow: w.Overflow, Columns: make([]Column, 0, len(w.Columns))}
125+ for _, c := range w.Columns {
126+ col := Column{X: c.Rect.X, Y: c.Rect.Y, Width: c.Rect.W, Height: c.Rect.H, Windows: make([]Tile, 0, len(c.Windows))}
127+ for _, t := range c.Windows {
128+ col.Windows = append(col.Windows, Tile{ID: uint64(t.ID), X: t.Rect.X, Y: t.Rect.Y, Width: t.Rect.W, Height: t.Rect.H})
129+ }
130+ ws.Columns = append(ws.Columns, col)
131+ }
132+ out.Workspaces = append(out.Workspaces, ws)
133+ }
88 if st.Window != nil {134 if st.Window != nil {
89 w := window(*st.Window, ids)135 w := window(*st.Window, ids)
90 out.Window = &w136 out.Window = &w
@@ -96,8 +142,13 @@ func window(w ports.WindowState, ids *workspaceid.IDs) Window {
96 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}142 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
97 }143 }
98 144
99-// Run writes every state it receives to path until ctx ends, then removes145+// writeInterval is the least time between two writes of the file.
100-// the file: a stale state must not outlive the session.146+const writeInterval = time.Second
147+
148+// Run writes the states it receives to path until ctx ends, then removes
149+// the file: a stale state must not outlive the session. It writes at most
150+// once per writeInterval: a state after a quiet interval is written at once,
151+// others are coalesced and the latest is written when the interval ends.
101 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {152 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
102 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {153 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
103 return err154 return err
@@ -105,15 +156,44 @@ func Run(ctx context.Context, path string, states <-chan ports.State, ids *works
105 // A file left by a crashed session on this socket is not ours.156 // A file left by a crashed session on this socket is not ours.
106 _ = os.Remove(path)157 _ = os.Remove(path)
107 defer os.Remove(path)158 defer os.Remove(path)
159+ flush := func(st ports.State) {
160+ if err := write(path, fromPorts(st, ids)); err != nil {
161+ // Scripts lose their state; the session goes on.
162+ log.Warn().Err(err).Str("path", path).Msg("write state")
163+ }
164+ }
165+ timer := time.NewTimer(0)
166+ defer timer.Stop()
167+ <-timer.C
168+ var (
169+ last time.Time
170+ pending ports.State
171+ waiting bool
172+ )
108 for {173 for {
174+ var due <-chan time.Time
175+ if waiting {
176+ due = timer.C
177+ }
109 select {178 select {
110 case <-ctx.Done():179 case <-ctx.Done():
111 return nil180 return nil
112 case st := <-states:181 case st := <-states:
113- if err := write(path, fromPorts(st, ids)); err != nil {182+ if waiting {
114- // Scripts lose their state; the session goes on.183+ pending = st
115- log.Warn().Err(err).Str("path", path).Msg("write state")184+ continue
185+ }
186+ if wait := writeInterval - time.Since(last); !last.IsZero() && wait > 0 {
187+ pending, waiting = st, true
188+ timer.Reset(wait)
189+ continue
116 }190 }
191+ flush(st)
192+ last = time.Now()
193+ case <-due:
194+ waiting = false
195+ flush(pending)
196+ last = time.Now()
117 }197 }
118 }198 }
119 }199 }
M internal/adapters/statefile/statefile_test.go +95 -1
@@ -9,6 +9,7 @@ import (
99 "reflect"
1010 "strings"
1111 "testing"
12+ "testing/synctest"
1213 "time"
1314
1415 "github.com/bnema/neferwl/internal/adapters/workspaceid"
@@ -41,7 +42,7 @@ func TestRunWritesAndRemoves(t *testing.T) {
4142 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
4243 focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}
4344 states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}
44- want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}}
45+ want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}, Workspaces: []Workspace{}}
4546 deadline := time.Now().Add(2 * time.Second)
4647 for {
4748 if got, err := Read(path); err == nil && got.Output != "stale" {
@@ -158,3 +159,96 @@ func TestWorkspaceIDStrings(t *testing.T) {
158159 t.Fatalf("%s %v", data, err)
159160 }
160161 }
162+
163+// Workspaces reach the file with their script-facing names.
164+func TestWorkspaceGeometryFields(t *testing.T) {
165+ ids := workspaceid.WithPrefix("0a1b2c3d")
166+ st := fromPorts(ports.State{Workspaces: []ports.WorkspaceState{
167+ {ID: 7, Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []ports.ColumnState{
168+ {Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 400}, Windows: []ports.TileState{{ID: 5, Rect: ports.Rect{X: 11, Y: 21, W: 298, H: 198}}}},
169+ }},
170+ {ID: 8, Output: "DP-2"},
171+ }}, ids)
172+ want := []Workspace{
173+ {ID: "name:web", Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []Column{
174+ {X: 10, Y: 20, Width: 300, Height: 400, Windows: []Tile{{ID: 5, X: 11, Y: 21, Width: 298, Height: 198}}},
175+ }},
176+ {ID: "0a1b2c3d-8", Output: "DP-2", Columns: []Column{}},
177+ }
178+ if !reflect.DeepEqual(st.Workspaces, want) {
179+ t.Fatalf("%+v", st.Workspaces)
180+ }
181+ data, err := json.Marshal(st)
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ for _, key := range []string{`"workspaces":[`, `"id":"name:web"`, `"number":2`, `"visible":true`, `"overflow":"fixed"`, `"columns":[{"x":10,"y":20,"width":300,"height":400,"windows":[{"id":5,"x":11,"y":21,"width":298,"height":198}]}]`} {
186+ if !strings.Contains(string(data), key) {
187+ t.Fatalf("%s missing %s", data, key)
188+ }
189+ }
190+}
191+
192+// The file is written at most once per second: the first state and one after
193+// a quiet second at once, a burst within the second as one trailing write of
194+// the latest state.
195+func TestRunRateLimit(t *testing.T) {
196+ synctest.Test(t, func(t *testing.T) {
197+ path := filepath.Join(t.TempDir(), "s", "wayland-9.json")
198+ states := make(chan ports.State)
199+ ctx, cancel := context.WithCancel(context.Background())
200+ done := make(chan error, 1)
201+ go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
202+ output := func() string {
203+ t.Helper()
204+ synctest.Wait()
205+ st, err := Read(path)
206+ if err != nil {
207+ return ""
208+ }
209+ return st.Output
210+ }
211+ send := func(name string) { states <- ports.State{Output: name} }
212+
213+ send("a")
214+ if got := output(); got != "a" {
215+ t.Fatalf("first state not written at once: %q", got)
216+ }
217+ // A burst within the second: nothing yet, then the latest only.
218+ time.Sleep(200 * time.Millisecond)
219+ send("b")
220+ send("c")
221+ send("d")
222+ if got := output(); got != "a" {
223+ t.Fatalf("written within the second: %q", got)
224+ }
225+ time.Sleep(799 * time.Millisecond)
226+ if got := output(); got != "a" {
227+ t.Fatalf("written before the second ended: %q", got)
228+ }
229+ time.Sleep(time.Millisecond)
230+ if got := output(); got != "d" {
231+ t.Fatalf("trailing write: %q, want d", got)
232+ }
233+ // Nothing more is pending: the file stays as it is.
234+ if err := os.Remove(path); err != nil {
235+ t.Fatal(err)
236+ }
237+ time.Sleep(5 * time.Second)
238+ if got := output(); got != "" {
239+ t.Fatalf("a write with no new state: %q", got)
240+ }
241+ // After a quiet period the next state is immediate.
242+ send("e")
243+ if got := output(); got != "e" {
244+ t.Fatalf("state after a quiet period: %q", got)
245+ }
246+ cancel()
247+ if err := <-done; err != nil {
248+ t.Fatal(err)
249+ }
250+ if _, err := os.Stat(path); !os.IsNotExist(err) {
251+ t.Fatal("state file left behind", err)
252+ }
253+ })
254+}
@@ -9,6 +9,7 @@ import (
9 "reflect"9 "reflect"
10 "strings"10 "strings"
11 "testing"11 "testing"
12+ "testing/synctest"
12 "time"13 "time"
13 14
14 "github.com/bnema/neferwl/internal/adapters/workspaceid"15 "github.com/bnema/neferwl/internal/adapters/workspaceid"
@@ -41,7 +42,7 @@ func TestRunWritesAndRemoves(t *testing.T) {
41 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()42 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
42 focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}43 focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}
43 states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}44 states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}
44- want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}}45+ want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}, Workspaces: []Workspace{}}
45 deadline := time.Now().Add(2 * time.Second)46 deadline := time.Now().Add(2 * time.Second)
46 for {47 for {
47 if got, err := Read(path); err == nil && got.Output != "stale" {48 if got, err := Read(path); err == nil && got.Output != "stale" {
@@ -158,3 +159,96 @@ func TestWorkspaceIDStrings(t *testing.T) {
158 t.Fatalf("%s %v", data, err)159 t.Fatalf("%s %v", data, err)
159 }160 }
160 }161 }
162+
163+// Workspaces reach the file with their script-facing names.
164+func TestWorkspaceGeometryFields(t *testing.T) {
165+ ids := workspaceid.WithPrefix("0a1b2c3d")
166+ st := fromPorts(ports.State{Workspaces: []ports.WorkspaceState{
167+ {ID: 7, Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []ports.ColumnState{
168+ {Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 400}, Windows: []ports.TileState{{ID: 5, Rect: ports.Rect{X: 11, Y: 21, W: 298, H: 198}}}},
169+ }},
170+ {ID: 8, Output: "DP-2"},
171+ }}, ids)
172+ want := []Workspace{
173+ {ID: "name:web", Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []Column{
174+ {X: 10, Y: 20, Width: 300, Height: 400, Windows: []Tile{{ID: 5, X: 11, Y: 21, Width: 298, Height: 198}}},
175+ }},
176+ {ID: "0a1b2c3d-8", Output: "DP-2", Columns: []Column{}},
177+ }
178+ if !reflect.DeepEqual(st.Workspaces, want) {
179+ t.Fatalf("%+v", st.Workspaces)
180+ }
181+ data, err := json.Marshal(st)
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ for _, key := range []string{`"workspaces":[`, `"id":"name:web"`, `"number":2`, `"visible":true`, `"overflow":"fixed"`, `"columns":[{"x":10,"y":20,"width":300,"height":400,"windows":[{"id":5,"x":11,"y":21,"width":298,"height":198}]}]`} {
186+ if !strings.Contains(string(data), key) {
187+ t.Fatalf("%s missing %s", data, key)
188+ }
189+ }
190+}
191+
192+// The file is written at most once per second: the first state and one after
193+// a quiet second at once, a burst within the second as one trailing write of
194+// the latest state.
195+func TestRunRateLimit(t *testing.T) {
196+ synctest.Test(t, func(t *testing.T) {
197+ path := filepath.Join(t.TempDir(), "s", "wayland-9.json")
198+ states := make(chan ports.State)
199+ ctx, cancel := context.WithCancel(context.Background())
200+ done := make(chan error, 1)
201+ go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
202+ output := func() string {
203+ t.Helper()
204+ synctest.Wait()
205+ st, err := Read(path)
206+ if err != nil {
207+ return ""
208+ }
209+ return st.Output
210+ }
211+ send := func(name string) { states <- ports.State{Output: name} }
212+
213+ send("a")
214+ if got := output(); got != "a" {
215+ t.Fatalf("first state not written at once: %q", got)
216+ }
217+ // A burst within the second: nothing yet, then the latest only.
218+ time.Sleep(200 * time.Millisecond)
219+ send("b")
220+ send("c")
221+ send("d")
222+ if got := output(); got != "a" {
223+ t.Fatalf("written within the second: %q", got)
224+ }
225+ time.Sleep(799 * time.Millisecond)
226+ if got := output(); got != "a" {
227+ t.Fatalf("written before the second ended: %q", got)
228+ }
229+ time.Sleep(time.Millisecond)
230+ if got := output(); got != "d" {
231+ t.Fatalf("trailing write: %q, want d", got)
232+ }
233+ // Nothing more is pending: the file stays as it is.
234+ if err := os.Remove(path); err != nil {
235+ t.Fatal(err)
236+ }
237+ time.Sleep(5 * time.Second)
238+ if got := output(); got != "" {
239+ t.Fatalf("a write with no new state: %q", got)
240+ }
241+ // After a quiet period the next state is immediate.
242+ send("e")
243+ if got := output(); got != "e" {
244+ t.Fatalf("state after a quiet period: %q", got)
245+ }
246+ cancel()
247+ if err := <-done; err != nil {
248+ t.Fatal(err)
249+ }
250+ if _, err := os.Stat(path); !os.IsNotExist(err) {
251+ t.Fatal("state file left behind", err)
252+ }
253+ })
254+}
M internal/core/cascade.go +4 -2
@@ -175,10 +175,12 @@ func (w *Workspace) focusBand(dir int) bool {
175175 w.scroll()
176176 return true
177177 }
178-func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
178+
179+// cascadeRectsAt is cascadeRectsInto with the view slide's shift given.
180+func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int) []Rect {
179181 g := w.gap()
180182 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
181- view := w.View + w.shiftPixels()
183+ view := w.View + shift
182184 w.walkBands(func(lane, first, last int) bool {
183185 S, u, sum := w.laneStats(first, last)
184186 x := g
@@ -175,10 +175,12 @@ func (w *Workspace) focusBand(dir int) bool {
175 w.scroll()175 w.scroll()
176 return true176 return true
177 }177 }
178-func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {178+
179+// cascadeRectsAt is cascadeRectsInto with the view slide's shift given.
180+func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int) []Rect {
179 g := w.gap()181 g := w.gap()
180 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]182 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
181- view := w.View + w.shiftPixels()183+ view := w.View + shift
182 w.walkBands(func(lane, first, last int) bool {184 w.walkBands(func(lane, first, last int) bool {
183 S, u, sum := w.laneStats(first, last)185 S, u, sum := w.laneStats(first, last)
184 x := g186 x := g
M internal/core/core.go +2 -0
@@ -196,6 +196,8 @@ type publishScratch struct {
196196 workspaces ports.Workspaces
197197 shown map[WindowID]bool
198198 ids []WindowID
199+ rects []Rect
200+ rows []Rect
199201 layout ports.Layout
200202 }
201203
@@ -196,6 +196,8 @@ type publishScratch struct {
196 workspaces ports.Workspaces196 workspaces ports.Workspaces
197 shown map[WindowID]bool197 shown map[WindowID]bool
198 ids []WindowID198 ids []WindowID
199+ rects []Rect
200+ rows []Rect
199 layout ports.Layout201 layout ports.Layout
200 }202 }
201 203
M internal/core/layout.go +8 -2
@@ -1726,13 +1726,19 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
17261726 // when too small): the result aliases dst, so a caller passing a reused
17271727 // buffer must be done with the previous result.
17281728 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1729+ return w.columnRectsAt(dst, ignoreFullWidth, w.shiftPixels())
1730+}
1731+
1732+// columnRectsAt is columnRectsInto with the view slide's shift given: 0 is
1733+// the settled geometry, without the animation's presentation offset.
1734+func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) []Rect {
17291735 if w.policy().wraps {
1730- return w.cascadeRectsInto(dst, ignoreFullWidth)
1736+ return w.cascadeRectsAt(dst, ignoreFullWidth, shift)
17311737 }
17321738 g := w.gap()
17331739 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
17341740 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1735- view := w.View + w.shiftPixels()
1741+ view := w.View + shift
17361742 x := w.Usable.X + g
17371743 for i := range w.Columns {
17381744 width := w.columnWidthFor(i, ignoreFullWidth)
@@ -1726,13 +1726,19 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1726 // when too small): the result aliases dst, so a caller passing a reused1726 // when too small): the result aliases dst, so a caller passing a reused
1727 // buffer must be done with the previous result.1727 // buffer must be done with the previous result.
1728 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {1728 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1729+ return w.columnRectsAt(dst, ignoreFullWidth, w.shiftPixels())
1730+}
1731+
1732+// columnRectsAt is columnRectsInto with the view slide's shift given: 0 is
1733+// the settled geometry, without the animation's presentation offset.
1734+func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) []Rect {
1729 if w.policy().wraps {1735 if w.policy().wraps {
1730- return w.cascadeRectsInto(dst, ignoreFullWidth)1736+ return w.cascadeRectsAt(dst, ignoreFullWidth, shift)
1731 }1737 }
1732 g := w.gap()1738 g := w.gap()
1733 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)1739 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
1734 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]1740 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1735- view := w.View + w.shiftPixels()1741+ view := w.View + shift
1736 x := w.Usable.X + g1742 x := w.Usable.X + g
1737 for i := range w.Columns {1743 for i := range w.Columns {
1738 width := w.columnWidthFor(i, ignoreFullWidth)1744 width := w.columnWidthFor(i, ignoreFullWidth)
M internal/core/publish_alloc_test.go +1 -1
@@ -447,7 +447,7 @@ func TestPublishSubscribersAllocations(t *testing.T) {
447447 t.Error("the sent output layout shares the scratch")
448448 }
449449 // And a later change leaves them as they were.
450- want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)}
450+ want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows), Workspaces: cloneState(st).Workspaces}
451451 w := *st.Window
452452 want.Window = &w
453453 c.windows.setIdleInhibit(st.Windows[0].ID, true)
@@ -447,7 +447,7 @@ func TestPublishSubscribersAllocations(t *testing.T) {
447 t.Error("the sent output layout shares the scratch")447 t.Error("the sent output layout shares the scratch")
448 }448 }
449 // And a later change leaves them as they were.449 // And a later change leaves them as they were.
450- want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)}450+ want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows), Workspaces: cloneState(st).Workspaces}
451 w := *st.Window451 w := *st.Window
452 want.Window = &w452 want.Window = &w
453 c.windows.setIdleInhibit(st.Windows[0].ID, true)453 c.windows.setIdleInhibit(st.Windows[0].ID, true)
M internal/core/screens_test.go +6 -0
@@ -838,6 +838,12 @@ func TestStateSnapshot(t *testing.T) {
838838 {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},
839839 },
840840 }
841+ want.Workspaces = []ports.WorkspaceState{
842+ {ID: 1, Number: 1, Output: "DP-1", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 200, H: 100}, Windows: []ports.TileState{{ID: 1, Rect: ports.Rect{W: 200, H: 100}}}}}},
843+ {ID: 3, Number: 2, Output: "DP-1", Overflow: "scroll", Columns: []ports.ColumnState{}},
844+ {ID: 2, Number: 1, Output: "DP-2", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 400, H: 200}, Windows: []ports.TileState{{ID: 2, Rect: ports.Rect{W: 400, H: 200}}}}}},
845+ {ID: 4, Number: 2, Output: "DP-2", Overflow: "scroll", Columns: []ports.ColumnState{}},
846+ }
841847 want.Window = &want.Windows[1]
842848 if !reflect.DeepEqual(st, want) {
843849 t.Fatalf("got %+v\nwant %+v", st, want)
@@ -838,6 +838,12 @@ func TestStateSnapshot(t *testing.T) {
838 {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},838 {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},
839 },839 },
840 }840 }
841+ want.Workspaces = []ports.WorkspaceState{
842+ {ID: 1, Number: 1, Output: "DP-1", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 200, H: 100}, Windows: []ports.TileState{{ID: 1, Rect: ports.Rect{W: 200, H: 100}}}}}},
843+ {ID: 3, Number: 2, Output: "DP-1", Overflow: "scroll", Columns: []ports.ColumnState{}},
844+ {ID: 2, Number: 1, Output: "DP-2", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 400, H: 200}, Windows: []ports.TileState{{ID: 2, Rect: ports.Rect{W: 400, H: 200}}}}}},
845+ {ID: 4, Number: 2, Output: "DP-2", Overflow: "scroll", Columns: []ports.ColumnState{}},
846+ }
841 want.Window = &want.Windows[1]847 want.Window = &want.Windows[1]
842 if !reflect.DeepEqual(st, want) {848 if !reflect.DeepEqual(st, want) {
843 t.Fatalf("got %+v\nwant %+v", st, want)849 t.Fatalf("got %+v\nwant %+v", st, want)
M internal/core/state.go +55 -0
@@ -33,6 +33,8 @@ func (c *Core) state() ports.State { return cloneState(c.stateInto(ports.State{}
3333 func (c *Core) stateInto(st ports.State) ports.State {
3434 st.Output, st.Window = c.cur().name(), nil
3535 st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0]
36+ // Workspaces keep their Columns storage across calls (see workspaceInto).
37+ nws := 0
3638 if c.scratch.shown == nil {
3739 c.scratch.shown = map[WindowID]bool{}
3840 }
@@ -60,6 +62,8 @@ func (c *Core) stateInto(st ports.State) ports.State {
6062 }
6163 for w := range m.all() {
6264 n := indexOf(m.Workspaces, w) + 1
65+ st.Workspaces = c.workspaceInto(st.Workspaces, nws, w, s.name(), n, w == cur)
66+ nws++
6367 c.scratch.ids = w.appendWindows(c.scratch.ids[:0])
6468 for _, id := range c.scratch.ids {
6569 rec := c.windows.lookup(id)
@@ -74,6 +78,7 @@ func (c *Core) stateInto(st ports.State) ports.State {
7478 }
7579 }
7680 }
81+ st.Workspaces = st.Workspaces[:nws]
7782 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
7883 for i := range st.Windows {
7984 if st.Windows[i].ID == focus && focus != 0 {
@@ -83,8 +88,50 @@ func (c *Core) stateInto(st ports.State) ports.State {
8388 return st
8489 }
8590
91+// workspaceInto fills ws[i] (appended when ws is shorter) with w's settled
92+// geometry, reusing the storage the element held: no slide, fade or zoom,
93+// floats and the stash left out. A fullscreen tiled window covers the output.
94+func (c *Core) workspaceInto(ws []ports.WorkspaceState, i int, w *Workspace, output string, number int, visible bool) []ports.WorkspaceState {
95+ if i < cap(ws) {
96+ ws = ws[:i+1]
97+ } else {
98+ ws = append(ws, ports.WorkspaceState{})
99+ }
100+ v := &ws[i]
101+ cols := v.Columns[:0]
102+ *v = ports.WorkspaceState{ID: w.ID, Number: number, Name: w.Name, Output: output, Visible: visible, Overflow: string(w.Overflow)}
103+ c.scratch.rects = w.columnRectsAt(c.scratch.rects, false, 0)
104+ for ci, col := range w.Columns {
105+ if ci < cap(cols) {
106+ cols = cols[:ci+1]
107+ } else {
108+ cols = append(cols, ports.ColumnState{})
109+ }
110+ cv := &cols[ci]
111+ tiles := cv.Windows[:0]
112+ r := c.scratch.rects[ci]
113+ c.scratch.rows = rowRectsInto(c.scratch.rows[:0], r, col, w.gap())
114+ for ri, id := range col.Windows {
115+ tr := c.scratch.rows[ri]
116+ if w.fullscreen == id {
117+ tr = w.Output
118+ }
119+ tiles = append(tiles, ports.TileState{ID: id, Rect: tr})
120+ }
121+ *cv = ports.ColumnState{Rect: r, Windows: tiles}
122+ }
123+ v.Columns = cols
124+ return ws
125+}
126+
86127 func sameState(a, b ports.State) bool {
87128 return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) &&
129+ slices.EqualFunc(a.Workspaces, b.Workspaces, func(x, y ports.WorkspaceState) bool {
130+ return x.ID == y.ID && x.Number == y.Number && x.Name == y.Name && x.Output == y.Output && x.Visible == y.Visible && x.Overflow == y.Overflow &&
131+ slices.EqualFunc(x.Columns, y.Columns, func(p, q ports.ColumnState) bool {
132+ return p.Rect == q.Rect && slices.Equal(p.Windows, q.Windows)
133+ })
134+ }) &&
88135 (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window)
89136 }
90137
@@ -92,6 +139,14 @@ func sameState(a, b ports.State) bool {
92139 // []), and Window is its own copy.
93140 func cloneState(st ports.State) ports.State {
94141 out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)}
142+ out.Workspaces = make([]ports.WorkspaceState, len(st.Workspaces))
143+ for i, w := range st.Workspaces {
144+ out.Workspaces[i] = w
145+ out.Workspaces[i].Columns = make([]ports.ColumnState, len(w.Columns))
146+ for j, col := range w.Columns {
147+ out.Workspaces[i].Columns[j] = ports.ColumnState{Rect: col.Rect, Windows: append([]ports.TileState{}, col.Windows...)}
148+ }
149+ }
95150 if st.Window != nil {
96151 w := *st.Window
97152 out.Window = &w
@@ -33,6 +33,8 @@ func (c *Core) state() ports.State { return cloneState(c.stateInto(ports.State{}
33 func (c *Core) stateInto(st ports.State) ports.State {33 func (c *Core) stateInto(st ports.State) ports.State {
34 st.Output, st.Window = c.cur().name(), nil34 st.Output, st.Window = c.cur().name(), nil
35 st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0]35 st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0]
36+ // Workspaces keep their Columns storage across calls (see workspaceInto).
37+ nws := 0
36 if c.scratch.shown == nil {38 if c.scratch.shown == nil {
37 c.scratch.shown = map[WindowID]bool{}39 c.scratch.shown = map[WindowID]bool{}
38 }40 }
@@ -60,6 +62,8 @@ func (c *Core) stateInto(st ports.State) ports.State {
60 }62 }
61 for w := range m.all() {63 for w := range m.all() {
62 n := indexOf(m.Workspaces, w) + 164 n := indexOf(m.Workspaces, w) + 1
65+ st.Workspaces = c.workspaceInto(st.Workspaces, nws, w, s.name(), n, w == cur)
66+ nws++
63 c.scratch.ids = w.appendWindows(c.scratch.ids[:0])67 c.scratch.ids = w.appendWindows(c.scratch.ids[:0])
64 for _, id := range c.scratch.ids {68 for _, id := range c.scratch.ids {
65 rec := c.windows.lookup(id)69 rec := c.windows.lookup(id)
@@ -74,6 +78,7 @@ func (c *Core) stateInto(st ports.State) ports.State {
74 }78 }
75 }79 }
76 }80 }
81+ st.Workspaces = st.Workspaces[:nws]
77 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })82 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
78 for i := range st.Windows {83 for i := range st.Windows {
79 if st.Windows[i].ID == focus && focus != 0 {84 if st.Windows[i].ID == focus && focus != 0 {
@@ -83,8 +88,50 @@ func (c *Core) stateInto(st ports.State) ports.State {
83 return st88 return st
84 }89 }
85 90
91+// workspaceInto fills ws[i] (appended when ws is shorter) with w's settled
92+// geometry, reusing the storage the element held: no slide, fade or zoom,
93+// floats and the stash left out. A fullscreen tiled window covers the output.
94+func (c *Core) workspaceInto(ws []ports.WorkspaceState, i int, w *Workspace, output string, number int, visible bool) []ports.WorkspaceState {
95+ if i < cap(ws) {
96+ ws = ws[:i+1]
97+ } else {
98+ ws = append(ws, ports.WorkspaceState{})
99+ }
100+ v := &ws[i]
101+ cols := v.Columns[:0]
102+ *v = ports.WorkspaceState{ID: w.ID, Number: number, Name: w.Name, Output: output, Visible: visible, Overflow: string(w.Overflow)}
103+ c.scratch.rects = w.columnRectsAt(c.scratch.rects, false, 0)
104+ for ci, col := range w.Columns {
105+ if ci < cap(cols) {
106+ cols = cols[:ci+1]
107+ } else {
108+ cols = append(cols, ports.ColumnState{})
109+ }
110+ cv := &cols[ci]
111+ tiles := cv.Windows[:0]
112+ r := c.scratch.rects[ci]
113+ c.scratch.rows = rowRectsInto(c.scratch.rows[:0], r, col, w.gap())
114+ for ri, id := range col.Windows {
115+ tr := c.scratch.rows[ri]
116+ if w.fullscreen == id {
117+ tr = w.Output
118+ }
119+ tiles = append(tiles, ports.TileState{ID: id, Rect: tr})
120+ }
121+ *cv = ports.ColumnState{Rect: r, Windows: tiles}
122+ }
123+ v.Columns = cols
124+ return ws
125+}
126+
86 func sameState(a, b ports.State) bool {127 func sameState(a, b ports.State) bool {
87 return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) &&128 return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) &&
129+ slices.EqualFunc(a.Workspaces, b.Workspaces, func(x, y ports.WorkspaceState) bool {
130+ return x.ID == y.ID && x.Number == y.Number && x.Name == y.Name && x.Output == y.Output && x.Visible == y.Visible && x.Overflow == y.Overflow &&
131+ slices.EqualFunc(x.Columns, y.Columns, func(p, q ports.ColumnState) bool {
132+ return p.Rect == q.Rect && slices.Equal(p.Windows, q.Windows)
133+ })
134+ }) &&
88 (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window)135 (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window)
89 }136 }
90 137
@@ -92,6 +139,14 @@ func sameState(a, b ports.State) bool {
92 // []), and Window is its own copy.139 // []), and Window is its own copy.
93 func cloneState(st ports.State) ports.State {140 func cloneState(st ports.State) ports.State {
94 out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)}141 out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)}
142+ out.Workspaces = make([]ports.WorkspaceState, len(st.Workspaces))
143+ for i, w := range st.Workspaces {
144+ out.Workspaces[i] = w
145+ out.Workspaces[i].Columns = make([]ports.ColumnState, len(w.Columns))
146+ for j, col := range w.Columns {
147+ out.Workspaces[i].Columns[j] = ports.ColumnState{Rect: col.Rect, Windows: append([]ports.TileState{}, col.Windows...)}
148+ }
149+ }
95 if st.Window != nil {150 if st.Window != nil {
96 w := *st.Window151 w := *st.Window
97 out.Window = &w152 out.Window = &w
A internal/core/state_geometry_test.go +91 -0
@@ -0,0 +1,91 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// Every workspace lists its tiled columns and windows with settled
10+// rectangles; only the workspace on screen is visible.
11+func TestStateWorkspaceGeometry(t *testing.T) {
12+ cfg := ports.Config{}
13+ cfg.Keyboard.CmdKey = "super"
14+ cfg.Layout.Overflow = "fixed"
15+ cfg.Layout.MaxColumns = 2
16+ cfg.Layout.Gaps = 10
17+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800})
22+ m := c.cur().mon
23+ shown := m.Current()
24+ shown.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}}
25+ m.normalize() // the trailing empty workspace, as in a session
26+ other := m.newWorkspace()
27+ other.Columns = []Column{{Windows: []WindowID{4}}}
28+ m.hidden = append(m.hidden, other)
29+
30+ got := c.state().Workspaces
31+ if len(got) != 3 {
32+ t.Fatalf("%d workspaces: %+v", len(got), got)
33+ }
34+ ws := got[0]
35+ if ws.ID != shown.ID || ws.Number != 1 || ws.Output != "OUT-1" || !ws.Visible || ws.Overflow != "fixed" {
36+ t.Fatalf("%+v", ws)
37+ }
38+ want := []ports.ColumnState{
39+ {Rect: Rect{X: 10, Y: 10, W: 485, H: 780}, Windows: []ports.TileState{{ID: 1, Rect: Rect{X: 10, Y: 10, W: 485, H: 780}}}},
40+ {Rect: Rect{X: 505, Y: 10, W: 485, H: 780}, Windows: []ports.TileState{{ID: 2, Rect: Rect{X: 505, Y: 10, W: 485, H: 385}}, {ID: 3, Rect: Rect{X: 505, Y: 405, W: 485, H: 385}}}},
41+ }
42+ if len(ws.Columns) != len(want) {
43+ t.Fatalf("%+v", ws.Columns)
44+ }
45+ for i := range want {
46+ if ws.Columns[i].Rect != want[i].Rect || len(ws.Columns[i].Windows) != len(want[i].Windows) {
47+ t.Fatalf("column %d: %+v, want %+v", i, ws.Columns[i], want[i])
48+ }
49+ for j := range want[i].Windows {
50+ if ws.Columns[i].Windows[j] != want[i].Windows[j] {
51+ t.Errorf("column %d tile %d: %+v, want %+v", i, j, ws.Columns[i].Windows[j], want[i].Windows[j])
52+ }
53+ }
54+ }
55+ var found bool
56+ for _, w := range got[1:] {
57+ if w.ID == other.ID {
58+ found = true
59+ if w.Visible || w.Number != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0].ID != 4 {
60+ t.Errorf("hidden workspace: %+v", w)
61+ }
62+ } else if w.Visible {
63+ t.Errorf("workspace %d visible", w.ID)
64+ }
65+ }
66+ if !found {
67+ t.Error("second workspace missing")
68+ }
69+}
70+
71+// A change of geometry is a change of state; an unchanged one is not.
72+func TestStateWorkspaceChange(t *testing.T) {
73+ cfg := ports.Config{}
74+ cfg.Keyboard.CmdKey = "super"
75+ cfg.Layout.MaxColumns = 2
76+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800})
81+ w := c.cur().mon.Current()
82+ w.Columns = []Column{{Windows: []WindowID{1}}}
83+ a := c.state()
84+ if !sameState(a, c.state()) {
85+ t.Fatal("same layout reported as changed")
86+ }
87+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2}}}
88+ if sameState(a, c.state()) {
89+ t.Fatal("new column not seen")
90+ }
91+}
@@ -0,0 +1,91 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// Every workspace lists its tiled columns and windows with settled
10+// rectangles; only the workspace on screen is visible.
11+func TestStateWorkspaceGeometry(t *testing.T) {
12+ cfg := ports.Config{}
13+ cfg.Keyboard.CmdKey = "super"
14+ cfg.Layout.Overflow = "fixed"
15+ cfg.Layout.MaxColumns = 2
16+ cfg.Layout.Gaps = 10
17+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800})
22+ m := c.cur().mon
23+ shown := m.Current()
24+ shown.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}}
25+ m.normalize() // the trailing empty workspace, as in a session
26+ other := m.newWorkspace()
27+ other.Columns = []Column{{Windows: []WindowID{4}}}
28+ m.hidden = append(m.hidden, other)
29+
30+ got := c.state().Workspaces
31+ if len(got) != 3 {
32+ t.Fatalf("%d workspaces: %+v", len(got), got)
33+ }
34+ ws := got[0]
35+ if ws.ID != shown.ID || ws.Number != 1 || ws.Output != "OUT-1" || !ws.Visible || ws.Overflow != "fixed" {
36+ t.Fatalf("%+v", ws)
37+ }
38+ want := []ports.ColumnState{
39+ {Rect: Rect{X: 10, Y: 10, W: 485, H: 780}, Windows: []ports.TileState{{ID: 1, Rect: Rect{X: 10, Y: 10, W: 485, H: 780}}}},
40+ {Rect: Rect{X: 505, Y: 10, W: 485, H: 780}, Windows: []ports.TileState{{ID: 2, Rect: Rect{X: 505, Y: 10, W: 485, H: 385}}, {ID: 3, Rect: Rect{X: 505, Y: 405, W: 485, H: 385}}}},
41+ }
42+ if len(ws.Columns) != len(want) {
43+ t.Fatalf("%+v", ws.Columns)
44+ }
45+ for i := range want {
46+ if ws.Columns[i].Rect != want[i].Rect || len(ws.Columns[i].Windows) != len(want[i].Windows) {
47+ t.Fatalf("column %d: %+v, want %+v", i, ws.Columns[i], want[i])
48+ }
49+ for j := range want[i].Windows {
50+ if ws.Columns[i].Windows[j] != want[i].Windows[j] {
51+ t.Errorf("column %d tile %d: %+v, want %+v", i, j, ws.Columns[i].Windows[j], want[i].Windows[j])
52+ }
53+ }
54+ }
55+ var found bool
56+ for _, w := range got[1:] {
57+ if w.ID == other.ID {
58+ found = true
59+ if w.Visible || w.Number != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0].ID != 4 {
60+ t.Errorf("hidden workspace: %+v", w)
61+ }
62+ } else if w.Visible {
63+ t.Errorf("workspace %d visible", w.ID)
64+ }
65+ }
66+ if !found {
67+ t.Error("second workspace missing")
68+ }
69+}
70+
71+// A change of geometry is a change of state; an unchanged one is not.
72+func TestStateWorkspaceChange(t *testing.T) {
73+ cfg := ports.Config{}
74+ cfg.Keyboard.CmdKey = "super"
75+ cfg.Layout.MaxColumns = 2
76+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800})
81+ w := c.cur().mon.Current()
82+ w.Columns = []Column{{Windows: []WindowID{1}}}
83+ a := c.state()
84+ if !sameState(a, c.state()) {
85+ t.Fatal("same layout reported as changed")
86+ }
87+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2}}}
88+ if sameState(a, c.state()) {
89+ t.Fatal("new column not seen")
90+ }
91+}
M internal/ports/state.go +26 -0
@@ -9,6 +9,32 @@ type State struct {
99 // Window is the focused window, nil when none.
1010 Window *WindowState
1111 Windows []WindowState
12+ // Workspaces is every workspace of every output.
13+ Workspaces []WorkspaceState
14+}
15+
16+// WorkspaceState is one workspace and its tiled columns, left to right in
17+// layout order. Rects are logical pixels in the output's coordinates.
18+type WorkspaceState struct {
19+ ID uint64
20+ Number int // numbered workspace, 0 for a hidden/named one
21+ Name string // configured name, "" if dynamic
22+ Output string
23+ Visible bool // on screen on its output
24+ Overflow string // "fixed", "scroll", "cascade"
25+ Columns []ColumnState
26+}
27+
28+// ColumnState is one column and its tiled windows.
29+type ColumnState struct {
30+ Rect Rect // column area, view offset applied (may be off-screen)
31+ Windows []TileState // top to bottom
32+}
33+
34+// TileState is one tiled window and its settled rectangle.
35+type TileState struct {
36+ ID WindowID
37+ Rect Rect
1238 }
1339
1440 // OutputState is one output: its workspace on screen and how many numbered
@@ -9,6 +9,32 @@ type State struct {
9 // Window is the focused window, nil when none.9 // Window is the focused window, nil when none.
10 Window *WindowState10 Window *WindowState
11 Windows []WindowState11 Windows []WindowState
12+ // Workspaces is every workspace of every output.
13+ Workspaces []WorkspaceState
14+}
15+
16+// WorkspaceState is one workspace and its tiled columns, left to right in
17+// layout order. Rects are logical pixels in the output's coordinates.
18+type WorkspaceState struct {
19+ ID uint64
20+ Number int // numbered workspace, 0 for a hidden/named one
21+ Name string // configured name, "" if dynamic
22+ Output string
23+ Visible bool // on screen on its output
24+ Overflow string // "fixed", "scroll", "cascade"
25+ Columns []ColumnState
26+}
27+
28+// ColumnState is one column and its tiled windows.
29+type ColumnState struct {
30+ Rect Rect // column area, view offset applied (may be off-screen)
31+ Windows []TileState // top to bottom
32+}
33+
34+// TileState is one tiled window and its settled rectangle.
35+type TileState struct {
36+ ID WindowID
37+ Rect Rect
12 }38 }
13 39
14 // OutputState is one output: its workspace on screen and how many numbered40 // OutputState is one output: its workspace on screen and how many numbered

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close