feat(overview): show named workspaces below their invocation workspace (#69)
- commit
- a61214e0fd52801be7bbac01b029545aca2119cf
- author
- bnema · · 2026-09-30 11:49 UTC
- committer
- GitHub · · 2026-09-30 11:49 UTC
- parent
- 2b00988
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
8 files · +580 -45
M docs/config.md +3 -3
| @@ -38,7 +38,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 38 | 38 | | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed | | |
| 39 | 39 | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | |
| 40 | 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 41 | - | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | |
| 41 | + | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back | | |
| 42 | 42 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | |
| 43 | 43 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | |
| 44 | 44 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | |
| @@ -146,7 +146,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou | |||
| 146 | 146 | ||
| 147 | 147 | ### Overview | |
| 148 | 148 | ||
| 149 | - | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. | |
| 149 | + | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. | |
| 150 | 150 | ||
| 151 | 151 | | Key | Action | | |
| 152 | 152 | | --- | --- | | |
| @@ -155,7 +155,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou | |||
| 155 | 155 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it | | |
| 156 | 156 | | `escape` | Restore the original focus, maximization and float order | | |
| 157 | 157 | ||
| 158 | - | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. | |
| 158 | + | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-up/down` also traverse cards before crossing workspace rows, including named ones. | |
| 159 | 159 | ||
| 160 | 160 | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window. | |
| 161 | 161 | ||
| @@ -38,7 +38,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 38 | | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed | | 38 | | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed | | ||
| 39 | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | 39 | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | ||
| 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | ||
| 41 | - | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | 41 | + | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back | |
| 42 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | 42 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | ||
| 43 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | 43 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | ||
| 44 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | 44 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | ||
| @@ -146,7 +146,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou | |||||
| 146 | 146 | ||||
| 147 | ### Overview | 147 | ### Overview | ||
| 148 | 148 | ||||
| 149 | - | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. | 149 | + | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. |
| 150 | 150 | ||||
| 151 | | Key | Action | | 151 | | Key | Action | | ||
| 152 | | --- | --- | | 152 | | --- | --- | | ||
| @@ -155,7 +155,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou | |||||
| 155 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it | | 155 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it | | ||
| 156 | | `escape` | Restore the original focus, maximization and float order | | 156 | | `escape` | Restore the original focus, maximization and float order | | ||
| 157 | 157 | ||||
| 158 | - | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. | 158 | + | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-up/down` also traverse cards before crossing workspace rows, including named ones. |
| 159 | 159 | ||||
| 160 | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window. | 160 | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window. | ||
| 161 | 161 | ||||
M internal/core/action.go +9 -1
| @@ -213,20 +213,28 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 213 | 213 | if a == ActionCloseWindow { | |
| 214 | 214 | return Effect{Close: m.overviewTarget()} | |
| 215 | 215 | } | |
| 216 | - | if overviewBlocks(a) { | |
| 216 | + | if m.overviewFocus(a) || overviewBlocks(a) { | |
| 217 | 217 | return Effect{} | |
| 218 | 218 | } | |
| 219 | 219 | } | |
| 220 | 220 | if n, op, ok := WorkspaceArg(a); ok { | |
| 221 | 221 | if op == FocusWorkspace { | |
| 222 | + | before := m.Current() | |
| 222 | 223 | m.FocusNumber(n) | |
| 224 | + | if m.ov.open && m.Current() != before { | |
| 225 | + | m.selectRow() | |
| 226 | + | } | |
| 223 | 227 | } else { | |
| 224 | 228 | m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace) | |
| 225 | 229 | } | |
| 226 | 230 | return Effect{} | |
| 227 | 231 | } | |
| 228 | 232 | if name, ok := NamedArg(a); ok { | |
| 233 | + | before := m.Current() | |
| 229 | 234 | m.ToggleNamed(name) | |
| 235 | + | if m.ov.open && m.Current() != before { | |
| 236 | + | m.selectRow() | |
| 237 | + | } | |
| 230 | 238 | return Effect{} | |
| 231 | 239 | } | |
| 232 | 240 | switch a { | |
| @@ -213,20 +213,28 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 213 | if a == ActionCloseWindow { | 213 | if a == ActionCloseWindow { | ||
| 214 | return Effect{Close: m.overviewTarget()} | 214 | return Effect{Close: m.overviewTarget()} | ||
| 215 | } | 215 | } | ||
| 216 | - | if overviewBlocks(a) { | 216 | + | if m.overviewFocus(a) || overviewBlocks(a) { |
| 217 | return Effect{} | 217 | return Effect{} | ||
| 218 | } | 218 | } | ||
| 219 | } | 219 | } | ||
| 220 | if n, op, ok := WorkspaceArg(a); ok { | 220 | if n, op, ok := WorkspaceArg(a); ok { | ||
| 221 | if op == FocusWorkspace { | 221 | if op == FocusWorkspace { | ||
| 222 | + | before := m.Current() | |||
| 222 | m.FocusNumber(n) | 223 | m.FocusNumber(n) | ||
| 224 | + | if m.ov.open && m.Current() != before { | |||
| 225 | + | m.selectRow() | |||
| 226 | + | } | |||
| 223 | } else { | 227 | } else { | ||
| 224 | m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace) | 228 | m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace) | ||
| 225 | } | 229 | } | ||
| 226 | return Effect{} | 230 | return Effect{} | ||
| 227 | } | 231 | } | ||
| 228 | if name, ok := NamedArg(a); ok { | 232 | if name, ok := NamedArg(a); ok { | ||
| 233 | + | before := m.Current() | |||
| 229 | m.ToggleNamed(name) | 234 | m.ToggleNamed(name) | ||
| 235 | + | if m.ov.open && m.Current() != before { | |||
| 236 | + | m.selectRow() | |||
| 237 | + | } | |||
| 230 | return Effect{} | 238 | return Effect{} | ||
| 231 | } | 239 | } | ||
| 232 | switch a { | 240 | switch a { | ||
M internal/core/core.go +3 -2
| @@ -471,8 +471,9 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 471 | 471 | // Only the focused output lights the focused window's lines. | |
| 472 | 472 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen) | |
| 473 | 473 | if sc.mon.ov.open { | |
| 474 | - | // Previews have no lines: the selected one is framed. | |
| 475 | - | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | |
| 474 | + | // Frame the selection and separate numbered and named row groups. | |
| 475 | + | _, scene.Separators = sc.mon.overviewRows() | |
| 476 | + | scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...) | |
| 476 | 477 | } | |
| 477 | 478 | for _, p := range layout { | |
| 478 | 479 | // Only the focused output has an activated window. | |
| @@ -471,8 +471,9 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 471 | // Only the focused output lights the focused window's lines. | 471 | // Only the focused output lights the focused window's lines. | ||
| 472 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen) | 472 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen) | ||
| 473 | if sc.mon.ov.open { | 473 | if sc.mon.ov.open { | ||
| 474 | - | // Previews have no lines: the selected one is framed. | 474 | + | // Frame the selection and separate numbered and named row groups. |
| 475 | - | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | 475 | + | _, scene.Separators = sc.mon.overviewRows() |
| 476 | + | scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...) | |||
| 476 | } | 477 | } | ||
| 477 | for _, p := range layout { | 478 | for _, p := range layout { | ||
| 478 | // Only the focused output has an activated window. | 479 | // Only the focused output has an activated window. | ||
M internal/core/layout.go +6 -3
| @@ -127,9 +127,12 @@ type Workspace struct { | |||
| 127 | 127 | // ID stays with the workspace across reorder and monitor moves. | |
| 128 | 128 | ID uint64 | |
| 129 | 129 | // Name is set for workspaces declared in config; empty for dynamic ones. | |
| 130 | - | Name string | |
| 131 | - | Overflow Overflow | |
| 132 | - | Columns []Column | |
| 130 | + | Name string | |
| 131 | + | // overviewAfter is the numbered workspace from which this named workspace | |
| 132 | + | // was invoked. It only orders overview rows; numbering stays unchanged. | |
| 133 | + | overviewAfter *Workspace | |
| 134 | + | Overflow Overflow | |
| 135 | + | Columns []Column | |
| 133 | 136 | // maximized remembers recently maximized columns by window ID, newest | |
| 134 | 137 | // first; stack() filters removed windows at read time. | |
| 135 | 138 | maximized []WindowID | |
| @@ -127,9 +127,12 @@ type Workspace struct { | |||||
| 127 | // ID stays with the workspace across reorder and monitor moves. | 127 | // ID stays with the workspace across reorder and monitor moves. | ||
| 128 | ID uint64 | 128 | ID uint64 | ||
| 129 | // Name is set for workspaces declared in config; empty for dynamic ones. | 129 | // Name is set for workspaces declared in config; empty for dynamic ones. | ||
| 130 | - | Name string | 130 | + | Name string |
| 131 | - | Overflow Overflow | 131 | + | // overviewAfter is the numbered workspace from which this named workspace |
| 132 | - | Columns []Column | 132 | + | // was invoked. It only orders overview rows; numbering stays unchanged. |
| 133 | + | overviewAfter *Workspace | |||
| 134 | + | Overflow Overflow | |||
| 135 | + | Columns []Column | |||
| 133 | // maximized remembers recently maximized columns by window ID, newest | 136 | // maximized remembers recently maximized columns by window ID, newest | ||
| 134 | // first; stack() filters removed windows at read time. | 137 | // first; stack() filters removed windows at read time. | ||
| 135 | maximized []WindowID | 138 | maximized []WindowID | ||
M internal/core/monitor.go +16 -2
| @@ -8,8 +8,8 @@ import "slices" | |||
| 8 | 8 | // except the first. Output-wide settings apply to every workspace. | |
| 9 | 9 | // | |
| 10 | 10 | // Named workspaces come from config and are never removed while configured. | |
| 11 | - | // Each named workspace is not numbered and not reachable by up/down: only its | |
| 12 | - | // `workspace <name>` bind shows it. | |
| 11 | + | // Named workspaces are outside normal numbered up/down navigation. Their | |
| 12 | + | // `workspace <name>` binds and the overview can show them. | |
| 13 | 13 | type Monitor struct { | |
| 14 | 14 | // Name is the connector; Key identifies the physical monitor (make, | |
| 15 | 15 | // model and serial, else the connector). Workspaces remember the Key of | |
| @@ -142,6 +142,7 @@ func (m *Monitor) newWorkspace() *Workspace { | |||
| 142 | 142 | w := m.template | |
| 143 | 143 | w.presets = append([]Width(nil), m.template.presets...) | |
| 144 | 144 | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{} | |
| 145 | + | w.overviewAfter = nil | |
| 145 | 146 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | |
| 146 | 147 | (*m.nextID)++ | |
| 147 | 148 | w.ID = *m.nextID | |
| @@ -168,6 +169,11 @@ func (m *Monitor) normalize() { | |||
| 168 | 169 | if n := len(m.Workspaces); n == 0 || !spare(n-1) { | |
| 169 | 170 | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |
| 170 | 171 | } | |
| 172 | + | for _, w := range m.hidden { | |
| 173 | + | if indexOf(m.Workspaces, w.overviewAfter) < 0 { | |
| 174 | + | w.overviewAfter = nil | |
| 175 | + | } | |
| 176 | + | } | |
| 171 | 177 | } | |
| 172 | 178 | ||
| 173 | 179 | // Focus switches to the numbered workspace at index i, clamped to the list. | |
| @@ -200,6 +206,14 @@ func (m *Monitor) ToggleNamed(name string) { | |||
| 200 | 206 | switch cur := m.Current(); { | |
| 201 | 207 | case target == nil: | |
| 202 | 208 | case target != cur: | |
| 209 | + | anchor := cur | |
| 210 | + | if m.isHidden(cur) { | |
| 211 | + | anchor = cur.overviewAfter | |
| 212 | + | } | |
| 213 | + | if indexOf(m.Workspaces, anchor) < 0 { | |
| 214 | + | anchor = m.Workspaces[m.Active] | |
| 215 | + | } | |
| 216 | + | target.overviewAfter = anchor | |
| 203 | 217 | m.back = cur | |
| 204 | 218 | m.show(target) | |
| 205 | 219 | case m.back != nil && m.has(m.back): | |
| @@ -8,8 +8,8 @@ import "slices" | |||||
| 8 | // except the first. Output-wide settings apply to every workspace. | 8 | // except the first. Output-wide settings apply to every workspace. | ||
| 9 | // | 9 | // | ||
| 10 | // Named workspaces come from config and are never removed while configured. | 10 | // Named workspaces come from config and are never removed while configured. | ||
| 11 | - | // Each named workspace is not numbered and not reachable by up/down: only its | 11 | + | // Named workspaces are outside normal numbered up/down navigation. Their |
| 12 | - | // `workspace <name>` bind shows it. | 12 | + | // `workspace <name>` binds and the overview can show them. |
| 13 | type Monitor struct { | 13 | type Monitor struct { | ||
| 14 | // Name is the connector; Key identifies the physical monitor (make, | 14 | // Name is the connector; Key identifies the physical monitor (make, | ||
| 15 | // model and serial, else the connector). Workspaces remember the Key of | 15 | // model and serial, else the connector). Workspaces remember the Key of | ||
| @@ -142,6 +142,7 @@ func (m *Monitor) newWorkspace() *Workspace { | |||||
| 142 | w := m.template | 142 | w := m.template | ||
| 143 | w.presets = append([]Width(nil), m.template.presets...) | 143 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 144 | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{} | 144 | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{} | ||
| 145 | + | w.overviewAfter = nil | |||
| 145 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | 146 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | ||
| 146 | (*m.nextID)++ | 147 | (*m.nextID)++ | ||
| 147 | w.ID = *m.nextID | 148 | w.ID = *m.nextID | ||
| @@ -168,6 +169,11 @@ func (m *Monitor) normalize() { | |||||
| 168 | if n := len(m.Workspaces); n == 0 || !spare(n-1) { | 169 | if n := len(m.Workspaces); n == 0 || !spare(n-1) { | ||
| 169 | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | 170 | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | ||
| 170 | } | 171 | } | ||
| 172 | + | for _, w := range m.hidden { | |||
| 173 | + | if indexOf(m.Workspaces, w.overviewAfter) < 0 { | |||
| 174 | + | w.overviewAfter = nil | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 171 | } | 177 | } | ||
| 172 | 178 | ||||
| 173 | // Focus switches to the numbered workspace at index i, clamped to the list. | 179 | // Focus switches to the numbered workspace at index i, clamped to the list. | ||
| @@ -200,6 +206,14 @@ func (m *Monitor) ToggleNamed(name string) { | |||||
| 200 | switch cur := m.Current(); { | 206 | switch cur := m.Current(); { | ||
| 201 | case target == nil: | 207 | case target == nil: | ||
| 202 | case target != cur: | 208 | case target != cur: | ||
| 209 | + | anchor := cur | |||
| 210 | + | if m.isHidden(cur) { | |||
| 211 | + | anchor = cur.overviewAfter | |||
| 212 | + | } | |||
| 213 | + | if indexOf(m.Workspaces, anchor) < 0 { | |||
| 214 | + | anchor = m.Workspaces[m.Active] | |||
| 215 | + | } | |||
| 216 | + | target.overviewAfter = anchor | |||
| 203 | m.back = cur | 217 | m.back = cur | ||
| 204 | m.show(target) | 218 | m.show(target) | ||
| 205 | case m.back != nil && m.has(m.back): | 219 | case m.back != nil && m.has(m.back): | ||
M internal/core/overview.go +108 -30
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | type overviewState struct { | |
| 14 | 14 | open bool | |
| 15 | 15 | from *Workspace | |
| 16 | + | back *Workspace | |
| 16 | 17 | fromID WindowID | |
| 17 | 18 | floats []Float | |
| 18 | 19 | fullWidth WindowID | |
| @@ -62,7 +63,7 @@ func (m *Monitor) ToggleOverview() { | |||
| 62 | 63 | // settled state. | |
| 63 | 64 | m.stopSwitch() | |
| 64 | 65 | m.each(func(w *Workspace) { w.stopSlide() }) | |
| 65 | - | m.ov.open, m.ov.from = true, w | |
| 66 | + | m.ov.open, m.ov.from, m.ov.back = true, w, m.back | |
| 66 | 67 | m.ov.fromID, _ = w.Focused() | |
| 67 | 68 | m.ov.floats = append([]Float(nil), w.Floats...) | |
| 68 | 69 | m.ov.maximized = slices.Clone(w.maximized) | |
| @@ -233,9 +234,69 @@ func (m *Monitor) CancelOverview() { | |||
| 233 | 234 | from.maximized = slices.Clone(m.ov.maximized) | |
| 234 | 235 | from.scroll() | |
| 235 | 236 | } | |
| 237 | + | m.back = m.ov.back | |
| 238 | + | if m.back != nil && !m.has(m.back) { | |
| 239 | + | m.back = nil | |
| 240 | + | } | |
| 236 | 241 | m.ov = overviewState{} | |
| 237 | 242 | } | |
| 238 | 243 | ||
| 244 | + | // overviewWorkspaces inserts named rows below their invocation workspace, | |
| 245 | + | // in configured order within each group. Unattached rows follow the last | |
| 246 | + | // occupied numbered row (or the first). Empty invocation anchors stay visible; | |
| 247 | + | // other empty rows are skipped unless current. | |
| 248 | + | func (m *Monitor) overviewWorkspaces() []*Workspace { | |
| 249 | + | fallback := m.Workspaces[0] | |
| 250 | + | for _, w := range m.Workspaces { | |
| 251 | + | if !w.empty() { | |
| 252 | + | fallback = w | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | groups := make(map[*Workspace][]*Workspace) | |
| 256 | + | named := slices.Clone(m.hidden) | |
| 257 | + | rank := func(w *Workspace) int { | |
| 258 | + | for i, spec := range m.named { | |
| 259 | + | if spec.Name == w.Name { | |
| 260 | + | return i | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | return len(m.named) | |
| 264 | + | } | |
| 265 | + | slices.SortStableFunc(named, func(a, b *Workspace) int { return rank(a) - rank(b) }) | |
| 266 | + | for _, w := range named { | |
| 267 | + | if w.empty() && w != m.Current() { | |
| 268 | + | continue | |
| 269 | + | } | |
| 270 | + | anchor := w.overviewAfter | |
| 271 | + | if indexOf(m.Workspaces, anchor) < 0 { | |
| 272 | + | anchor = fallback | |
| 273 | + | } | |
| 274 | + | groups[anchor] = append(groups[anchor], w) | |
| 275 | + | } | |
| 276 | + | var rows []*Workspace | |
| 277 | + | for _, w := range m.Workspaces { | |
| 278 | + | if !w.empty() || w == m.Current() || len(groups[w]) > 0 { | |
| 279 | + | rows = append(rows, w) | |
| 280 | + | rows = append(rows, groups[w]...) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | return rows | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // showOverview changes rows without committing the provisional stack. Entering | |
| 287 | + | // a named group uses its invocation anchor as the toggle return target, not | |
| 288 | + | // rows merely browsed in the overview. | |
| 289 | + | func (m *Monitor) showOverview(w *Workspace) { | |
| 290 | + | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | |
| 291 | + | m.back = m.Current() | |
| 292 | + | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | |
| 293 | + | m.back = w.overviewAfter | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | m.show(w) | |
| 297 | + | m.selectRow() | |
| 298 | + | } | |
| 299 | + | ||
| 239 | 300 | // OverviewMove routes every vertical input through moveStack, then falls | |
| 240 | 301 | // through to workspace navigation at the stack boundary. Horizontal moves | |
| 241 | 302 | // stay inside a column group; left on a single card sends it behind. | |
| @@ -245,12 +306,12 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||
| 245 | 306 | if m.moveStack(-dy) { | |
| 246 | 307 | return | |
| 247 | 308 | } | |
| 248 | - | i := m.Active + dy | |
| 249 | - | if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() { | |
| 309 | + | rows := m.overviewWorkspaces() | |
| 310 | + | i := indexOf(rows, w) + dy | |
| 311 | + | if i < 0 || i >= len(rows) { | |
| 250 | 312 | return | |
| 251 | 313 | } | |
| 252 | - | m.Focus(i) | |
| 253 | - | m.selectRow() | |
| 314 | + | m.showOverview(rows[i]) | |
| 254 | 315 | return | |
| 255 | 316 | } | |
| 256 | 317 | if w.pinned() { | |
| @@ -331,7 +392,7 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 331 | 392 | return | |
| 332 | 393 | } | |
| 333 | 394 | } | |
| 334 | - | m.show(w) | |
| 395 | + | m.showOverview(w) | |
| 335 | 396 | m.ov.card, m.ov.cardOf = 0, nil | |
| 336 | 397 | if i := w.stashIndex(id); i >= 0 { | |
| 337 | 398 | m.selectCard(w, i) | |
| @@ -355,27 +416,51 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 355 | 416 | m.closeOverview() | |
| 356 | 417 | } | |
| 357 | 418 | ||
| 358 | - | // overviewLayout places the current workspace's row in the middle of the | |
| 359 | - | // usable area and its numbered neighbors above and below, dimmed, each | |
| 360 | - | // with its stash pile and covering-float stack. Other workspaces are hidden. | |
| 361 | - | func (m *Monitor) overviewLayout() []Placement { | |
| 419 | + | // overviewRows centers the selected row with its neighbors above and below. | |
| 420 | + | // The numbered/named boundary gets extra spacing and an inactive horizontal | |
| 421 | + | // rule. Row widths, stash piles and preview scales remain unchanged. | |
| 422 | + | func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | |
| 362 | 423 | cur := m.Current() | |
| 363 | 424 | u := cur.overviewArea() | |
| 364 | - | gap := u.H * 3 / 100 | |
| 365 | - | var result []Placement | |
| 425 | + | gap := max(1, u.H*3/100) | |
| 366 | 426 | hc := cur.rowHeight() | |
| 367 | 427 | y := u.Y + (u.H-hc)/2 | |
| 368 | - | rows := map[*Workspace]int{} | |
| 369 | - | rows[cur] = y | |
| 370 | - | if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil { | |
| 371 | - | if i > 0 { | |
| 372 | - | up := m.Workspaces[i-1] | |
| 373 | - | rows[up] = y - gap - up.rowHeight() | |
| 428 | + | rows := map[*Workspace]int{cur: y} | |
| 429 | + | var dividers []ports.Separator | |
| 430 | + | list := m.overviewWorkspaces() | |
| 431 | + | i := indexOf(list, cur) | |
| 432 | + | for _, next := range []int{i - 1, i + 1} { | |
| 433 | + | if next < 0 || next >= len(list) { | |
| 434 | + | continue | |
| 374 | 435 | } | |
| 375 | - | if i+1 < len(m.Workspaces) { | |
| 376 | - | rows[m.Workspaces[i+1]] = y + hc + gap | |
| 436 | + | w := list[next] | |
| 437 | + | spacing := gap | |
| 438 | + | boundary := m.isHidden(w) != m.isHidden(cur) | |
| 439 | + | if boundary { | |
| 440 | + | spacing *= 2 | |
| 441 | + | } | |
| 442 | + | top := y + hc | |
| 443 | + | if next < i { | |
| 444 | + | rows[w] = y - spacing - w.rowHeight() | |
| 445 | + | top = y - spacing | |
| 446 | + | } else { | |
| 447 | + | rows[w] = y + hc + spacing | |
| 448 | + | } | |
| 449 | + | if boundary { | |
| 450 | + | margin := u.W * 4 / 100 | |
| 451 | + | dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}}) | |
| 377 | 452 | } | |
| 378 | 453 | } | |
| 454 | + | return rows, dividers | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // overviewLayout places the current workspace and its overview neighbors, | |
| 458 | + | // each with its full-width stash pile and covering-float stack. Other rows | |
| 459 | + | // stay hidden; numbered and named workspaces share the same preview geometry. | |
| 460 | + | func (m *Monitor) overviewLayout() []Placement { | |
| 461 | + | cur := m.Current() | |
| 462 | + | rows, _ := m.overviewRows() | |
| 463 | + | var result []Placement | |
| 379 | 464 | for _, w := range m.all() { | |
| 380 | 465 | ry, shown := rows[w] | |
| 381 | 466 | if shown && w.pinned() { | |
| @@ -716,15 +801,8 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 716 | 801 | return true, c.publish(ctx) | |
| 717 | 802 | } | |
| 718 | 803 | ||
| 719 | - | // overviewSwipe moves the selection like the keys: a workspace swipe | |
| 720 | - | // must not enter the empty workspace below the last one. | |
| 804 | + | // overviewSwipe moves the selection like the keys, through stacks and rows. | |
| 721 | 805 | func (m *Monitor) overviewSwipe(a Action) { | |
| 722 | - | switch a { | |
| 723 | - | case ActionFocusWorkspaceUp: | |
| 724 | - | a = ActionFocusWindowUp | |
| 725 | - | case ActionFocusWorkspaceDown: | |
| 726 | - | a = ActionFocusWindowDown | |
| 727 | - | } | |
| 728 | 806 | m.overviewFocus(a) | |
| 729 | 807 | } | |
| 730 | 808 | ||
| @@ -737,9 +815,9 @@ func (m *Monitor) overviewFocus(a Action) bool { | |||
| 737 | 815 | m.OverviewMove(-1, 0) | |
| 738 | 816 | case ActionFocusColumnRight: | |
| 739 | 817 | m.OverviewMove(1, 0) | |
| 740 | - | case ActionFocusWindowUp: | |
| 818 | + | case ActionFocusWindowUp, ActionFocusWorkspaceUp: | |
| 741 | 819 | m.OverviewMove(0, -1) | |
| 742 | - | case ActionFocusWindowDown: | |
| 820 | + | case ActionFocusWindowDown, ActionFocusWorkspaceDown: | |
| 743 | 821 | m.OverviewMove(0, 1) | |
| 744 | 822 | default: | |
| 745 | 823 | return false | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | type overviewState struct { | 13 | type overviewState struct { | ||
| 14 | open bool | 14 | open bool | ||
| 15 | from *Workspace | 15 | from *Workspace | ||
| 16 | + | back *Workspace | |||
| 16 | fromID WindowID | 17 | fromID WindowID | ||
| 17 | floats []Float | 18 | floats []Float | ||
| 18 | fullWidth WindowID | 19 | fullWidth WindowID | ||
| @@ -62,7 +63,7 @@ func (m *Monitor) ToggleOverview() { | |||||
| 62 | // settled state. | 63 | // settled state. | ||
| 63 | m.stopSwitch() | 64 | m.stopSwitch() | ||
| 64 | m.each(func(w *Workspace) { w.stopSlide() }) | 65 | m.each(func(w *Workspace) { w.stopSlide() }) | ||
| 65 | - | m.ov.open, m.ov.from = true, w | 66 | + | m.ov.open, m.ov.from, m.ov.back = true, w, m.back |
| 66 | m.ov.fromID, _ = w.Focused() | 67 | m.ov.fromID, _ = w.Focused() | ||
| 67 | m.ov.floats = append([]Float(nil), w.Floats...) | 68 | m.ov.floats = append([]Float(nil), w.Floats...) | ||
| 68 | m.ov.maximized = slices.Clone(w.maximized) | 69 | m.ov.maximized = slices.Clone(w.maximized) | ||
| @@ -233,9 +234,69 @@ func (m *Monitor) CancelOverview() { | |||||
| 233 | from.maximized = slices.Clone(m.ov.maximized) | 234 | from.maximized = slices.Clone(m.ov.maximized) | ||
| 234 | from.scroll() | 235 | from.scroll() | ||
| 235 | } | 236 | } | ||
| 237 | + | m.back = m.ov.back | |||
| 238 | + | if m.back != nil && !m.has(m.back) { | |||
| 239 | + | m.back = nil | |||
| 240 | + | } | |||
| 236 | m.ov = overviewState{} | 241 | m.ov = overviewState{} | ||
| 237 | } | 242 | } | ||
| 238 | 243 | ||||
| 244 | + | // overviewWorkspaces inserts named rows below their invocation workspace, | |||
| 245 | + | // in configured order within each group. Unattached rows follow the last | |||
| 246 | + | // occupied numbered row (or the first). Empty invocation anchors stay visible; | |||
| 247 | + | // other empty rows are skipped unless current. | |||
| 248 | + | func (m *Monitor) overviewWorkspaces() []*Workspace { | |||
| 249 | + | fallback := m.Workspaces[0] | |||
| 250 | + | for _, w := range m.Workspaces { | |||
| 251 | + | if !w.empty() { | |||
| 252 | + | fallback = w | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | groups := make(map[*Workspace][]*Workspace) | |||
| 256 | + | named := slices.Clone(m.hidden) | |||
| 257 | + | rank := func(w *Workspace) int { | |||
| 258 | + | for i, spec := range m.named { | |||
| 259 | + | if spec.Name == w.Name { | |||
| 260 | + | return i | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | return len(m.named) | |||
| 264 | + | } | |||
| 265 | + | slices.SortStableFunc(named, func(a, b *Workspace) int { return rank(a) - rank(b) }) | |||
| 266 | + | for _, w := range named { | |||
| 267 | + | if w.empty() && w != m.Current() { | |||
| 268 | + | continue | |||
| 269 | + | } | |||
| 270 | + | anchor := w.overviewAfter | |||
| 271 | + | if indexOf(m.Workspaces, anchor) < 0 { | |||
| 272 | + | anchor = fallback | |||
| 273 | + | } | |||
| 274 | + | groups[anchor] = append(groups[anchor], w) | |||
| 275 | + | } | |||
| 276 | + | var rows []*Workspace | |||
| 277 | + | for _, w := range m.Workspaces { | |||
| 278 | + | if !w.empty() || w == m.Current() || len(groups[w]) > 0 { | |||
| 279 | + | rows = append(rows, w) | |||
| 280 | + | rows = append(rows, groups[w]...) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | return rows | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // showOverview changes rows without committing the provisional stack. Entering | |||
| 287 | + | // a named group uses its invocation anchor as the toggle return target, not | |||
| 288 | + | // rows merely browsed in the overview. | |||
| 289 | + | func (m *Monitor) showOverview(w *Workspace) { | |||
| 290 | + | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | |||
| 291 | + | m.back = m.Current() | |||
| 292 | + | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | |||
| 293 | + | m.back = w.overviewAfter | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | m.show(w) | |||
| 297 | + | m.selectRow() | |||
| 298 | + | } | |||
| 299 | + | ||||
| 239 | // OverviewMove routes every vertical input through moveStack, then falls | 300 | // OverviewMove routes every vertical input through moveStack, then falls | ||
| 240 | // through to workspace navigation at the stack boundary. Horizontal moves | 301 | // through to workspace navigation at the stack boundary. Horizontal moves | ||
| 241 | // stay inside a column group; left on a single card sends it behind. | 302 | // stay inside a column group; left on a single card sends it behind. | ||
| @@ -245,12 +306,12 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||||
| 245 | if m.moveStack(-dy) { | 306 | if m.moveStack(-dy) { | ||
| 246 | return | 307 | return | ||
| 247 | } | 308 | } | ||
| 248 | - | i := m.Active + dy | 309 | + | rows := m.overviewWorkspaces() |
| 249 | - | if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() { | 310 | + | i := indexOf(rows, w) + dy |
| 311 | + | if i < 0 || i >= len(rows) { | |||
| 250 | return | 312 | return | ||
| 251 | } | 313 | } | ||
| 252 | - | m.Focus(i) | 314 | + | m.showOverview(rows[i]) |
| 253 | - | m.selectRow() | |||
| 254 | return | 315 | return | ||
| 255 | } | 316 | } | ||
| 256 | if w.pinned() { | 317 | if w.pinned() { | ||
| @@ -331,7 +392,7 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||||
| 331 | return | 392 | return | ||
| 332 | } | 393 | } | ||
| 333 | } | 394 | } | ||
| 334 | - | m.show(w) | 395 | + | m.showOverview(w) |
| 335 | m.ov.card, m.ov.cardOf = 0, nil | 396 | m.ov.card, m.ov.cardOf = 0, nil | ||
| 336 | if i := w.stashIndex(id); i >= 0 { | 397 | if i := w.stashIndex(id); i >= 0 { | ||
| 337 | m.selectCard(w, i) | 398 | m.selectCard(w, i) | ||
| @@ -355,27 +416,51 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||||
| 355 | m.closeOverview() | 416 | m.closeOverview() | ||
| 356 | } | 417 | } | ||
| 357 | 418 | ||||
| 358 | - | // overviewLayout places the current workspace's row in the middle of the | 419 | + | // overviewRows centers the selected row with its neighbors above and below. |
| 359 | - | // usable area and its numbered neighbors above and below, dimmed, each | 420 | + | // The numbered/named boundary gets extra spacing and an inactive horizontal |
| 360 | - | // with its stash pile and covering-float stack. Other workspaces are hidden. | 421 | + | // rule. Row widths, stash piles and preview scales remain unchanged. |
| 361 | - | func (m *Monitor) overviewLayout() []Placement { | 422 | + | func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { |
| 362 | cur := m.Current() | 423 | cur := m.Current() | ||
| 363 | u := cur.overviewArea() | 424 | u := cur.overviewArea() | ||
| 364 | - | gap := u.H * 3 / 100 | 425 | + | gap := max(1, u.H*3/100) |
| 365 | - | var result []Placement | |||
| 366 | hc := cur.rowHeight() | 426 | hc := cur.rowHeight() | ||
| 367 | y := u.Y + (u.H-hc)/2 | 427 | y := u.Y + (u.H-hc)/2 | ||
| 368 | - | rows := map[*Workspace]int{} | 428 | + | rows := map[*Workspace]int{cur: y} |
| 369 | - | rows[cur] = y | 429 | + | var dividers []ports.Separator |
| 370 | - | if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil { | 430 | + | list := m.overviewWorkspaces() |
| 371 | - | if i > 0 { | 431 | + | i := indexOf(list, cur) |
| 372 | - | up := m.Workspaces[i-1] | 432 | + | for _, next := range []int{i - 1, i + 1} { |
| 373 | - | rows[up] = y - gap - up.rowHeight() | 433 | + | if next < 0 || next >= len(list) { |
| 434 | + | continue | |||
| 374 | } | 435 | } | ||
| 375 | - | if i+1 < len(m.Workspaces) { | 436 | + | w := list[next] |
| 376 | - | rows[m.Workspaces[i+1]] = y + hc + gap | 437 | + | spacing := gap |
| 438 | + | boundary := m.isHidden(w) != m.isHidden(cur) | |||
| 439 | + | if boundary { | |||
| 440 | + | spacing *= 2 | |||
| 441 | + | } | |||
| 442 | + | top := y + hc | |||
| 443 | + | if next < i { | |||
| 444 | + | rows[w] = y - spacing - w.rowHeight() | |||
| 445 | + | top = y - spacing | |||
| 446 | + | } else { | |||
| 447 | + | rows[w] = y + hc + spacing | |||
| 448 | + | } | |||
| 449 | + | if boundary { | |||
| 450 | + | margin := u.W * 4 / 100 | |||
| 451 | + | dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}}) | |||
| 377 | } | 452 | } | ||
| 378 | } | 453 | } | ||
| 454 | + | return rows, dividers | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | // overviewLayout places the current workspace and its overview neighbors, | |||
| 458 | + | // each with its full-width stash pile and covering-float stack. Other rows | |||
| 459 | + | // stay hidden; numbered and named workspaces share the same preview geometry. | |||
| 460 | + | func (m *Monitor) overviewLayout() []Placement { | |||
| 461 | + | cur := m.Current() | |||
| 462 | + | rows, _ := m.overviewRows() | |||
| 463 | + | var result []Placement | |||
| 379 | for _, w := range m.all() { | 464 | for _, w := range m.all() { | ||
| 380 | ry, shown := rows[w] | 465 | ry, shown := rows[w] | ||
| 381 | if shown && w.pinned() { | 466 | if shown && w.pinned() { | ||
| @@ -716,15 +801,8 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||||
| 716 | return true, c.publish(ctx) | 801 | return true, c.publish(ctx) | ||
| 717 | } | 802 | } | ||
| 718 | 803 | ||||
| 719 | - | // overviewSwipe moves the selection like the keys: a workspace swipe | 804 | + | // overviewSwipe moves the selection like the keys, through stacks and rows. |
| 720 | - | // must not enter the empty workspace below the last one. | |||
| 721 | func (m *Monitor) overviewSwipe(a Action) { | 805 | func (m *Monitor) overviewSwipe(a Action) { | ||
| 722 | - | switch a { | |||
| 723 | - | case ActionFocusWorkspaceUp: | |||
| 724 | - | a = ActionFocusWindowUp | |||
| 725 | - | case ActionFocusWorkspaceDown: | |||
| 726 | - | a = ActionFocusWindowDown | |||
| 727 | - | } | |||
| 728 | m.overviewFocus(a) | 806 | m.overviewFocus(a) | ||
| 729 | } | 807 | } | ||
| 730 | 808 | ||||
| @@ -737,9 +815,9 @@ func (m *Monitor) overviewFocus(a Action) bool { | |||||
| 737 | m.OverviewMove(-1, 0) | 815 | m.OverviewMove(-1, 0) | ||
| 738 | case ActionFocusColumnRight: | 816 | case ActionFocusColumnRight: | ||
| 739 | m.OverviewMove(1, 0) | 817 | m.OverviewMove(1, 0) | ||
| 740 | - | case ActionFocusWindowUp: | 818 | + | case ActionFocusWindowUp, ActionFocusWorkspaceUp: |
| 741 | m.OverviewMove(0, -1) | 819 | m.OverviewMove(0, -1) | ||
| 742 | - | case ActionFocusWindowDown: | 820 | + | case ActionFocusWindowDown, ActionFocusWorkspaceDown: |
| 743 | m.OverviewMove(0, 1) | 821 | m.OverviewMove(0, 1) | ||
| 744 | default: | 822 | default: | ||
| 745 | return false | 823 | return false | ||
A internal/core/overview_named_test.go +421 -0
| @@ -0,0 +1,421 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "slices" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func namedOverviewMonitor() *Monitor { | |
| 12 | + | m := overviewMonitor() | |
| 13 | + | m.SetNamed([]NamedWorkspace{{Name: "dev"}, {Name: "empty"}, {Name: "game"}}) | |
| 14 | + | m.Focus(1) | |
| 15 | + | m.ToggleNamed("dev") | |
| 16 | + | m.AddWindow(5) | |
| 17 | + | m.AddWindow(6) | |
| 18 | + | m.Current().ToggleWindowStash() | |
| 19 | + | m.ToggleNamed("game") | |
| 20 | + | m.AddWindow(7) | |
| 21 | + | m.Focus(1) | |
| 22 | + | return m | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) { | |
| 26 | + | m := namedOverviewMonitor() | |
| 27 | + | numbered := slices.Clone(m.Workspaces) | |
| 28 | + | m.Focus(0) | |
| 29 | + | m.ToggleNamed("dev") | |
| 30 | + | m.ToggleNamed("dev") | |
| 31 | + | m.ToggleOverview() | |
| 32 | + | if !slices.Equal(m.overviewWorkspaces(), []*Workspace{numbered[0], m.byName("dev"), numbered[1], m.byName("game")}) { | |
| 33 | + | t.Fatal("named workspace not directly below its invocation workspace") | |
| 34 | + | } | |
| 35 | + | m.OverviewMove(0, 1) | |
| 36 | + | if m.Current().Name != "dev" { | |
| 37 | + | t.Fatal("down from workspace 1 did not reach invoked named workspace") | |
| 38 | + | } | |
| 39 | + | m.OverviewMove(0, 1) | |
| 40 | + | if m.Current() != numbered[1] { | |
| 41 | + | t.Fatal("down from named did not resume numbered sequence") | |
| 42 | + | } | |
| 43 | + | m.CancelOverview() | |
| 44 | + | m.Focus(1) | |
| 45 | + | m.ToggleNamed("dev") | |
| 46 | + | m.ToggleNamed("dev") | |
| 47 | + | m.ToggleOverview() | |
| 48 | + | if !slices.Equal(m.overviewWorkspaces(), []*Workspace{numbered[0], numbered[1], m.byName("dev"), m.byName("game")}) { | |
| 49 | + | t.Fatal("calling named workspace elsewhere did not update its placement") | |
| 50 | + | } | |
| 51 | + | if !slices.Equal(m.Workspaces, numbered) { | |
| 52 | + | t.Fatal("overview attachment changed workspace numbering") | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestOverviewNamedNavigationDoesNotReattach(t *testing.T) { | |
| 57 | + | m := namedOverviewMonitor() | |
| 58 | + | m.Focus(0) | |
| 59 | + | m.ToggleNamed("dev") | |
| 60 | + | m.ToggleNamed("dev") | |
| 61 | + | m.ToggleOverview() | |
| 62 | + | before := slices.Clone(m.overviewWorkspaces()) | |
| 63 | + | m.OverviewMove(0, 1) | |
| 64 | + | m.OverviewMove(0, 1) | |
| 65 | + | m.OverviewMove(0, -1) | |
| 66 | + | if !slices.Equal(m.overviewWorkspaces(), before) || m.Current().Name != "dev" { | |
| 67 | + | t.Fatal("browsing from below changed named attachment") | |
| 68 | + | } | |
| 69 | + | _, dividers := m.overviewRows() | |
| 70 | + | if len(dividers) != 2 { | |
| 71 | + | t.Fatalf("named group needs upper and lower dividers: %+v", dividers) | |
| 72 | + | } | |
| 73 | + | m.ToggleOverview() | |
| 74 | + | m.ToggleNamed("dev") | |
| 75 | + | if m.Current() != before[0] { | |
| 76 | + | t.Fatal("entering named from below lost invocation return") | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestOverviewNamedAnchorRemoved(t *testing.T) { | |
| 81 | + | m := namedOverviewMonitor() | |
| 82 | + | m.Focus(0) | |
| 83 | + | m.take(m.Workspaces[1]) | |
| 84 | + | m.ToggleOverview() | |
| 85 | + | rows := m.overviewWorkspaces() | |
| 86 | + | if !slices.Equal(rows, []*Workspace{m.Workspaces[0], m.byName("dev"), m.byName("game")}) { | |
| 87 | + | t.Fatal("removed anchor left named rows unreachable") | |
| 88 | + | } | |
| 89 | + | m.OverviewMove(0, 1) | |
| 90 | + | if m.Current().Name != "dev" { | |
| 91 | + | t.Fatal("orphan named row not reachable") | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestOverviewNamedAnchorSurvivesConfigAndMonitorMove(t *testing.T) { | |
| 96 | + | m := namedOverviewMonitor() | |
| 97 | + | anchor, dev, game := m.Workspaces[1], m.byName("dev"), m.byName("game") | |
| 98 | + | m.SetNamed([]NamedWorkspace{{Name: "game"}, {Name: "dev"}}) | |
| 99 | + | if !slices.Equal(m.overviewWorkspaces(), []*Workspace{m.Workspaces[0], anchor, game, dev}) { | |
| 100 | + | t.Fatal("config reorder lost attachment or ignored named order") | |
| 101 | + | } | |
| 102 | + | m.Focus(0) | |
| 103 | + | m.take(anchor) | |
| 104 | + | m.take(dev) | |
| 105 | + | m.take(game) | |
| 106 | + | dst := newMonitor("", "") | |
| 107 | + | dst.SetOutput(300, 200) | |
| 108 | + | dst.useSpecs([]NamedWorkspace{{Name: "dev"}, {Name: "game"}}) | |
| 109 | + | dst.adopt(anchor, false, 0) | |
| 110 | + | dst.adopt(game, true, 0) | |
| 111 | + | dst.adopt(dev, true, 0) | |
| 112 | + | rows := dst.overviewWorkspaces() | |
| 113 | + | if at := slices.Index(rows, anchor); at < 0 || at+2 >= len(rows) || rows[at+1] != dev || rows[at+2] != game { | |
| 114 | + | t.Fatal("moved named workspaces not reachable on destination monitor") | |
| 115 | + | } | |
| 116 | + | if dev.overviewAfter != nil && !dst.has(dev.overviewAfter) { | |
| 117 | + | t.Fatal("named workspace retained detached monitor anchor") | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestOverviewNamedAnchorSurvivesReplug(t *testing.T) { | |
| 122 | + | cfg := ports.Config{} | |
| 123 | + | cfg.Layout.MaxColumns = 3 | |
| 124 | + | cfg.Keyboard.CmdKey = "super" | |
| 125 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Monitor: "OUT-1"}} | |
| 126 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}) | |
| 127 | + | if err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}) | |
| 131 | + | home := c.cur() | |
| 132 | + | home.mon.SetNamed([]NamedWorkspace{{Name: "dev"}}) | |
| 133 | + | home.mon.AddWindow(1) | |
| 134 | + | anchor := home.mon.Current() | |
| 135 | + | home.mon.ToggleNamed("dev") | |
| 136 | + | home.mon.AddWindow(2) | |
| 137 | + | dev := home.mon.Current() | |
| 138 | + | home.mon.Focus(1) | |
| 139 | + | home.mon.AddWindow(3) // a later row makes bottom fallback detectably wrong | |
| 140 | + | c.addScreen(ports.OutputInfo{Name: "OUT-2", Width: 300, Height: 200}) | |
| 141 | + | c.removeScreen("OUT-1") | |
| 142 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}) | |
| 143 | + | c.settleGuests() | |
| 144 | + | for _, sc := range c.screens { | |
| 145 | + | if sc.name() != "OUT-1" { | |
| 146 | + | continue | |
| 147 | + | } | |
| 148 | + | if !sc.mon.has(dev) || dev.overviewAfter != anchor { | |
| 149 | + | t.Fatal("replug lost named invocation anchor") | |
| 150 | + | } | |
| 151 | + | rows := sc.mon.overviewWorkspaces() | |
| 152 | + | at := slices.Index(rows, anchor) | |
| 153 | + | if at < 0 || at+1 >= len(rows) || rows[at+1] != dev { | |
| 154 | + | t.Fatal("replug did not restore named row below invocation workspace") | |
| 155 | + | } | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | t.Fatal("missing replugged screen") | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestOverviewNamedEmptyAnchor(t *testing.T) { | |
| 162 | + | m := namedOverviewMonitor() | |
| 163 | + | m.Focus(len(m.Workspaces) - 1) | |
| 164 | + | anchor := m.Current() | |
| 165 | + | m.ToggleNamed("dev") | |
| 166 | + | m.ToggleOverview() | |
| 167 | + | rows := m.overviewWorkspaces() | |
| 168 | + | if at := slices.Index(rows, anchor); at < 0 || at+1 >= len(rows) || rows[at+1] != m.byName("dev") { | |
| 169 | + | t.Fatal("empty invocation workspace missing above named row") | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestOverviewNamedNavigation(t *testing.T) { | |
| 174 | + | m := namedOverviewMonitor() | |
| 175 | + | numbered := slices.Clone(m.Workspaces) | |
| 176 | + | m.ToggleOverview() | |
| 177 | + | m.OverviewMove(0, 1) | |
| 178 | + | if m.Current().Name != "dev" { | |
| 179 | + | t.Fatalf("down from last occupied numbered workspace: %q", m.Current().Name) | |
| 180 | + | } | |
| 181 | + | if id := m.card(); id != 6 { | |
| 182 | + | t.Fatalf("named stash selection: %d", id) | |
| 183 | + | } | |
| 184 | + | m.OverviewMove(0, 1) | |
| 185 | + | if m.Current().Name != "game" || m.card() != 0 { | |
| 186 | + | t.Fatalf("next named workspace: %q, card %d", m.Current().Name, m.card()) | |
| 187 | + | } | |
| 188 | + | m.OverviewMove(0, 1) | |
| 189 | + | if m.Current().Name != "game" { | |
| 190 | + | t.Fatal("past last named workspace") | |
| 191 | + | } | |
| 192 | + | m.OverviewMove(0, -1) | |
| 193 | + | m.OverviewMove(0, -1) | |
| 194 | + | if m.Current() != numbered[1] || !slices.Equal(m.Workspaces, numbered) { | |
| 195 | + | t.Fatal("return to numbered workspaces changed numbering") | |
| 196 | + | } | |
| 197 | + | m.CancelOverview() | |
| 198 | + | m.Focus(0) | |
| 199 | + | m.ToggleOverview() | |
| 200 | + | _, dividers := m.overviewRows() | |
| 201 | + | if len(dividers) != 0 { | |
| 202 | + | t.Fatalf("divider within numbered group: %+v", dividers) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestOverviewNamedRowsKeepWidthAndStash(t *testing.T) { | |
| 207 | + | m := namedOverviewMonitor() | |
| 208 | + | m.ToggleOverview() | |
| 209 | + | _, dividers := m.overviewRows() | |
| 210 | + | if len(dividers) != 1 || dividers[0].Active || dividers[0].Rect.W != 276 { | |
| 211 | + | t.Fatalf("group divider: %+v", dividers) | |
| 212 | + | } | |
| 213 | + | below := previewOf(t, m.Layout(), 5) | |
| 214 | + | pile := previewOf(t, m.Layout(), 6) | |
| 215 | + | if below.Hidden || !below.Peek || pile.Hidden || !pile.Peek { | |
| 216 | + | t.Fatalf("named neighbor and stash not visible: %+v, %+v", below, pile) | |
| 217 | + | } | |
| 218 | + | if pile.Preview != overviewCardZoom { | |
| 219 | + | t.Fatalf("stash zoom %v", pile.Preview) | |
| 220 | + | } | |
| 221 | + | m.OverviewMove(0, 1) | |
| 222 | + | selected := previewOf(t, m.Layout(), 5) | |
| 223 | + | if selected.Hidden || selected.Rect.X != below.Rect.X || selected.Rect.W != below.Rect.W || selected.Preview != below.Preview { | |
| 224 | + | t.Fatalf("named row geometry changed: %+v, %+v", below, selected) | |
| 225 | + | } | |
| 226 | + | above := previewOf(t, m.Layout(), 4) | |
| 227 | + | if above.Hidden || !above.Peek || above.Rect.Y >= selected.Rect.Y { | |
| 228 | + | t.Fatalf("numbered neighbor above named: %+v", above) | |
| 229 | + | } | |
| 230 | + | _, dividers = m.overviewRows() | |
| 231 | + | if len(dividers) != 1 || dividers[0].Rect.Y <= above.Rect.Y+above.Rect.H || dividers[0].Rect.Y >= selected.Rect.Y { | |
| 232 | + | t.Fatalf("divider not between groups: %+v", dividers) | |
| 233 | + | } | |
| 234 | + | game := previewOf(t, m.Layout(), 7) | |
| 235 | + | if game.Hidden || !game.Peek || game.Rect.Y <= selected.Rect.Y { | |
| 236 | + | t.Fatalf("next named neighbor: %+v", game) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestOverviewNamedCancelAndToggleReturn(t *testing.T) { | |
| 241 | + | m := namedOverviewMonitor() | |
| 242 | + | m.ToggleNamed("dev") | |
| 243 | + | back := m.back | |
| 244 | + | m.ToggleOverview() | |
| 245 | + | m.OverviewMove(0, 1) | |
| 246 | + | m.CancelOverview() | |
| 247 | + | if m.Current().Name != "dev" || m.back != back { | |
| 248 | + | t.Fatal("Escape did not restore named workspace and its return target") | |
| 249 | + | } | |
| 250 | + | m.Focus(1) | |
| 251 | + | m.ToggleOverview() | |
| 252 | + | m.OverviewMove(0, 1) | |
| 253 | + | m.ToggleOverview() | |
| 254 | + | m.ToggleNamed("dev") | |
| 255 | + | if m.Current() != back { | |
| 256 | + | t.Fatal("named toggle did not return to overview entry workspace") | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestOverviewNamedBrowseKeepsNumberedReturn(t *testing.T) { | |
| 261 | + | for _, click := range []bool{false, true} { | |
| 262 | + | m := namedOverviewMonitor() | |
| 263 | + | back := m.Current() | |
| 264 | + | m.ToggleOverview() | |
| 265 | + | m.OverviewMove(0, 1) | |
| 266 | + | if click { | |
| 267 | + | m.OverviewPick(7) | |
| 268 | + | } else { | |
| 269 | + | m.OverviewMove(0, 1) | |
| 270 | + | m.ToggleOverview() | |
| 271 | + | } | |
| 272 | + | if m.Current().Name != "game" || m.back != back { | |
| 273 | + | t.Fatalf("click %v: browsing lost numbered return", click) | |
| 274 | + | } | |
| 275 | + | m.ToggleNamed("game") | |
| 276 | + | if m.Current() != back { | |
| 277 | + | t.Fatalf("click %v: toggle did not return to numbered workspace", click) | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func TestOverviewWorkspaceBindKeepsCurrentSelection(t *testing.T) { | |
| 283 | + | m := maximizedOverview() | |
| 284 | + | m.ToggleOverview() | |
| 285 | + | m.OverviewMove(0, -1) | |
| 286 | + | m.OverviewMove(1, 0) | |
| 287 | + | front, selected := m.stackFront(m.Current()), m.overviewTarget() | |
| 288 | + | if front.kind != stackColumns || selected != 3 { | |
| 289 | + | t.Fatal("expected provisional hidden column 3") | |
| 290 | + | } | |
| 291 | + | for _, action := range []Action{"focus-workspace 1", "workspace unknown"} { | |
| 292 | + | m.Apply(action) | |
| 293 | + | if m.stackFront(m.Current()) != front || m.overviewTarget() != selected { | |
| 294 | + | t.Fatalf("%s reset provisional selection", action) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func TestOverviewNamedNeighborPick(t *testing.T) { | |
| 300 | + | m := namedOverviewMonitor() | |
| 301 | + | back := m.Current() | |
| 302 | + | m.ToggleOverview() | |
| 303 | + | p := previewOf(t, m.Layout(), 6) | |
| 304 | + | if p.Hidden { | |
| 305 | + | t.Fatal("named stash neighbor hidden") | |
| 306 | + | } | |
| 307 | + | id := m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) | |
| 308 | + | if id != 6 { | |
| 309 | + | t.Fatalf("stash hit: %d", id) | |
| 310 | + | } | |
| 311 | + | m.OverviewPick(id) | |
| 312 | + | if m.ov.open || m.Current().Name != "dev" || m.back != back || !m.Current().stashFocused() { | |
| 313 | + | t.Fatal("named stash click did not commit selection and return target") | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func TestOverviewNamedFocusBinds(t *testing.T) { | |
| 318 | + | for _, action := range []Action{ActionFocusWindowDown, ActionFocusWorkspaceDown} { | |
| 319 | + | t.Run(string(action), func(t *testing.T) { | |
| 320 | + | m := namedOverviewMonitor() | |
| 321 | + | m.ToggleOverview() | |
| 322 | + | m.Apply(action) | |
| 323 | + | if m.Current().Name != "dev" { | |
| 324 | + | t.Fatalf("focus bind selected %q", m.Current().Name) | |
| 325 | + | } | |
| 326 | + | }) | |
| 327 | + | } | |
| 328 | + | m := namedOverviewMonitor() | |
| 329 | + | m.ToggleOverview() | |
| 330 | + | m.Apply(Action("workspace dev")) | |
| 331 | + | if m.card() != 6 { | |
| 332 | + | t.Fatal("named bind did not initialize stash selection") | |
| 333 | + | } | |
| 334 | + | m.Apply(Action("focus-workspace 2")) | |
| 335 | + | if m.card() != 0 || m.ov.row != nil { | |
| 336 | + | t.Fatal("numbered bind retained previous row selection") | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func TestOverviewNamedOutsideNavigationUnchanged(t *testing.T) { | |
| 341 | + | m := namedOverviewMonitor() | |
| 342 | + | m.Apply(ActionFocusWorkspaceDown) | |
| 343 | + | if m.shown != nil || !m.Current().empty() { | |
| 344 | + | t.Fatal("outside overview, down no longer reaches numbered spare") | |
| 345 | + | } | |
| 346 | + | m.ToggleNamed("dev") | |
| 347 | + | m.Apply(ActionFocusWorkspaceUp) | |
| 348 | + | if m.Current().Name != "dev" { | |
| 349 | + | t.Fatal("outside overview, named workspace entered numbered list") | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func TestOverviewNamedEmptyCurrentAndRemoval(t *testing.T) { | |
| 354 | + | m := namedOverviewMonitor() | |
| 355 | + | m.ToggleNamed("empty") | |
| 356 | + | m.ToggleOverview() | |
| 357 | + | if !m.Current().empty() { | |
| 358 | + | t.Fatal("expected empty named current") | |
| 359 | + | } | |
| 360 | + | m.OverviewMove(0, 1) | |
| 361 | + | if m.Current().Name != "game" { | |
| 362 | + | t.Fatal("could not leave empty named row") | |
| 363 | + | } | |
| 364 | + | m.SetNamed([]NamedWorkspace{{Name: "game"}}) | |
| 365 | + | m.CancelOverview() | |
| 366 | + | if !m.has(m.Current()) || m.back != nil && !m.has(m.back) { | |
| 367 | + | t.Fatal("config removal left dangling overview return") | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func TestOverviewNamedColumnPickClearsStash(t *testing.T) { | |
| 372 | + | m := namedOverviewMonitor() | |
| 373 | + | m.ToggleNamed("dev") | |
| 374 | + | m.ToggleOverview() | |
| 375 | + | if m.card() != 6 { | |
| 376 | + | t.Fatal("expected stash selection") | |
| 377 | + | } | |
| 378 | + | m.OverviewPick(5) | |
| 379 | + | if m.Current().stashFocused() || m.ov.open { | |
| 380 | + | t.Fatal("column click kept stash selection") | |
| 381 | + | } | |
| 382 | + | id, _ := m.Focused() | |
| 383 | + | if id != 5 { | |
| 384 | + | t.Fatalf("column click focus %d", id) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func TestOverviewNamedDividerPublished(t *testing.T) { | |
| 389 | + | scenes := make(chan []ports.Scene, 1) | |
| 390 | + | cfg := ports.Config{} | |
| 391 | + | cfg.Keyboard.CmdKey = "super" | |
| 392 | + | cfg.Layout.MaxColumns = 3 | |
| 393 | + | c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 256), Scenes: scenes}) | |
| 394 | + | if err != nil { | |
| 395 | + | t.Fatal(err) | |
| 396 | + | } | |
| 397 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}) | |
| 398 | + | c.cur().mon = namedOverviewMonitor() | |
| 399 | + | c.cur().mon.ToggleOverview() | |
| 400 | + | if err := c.publish(context.Background()); err != nil { | |
| 401 | + | t.Fatal(err) | |
| 402 | + | } | |
| 403 | + | scene := (<-scenes)[0] | |
| 404 | + | _, dividers := c.cur().mon.overviewRows() | |
| 405 | + | if len(dividers) != 1 || !slices.Contains(scene.Separators, dividers[0]) { | |
| 406 | + | t.Fatalf("group divider not published: %+v", scene.Separators) | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func TestOverviewNamedScrollAndSwipe(t *testing.T) { | |
| 411 | + | m := namedOverviewMonitor() | |
| 412 | + | m.ToggleOverview() | |
| 413 | + | m.overviewSwipe(ActionFocusWorkspaceDown) | |
| 414 | + | if m.Current().Name != "dev" { | |
| 415 | + | t.Fatal("swipe did not cross groups") | |
| 416 | + | } | |
| 417 | + | m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}}) | |
| 418 | + | if m.Current().Name != "game" { | |
| 419 | + | t.Fatal("scroll did not reach next named workspace") | |
| 420 | + | } | |
| 421 | + | } | |
| @@ -0,0 +1,421 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "slices" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func namedOverviewMonitor() *Monitor { | |||
| 12 | + | m := overviewMonitor() | |||
| 13 | + | m.SetNamed([]NamedWorkspace{{Name: "dev"}, {Name: "empty"}, {Name: "game"}}) | |||
| 14 | + | m.Focus(1) | |||
| 15 | + | m.ToggleNamed("dev") | |||
| 16 | + | m.AddWindow(5) | |||
| 17 | + | m.AddWindow(6) | |||
| 18 | + | m.Current().ToggleWindowStash() | |||
| 19 | + | m.ToggleNamed("game") | |||
| 20 | + | m.AddWindow(7) | |||
| 21 | + | m.Focus(1) | |||
| 22 | + | return m | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) { | |||
| 26 | + | m := namedOverviewMonitor() | |||
| 27 | + | numbered := slices.Clone(m.Workspaces) | |||
| 28 | + | m.Focus(0) | |||
| 29 | + | m.ToggleNamed("dev") | |||
| 30 | + | m.ToggleNamed("dev") | |||
| 31 | + | m.ToggleOverview() | |||
| 32 | + | if !slices.Equal(m.overviewWorkspaces(), []*Workspace{numbered[0], m.byName("dev"), numbered[1], m.byName("game")}) { | |||
| 33 | + | t.Fatal("named workspace not directly below its invocation workspace") | |||
| 34 | + | } | |||
| 35 | + | m.OverviewMove(0, 1) | |||
| 36 | + | if m.Current().Name != "dev" { | |||
| 37 | + | t.Fatal("down from workspace 1 did not reach invoked named workspace") | |||
| 38 | + | } | |||
| 39 | + | m.OverviewMove(0, 1) | |||
| 40 | + | if m.Current() != numbered[1] { | |||
| 41 | + | t.Fatal("down from named did not resume numbered sequence") | |||
| 42 | + | } | |||
| 43 | + | m.CancelOverview() | |||
| 44 | + | m.Focus(1) | |||
| 45 | + | m.ToggleNamed("dev") | |||
| 46 | + | m.ToggleNamed("dev") | |||
| 47 | + | m.ToggleOverview() | |||
| 48 | + | if !slices.Equal(m.overviewWorkspaces(), []*Workspace{numbered[0], numbered[1], m.byName("dev"), m.byName("game")}) { | |||
| 49 | + | t.Fatal("calling named workspace elsewhere did not update its placement") | |||
| 50 | + | } | |||
| 51 | + | if !slices.Equal(m.Workspaces, numbered) { | |||
| 52 | + | t.Fatal("overview attachment changed workspace numbering") | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestOverviewNamedNavigationDoesNotReattach(t *testing.T) { | |||
| 57 | + | m := namedOverviewMonitor() | |||
| 58 | + | m.Focus(0) | |||
| 59 | + | m.ToggleNamed("dev") | |||
| 60 | + | m.ToggleNamed("dev") | |||
| 61 | + | m.ToggleOverview() | |||
| 62 | + | before := slices.Clone(m.overviewWorkspaces()) | |||
| 63 | + | m.OverviewMove(0, 1) | |||
| 64 | + | m.OverviewMove(0, 1) | |||
| 65 | + | m.OverviewMove(0, -1) | |||
| 66 | + | if !slices.Equal(m.overviewWorkspaces(), before) || m.Current().Name != "dev" { | |||
| 67 | + | t.Fatal("browsing from below changed named attachment") | |||
| 68 | + | } | |||
| 69 | + | _, dividers := m.overviewRows() | |||
| 70 | + | if len(dividers) != 2 { | |||
| 71 | + | t.Fatalf("named group needs upper and lower dividers: %+v", dividers) | |||
| 72 | + | } | |||
| 73 | + | m.ToggleOverview() | |||
| 74 | + | m.ToggleNamed("dev") | |||
| 75 | + | if m.Current() != before[0] { | |||
| 76 | + | t.Fatal("entering named from below lost invocation return") | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestOverviewNamedAnchorRemoved(t *testing.T) { | |||
| 81 | + | m := namedOverviewMonitor() | |||
| 82 | + | m.Focus(0) | |||
| 83 | + | m.take(m.Workspaces[1]) | |||
| 84 | + | m.ToggleOverview() | |||
| 85 | + | rows := m.overviewWorkspaces() | |||
| 86 | + | if !slices.Equal(rows, []*Workspace{m.Workspaces[0], m.byName("dev"), m.byName("game")}) { | |||
| 87 | + | t.Fatal("removed anchor left named rows unreachable") | |||
| 88 | + | } | |||
| 89 | + | m.OverviewMove(0, 1) | |||
| 90 | + | if m.Current().Name != "dev" { | |||
| 91 | + | t.Fatal("orphan named row not reachable") | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestOverviewNamedAnchorSurvivesConfigAndMonitorMove(t *testing.T) { | |||
| 96 | + | m := namedOverviewMonitor() | |||
| 97 | + | anchor, dev, game := m.Workspaces[1], m.byName("dev"), m.byName("game") | |||
| 98 | + | m.SetNamed([]NamedWorkspace{{Name: "game"}, {Name: "dev"}}) | |||
| 99 | + | if !slices.Equal(m.overviewWorkspaces(), []*Workspace{m.Workspaces[0], anchor, game, dev}) { | |||
| 100 | + | t.Fatal("config reorder lost attachment or ignored named order") | |||
| 101 | + | } | |||
| 102 | + | m.Focus(0) | |||
| 103 | + | m.take(anchor) | |||
| 104 | + | m.take(dev) | |||
| 105 | + | m.take(game) | |||
| 106 | + | dst := newMonitor("", "") | |||
| 107 | + | dst.SetOutput(300, 200) | |||
| 108 | + | dst.useSpecs([]NamedWorkspace{{Name: "dev"}, {Name: "game"}}) | |||
| 109 | + | dst.adopt(anchor, false, 0) | |||
| 110 | + | dst.adopt(game, true, 0) | |||
| 111 | + | dst.adopt(dev, true, 0) | |||
| 112 | + | rows := dst.overviewWorkspaces() | |||
| 113 | + | if at := slices.Index(rows, anchor); at < 0 || at+2 >= len(rows) || rows[at+1] != dev || rows[at+2] != game { | |||
| 114 | + | t.Fatal("moved named workspaces not reachable on destination monitor") | |||
| 115 | + | } | |||
| 116 | + | if dev.overviewAfter != nil && !dst.has(dev.overviewAfter) { | |||
| 117 | + | t.Fatal("named workspace retained detached monitor anchor") | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestOverviewNamedAnchorSurvivesReplug(t *testing.T) { | |||
| 122 | + | cfg := ports.Config{} | |||
| 123 | + | cfg.Layout.MaxColumns = 3 | |||
| 124 | + | cfg.Keyboard.CmdKey = "super" | |||
| 125 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Monitor: "OUT-1"}} | |||
| 126 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}) | |||
| 127 | + | if err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}) | |||
| 131 | + | home := c.cur() | |||
| 132 | + | home.mon.SetNamed([]NamedWorkspace{{Name: "dev"}}) | |||
| 133 | + | home.mon.AddWindow(1) | |||
| 134 | + | anchor := home.mon.Current() | |||
| 135 | + | home.mon.ToggleNamed("dev") | |||
| 136 | + | home.mon.AddWindow(2) | |||
| 137 | + | dev := home.mon.Current() | |||
| 138 | + | home.mon.Focus(1) | |||
| 139 | + | home.mon.AddWindow(3) // a later row makes bottom fallback detectably wrong | |||
| 140 | + | c.addScreen(ports.OutputInfo{Name: "OUT-2", Width: 300, Height: 200}) | |||
| 141 | + | c.removeScreen("OUT-1") | |||
| 142 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}) | |||
| 143 | + | c.settleGuests() | |||
| 144 | + | for _, sc := range c.screens { | |||
| 145 | + | if sc.name() != "OUT-1" { | |||
| 146 | + | continue | |||
| 147 | + | } | |||
| 148 | + | if !sc.mon.has(dev) || dev.overviewAfter != anchor { | |||
| 149 | + | t.Fatal("replug lost named invocation anchor") | |||
| 150 | + | } | |||
| 151 | + | rows := sc.mon.overviewWorkspaces() | |||
| 152 | + | at := slices.Index(rows, anchor) | |||
| 153 | + | if at < 0 || at+1 >= len(rows) || rows[at+1] != dev { | |||
| 154 | + | t.Fatal("replug did not restore named row below invocation workspace") | |||
| 155 | + | } | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | t.Fatal("missing replugged screen") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestOverviewNamedEmptyAnchor(t *testing.T) { | |||
| 162 | + | m := namedOverviewMonitor() | |||
| 163 | + | m.Focus(len(m.Workspaces) - 1) | |||
| 164 | + | anchor := m.Current() | |||
| 165 | + | m.ToggleNamed("dev") | |||
| 166 | + | m.ToggleOverview() | |||
| 167 | + | rows := m.overviewWorkspaces() | |||
| 168 | + | if at := slices.Index(rows, anchor); at < 0 || at+1 >= len(rows) || rows[at+1] != m.byName("dev") { | |||
| 169 | + | t.Fatal("empty invocation workspace missing above named row") | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestOverviewNamedNavigation(t *testing.T) { | |||
| 174 | + | m := namedOverviewMonitor() | |||
| 175 | + | numbered := slices.Clone(m.Workspaces) | |||
| 176 | + | m.ToggleOverview() | |||
| 177 | + | m.OverviewMove(0, 1) | |||
| 178 | + | if m.Current().Name != "dev" { | |||
| 179 | + | t.Fatalf("down from last occupied numbered workspace: %q", m.Current().Name) | |||
| 180 | + | } | |||
| 181 | + | if id := m.card(); id != 6 { | |||
| 182 | + | t.Fatalf("named stash selection: %d", id) | |||
| 183 | + | } | |||
| 184 | + | m.OverviewMove(0, 1) | |||
| 185 | + | if m.Current().Name != "game" || m.card() != 0 { | |||
| 186 | + | t.Fatalf("next named workspace: %q, card %d", m.Current().Name, m.card()) | |||
| 187 | + | } | |||
| 188 | + | m.OverviewMove(0, 1) | |||
| 189 | + | if m.Current().Name != "game" { | |||
| 190 | + | t.Fatal("past last named workspace") | |||
| 191 | + | } | |||
| 192 | + | m.OverviewMove(0, -1) | |||
| 193 | + | m.OverviewMove(0, -1) | |||
| 194 | + | if m.Current() != numbered[1] || !slices.Equal(m.Workspaces, numbered) { | |||
| 195 | + | t.Fatal("return to numbered workspaces changed numbering") | |||
| 196 | + | } | |||
| 197 | + | m.CancelOverview() | |||
| 198 | + | m.Focus(0) | |||
| 199 | + | m.ToggleOverview() | |||
| 200 | + | _, dividers := m.overviewRows() | |||
| 201 | + | if len(dividers) != 0 { | |||
| 202 | + | t.Fatalf("divider within numbered group: %+v", dividers) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestOverviewNamedRowsKeepWidthAndStash(t *testing.T) { | |||
| 207 | + | m := namedOverviewMonitor() | |||
| 208 | + | m.ToggleOverview() | |||
| 209 | + | _, dividers := m.overviewRows() | |||
| 210 | + | if len(dividers) != 1 || dividers[0].Active || dividers[0].Rect.W != 276 { | |||
| 211 | + | t.Fatalf("group divider: %+v", dividers) | |||
| 212 | + | } | |||
| 213 | + | below := previewOf(t, m.Layout(), 5) | |||
| 214 | + | pile := previewOf(t, m.Layout(), 6) | |||
| 215 | + | if below.Hidden || !below.Peek || pile.Hidden || !pile.Peek { | |||
| 216 | + | t.Fatalf("named neighbor and stash not visible: %+v, %+v", below, pile) | |||
| 217 | + | } | |||
| 218 | + | if pile.Preview != overviewCardZoom { | |||
| 219 | + | t.Fatalf("stash zoom %v", pile.Preview) | |||
| 220 | + | } | |||
| 221 | + | m.OverviewMove(0, 1) | |||
| 222 | + | selected := previewOf(t, m.Layout(), 5) | |||
| 223 | + | if selected.Hidden || selected.Rect.X != below.Rect.X || selected.Rect.W != below.Rect.W || selected.Preview != below.Preview { | |||
| 224 | + | t.Fatalf("named row geometry changed: %+v, %+v", below, selected) | |||
| 225 | + | } | |||
| 226 | + | above := previewOf(t, m.Layout(), 4) | |||
| 227 | + | if above.Hidden || !above.Peek || above.Rect.Y >= selected.Rect.Y { | |||
| 228 | + | t.Fatalf("numbered neighbor above named: %+v", above) | |||
| 229 | + | } | |||
| 230 | + | _, dividers = m.overviewRows() | |||
| 231 | + | if len(dividers) != 1 || dividers[0].Rect.Y <= above.Rect.Y+above.Rect.H || dividers[0].Rect.Y >= selected.Rect.Y { | |||
| 232 | + | t.Fatalf("divider not between groups: %+v", dividers) | |||
| 233 | + | } | |||
| 234 | + | game := previewOf(t, m.Layout(), 7) | |||
| 235 | + | if game.Hidden || !game.Peek || game.Rect.Y <= selected.Rect.Y { | |||
| 236 | + | t.Fatalf("next named neighbor: %+v", game) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestOverviewNamedCancelAndToggleReturn(t *testing.T) { | |||
| 241 | + | m := namedOverviewMonitor() | |||
| 242 | + | m.ToggleNamed("dev") | |||
| 243 | + | back := m.back | |||
| 244 | + | m.ToggleOverview() | |||
| 245 | + | m.OverviewMove(0, 1) | |||
| 246 | + | m.CancelOverview() | |||
| 247 | + | if m.Current().Name != "dev" || m.back != back { | |||
| 248 | + | t.Fatal("Escape did not restore named workspace and its return target") | |||
| 249 | + | } | |||
| 250 | + | m.Focus(1) | |||
| 251 | + | m.ToggleOverview() | |||
| 252 | + | m.OverviewMove(0, 1) | |||
| 253 | + | m.ToggleOverview() | |||
| 254 | + | m.ToggleNamed("dev") | |||
| 255 | + | if m.Current() != back { | |||
| 256 | + | t.Fatal("named toggle did not return to overview entry workspace") | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestOverviewNamedBrowseKeepsNumberedReturn(t *testing.T) { | |||
| 261 | + | for _, click := range []bool{false, true} { | |||
| 262 | + | m := namedOverviewMonitor() | |||
| 263 | + | back := m.Current() | |||
| 264 | + | m.ToggleOverview() | |||
| 265 | + | m.OverviewMove(0, 1) | |||
| 266 | + | if click { | |||
| 267 | + | m.OverviewPick(7) | |||
| 268 | + | } else { | |||
| 269 | + | m.OverviewMove(0, 1) | |||
| 270 | + | m.ToggleOverview() | |||
| 271 | + | } | |||
| 272 | + | if m.Current().Name != "game" || m.back != back { | |||
| 273 | + | t.Fatalf("click %v: browsing lost numbered return", click) | |||
| 274 | + | } | |||
| 275 | + | m.ToggleNamed("game") | |||
| 276 | + | if m.Current() != back { | |||
| 277 | + | t.Fatalf("click %v: toggle did not return to numbered workspace", click) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func TestOverviewWorkspaceBindKeepsCurrentSelection(t *testing.T) { | |||
| 283 | + | m := maximizedOverview() | |||
| 284 | + | m.ToggleOverview() | |||
| 285 | + | m.OverviewMove(0, -1) | |||
| 286 | + | m.OverviewMove(1, 0) | |||
| 287 | + | front, selected := m.stackFront(m.Current()), m.overviewTarget() | |||
| 288 | + | if front.kind != stackColumns || selected != 3 { | |||
| 289 | + | t.Fatal("expected provisional hidden column 3") | |||
| 290 | + | } | |||
| 291 | + | for _, action := range []Action{"focus-workspace 1", "workspace unknown"} { | |||
| 292 | + | m.Apply(action) | |||
| 293 | + | if m.stackFront(m.Current()) != front || m.overviewTarget() != selected { | |||
| 294 | + | t.Fatalf("%s reset provisional selection", action) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func TestOverviewNamedNeighborPick(t *testing.T) { | |||
| 300 | + | m := namedOverviewMonitor() | |||
| 301 | + | back := m.Current() | |||
| 302 | + | m.ToggleOverview() | |||
| 303 | + | p := previewOf(t, m.Layout(), 6) | |||
| 304 | + | if p.Hidden { | |||
| 305 | + | t.Fatal("named stash neighbor hidden") | |||
| 306 | + | } | |||
| 307 | + | id := m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) | |||
| 308 | + | if id != 6 { | |||
| 309 | + | t.Fatalf("stash hit: %d", id) | |||
| 310 | + | } | |||
| 311 | + | m.OverviewPick(id) | |||
| 312 | + | if m.ov.open || m.Current().Name != "dev" || m.back != back || !m.Current().stashFocused() { | |||
| 313 | + | t.Fatal("named stash click did not commit selection and return target") | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func TestOverviewNamedFocusBinds(t *testing.T) { | |||
| 318 | + | for _, action := range []Action{ActionFocusWindowDown, ActionFocusWorkspaceDown} { | |||
| 319 | + | t.Run(string(action), func(t *testing.T) { | |||
| 320 | + | m := namedOverviewMonitor() | |||
| 321 | + | m.ToggleOverview() | |||
| 322 | + | m.Apply(action) | |||
| 323 | + | if m.Current().Name != "dev" { | |||
| 324 | + | t.Fatalf("focus bind selected %q", m.Current().Name) | |||
| 325 | + | } | |||
| 326 | + | }) | |||
| 327 | + | } | |||
| 328 | + | m := namedOverviewMonitor() | |||
| 329 | + | m.ToggleOverview() | |||
| 330 | + | m.Apply(Action("workspace dev")) | |||
| 331 | + | if m.card() != 6 { | |||
| 332 | + | t.Fatal("named bind did not initialize stash selection") | |||
| 333 | + | } | |||
| 334 | + | m.Apply(Action("focus-workspace 2")) | |||
| 335 | + | if m.card() != 0 || m.ov.row != nil { | |||
| 336 | + | t.Fatal("numbered bind retained previous row selection") | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func TestOverviewNamedOutsideNavigationUnchanged(t *testing.T) { | |||
| 341 | + | m := namedOverviewMonitor() | |||
| 342 | + | m.Apply(ActionFocusWorkspaceDown) | |||
| 343 | + | if m.shown != nil || !m.Current().empty() { | |||
| 344 | + | t.Fatal("outside overview, down no longer reaches numbered spare") | |||
| 345 | + | } | |||
| 346 | + | m.ToggleNamed("dev") | |||
| 347 | + | m.Apply(ActionFocusWorkspaceUp) | |||
| 348 | + | if m.Current().Name != "dev" { | |||
| 349 | + | t.Fatal("outside overview, named workspace entered numbered list") | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func TestOverviewNamedEmptyCurrentAndRemoval(t *testing.T) { | |||
| 354 | + | m := namedOverviewMonitor() | |||
| 355 | + | m.ToggleNamed("empty") | |||
| 356 | + | m.ToggleOverview() | |||
| 357 | + | if !m.Current().empty() { | |||
| 358 | + | t.Fatal("expected empty named current") | |||
| 359 | + | } | |||
| 360 | + | m.OverviewMove(0, 1) | |||
| 361 | + | if m.Current().Name != "game" { | |||
| 362 | + | t.Fatal("could not leave empty named row") | |||
| 363 | + | } | |||
| 364 | + | m.SetNamed([]NamedWorkspace{{Name: "game"}}) | |||
| 365 | + | m.CancelOverview() | |||
| 366 | + | if !m.has(m.Current()) || m.back != nil && !m.has(m.back) { | |||
| 367 | + | t.Fatal("config removal left dangling overview return") | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | func TestOverviewNamedColumnPickClearsStash(t *testing.T) { | |||
| 372 | + | m := namedOverviewMonitor() | |||
| 373 | + | m.ToggleNamed("dev") | |||
| 374 | + | m.ToggleOverview() | |||
| 375 | + | if m.card() != 6 { | |||
| 376 | + | t.Fatal("expected stash selection") | |||
| 377 | + | } | |||
| 378 | + | m.OverviewPick(5) | |||
| 379 | + | if m.Current().stashFocused() || m.ov.open { | |||
| 380 | + | t.Fatal("column click kept stash selection") | |||
| 381 | + | } | |||
| 382 | + | id, _ := m.Focused() | |||
| 383 | + | if id != 5 { | |||
| 384 | + | t.Fatalf("column click focus %d", id) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func TestOverviewNamedDividerPublished(t *testing.T) { | |||
| 389 | + | scenes := make(chan []ports.Scene, 1) | |||
| 390 | + | cfg := ports.Config{} | |||
| 391 | + | cfg.Keyboard.CmdKey = "super" | |||
| 392 | + | cfg.Layout.MaxColumns = 3 | |||
| 393 | + | c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 256), Scenes: scenes}) | |||
| 394 | + | if err != nil { | |||
| 395 | + | t.Fatal(err) | |||
| 396 | + | } | |||
| 397 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}) | |||
| 398 | + | c.cur().mon = namedOverviewMonitor() | |||
| 399 | + | c.cur().mon.ToggleOverview() | |||
| 400 | + | if err := c.publish(context.Background()); err != nil { | |||
| 401 | + | t.Fatal(err) | |||
| 402 | + | } | |||
| 403 | + | scene := (<-scenes)[0] | |||
| 404 | + | _, dividers := c.cur().mon.overviewRows() | |||
| 405 | + | if len(dividers) != 1 || !slices.Contains(scene.Separators, dividers[0]) { | |||
| 406 | + | t.Fatalf("group divider not published: %+v", scene.Separators) | |||
| 407 | + | } | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | func TestOverviewNamedScrollAndSwipe(t *testing.T) { | |||
| 411 | + | m := namedOverviewMonitor() | |||
| 412 | + | m.ToggleOverview() | |||
| 413 | + | m.overviewSwipe(ActionFocusWorkspaceDown) | |||
| 414 | + | if m.Current().Name != "dev" { | |||
| 415 | + | t.Fatal("swipe did not cross groups") | |||
| 416 | + | } | |||
| 417 | + | m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}}) | |||
| 418 | + | if m.Current().Name != "game" { | |||
| 419 | + | t.Fatal("scroll did not reach next named workspace") | |||
| 420 | + | } | |||
| 421 | + | } | |||
M internal/core/screens.go +14 -4
| @@ -256,8 +256,9 @@ func (c *Core) removeScreen(name string) { | |||
| 256 | 256 | func (c *Core) settleGuests() { | |
| 257 | 257 | for _, home := range c.screens { | |
| 258 | 258 | type guest struct { | |
| 259 | - | w *Workspace | |
| 260 | - | hidden bool | |
| 259 | + | w, anchor *Workspace | |
| 260 | + | host *Monitor | |
| 261 | + | hidden bool | |
| 261 | 262 | } | |
| 262 | 263 | var list []guest | |
| 263 | 264 | for _, host := range c.screens { | |
| @@ -266,11 +267,15 @@ func (c *Core) settleGuests() { | |||
| 266 | 267 | } | |
| 267 | 268 | for _, w := range host.mon.all() { | |
| 268 | 269 | if home.mon.matches(w.home) && host.mon.Current() != w { | |
| 269 | - | list = append(list, guest{w, host.mon.isHidden(w)}) | |
| 270 | - | host.mon.take(w) | |
| 270 | + | list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)}) | |
| 271 | 271 | } | |
| 272 | 272 | } | |
| 273 | 273 | } | |
| 274 | + | // Snapshot every anchor before take normalizes a host and clears | |
| 275 | + | // references to numbered workspaces returning alongside named rows. | |
| 276 | + | for _, g := range list { | |
| 277 | + | g.host.take(g.w) | |
| 278 | + | } | |
| 274 | 279 | slices.SortStableFunc(list, func(a, b guest) int { return a.w.homePos - b.w.homePos }) | |
| 275 | 280 | // A monitor showing an empty workspace (just plugged in) shows | |
| 276 | 281 | // its first returning workspace instead. | |
| @@ -283,6 +288,11 @@ func (c *Core) settleGuests() { | |||
| 283 | 288 | idle = false | |
| 284 | 289 | } | |
| 285 | 290 | } | |
| 291 | + | for _, g := range list { | |
| 292 | + | if g.hidden && indexOf(home.mon.Workspaces, g.anchor) >= 0 { | |
| 293 | + | g.w.overviewAfter = g.anchor | |
| 294 | + | } | |
| 295 | + | } | |
| 286 | 296 | } | |
| 287 | 297 | } | |
| 288 | 298 | ||
| @@ -256,8 +256,9 @@ func (c *Core) removeScreen(name string) { | |||||
| 256 | func (c *Core) settleGuests() { | 256 | func (c *Core) settleGuests() { | ||
| 257 | for _, home := range c.screens { | 257 | for _, home := range c.screens { | ||
| 258 | type guest struct { | 258 | type guest struct { | ||
| 259 | - | w *Workspace | 259 | + | w, anchor *Workspace |
| 260 | - | hidden bool | 260 | + | host *Monitor |
| 261 | + | hidden bool | |||
| 261 | } | 262 | } | ||
| 262 | var list []guest | 263 | var list []guest | ||
| 263 | for _, host := range c.screens { | 264 | for _, host := range c.screens { | ||
| @@ -266,11 +267,15 @@ func (c *Core) settleGuests() { | |||||
| 266 | } | 267 | } | ||
| 267 | for _, w := range host.mon.all() { | 268 | for _, w := range host.mon.all() { | ||
| 268 | if home.mon.matches(w.home) && host.mon.Current() != w { | 269 | if home.mon.matches(w.home) && host.mon.Current() != w { | ||
| 269 | - | list = append(list, guest{w, host.mon.isHidden(w)}) | 270 | + | list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)}) |
| 270 | - | host.mon.take(w) | |||
| 271 | } | 271 | } | ||
| 272 | } | 272 | } | ||
| 273 | } | 273 | } | ||
| 274 | + | // Snapshot every anchor before take normalizes a host and clears | |||
| 275 | + | // references to numbered workspaces returning alongside named rows. | |||
| 276 | + | for _, g := range list { | |||
| 277 | + | g.host.take(g.w) | |||
| 278 | + | } | |||
| 274 | slices.SortStableFunc(list, func(a, b guest) int { return a.w.homePos - b.w.homePos }) | 279 | slices.SortStableFunc(list, func(a, b guest) int { return a.w.homePos - b.w.homePos }) | ||
| 275 | // A monitor showing an empty workspace (just plugged in) shows | 280 | // A monitor showing an empty workspace (just plugged in) shows | ||
| 276 | // its first returning workspace instead. | 281 | // its first returning workspace instead. | ||
| @@ -283,6 +288,11 @@ func (c *Core) settleGuests() { | |||||
| 283 | idle = false | 288 | idle = false | ||
| 284 | } | 289 | } | ||
| 285 | } | 290 | } | ||
| 291 | + | for _, g := range list { | |||
| 292 | + | if g.hidden && indexOf(home.mon.Workspaces, g.anchor) >= 0 { | |||
| 293 | + | g.w.overviewAfter = g.anchor | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 286 | } | 296 | } | ||
| 287 | } | 297 | } | ||
| 288 | 298 | ||||