Merge 672ba5313e2c0dd02ca39e7fa7e759c3103a6e2f into 608a5b903e02696508ed604565078285738a86bb
11 files · +377 -12
M docs/desktop.md +3 -0
| @@ -133,8 +133,11 @@ Workspace IDs are `name:<name>` for named workspaces, stable across launches. Nu | |||
| 133 | 133 | ||
| 134 | 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 | 138 | ```sh | |
| 137 | 139 | neferwl state # the whole state | |
| 138 | 140 | neferwl state output-of "$PID" # the output showing that process's window | |
| 139 | 141 | 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 | ``` | |
| @@ -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 | ```sh | 138 | ```sh | ||
| 137 | neferwl state # the whole state | 139 | neferwl state # the whole state | ||
| 138 | neferwl state output-of "$PID" # the output showing that process's window | 140 | neferwl state output-of "$PID" # the output showing that process's window | ||
| 139 | neferwl state | jq '[.windows[] | select(.hidden)] | length' # windows in hidden stashes | 141 | 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 ( | |||
| 12 | 12 | "path/filepath" | |
| 13 | 13 | "strconv" | |
| 14 | 14 | "strings" | |
| 15 | + | "time" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/neferwl/internal/adapters/workspaceid" | |
| 17 | 18 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -37,6 +38,40 @@ type State struct { | |||
| 37 | 38 | Outputs []Output `json:"outputs"` | |
| 38 | 39 | Window *Window `json:"window"` | |
| 39 | 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 | 77 | // 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 | 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 | 117 | for _, o := range st.Outputs { | |
| 83 | 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 | 120 | for _, w := range st.Windows { | |
| 86 | 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 | 134 | if st.Window != nil { | |
| 89 | 135 | w := window(*st.Window, ids) | |
| 90 | 136 | out.Window = &w | |
| @@ -96,8 +142,13 @@ func window(w ports.WindowState, ids *workspaceid.IDs) Window { | |||
| 96 | 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 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. | |
| 101 | 152 | func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error { | |
| 102 | 153 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 103 | 154 | return err | |
| @@ -105,15 +156,44 @@ func Run(ctx context.Context, path string, states <-chan ports.State, ids *works | |||
| 105 | 156 | // A file left by a crashed session on this socket is not ours. | |
| 106 | 157 | _ = os.Remove(path) | |
| 107 | 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 | 173 | for { | |
| 174 | + | var due <-chan time.Time | |
| 175 | + | if waiting { | |
| 176 | + | due = timer.C | |
| 177 | + | } | |
| 109 | 178 | select { | |
| 110 | 179 | case <-ctx.Done(): | |
| 111 | 180 | return nil | |
| 112 | 181 | 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 | |
| 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 | } | |
| @@ -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 one | 77 | // 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 = &w | 136 | 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 removes | 145 | + | // 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 err | 154 | 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 nil | 180 | 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 ( | |||
| 9 | 9 | "reflect" | |
| 10 | 10 | "strings" | |
| 11 | 11 | "testing" | |
| 12 | + | "testing/synctest" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/neferwl/internal/adapters/workspaceid" | |
| @@ -41,7 +42,7 @@ func TestRunWritesAndRemoves(t *testing.T) { | |||
| 41 | 42 | go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }() | |
| 42 | 43 | focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true} | |
| 43 | 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 | 46 | deadline := time.Now().Add(2 * time.Second) | |
| 46 | 47 | for { | |
| 47 | 48 | if got, err := Read(path); err == nil && got.Output != "stale" { | |
| @@ -158,3 +159,96 @@ func TestWorkspaceIDStrings(t *testing.T) { | |||
| 158 | 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 | + | } | |
| @@ -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 { | |||
| 175 | 175 | w.scroll() | |
| 176 | 176 | 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 | 181 | g := w.gap() | |
| 180 | 182 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 181 | - | view := w.View + w.shiftPixels() | |
| 183 | + | view := w.View + shift | |
| 182 | 184 | w.walkBands(func(lane, first, last int) bool { | |
| 183 | 185 | S, u, sum := w.laneStats(first, last) | |
| 184 | 186 | x := g | |
| @@ -175,10 +175,12 @@ func (w *Workspace) focusBand(dir int) bool { | |||||
| 175 | w.scroll() | 175 | w.scroll() | ||
| 176 | return true | 176 | 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 := g | 186 | x := g | ||
M internal/core/core.go +2 -0
| @@ -196,6 +196,8 @@ type publishScratch struct { | |||
| 196 | 196 | workspaces ports.Workspaces | |
| 197 | 197 | shown map[WindowID]bool | |
| 198 | 198 | ids []WindowID | |
| 199 | + | rects []Rect | |
| 200 | + | rows []Rect | |
| 199 | 201 | layout ports.Layout | |
| 200 | 202 | } | |
| 201 | 203 | ||
| @@ -196,6 +196,8 @@ type publishScratch struct { | |||||
| 196 | workspaces ports.Workspaces | 196 | workspaces ports.Workspaces | ||
| 197 | shown map[WindowID]bool | 197 | shown map[WindowID]bool | ||
| 198 | ids []WindowID | 198 | ids []WindowID | ||
| 199 | + | rects []Rect | |||
| 200 | + | rows []Rect | |||
| 199 | layout ports.Layout | 201 | 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 { | |||
| 1726 | 1726 | // when too small): the result aliases dst, so a caller passing a reused | |
| 1727 | 1727 | // buffer must be done with the previous result. | |
| 1728 | 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 | 1735 | if w.policy().wraps { | |
| 1730 | - | return w.cascadeRectsInto(dst, ignoreFullWidth) | |
| 1736 | + | return w.cascadeRectsAt(dst, ignoreFullWidth, shift) | |
| 1731 | 1737 | } | |
| 1732 | 1738 | g := w.gap() | |
| 1733 | 1739 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |
| 1734 | 1740 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 1735 | - | view := w.View + w.shiftPixels() | |
| 1741 | + | view := w.View + shift | |
| 1736 | 1742 | x := w.Usable.X + g | |
| 1737 | 1743 | for i := range w.Columns { | |
| 1738 | 1744 | 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 reused | 1726 | // 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 + g | 1742 | 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) { | |||
| 447 | 447 | t.Error("the sent output layout shares the scratch") | |
| 448 | 448 | } | |
| 449 | 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 | 451 | w := *st.Window | |
| 452 | 452 | want.Window = &w | |
| 453 | 453 | 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.Window | 451 | w := *st.Window | ||
| 452 | want.Window = &w | 452 | 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) { | |||
| 838 | 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 | 847 | want.Window = &want.Windows[1] | |
| 842 | 848 | if !reflect.DeepEqual(st, want) { | |
| 843 | 849 | 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{} | |||
| 33 | 33 | func (c *Core) stateInto(st ports.State) ports.State { | |
| 34 | 34 | st.Output, st.Window = c.cur().name(), nil | |
| 35 | 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 | 38 | if c.scratch.shown == nil { | |
| 37 | 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 | 63 | for w := range m.all() { | |
| 62 | 64 | n := indexOf(m.Workspaces, w) + 1 | |
| 65 | + | st.Workspaces = c.workspaceInto(st.Workspaces, nws, w, s.name(), n, w == cur) | |
| 66 | + | nws++ | |
| 63 | 67 | c.scratch.ids = w.appendWindows(c.scratch.ids[:0]) | |
| 64 | 68 | for _, id := range c.scratch.ids { | |
| 65 | 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 | 82 | slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) }) | |
| 78 | 83 | for i := range st.Windows { | |
| 79 | 84 | if st.Windows[i].ID == focus && focus != 0 { | |
| @@ -83,8 +88,50 @@ func (c *Core) stateInto(st ports.State) ports.State { | |||
| 83 | 88 | 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 | 127 | func sameState(a, b ports.State) bool { | |
| 87 | 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 | 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 | 139 | // []), and Window is its own copy. | |
| 93 | 140 | func cloneState(st ports.State) ports.State { | |
| 94 | 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 | 150 | if st.Window != nil { | |
| 96 | 151 | w := *st.Window | |
| 97 | 152 | 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(), nil | 34 | 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) + 1 | 64 | 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 st | 88 | 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.Window | 151 | w := *st.Window | ||
| 97 | out.Window = &w | 152 | 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 { | |||
| 9 | 9 | // Window is the focused window, nil when none. | |
| 10 | 10 | Window *WindowState | |
| 11 | 11 | 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 | 40 | // 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 *WindowState | 10 | Window *WindowState | ||
| 11 | Windows []WindowState | 11 | 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 numbered | 40 | // OutputState is one output: its workspace on screen and how many numbered | ||