detached a61214e

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 UnverifiedB5690EEEBB952194 The 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`).
3838 | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed |
3939 | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` |
4040 | `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 |
4242 | `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 |
4343 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
4444 | `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
146146
147147 ### Overview
148148
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.
150150
151151 | Key | Action |
152152 | --- | --- |
@@ -155,7 +155,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou
155155 | `return` | Show the selected front card; picking a hidden column moves the maximization to it |
156156 | `escape` | Restore the original focus, maximization and float order |
157157
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.
159159
160160 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.
161161
@@ -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 ### Overview147 ### 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 {
213213 if a == ActionCloseWindow {
214214 return Effect{Close: m.overviewTarget()}
215215 }
216- if overviewBlocks(a) {
216+ if m.overviewFocus(a) || overviewBlocks(a) {
217217 return Effect{}
218218 }
219219 }
220220 if n, op, ok := WorkspaceArg(a); ok {
221221 if op == FocusWorkspace {
222+ before := m.Current()
222223 m.FocusNumber(n)
224+ if m.ov.open && m.Current() != before {
225+ m.selectRow()
226+ }
223227 } else {
224228 m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace)
225229 }
226230 return Effect{}
227231 }
228232 if name, ok := NamedArg(a); ok {
233+ before := m.Current()
229234 m.ToggleNamed(name)
235+ if m.ov.open && m.Current() != before {
236+ m.selectRow()
237+ }
230238 return Effect{}
231239 }
232240 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 {
471471 // Only the focused output lights the focused window's lines.
472472 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
473473 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))...)
476477 }
477478 for _, p := range layout {
478479 // 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 {
127127 // ID stays with the workspace across reorder and monitor moves.
128128 ID uint64
129129 // 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
133136 // maximized remembers recently maximized columns by window ID, newest
134137 // first; stack() filters removed windows at read time.
135138 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 uint64128 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 string130+ Name string
131- Overflow Overflow131+ // overviewAfter is the numbered workspace from which this named workspace
132- Columns []Column132+ // 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, newest136 // 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 []WindowID138 maximized []WindowID
M internal/core/monitor.go +16 -2
@@ -8,8 +8,8 @@ import "slices"
88 // except the first. Output-wide settings apply to every workspace.
99 //
1010 // 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.
1313 type Monitor struct {
1414 // Name is the connector; Key identifies the physical monitor (make,
1515 // model and serial, else the connector). Workspaces remember the Key of
@@ -142,6 +142,7 @@ func (m *Monitor) newWorkspace() *Workspace {
142142 w := m.template
143143 w.presets = append([]Width(nil), m.template.presets...)
144144 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{}
145+ w.overviewAfter = nil
145146 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
146147 (*m.nextID)++
147148 w.ID = *m.nextID
@@ -168,6 +169,11 @@ func (m *Monitor) normalize() {
168169 if n := len(m.Workspaces); n == 0 || !spare(n-1) {
169170 m.Workspaces = append(m.Workspaces, m.newWorkspace())
170171 }
172+ for _, w := range m.hidden {
173+ if indexOf(m.Workspaces, w.overviewAfter) < 0 {
174+ w.overviewAfter = nil
175+ }
176+ }
171177 }
172178
173179 // Focus switches to the numbered workspace at index i, clamped to the list.
@@ -200,6 +206,14 @@ func (m *Monitor) ToggleNamed(name string) {
200206 switch cur := m.Current(); {
201207 case target == nil:
202208 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
203217 m.back = cur
204218 m.show(target)
205219 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 its11+// 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 of15 // model and serial, else the connector). Workspaces remember the Key of
@@ -142,6 +142,7 @@ func (m *Monitor) newWorkspace() *Workspace {
142 w := m.template142 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, 0146 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.nextID148 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 = cur217 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 (
1313 type overviewState struct {
1414 open bool
1515 from *Workspace
16+ back *Workspace
1617 fromID WindowID
1718 floats []Float
1819 fullWidth WindowID
@@ -62,7 +63,7 @@ func (m *Monitor) ToggleOverview() {
6263 // settled state.
6364 m.stopSwitch()
6465 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
6667 m.ov.fromID, _ = w.Focused()
6768 m.ov.floats = append([]Float(nil), w.Floats...)
6869 m.ov.maximized = slices.Clone(w.maximized)
@@ -233,9 +234,69 @@ func (m *Monitor) CancelOverview() {
233234 from.maximized = slices.Clone(m.ov.maximized)
234235 from.scroll()
235236 }
237+ m.back = m.ov.back
238+ if m.back != nil && !m.has(m.back) {
239+ m.back = nil
240+ }
236241 m.ov = overviewState{}
237242 }
238243
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+
239300 // OverviewMove routes every vertical input through moveStack, then falls
240301 // through to workspace navigation at the stack boundary. Horizontal moves
241302 // stay inside a column group; left on a single card sends it behind.
@@ -245,12 +306,12 @@ func (m *Monitor) OverviewMove(dx, dy int) {
245306 if m.moveStack(-dy) {
246307 return
247308 }
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) {
250312 return
251313 }
252- m.Focus(i)
253- m.selectRow()
314+ m.showOverview(rows[i])
254315 return
255316 }
256317 if w.pinned() {
@@ -331,7 +392,7 @@ func (m *Monitor) OverviewPick(id WindowID) {
331392 return
332393 }
333394 }
334- m.show(w)
395+ m.showOverview(w)
335396 m.ov.card, m.ov.cardOf = 0, nil
336397 if i := w.stashIndex(id); i >= 0 {
337398 m.selectCard(w, i)
@@ -355,27 +416,51 @@ func (m *Monitor) OverviewPick(id WindowID) {
355416 m.closeOverview()
356417 }
357418
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) {
362423 cur := m.Current()
363424 u := cur.overviewArea()
364- gap := u.H * 3 / 100
365- var result []Placement
425+ gap := max(1, u.H*3/100)
366426 hc := cur.rowHeight()
367427 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
374435 }
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}})
377452 }
378453 }
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
379464 for _, w := range m.all() {
380465 ry, shown := rows[w]
381466 if shown && w.pinned() {
@@ -716,15 +801,8 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
716801 return true, c.publish(ctx)
717802 }
718803
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.
721805 func (m *Monitor) overviewSwipe(a Action) {
722- switch a {
723- case ActionFocusWorkspaceUp:
724- a = ActionFocusWindowUp
725- case ActionFocusWorkspaceDown:
726- a = ActionFocusWindowDown
727- }
728806 m.overviewFocus(a)
729807 }
730808
@@ -737,9 +815,9 @@ func (m *Monitor) overviewFocus(a Action) bool {
737815 m.OverviewMove(-1, 0)
738816 case ActionFocusColumnRight:
739817 m.OverviewMove(1, 0)
740- case ActionFocusWindowUp:
818+ case ActionFocusWindowUp, ActionFocusWorkspaceUp:
741819 m.OverviewMove(0, -1)
742- case ActionFocusWindowDown:
820+ case ActionFocusWindowDown, ActionFocusWorkspaceDown:
743821 m.OverviewMove(0, 1)
744822 default:
745823 return false
@@ -13,6 +13,7 @@ import (
13 type overviewState struct {13 type overviewState struct {
14 open bool14 open bool
15 from *Workspace15 from *Workspace
16+ back *Workspace
16 fromID WindowID17 fromID WindowID
17 floats []Float18 floats []Float
18 fullWidth WindowID19 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, w66+ 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 falls300 // OverviewMove routes every vertical input through moveStack, then falls
240 // through to workspace navigation at the stack boundary. Horizontal moves301 // 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 return307 return
247 }308 }
248- i := m.Active + dy309+ 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 return312 return
251 }313 }
252- m.Focus(i)314+ m.showOverview(rows[i])
253- m.selectRow()
254 return315 return
255 }316 }
256 if w.pinned() {317 if w.pinned() {
@@ -331,7 +392,7 @@ func (m *Monitor) OverviewPick(id WindowID) {
331 return392 return
332 }393 }
333 }394 }
334- m.show(w)395+ m.showOverview(w)
335 m.ov.card, m.ov.cardOf = 0, nil396 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 the419+// overviewRows centers the selected row with its neighbors above and below.
359-// usable area and its numbered neighbors above and below, dimmed, each420+// 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 / 100425+ 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)/2427 y := u.Y + (u.H-hc)/2
368- rows := map[*Workspace]int{}428+ rows := map[*Workspace]int{cur: y}
369- rows[cur] = y429+ 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 + gap437+ 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 swipe804+// 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 false823 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) {
256256 func (c *Core) settleGuests() {
257257 for _, home := range c.screens {
258258 type guest struct {
259- w *Workspace
260- hidden bool
259+ w, anchor *Workspace
260+ host *Monitor
261+ hidden bool
261262 }
262263 var list []guest
263264 for _, host := range c.screens {
@@ -266,11 +267,15 @@ func (c *Core) settleGuests() {
266267 }
267268 for _, w := range host.mon.all() {
268269 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)})
271271 }
272272 }
273273 }
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+ }
274279 slices.SortStableFunc(list, func(a, b guest) int { return a.w.homePos - b.w.homePos })
275280 // A monitor showing an empty workspace (just plugged in) shows
276281 // its first returning workspace instead.
@@ -283,6 +288,11 @@ func (c *Core) settleGuests() {
283288 idle = false
284289 }
285290 }
291+ for _, g := range list {
292+ if g.hidden && indexOf(home.mon.Workspaces, g.anchor) >= 0 {
293+ g.w.overviewAfter = g.anchor
294+ }
295+ }
286296 }
287297 }
288298
@@ -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 *Workspace259+ w, anchor *Workspace
260- hidden bool260+ host *Monitor
261+ hidden bool
261 }262 }
262 var list []guest263 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) shows280 // 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 = false288 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

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close