detached 01dbe2a

feat(core): overview row restore, stash pile fix and move binds (#84)

* test(core): cover overview click and escape through core input

* fix(core): restore browsed overview rows on close

* fix(core): leave the overview stash pile toward floats without columns

* refactor(core): route overview binds through one gate

* feat(core): move the selected overview preview to a workspace with move binds

* fix(core): move only a selected overview preview and keep its destination focus

* fix(core): keep the opening row snapshot when an overview move lands elsewhere

* test(core): cover overview moves from a fixed fullscreen tile
commit
01dbe2a42f9c76f6d4597bbbebeaaefbe2103482
author
bnema · · 2026-10-01 16:13 UTC
committer
GitHub · · 2026-10-01 16:13 UTC
parent
348689c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +677 -117
M docs/config.md +3 -3
@@ -152,8 +152,8 @@ Native floating windows are not in the stash: they stay centred in the usable ou
152152 | --- | --- |
153153 | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. |
154154 | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary |
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 |
155+| `return` | Show the selected front card; picking a hidden column moves the maximization to it. Other rows shown keep their focus, maximization and float order |
156+| `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order |
157157
158158 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
@@ -161,7 +161,7 @@ Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the
161161
162162 A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift.
163163
164-Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Window mutation binds (moving, resizing, maximizing, fullscreen and stash toggles) and workspace moves are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.
164+Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.
165165
166166 ### Default binds
167167
@@ -152,8 +152,8 @@ Native floating windows are not in the stash: they stay centred in the usable ou
152 | --- | --- |152 | --- | --- |
153 | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. |153 | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. |
154 | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary |154 | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary |
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. Other rows shown keep their focus, maximization and float order |
156-| `escape` | Restore the original focus, maximization and float order |156+| `escape` | Return to the original workspace; every row shown keeps its 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. In the overview, `focus-workspace-up/down` also traverse cards before crossing workspace rows, including named ones.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
@@ -161,7 +161,7 @@ Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the
161 161
162 A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift.162 A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift.
163 163
164-Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Window mutation binds (moving, resizing, maximizing, fullscreen and stash toggles) and workspace moves are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.164+Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.
165 165
166 ### Default binds166 ### Default binds
167 167
M internal/core/action.go +45 -31
@@ -109,13 +109,8 @@ type Effect struct {
109109 // screen; column focus past the edge column moves to the neighbor screen;
110110 // everything else applies to the focused screen's monitor.
111111 func (c *Core) applyAction(a Action) Effect {
112- if m := c.cur().mon; m.ov.open {
113- if m.overviewFocus(a) || overviewBlocks(a) {
114- return Effect{}
115- }
116- if a == ActionCloseWindow {
117- return m.Apply(a)
118- }
112+ if e, ok := c.cur().mon.overviewAction(a); ok {
113+ return e
119114 }
120115 // A bind acts on the window on screen: under a covering fullscreen
121116 // window, that is the one Focused reports.
@@ -222,23 +217,20 @@ func (c *Core) applyAction(a Action) Effect {
222217
223218 // Apply runs a bind action on the monitor.
224219 func (m *Monitor) Apply(a Action) Effect {
225- if m.ov.open {
226- if a == ActionCloseWindow {
227- return Effect{Close: m.overviewTarget()}
228- }
229- if m.overviewFocus(a) || overviewBlocks(a) {
230- return Effect{}
220+ if e, ok := m.overviewAction(a); ok {
221+ return e
222+ }
223+ if movesToWorkspace(a) {
224+ if i, column, ok := m.moveDest(a); ok {
225+ m.MoveToWorkspace(i, column)
231226 }
227+ return Effect{}
232228 }
233- if n, op, ok := WorkspaceArg(a); ok {
234- if op == FocusWorkspace {
235- before := m.Current()
236- m.FocusNumber(n)
237- if m.ov.open && m.Current() != before {
238- m.selectRow()
239- }
240- } else {
241- m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace)
229+ if n, _, ok := WorkspaceArg(a); ok {
230+ before := m.Current()
231+ m.FocusNumber(n)
232+ if m.ov.open && m.Current() != before {
233+ m.selectRow()
242234 }
243235 return Effect{}
244236 }
@@ -291,18 +283,40 @@ func (m *Monitor) Apply(a Action) Effect {
291283 m.Focus(m.Active + 1)
292284 }
293285 return Effect{}
286+ }
287+ return m.Current().Apply(a)
288+}
289+
290+// movesToWorkspace reports a bind that moves a window to another workspace.
291+func movesToWorkspace(a Action) bool {
292+ if _, op, ok := WorkspaceArg(a); ok {
293+ return op != FocusWorkspace
294+ }
295+ switch a {
296+ case ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown,
297+ ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown:
298+ return true
299+ }
300+ return false
301+}
302+
303+// moveDest resolves a move-to-workspace bind to the workspace index it
304+// moves to and whether the whole column goes; ok is false when it has
305+// nowhere to go. Up and down do nothing on a named workspace.
306+func (m *Monitor) moveDest(a Action) (i int, column, ok bool) {
307+ if n, op, isArg := WorkspaceArg(a); isArg {
308+ return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true
309+ }
310+ if m.shown != nil {
311+ return 0, false, false
312+ }
313+ switch a {
294314 case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp:
295- if m.shown == nil && m.Active > 0 {
296- m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp)
297- }
298- return Effect{}
315+ return m.Active - 1, a == ActionMoveColumnToWorkspaceUp, m.Active > 0
299316 case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown:
300- if m.shown == nil {
301- m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown)
302- }
303- return Effect{}
317+ return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceDown, true
304318 }
305- return m.Current().Apply(a)
319+ return 0, false, false
306320 }
307321
308322 // Apply runs a bind action that only touches this workspace.
@@ -109,13 +109,8 @@ type Effect struct {
109 // screen; column focus past the edge column moves to the neighbor screen;109 // screen; column focus past the edge column moves to the neighbor screen;
110 // everything else applies to the focused screen's monitor.110 // everything else applies to the focused screen's monitor.
111 func (c *Core) applyAction(a Action) Effect {111 func (c *Core) applyAction(a Action) Effect {
112- if m := c.cur().mon; m.ov.open {112+ if e, ok := c.cur().mon.overviewAction(a); ok {
113- if m.overviewFocus(a) || overviewBlocks(a) {113+ return e
114- return Effect{}
115- }
116- if a == ActionCloseWindow {
117- return m.Apply(a)
118- }
119 }114 }
120 // A bind acts on the window on screen: under a covering fullscreen115 // A bind acts on the window on screen: under a covering fullscreen
121 // window, that is the one Focused reports.116 // window, that is the one Focused reports.
@@ -222,23 +217,20 @@ func (c *Core) applyAction(a Action) Effect {
222 217
223 // Apply runs a bind action on the monitor.218 // Apply runs a bind action on the monitor.
224 func (m *Monitor) Apply(a Action) Effect {219 func (m *Monitor) Apply(a Action) Effect {
225- if m.ov.open {220+ if e, ok := m.overviewAction(a); ok {
226- if a == ActionCloseWindow {221+ return e
227- return Effect{Close: m.overviewTarget()}222+ }
228- }223+ if movesToWorkspace(a) {
229- if m.overviewFocus(a) || overviewBlocks(a) {224+ if i, column, ok := m.moveDest(a); ok {
230- return Effect{}225+ m.MoveToWorkspace(i, column)
231 }226 }
227+ return Effect{}
232 }228 }
233- if n, op, ok := WorkspaceArg(a); ok {229+ if n, _, ok := WorkspaceArg(a); ok {
234- if op == FocusWorkspace {230+ before := m.Current()
235- before := m.Current()231+ m.FocusNumber(n)
236- m.FocusNumber(n)232+ if m.ov.open && m.Current() != before {
237- if m.ov.open && m.Current() != before {233+ m.selectRow()
238- m.selectRow()
239- }
240- } else {
241- m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace)
242 }234 }
243 return Effect{}235 return Effect{}
244 }236 }
@@ -291,18 +283,40 @@ func (m *Monitor) Apply(a Action) Effect {
291 m.Focus(m.Active + 1)283 m.Focus(m.Active + 1)
292 }284 }
293 return Effect{}285 return Effect{}
286+ }
287+ return m.Current().Apply(a)
288+}
289+
290+// movesToWorkspace reports a bind that moves a window to another workspace.
291+func movesToWorkspace(a Action) bool {
292+ if _, op, ok := WorkspaceArg(a); ok {
293+ return op != FocusWorkspace
294+ }
295+ switch a {
296+ case ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown,
297+ ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown:
298+ return true
299+ }
300+ return false
301+}
302+
303+// moveDest resolves a move-to-workspace bind to the workspace index it
304+// moves to and whether the whole column goes; ok is false when it has
305+// nowhere to go. Up and down do nothing on a named workspace.
306+func (m *Monitor) moveDest(a Action) (i int, column, ok bool) {
307+ if n, op, isArg := WorkspaceArg(a); isArg {
308+ return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true
309+ }
310+ if m.shown != nil {
311+ return 0, false, false
312+ }
313+ switch a {
294 case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp:314 case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp:
295- if m.shown == nil && m.Active > 0 {315+ return m.Active - 1, a == ActionMoveColumnToWorkspaceUp, m.Active > 0
296- m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp)
297- }
298- return Effect{}
299 case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown:316 case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown:
300- if m.shown == nil {317+ return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceDown, true
301- m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown)
302- }
303- return Effect{}
304 }318 }
305- return m.Current().Apply(a)319+ return 0, false, false
306 }320 }
307 321
308 // Apply runs a bind action that only touches this workspace.322 // Apply runs a bind action that only touches this workspace.
M internal/core/gesture.go +1 -1
@@ -268,7 +268,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
268268 before := c.cur().mon.Current()
269269 c.keyboard.takeBack()
270270 if mon := c.cur().mon; mon.ov.open {
271- mon.overviewSwipe(a)
271+ mon.overviewFocus(a)
272272 } else {
273273 c.applyAction(a)
274274 }
@@ -268,7 +268,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
268 before := c.cur().mon.Current()268 before := c.cur().mon.Current()
269 c.keyboard.takeBack()269 c.keyboard.takeBack()
270 if mon := c.cur().mon; mon.ov.open {270 if mon := c.cur().mon; mon.ov.open {
271- mon.overviewSwipe(a)271+ mon.overviewFocus(a)
272 } else {272 } else {
273 c.applyAction(a)273 c.applyAction(a)
274 }274 }
M internal/core/overview.go +135 -73
@@ -8,27 +8,34 @@ import (
88 "github.com/bnema/neferwl/internal/ports"
99 )
1010
11-// overviewState owns the provisional selection and opening snapshot. row is
12-// the workspace whose stack has a provisional front; nil uses its real front.
11+// overviewState owns the provisional selection and the snapshot of each row
12+// it showed. row is the workspace whose stack has a provisional front; nil
13+// uses its real front.
1314 type overviewState struct {
14- open bool
15- from *Workspace
16- back *Workspace
17- fromID WindowID
18- floats []Float
19- fullWidth WindowID
20- row *Workspace
21- front stackItem
22- frontAt int
23- cardOf *Workspace
24- card WindowID
25- selected WindowID
26- selectedAt int
27- // FullWidth history is restored on Escape when active binds changed it.
28- maximized []WindowID
15+ open bool
16+ from *Workspace
17+ back *Workspace
18+ rows map[*Workspace]rowSnapshot
19+ row *Workspace
20+ front stackItem
21+ frontAt int
22+ cardOf *Workspace
23+ card WindowID
24+ selected WindowID
25+ selectedAt int
2926 scrollX, scrollY float64
3027 }
3128
29+// rowSnapshot is a row as the overview first showed it: browsing moves its
30+// focus and scroll, closing puts them back.
31+type rowSnapshot struct {
32+ focus WindowID
33+ floats []Float
34+ fullWidth WindowID
35+ // FullWidth history is restored when active binds changed it.
36+ maximized []WindowID
37+}
38+
3239 // The overview scales workspace rows without resizing clients. Covering
3340 // floats and the columns form a linear stack of cards; small floats stay
3441 // hidden. Up/down traverse the stack before changing workspace. A pinned
@@ -64,21 +71,34 @@ func (m *Monitor) ToggleOverview() {
6471 m.stopSwitch()
6572 m.each(func(w *Workspace) { w.stopSlide() })
6673 m.ov.open, m.ov.from, m.ov.back = true, w, m.back
67- m.ov.fromID, _ = w.Focused()
68- m.ov.floats = append([]Float(nil), w.Floats...)
69- m.ov.maximized = slices.Clone(w.maximized)
70- m.ov.fullWidth = 0
71- if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
72- c := w.Columns[w.Focus]
73- m.ov.fullWidth = c.Windows[c.Focus]
74- }
7574 m.ov.scrollX, m.ov.scrollY = 0, 0
7675 m.overviewOpens++
7776 m.selectRow()
7877 }
7978
79+// remember snapshots w the first time the overview shows it.
80+func (m *Monitor) remember(w *Workspace) {
81+ if _, ok := m.ov.rows[w]; ok {
82+ return
83+ }
84+ if m.ov.rows == nil {
85+ m.ov.rows = make(map[*Workspace]rowSnapshot)
86+ }
87+ s := rowSnapshot{
88+ floats: append([]Float(nil), w.Floats...),
89+ maximized: slices.Clone(w.maximized),
90+ }
91+ s.focus, _ = w.Focused()
92+ if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
93+ c := w.Columns[w.Focus]
94+ s.fullWidth = c.Windows[c.Focus]
95+ }
96+ m.ov.rows[w] = s
97+}
98+
8099 // selectRow resets provisional stack and stash selection for a new row.
81100 func (m *Monitor) selectRow() {
101+ m.remember(m.Current())
82102 m.ov.card, m.ov.cardOf = 0, nil
83103 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
84104 m.ov.selected, m.ov.selectedAt = 0, -1
@@ -158,23 +178,38 @@ func (m *Monitor) overviewTarget() WindowID {
158178 }
159179
160180 // overviewBlocks makes window mutations explicit: they are disabled while
161-// selection is provisional. Workspace and monitor navigation remain active.
181+// selection is provisional. Workspace and monitor navigation and moves to
182+// another workspace remain active.
162183 func overviewBlocks(a Action) bool {
163- if _, op, ok := WorkspaceArg(a); ok {
164- return op != FocusWorkspace
165- }
166184 switch a {
167185 case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
168186 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
169- ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp,
170- ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp,
171- ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft,
187+ ActionToggleStashVisible, ActionConsumeOrExpelLeft,
172188 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
173189 return true
174190 }
175191 return false
176192 }
177193
194+// overviewMoveTo moves the selected preview at once, like close-window: the
195+// overview stays open and closing it does not undo the move. Nothing moves
196+// without a selected preview, from a stash card, or when the bind has
197+// nowhere to go.
198+func (m *Monitor) overviewMoveTo(a Action) {
199+ cur, id := m.Current(), m.overviewTarget()
200+ i, column, ok := m.moveDest(a)
201+ if m.card() != 0 || id == 0 || !ok || m.Workspaces[i] == cur {
202+ return
203+ }
204+ cur.FocusID(id)
205+ m.MoveToWorkspace(i, column)
206+ // The row that received the window keeps it focused on close.
207+ if to, _ := m.find(id); to != nil && to != cur {
208+ delete(m.ov.rows, to)
209+ }
210+ m.selectRow()
211+}
212+
178213 // closeOverview closes the overview on the selection: a card shows the
179214 // stash on it. A fullscreen request another stashed window made while
180215 // the stash was hidden is dropped: the picked card stays in front.
@@ -189,50 +224,25 @@ func (m *Monitor) closeOverview() {
189224 } else if !w.pinned() && len(w.stack()) > 0 {
190225 w.apply(m.stackFront(w), m.overviewTarget())
191226 }
227+ for r, s := range m.ov.rows {
228+ if r != w && m.has(r) {
229+ restoreRow(r, s)
230+ }
231+ }
192232 m.ov = overviewState{}
193233 }
194234
195235 // CancelOverview closes the overview and returns to the workspace and
196-// window it opened on, if they are still there.
236+// window it opened on, if they are still there. Every row it showed gets
237+// its focus back.
197238 func (m *Monitor) CancelOverview() {
198- from, id := m.ov.from, m.ov.fromID
199- if from != nil && m.has(from) {
239+ if from := m.ov.from; from != nil && m.has(from) {
200240 m.show(from)
201- if id != 0 {
202- from.FocusID(id)
203- } else {
204- from.floatFocus, from.stashFocus = false, false
205- }
206- // FocusID on a float raises it; restore the initial order and below
207- // flags for floats still present, leaving new floats in their order.
208- initial := m.ov.floats
209- current := from.Floats
210- from.Floats = nil
211- for _, f := range initial {
212- for i, now := range current {
213- if now.ID == f.ID {
214- now.below = f.below
215- from.Floats = append(from.Floats, now)
216- current = append(current[:i], current[i+1:]...)
217- break
218- }
219- }
220- }
221- from.Floats = append(from.Floats, current...)
222- // Fixed overflow maximizes at most one column: only the one holding
223- // the anchor window, wherever active binds moved it. Scroll overflow
224- // keeps each column's own width.
225- if from.Overflow == OverflowFixed {
226- from.unmaximize()
227- for i := range from.Columns {
228- if m.ov.fullWidth != 0 && slices.Contains(from.Columns[i].Windows, m.ov.fullWidth) {
229- from.maximize(i)
230- break
231- }
232- }
241+ }
242+ for r, s := range m.ov.rows {
243+ if m.has(r) {
244+ restoreRow(r, s)
233245 }
234- from.maximized = slices.Clone(m.ov.maximized)
235- from.scroll()
236246 }
237247 m.back = m.ov.back
238248 if m.back != nil && !m.has(m.back) {
@@ -241,6 +251,45 @@ func (m *Monitor) CancelOverview() {
241251 m.ov = overviewState{}
242252 }
243253
254+// restoreRow puts w back as snapshot s found it.
255+func restoreRow(w *Workspace, s rowSnapshot) {
256+ if s.focus != 0 {
257+ w.FocusID(s.focus)
258+ } else {
259+ w.floatFocus, w.stashFocus = false, false
260+ }
261+ // FocusID on a float raises it; restore the initial order and below
262+ // flags for floats still present, leaving new floats in their order.
263+ current := w.Floats
264+ w.Floats = nil
265+ for _, f := range s.floats {
266+ for i, now := range current {
267+ if now.ID == f.ID {
268+ now.below = f.below
269+ w.Floats = append(w.Floats, now)
270+ current = append(current[:i], current[i+1:]...)
271+ break
272+ }
273+ }
274+ }
275+ w.Floats = append(w.Floats, current...)
276+ // Fixed overflow maximizes at most one column: only the one holding
277+ // the anchor window, wherever active binds moved it. Scroll overflow
278+ // keeps each column's own width.
279+ if w.Overflow == OverflowFixed {
280+ w.unmaximize()
281+ for i := range w.Columns {
282+ if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
283+ w.maximize(i)
284+ break
285+ }
286+ }
287+ }
288+ // Windows that left the row since leave its maximize history too.
289+ w.maximized = slices.DeleteFunc(slices.Clone(s.maximized), func(id WindowID) bool { return !w.has(id) })
290+ w.scroll()
291+}
292+
244293 // overviewWorkspaces inserts named rows below their invocation workspace,
245294 // in configured order within each group. Unattached rows follow the last
246295 // occupied numbered row (or the first). Empty invocation anchors stay visible;
@@ -321,7 +370,7 @@ func (m *Monitor) OverviewMove(dx, dy int) {
321370 switch {
322371 case at+dx >= 0 && at+dx < len(w.Stash):
323372 m.selectCard(w, at+dx)
324- case dx > 0 && len(w.Columns) > 0:
373+ case dx > 0 && len(w.stack()) > 0:
325374 m.ov.card, m.ov.cardOf = 0, nil
326375 item := m.stackFront(w)
327376 if item.kind == stackColumns {
@@ -801,9 +850,22 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
801850 return true, c.publish(ctx)
802851 }
803852
804-// overviewSwipe moves the selection like the keys, through stacks and rows.
805-func (m *Monitor) overviewSwipe(a Action) {
806- m.overviewFocus(a)
853+// overviewAction runs a bind while the overview is open: focus binds move
854+// the selection, close-window and move-to-workspace binds act on the
855+// selected preview, other mutations are dropped. handled is false for binds
856+// that run as usual.
857+func (m *Monitor) overviewAction(a Action) (e Effect, handled bool) {
858+ if !m.ov.open {
859+ return Effect{}, false
860+ }
861+ if a == ActionCloseWindow {
862+ return Effect{Close: m.overviewTarget()}, true
863+ }
864+ if movesToWorkspace(a) {
865+ m.overviewMoveTo(a)
866+ return Effect{}, true
867+ }
868+ return Effect{}, m.overviewFocus(a) || overviewBlocks(a)
807869 }
808870
809871 // overviewFocus runs a focus bind as an overview move, so the focus binds
@@ -8,27 +8,34 @@ import (
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9 )9 )
10 10
11-// overviewState owns the provisional selection and opening snapshot. row is11+// overviewState owns the provisional selection and the snapshot of each row
12-// the workspace whose stack has a provisional front; nil uses its real front.12+// it showed. row is the workspace whose stack has a provisional front; nil
13+// uses its real front.
13 type overviewState struct {14 type overviewState struct {
14- open bool15+ open bool
15- from *Workspace16+ from *Workspace
16- back *Workspace17+ back *Workspace
17- fromID WindowID18+ rows map[*Workspace]rowSnapshot
18- floats []Float19+ row *Workspace
19- fullWidth WindowID20+ front stackItem
20- row *Workspace21+ frontAt int
21- front stackItem22+ cardOf *Workspace
22- frontAt int23+ card WindowID
23- cardOf *Workspace24+ selected WindowID
24- card WindowID25+ selectedAt int
25- selected WindowID
26- selectedAt int
27- // FullWidth history is restored on Escape when active binds changed it.
28- maximized []WindowID
29 scrollX, scrollY float6426 scrollX, scrollY float64
30 }27 }
31 28
29+// rowSnapshot is a row as the overview first showed it: browsing moves its
30+// focus and scroll, closing puts them back.
31+type rowSnapshot struct {
32+ focus WindowID
33+ floats []Float
34+ fullWidth WindowID
35+ // FullWidth history is restored when active binds changed it.
36+ maximized []WindowID
37+}
38+
32 // The overview scales workspace rows without resizing clients. Covering39 // The overview scales workspace rows without resizing clients. Covering
33 // floats and the columns form a linear stack of cards; small floats stay40 // floats and the columns form a linear stack of cards; small floats stay
34 // hidden. Up/down traverse the stack before changing workspace. A pinned41 // hidden. Up/down traverse the stack before changing workspace. A pinned
@@ -64,21 +71,34 @@ func (m *Monitor) ToggleOverview() {
64 m.stopSwitch()71 m.stopSwitch()
65 m.each(func(w *Workspace) { w.stopSlide() })72 m.each(func(w *Workspace) { w.stopSlide() })
66 m.ov.open, m.ov.from, m.ov.back = true, w, m.back73 m.ov.open, m.ov.from, m.ov.back = true, w, m.back
67- m.ov.fromID, _ = w.Focused()
68- m.ov.floats = append([]Float(nil), w.Floats...)
69- m.ov.maximized = slices.Clone(w.maximized)
70- m.ov.fullWidth = 0
71- if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
72- c := w.Columns[w.Focus]
73- m.ov.fullWidth = c.Windows[c.Focus]
74- }
75 m.ov.scrollX, m.ov.scrollY = 0, 074 m.ov.scrollX, m.ov.scrollY = 0, 0
76 m.overviewOpens++75 m.overviewOpens++
77 m.selectRow()76 m.selectRow()
78 }77 }
79 78
79+// remember snapshots w the first time the overview shows it.
80+func (m *Monitor) remember(w *Workspace) {
81+ if _, ok := m.ov.rows[w]; ok {
82+ return
83+ }
84+ if m.ov.rows == nil {
85+ m.ov.rows = make(map[*Workspace]rowSnapshot)
86+ }
87+ s := rowSnapshot{
88+ floats: append([]Float(nil), w.Floats...),
89+ maximized: slices.Clone(w.maximized),
90+ }
91+ s.focus, _ = w.Focused()
92+ if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
93+ c := w.Columns[w.Focus]
94+ s.fullWidth = c.Windows[c.Focus]
95+ }
96+ m.ov.rows[w] = s
97+}
98+
80 // selectRow resets provisional stack and stash selection for a new row.99 // selectRow resets provisional stack and stash selection for a new row.
81 func (m *Monitor) selectRow() {100 func (m *Monitor) selectRow() {
101+ m.remember(m.Current())
82 m.ov.card, m.ov.cardOf = 0, nil102 m.ov.card, m.ov.cardOf = 0, nil
83 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0103 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
84 m.ov.selected, m.ov.selectedAt = 0, -1104 m.ov.selected, m.ov.selectedAt = 0, -1
@@ -158,23 +178,38 @@ func (m *Monitor) overviewTarget() WindowID {
158 }178 }
159 179
160 // overviewBlocks makes window mutations explicit: they are disabled while180 // overviewBlocks makes window mutations explicit: they are disabled while
161-// selection is provisional. Workspace and monitor navigation remain active.181+// selection is provisional. Workspace and monitor navigation and moves to
182+// another workspace remain active.
162 func overviewBlocks(a Action) bool {183 func overviewBlocks(a Action) bool {
163- if _, op, ok := WorkspaceArg(a); ok {
164- return op != FocusWorkspace
165- }
166 switch a {184 switch a {
167 case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,185 case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
168 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,186 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
169- ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp,187+ ActionToggleStashVisible, ActionConsumeOrExpelLeft,
170- ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp,
171- ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft,
172 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:188 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
173 return true189 return true
174 }190 }
175 return false191 return false
176 }192 }
177 193
194+// overviewMoveTo moves the selected preview at once, like close-window: the
195+// overview stays open and closing it does not undo the move. Nothing moves
196+// without a selected preview, from a stash card, or when the bind has
197+// nowhere to go.
198+func (m *Monitor) overviewMoveTo(a Action) {
199+ cur, id := m.Current(), m.overviewTarget()
200+ i, column, ok := m.moveDest(a)
201+ if m.card() != 0 || id == 0 || !ok || m.Workspaces[i] == cur {
202+ return
203+ }
204+ cur.FocusID(id)
205+ m.MoveToWorkspace(i, column)
206+ // The row that received the window keeps it focused on close.
207+ if to, _ := m.find(id); to != nil && to != cur {
208+ delete(m.ov.rows, to)
209+ }
210+ m.selectRow()
211+}
212+
178 // closeOverview closes the overview on the selection: a card shows the213 // closeOverview closes the overview on the selection: a card shows the
179 // stash on it. A fullscreen request another stashed window made while214 // stash on it. A fullscreen request another stashed window made while
180 // the stash was hidden is dropped: the picked card stays in front.215 // the stash was hidden is dropped: the picked card stays in front.
@@ -189,50 +224,25 @@ func (m *Monitor) closeOverview() {
189 } else if !w.pinned() && len(w.stack()) > 0 {224 } else if !w.pinned() && len(w.stack()) > 0 {
190 w.apply(m.stackFront(w), m.overviewTarget())225 w.apply(m.stackFront(w), m.overviewTarget())
191 }226 }
227+ for r, s := range m.ov.rows {
228+ if r != w && m.has(r) {
229+ restoreRow(r, s)
230+ }
231+ }
192 m.ov = overviewState{}232 m.ov = overviewState{}
193 }233 }
194 234
195 // CancelOverview closes the overview and returns to the workspace and235 // CancelOverview closes the overview and returns to the workspace and
196-// window it opened on, if they are still there.236+// window it opened on, if they are still there. Every row it showed gets
237+// its focus back.
197 func (m *Monitor) CancelOverview() {238 func (m *Monitor) CancelOverview() {
198- from, id := m.ov.from, m.ov.fromID239+ if from := m.ov.from; from != nil && m.has(from) {
199- if from != nil && m.has(from) {
200 m.show(from)240 m.show(from)
201- if id != 0 {241+ }
202- from.FocusID(id)242+ for r, s := range m.ov.rows {
203- } else {243+ if m.has(r) {
204- from.floatFocus, from.stashFocus = false, false244+ restoreRow(r, s)
205- }
206- // FocusID on a float raises it; restore the initial order and below
207- // flags for floats still present, leaving new floats in their order.
208- initial := m.ov.floats
209- current := from.Floats
210- from.Floats = nil
211- for _, f := range initial {
212- for i, now := range current {
213- if now.ID == f.ID {
214- now.below = f.below
215- from.Floats = append(from.Floats, now)
216- current = append(current[:i], current[i+1:]...)
217- break
218- }
219- }
220- }
221- from.Floats = append(from.Floats, current...)
222- // Fixed overflow maximizes at most one column: only the one holding
223- // the anchor window, wherever active binds moved it. Scroll overflow
224- // keeps each column's own width.
225- if from.Overflow == OverflowFixed {
226- from.unmaximize()
227- for i := range from.Columns {
228- if m.ov.fullWidth != 0 && slices.Contains(from.Columns[i].Windows, m.ov.fullWidth) {
229- from.maximize(i)
230- break
231- }
232- }
233 }245 }
234- from.maximized = slices.Clone(m.ov.maximized)
235- from.scroll()
236 }246 }
237 m.back = m.ov.back247 m.back = m.ov.back
238 if m.back != nil && !m.has(m.back) {248 if m.back != nil && !m.has(m.back) {
@@ -241,6 +251,45 @@ func (m *Monitor) CancelOverview() {
241 m.ov = overviewState{}251 m.ov = overviewState{}
242 }252 }
243 253
254+// restoreRow puts w back as snapshot s found it.
255+func restoreRow(w *Workspace, s rowSnapshot) {
256+ if s.focus != 0 {
257+ w.FocusID(s.focus)
258+ } else {
259+ w.floatFocus, w.stashFocus = false, false
260+ }
261+ // FocusID on a float raises it; restore the initial order and below
262+ // flags for floats still present, leaving new floats in their order.
263+ current := w.Floats
264+ w.Floats = nil
265+ for _, f := range s.floats {
266+ for i, now := range current {
267+ if now.ID == f.ID {
268+ now.below = f.below
269+ w.Floats = append(w.Floats, now)
270+ current = append(current[:i], current[i+1:]...)
271+ break
272+ }
273+ }
274+ }
275+ w.Floats = append(w.Floats, current...)
276+ // Fixed overflow maximizes at most one column: only the one holding
277+ // the anchor window, wherever active binds moved it. Scroll overflow
278+ // keeps each column's own width.
279+ if w.Overflow == OverflowFixed {
280+ w.unmaximize()
281+ for i := range w.Columns {
282+ if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
283+ w.maximize(i)
284+ break
285+ }
286+ }
287+ }
288+ // Windows that left the row since leave its maximize history too.
289+ w.maximized = slices.DeleteFunc(slices.Clone(s.maximized), func(id WindowID) bool { return !w.has(id) })
290+ w.scroll()
291+}
292+
244 // overviewWorkspaces inserts named rows below their invocation workspace,293 // overviewWorkspaces inserts named rows below their invocation workspace,
245 // in configured order within each group. Unattached rows follow the last294 // in configured order within each group. Unattached rows follow the last
246 // occupied numbered row (or the first). Empty invocation anchors stay visible;295 // occupied numbered row (or the first). Empty invocation anchors stay visible;
@@ -321,7 +370,7 @@ func (m *Monitor) OverviewMove(dx, dy int) {
321 switch {370 switch {
322 case at+dx >= 0 && at+dx < len(w.Stash):371 case at+dx >= 0 && at+dx < len(w.Stash):
323 m.selectCard(w, at+dx)372 m.selectCard(w, at+dx)
324- case dx > 0 && len(w.Columns) > 0:373+ case dx > 0 && len(w.stack()) > 0:
325 m.ov.card, m.ov.cardOf = 0, nil374 m.ov.card, m.ov.cardOf = 0, nil
326 item := m.stackFront(w)375 item := m.stackFront(w)
327 if item.kind == stackColumns {376 if item.kind == stackColumns {
@@ -801,9 +850,22 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
801 return true, c.publish(ctx)850 return true, c.publish(ctx)
802 }851 }
803 852
804-// overviewSwipe moves the selection like the keys, through stacks and rows.853+// overviewAction runs a bind while the overview is open: focus binds move
805-func (m *Monitor) overviewSwipe(a Action) {854+// the selection, close-window and move-to-workspace binds act on the
806- m.overviewFocus(a)855+// selected preview, other mutations are dropped. handled is false for binds
856+// that run as usual.
857+func (m *Monitor) overviewAction(a Action) (e Effect, handled bool) {
858+ if !m.ov.open {
859+ return Effect{}, false
860+ }
861+ if a == ActionCloseWindow {
862+ return Effect{Close: m.overviewTarget()}, true
863+ }
864+ if movesToWorkspace(a) {
865+ m.overviewMoveTo(a)
866+ return Effect{}, true
867+ }
868+ return Effect{}, m.overviewFocus(a) || overviewBlocks(a)
807 }869 }
808 870
809 // overviewFocus runs a focus bind as an overview move, so the focus binds871 // overviewFocus runs a focus bind as an overview move, so the focus binds
M internal/core/overview_behavior_test.go +64 -3
@@ -15,15 +15,15 @@ func TestOverviewVerticalBoundariesAndInputs(t *testing.T) {
1515 if m.stackFront(w) != (stackItem{stackFloat, 9}) {
1616 t.Fatal("screen front")
1717 }
18- m.overviewSwipe(ActionFocusWorkspaceUp)
18+ m.overviewFocus(ActionFocusWorkspaceUp)
1919 if m.stackFront(w).kind != stackColumns || m.Active != 0 {
2020 t.Fatal("swipe did not visit card")
2121 }
22- m.overviewSwipe(ActionFocusWorkspaceUp)
22+ m.overviewFocus(ActionFocusWorkspaceUp)
2323 if m.Active != 0 {
2424 t.Fatal("up at top crossed workspace boundary")
2525 }
26- m.overviewSwipe(ActionFocusWorkspaceDown)
26+ m.overviewFocus(ActionFocusWorkspaceDown)
2727 if m.stackFront(w).kind != stackFloat || m.Active != 0 {
2828 t.Fatal("down did not return to front")
2929 }
@@ -128,3 +128,64 @@ func TestOverviewStackMRUAndSize(t *testing.T) {
128128 }
129129 m.CancelOverview()
130130 }
131+
132+// browsedMonitor has window 1 on workspace 1 and columns 2..5 on workspace
133+// 2, focused on 5 with one column per screen, and shows workspace 1.
134+func browsedMonitor(ov Overflow) (*Monitor, *Workspace) {
135+ m := newMonitor("", "")
136+ m.SetOutput(300, 200)
137+ m.SetMaxColumns(1)
138+ m.AddWindow(1)
139+ m.Focus(1)
140+ for id := WindowID(2); id <= 5; id++ {
141+ m.AddWindow(id)
142+ }
143+ ws2 := m.Workspaces[1]
144+ ws2.Overflow = ov
145+ ws2.FocusID(5)
146+ ws2.scroll()
147+ m.Focus(0)
148+ return m, ws2
149+}
150+
151+// Escape gives every row the overview showed its focus and scroll back,
152+// as first shown, even after leaving and revisiting it.
153+func TestOverviewBrowsedRowsRestored(t *testing.T) {
154+ paths := map[string][][2]int{
155+ "browse": {{0, 1}, {-1, 0}, {-1, 0}},
156+ "revisit": {{0, 1}, {-1, 0}, {0, -1}, {0, 1}, {-1, 0}},
157+ }
158+ for _, ov := range []Overflow{OverflowScroll, OverflowFixed} {
159+ for name, path := range paths {
160+ m, ws2 := browsedMonitor(ov)
161+ view := ws2.ViewX
162+ m.ToggleOverview()
163+ for _, d := range path {
164+ m.OverviewMove(d[0], d[1])
165+ }
166+ m.CancelOverview()
167+ if id, _ := ws2.Focused(); id != 5 || ws2.ViewX != view || m.Current() != m.Workspaces[0] {
168+ t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.ViewX, view, m.Active)
169+ }
170+ }
171+ }
172+}
173+
174+func TestOverviewReturnRestoresBrowsedRows(t *testing.T) {
175+ m, ws2 := browsedMonitor(OverflowScroll)
176+ m.Focus(2)
177+ m.AddWindow(6)
178+ m.Focus(0)
179+ m.ToggleOverview()
180+ m.OverviewMove(0, 1)
181+ m.OverviewMove(-1, 0)
182+ m.OverviewMove(-1, 0)
183+ m.OverviewMove(0, 1)
184+ if m.Current() != m.Workspaces[2] {
185+ t.Fatal("not on workspace 3")
186+ }
187+ m.ToggleOverview()
188+ if id, _ := ws2.Focused(); id != 5 || m.Current() != m.Workspaces[2] {
189+ t.Fatalf("focus %d current %v", id, m.Active)
190+ }
191+}
@@ -15,15 +15,15 @@ func TestOverviewVerticalBoundariesAndInputs(t *testing.T) {
15 if m.stackFront(w) != (stackItem{stackFloat, 9}) {15 if m.stackFront(w) != (stackItem{stackFloat, 9}) {
16 t.Fatal("screen front")16 t.Fatal("screen front")
17 }17 }
18- m.overviewSwipe(ActionFocusWorkspaceUp)18+ m.overviewFocus(ActionFocusWorkspaceUp)
19 if m.stackFront(w).kind != stackColumns || m.Active != 0 {19 if m.stackFront(w).kind != stackColumns || m.Active != 0 {
20 t.Fatal("swipe did not visit card")20 t.Fatal("swipe did not visit card")
21 }21 }
22- m.overviewSwipe(ActionFocusWorkspaceUp)22+ m.overviewFocus(ActionFocusWorkspaceUp)
23 if m.Active != 0 {23 if m.Active != 0 {
24 t.Fatal("up at top crossed workspace boundary")24 t.Fatal("up at top crossed workspace boundary")
25 }25 }
26- m.overviewSwipe(ActionFocusWorkspaceDown)26+ m.overviewFocus(ActionFocusWorkspaceDown)
27 if m.stackFront(w).kind != stackFloat || m.Active != 0 {27 if m.stackFront(w).kind != stackFloat || m.Active != 0 {
28 t.Fatal("down did not return to front")28 t.Fatal("down did not return to front")
29 }29 }
@@ -128,3 +128,64 @@ func TestOverviewStackMRUAndSize(t *testing.T) {
128 }128 }
129 m.CancelOverview()129 m.CancelOverview()
130 }130 }
131+
132+// browsedMonitor has window 1 on workspace 1 and columns 2..5 on workspace
133+// 2, focused on 5 with one column per screen, and shows workspace 1.
134+func browsedMonitor(ov Overflow) (*Monitor, *Workspace) {
135+ m := newMonitor("", "")
136+ m.SetOutput(300, 200)
137+ m.SetMaxColumns(1)
138+ m.AddWindow(1)
139+ m.Focus(1)
140+ for id := WindowID(2); id <= 5; id++ {
141+ m.AddWindow(id)
142+ }
143+ ws2 := m.Workspaces[1]
144+ ws2.Overflow = ov
145+ ws2.FocusID(5)
146+ ws2.scroll()
147+ m.Focus(0)
148+ return m, ws2
149+}
150+
151+// Escape gives every row the overview showed its focus and scroll back,
152+// as first shown, even after leaving and revisiting it.
153+func TestOverviewBrowsedRowsRestored(t *testing.T) {
154+ paths := map[string][][2]int{
155+ "browse": {{0, 1}, {-1, 0}, {-1, 0}},
156+ "revisit": {{0, 1}, {-1, 0}, {0, -1}, {0, 1}, {-1, 0}},
157+ }
158+ for _, ov := range []Overflow{OverflowScroll, OverflowFixed} {
159+ for name, path := range paths {
160+ m, ws2 := browsedMonitor(ov)
161+ view := ws2.ViewX
162+ m.ToggleOverview()
163+ for _, d := range path {
164+ m.OverviewMove(d[0], d[1])
165+ }
166+ m.CancelOverview()
167+ if id, _ := ws2.Focused(); id != 5 || ws2.ViewX != view || m.Current() != m.Workspaces[0] {
168+ t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.ViewX, view, m.Active)
169+ }
170+ }
171+ }
172+}
173+
174+func TestOverviewReturnRestoresBrowsedRows(t *testing.T) {
175+ m, ws2 := browsedMonitor(OverflowScroll)
176+ m.Focus(2)
177+ m.AddWindow(6)
178+ m.Focus(0)
179+ m.ToggleOverview()
180+ m.OverviewMove(0, 1)
181+ m.OverviewMove(-1, 0)
182+ m.OverviewMove(-1, 0)
183+ m.OverviewMove(0, 1)
184+ if m.Current() != m.Workspaces[2] {
185+ t.Fatal("not on workspace 3")
186+ }
187+ m.ToggleOverview()
188+ if id, _ := ws2.Focused(); id != 5 || m.Current() != m.Workspaces[2] {
189+ t.Fatalf("focus %d current %v", id, m.Active)
190+ }
191+}
A internal/core/overview_core_test.go +168 -0
@@ -0,0 +1,168 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// outputScene returns the scene of the named output in set.
11+func outputScene(t *testing.T, set []ports.Scene, name string) ports.Scene {
12+ t.Helper()
13+ for _, s := range set {
14+ if s.Output == name {
15+ return s
16+ }
17+ }
18+ t.Fatalf("no scene for %s", name)
19+ return ports.Scene{}
20+}
21+
22+// scenePreview returns window id's overview preview in s.
23+func scenePreview(s ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
24+ for _, w := range s.Windows {
25+ if w.ID == id && w.Preview > 0 {
26+ return w, true
27+ }
28+ }
29+ return ports.SceneWindow{}, false
30+}
31+
32+func hasPreview(s ports.Scene) bool {
33+ for _, w := range s.Windows {
34+ if w.Preview > 0 {
35+ return true
36+ }
37+ }
38+ return false
39+}
40+
41+// noButtonSent fails if core forwarded a click to a client.
42+func noButtonSent(t *testing.T, ch chan ports.ClientCommand) {
43+ t.Helper()
44+ for len(ch) > 0 {
45+ if v, ok := (<-ch).(ports.PointerButtonTo); ok {
46+ t.Fatalf("click reached a client: %+v", v)
47+ }
48+ }
49+}
50+
51+func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
52+ r := startMulti(t, nil, left, right)
53+ r.mapWindow(t, 1)
54+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
55+ r.mapWindow(t, 2)
56+ r.mapWindow(t, 3)
57+ set := r.key(t, "o", ports.ModAlt)
58+ p, ok := scenePreview(outputScene(t, set, "DP-2"), 2)
59+ if !ok {
60+ t.Fatal("no preview of window 2 on DP-2")
61+ }
62+ for len(r.commands) > 0 {
63+ <-r.commands
64+ }
65+ r.input <- ports.PointerMotion{X: float64(left.Width + p.Rect.X + p.Rect.W/2), Y: float64(p.Rect.Y + p.Rect.H/2)}
66+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
67+ deadline := time.After(time.Second)
68+ for picked := false; !picked; {
69+ select {
70+ case set := <-r.scenes:
71+ s := outputScene(t, set, "DP-2")
72+ for _, w := range s.Windows {
73+ if w.ID == 2 && w.Preview == 0 && w.Focused && !w.Hidden {
74+ picked = true
75+ }
76+ }
77+ case <-deadline:
78+ t.Fatal("window 2 not picked")
79+ }
80+ }
81+ noButtonSent(t, r.commands)
82+}
83+
84+func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) {
85+ r := startMulti(t, nil, right)
86+ r.mapWindow(t, 1)
87+ r.mapWindow(t, 2)
88+ set := r.key(t, "o", ports.ModAlt)
89+ for _, w := range set[0].Windows {
90+ if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 {
91+ t.Fatalf("(1, 1) is inside preview %+v", w)
92+ }
93+ }
94+ for len(r.commands) > 0 {
95+ <-r.commands
96+ }
97+ r.input <- ports.PointerMotion{X: 1, Y: 1}
98+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
99+ r.input <- ports.PointerButton{Button: 0x110}
100+ // h moves the selection in the overview; it would be a plain key
101+ // otherwise, with no scene.
102+ r.input <- ports.KeyEvent{Keysym: "h", Pressed: true}
103+ select {
104+ case s := <-r.scenes:
105+ if !hasPreview(s[0]) {
106+ t.Fatal("overview closed by a click outside the previews")
107+ }
108+ case <-time.After(time.Second):
109+ t.Fatal("overview closed by a click outside the previews: h published nothing")
110+ }
111+ noButtonSent(t, r.commands)
112+}
113+
114+func TestOverviewCoreEscapeRestoresFocus(t *testing.T) {
115+ r := startMulti(t, nil, right)
116+ r.mapWindow(t, 1)
117+ r.mapWindow(t, 2)
118+ r.mapWindow(t, 3)
119+ r.key(t, "o", ports.ModAlt)
120+ r.key(t, "h", 0)
121+ set := r.key(t, "h", 0)
122+ if p, ok := scenePreview(set[0], 1); !ok || !p.Focused {
123+ t.Fatalf("preview 1 not selected: %+v", set[0].Windows)
124+ }
125+ set = r.key(t, "Escape", 0)
126+ if hasPreview(set[0]) {
127+ t.Fatal("overview still open")
128+ }
129+ for _, w := range set[0].Windows {
130+ if w.Focused != (w.ID == 3) {
131+ t.Fatalf("focus: %+v", w)
132+ }
133+ }
134+}
135+
136+// The default move bind moves the selected preview and keeps the overview
137+// open, through the same path as any key.
138+func TestOverviewCoreMoveBind(t *testing.T) {
139+ r := startMulti(t, nil, right)
140+ r.mapWindow(t, 1)
141+ r.mapWindow(t, 2)
142+ r.mapWindow(t, 3)
143+ r.key(t, "o", ports.ModAlt)
144+ r.key(t, "h", 0)
145+ if set := r.key(t, "Next", ports.ModAlt|ports.ModShift); !hasPreview(set[0]) {
146+ t.Fatal("overview closed")
147+ }
148+ r.key(t, "Escape", 0)
149+ // The state channel keeps the latest snapshot: wait for the one after
150+ // Escape, back on window 3 of workspace 1. The overview showed window
151+ // 2 in the row below; closed, it is off screen on workspace 2.
152+ deadline := time.After(time.Second)
153+ for {
154+ select {
155+ case st := <-r.state:
156+ if st.Window == nil || st.Window.ID != 3 || st.Outputs[0].Active != 1 {
157+ continue
158+ }
159+ for _, w := range st.Windows {
160+ if w.ID == 2 && w.Workspace == 2 && !w.Visible {
161+ return
162+ }
163+ }
164+ case <-deadline:
165+ t.Fatal("window 2 not on workspace 2 after escape")
166+ }
167+ }
168+}
@@ -0,0 +1,168 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// outputScene returns the scene of the named output in set.
11+func outputScene(t *testing.T, set []ports.Scene, name string) ports.Scene {
12+ t.Helper()
13+ for _, s := range set {
14+ if s.Output == name {
15+ return s
16+ }
17+ }
18+ t.Fatalf("no scene for %s", name)
19+ return ports.Scene{}
20+}
21+
22+// scenePreview returns window id's overview preview in s.
23+func scenePreview(s ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
24+ for _, w := range s.Windows {
25+ if w.ID == id && w.Preview > 0 {
26+ return w, true
27+ }
28+ }
29+ return ports.SceneWindow{}, false
30+}
31+
32+func hasPreview(s ports.Scene) bool {
33+ for _, w := range s.Windows {
34+ if w.Preview > 0 {
35+ return true
36+ }
37+ }
38+ return false
39+}
40+
41+// noButtonSent fails if core forwarded a click to a client.
42+func noButtonSent(t *testing.T, ch chan ports.ClientCommand) {
43+ t.Helper()
44+ for len(ch) > 0 {
45+ if v, ok := (<-ch).(ports.PointerButtonTo); ok {
46+ t.Fatalf("click reached a client: %+v", v)
47+ }
48+ }
49+}
50+
51+func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
52+ r := startMulti(t, nil, left, right)
53+ r.mapWindow(t, 1)
54+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
55+ r.mapWindow(t, 2)
56+ r.mapWindow(t, 3)
57+ set := r.key(t, "o", ports.ModAlt)
58+ p, ok := scenePreview(outputScene(t, set, "DP-2"), 2)
59+ if !ok {
60+ t.Fatal("no preview of window 2 on DP-2")
61+ }
62+ for len(r.commands) > 0 {
63+ <-r.commands
64+ }
65+ r.input <- ports.PointerMotion{X: float64(left.Width + p.Rect.X + p.Rect.W/2), Y: float64(p.Rect.Y + p.Rect.H/2)}
66+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
67+ deadline := time.After(time.Second)
68+ for picked := false; !picked; {
69+ select {
70+ case set := <-r.scenes:
71+ s := outputScene(t, set, "DP-2")
72+ for _, w := range s.Windows {
73+ if w.ID == 2 && w.Preview == 0 && w.Focused && !w.Hidden {
74+ picked = true
75+ }
76+ }
77+ case <-deadline:
78+ t.Fatal("window 2 not picked")
79+ }
80+ }
81+ noButtonSent(t, r.commands)
82+}
83+
84+func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) {
85+ r := startMulti(t, nil, right)
86+ r.mapWindow(t, 1)
87+ r.mapWindow(t, 2)
88+ set := r.key(t, "o", ports.ModAlt)
89+ for _, w := range set[0].Windows {
90+ if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 {
91+ t.Fatalf("(1, 1) is inside preview %+v", w)
92+ }
93+ }
94+ for len(r.commands) > 0 {
95+ <-r.commands
96+ }
97+ r.input <- ports.PointerMotion{X: 1, Y: 1}
98+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
99+ r.input <- ports.PointerButton{Button: 0x110}
100+ // h moves the selection in the overview; it would be a plain key
101+ // otherwise, with no scene.
102+ r.input <- ports.KeyEvent{Keysym: "h", Pressed: true}
103+ select {
104+ case s := <-r.scenes:
105+ if !hasPreview(s[0]) {
106+ t.Fatal("overview closed by a click outside the previews")
107+ }
108+ case <-time.After(time.Second):
109+ t.Fatal("overview closed by a click outside the previews: h published nothing")
110+ }
111+ noButtonSent(t, r.commands)
112+}
113+
114+func TestOverviewCoreEscapeRestoresFocus(t *testing.T) {
115+ r := startMulti(t, nil, right)
116+ r.mapWindow(t, 1)
117+ r.mapWindow(t, 2)
118+ r.mapWindow(t, 3)
119+ r.key(t, "o", ports.ModAlt)
120+ r.key(t, "h", 0)
121+ set := r.key(t, "h", 0)
122+ if p, ok := scenePreview(set[0], 1); !ok || !p.Focused {
123+ t.Fatalf("preview 1 not selected: %+v", set[0].Windows)
124+ }
125+ set = r.key(t, "Escape", 0)
126+ if hasPreview(set[0]) {
127+ t.Fatal("overview still open")
128+ }
129+ for _, w := range set[0].Windows {
130+ if w.Focused != (w.ID == 3) {
131+ t.Fatalf("focus: %+v", w)
132+ }
133+ }
134+}
135+
136+// The default move bind moves the selected preview and keeps the overview
137+// open, through the same path as any key.
138+func TestOverviewCoreMoveBind(t *testing.T) {
139+ r := startMulti(t, nil, right)
140+ r.mapWindow(t, 1)
141+ r.mapWindow(t, 2)
142+ r.mapWindow(t, 3)
143+ r.key(t, "o", ports.ModAlt)
144+ r.key(t, "h", 0)
145+ if set := r.key(t, "Next", ports.ModAlt|ports.ModShift); !hasPreview(set[0]) {
146+ t.Fatal("overview closed")
147+ }
148+ r.key(t, "Escape", 0)
149+ // The state channel keeps the latest snapshot: wait for the one after
150+ // Escape, back on window 3 of workspace 1. The overview showed window
151+ // 2 in the row below; closed, it is off screen on workspace 2.
152+ deadline := time.After(time.Second)
153+ for {
154+ select {
155+ case st := <-r.state:
156+ if st.Window == nil || st.Window.ID != 3 || st.Outputs[0].Active != 1 {
157+ continue
158+ }
159+ for _, w := range st.Windows {
160+ if w.ID == 2 && w.Workspace == 2 && !w.Visible {
161+ return
162+ }
163+ }
164+ case <-deadline:
165+ t.Fatal("window 2 not on workspace 2 after escape")
166+ }
167+ }
168+}
A internal/core/overview_move_test.go +237 -0
@@ -0,0 +1,237 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// Move-to-workspace binds move the selected preview, not the focus the
9+// overview opened on, and keep the overview open.
10+func TestOverviewMove(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ action Action
14+ follow bool
15+ // row is the workspace index current after the move.
16+ row int
17+ }{
18+ {"column down", ActionMoveColumnToWorkspaceDown, false, 0},
19+ {"window down", ActionMoveWindowToWorkspaceDown, false, 0},
20+ {"column numbered", "move-column-to-workspace 2", false, 0},
21+ {"window numbered", "move-window-to-workspace 2", false, 0},
22+ {"follows", ActionMoveColumnToWorkspaceDown, true, 1},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ m := overviewMonitor()
27+ m.SetFollowMove(tt.follow)
28+ m.Workspaces[0].FocusID(3)
29+ m.ToggleOverview()
30+ m.OverviewMove(-1, 0)
31+ if m.overviewTarget() != 2 {
32+ t.Fatalf("selection %d, want 2", m.overviewTarget())
33+ }
34+ m.Apply(tt.action)
35+ if !slices.Equal(m.Workspaces[0].windows(), []WindowID{1, 3}) || !slices.Contains(m.Workspaces[1].windows(), 2) {
36+ t.Fatalf("window 2 not moved: %v", windows(m))
37+ }
38+ if !m.ov.open || m.Current() != m.Workspaces[tt.row] {
39+ t.Fatalf("open %v row %d", m.ov.open, m.Active)
40+ }
41+ if tt.follow && m.overviewTarget() != 2 {
42+ t.Fatalf("selection %d did not follow", m.overviewTarget())
43+ }
44+ m.CancelOverview()
45+ if !slices.Contains(m.Workspaces[1].windows(), 2) {
46+ t.Fatal("escape undid the move")
47+ }
48+ if id, _ := m.Workspaces[0].Focused(); m.Current() != m.Workspaces[0] || id != 3 {
49+ t.Fatalf("workspace %d focus %d", m.Active, id)
50+ }
51+ })
52+ }
53+}
54+
55+// The window variant moves only the selected window of a stacked column.
56+func TestOverviewMoveWindowFromStack(t *testing.T) {
57+ m := overviewMonitor()
58+ w := m.Current()
59+ w.FocusID(2)
60+ w.ConsumeOrExpel(-1)
61+ if len(w.Columns) != 2 {
62+ t.Fatalf("columns %v", windows(m))
63+ }
64+ w.FocusID(3)
65+ m.ToggleOverview()
66+ m.OverviewMove(-1, 0)
67+ if m.overviewTarget() != 2 {
68+ t.Fatalf("selection %d", m.overviewTarget())
69+ }
70+ m.Apply(ActionMoveWindowToWorkspaceDown)
71+ if !slices.Equal(w.windows(), []WindowID{1, 3}) || !slices.Contains(m.Workspaces[1].windows(), 2) {
72+ t.Fatalf("windows %v", windows(m))
73+ }
74+}
75+
76+// The selected column moves even when a covering float is the row's focus.
77+func TestOverviewMoveSelectedUnderFloat(t *testing.T) {
78+ m := stackMonitor()
79+ m.ToggleOverview()
80+ m.OverviewMove(0, -1)
81+ id := m.overviewTarget()
82+ if id == 0 || id == 9 {
83+ t.Fatalf("selection %d", id)
84+ }
85+ m.Apply(ActionMoveColumnToWorkspaceDown)
86+ if !slices.Contains(m.Workspaces[1].windows(), id) || m.Workspaces[1].isFloat(9) {
87+ t.Fatalf("moved the wrong window: %v floats %v", windows(m), m.Workspaces[0].Floats)
88+ }
89+ // The provisional stack front went with the moved column.
90+ if m.ov.row != nil || m.ov.selected != 0 {
91+ t.Fatalf("stale selection: row %v selected %d", m.ov.row != nil, m.ov.selected)
92+ }
93+}
94+
95+// A bind with nothing to move or nowhere to go leaves the overview as is.
96+func TestOverviewMoveNoop(t *testing.T) {
97+ tests := []struct {
98+ name string
99+ setup func() *Monitor
100+ action Action
101+ }{
102+ {"stash card", func() *Monitor {
103+ m := pileMonitor()
104+ m.ToggleOverview()
105+ m.OverviewMove(-1, 0)
106+ if m.cardAt(m.Current()) < 0 {
107+ t.Fatal("no stash card selected")
108+ }
109+ return m
110+ }, ActionMoveColumnToWorkspaceDown},
111+ {"hidden small float", func() *Monitor {
112+ m := newMonitor("", "")
113+ m.SetOutput(300, 200)
114+ m.AddFloating(9, 50, 50)
115+ m.ToggleOverview()
116+ return m
117+ }, ActionMoveWindowToWorkspaceDown},
118+ {"up from first", func() *Monitor {
119+ m := overviewMonitor()
120+ m.ToggleOverview()
121+ m.OverviewMove(-1, 0)
122+ return m
123+ }, ActionMoveColumnToWorkspaceUp},
124+ {"to its own row", func() *Monitor {
125+ m := overviewMonitor()
126+ m.ToggleOverview()
127+ m.OverviewMove(-1, 0)
128+ return m
129+ }, "move-column-to-workspace 1"},
130+ {"down from named", func() *Monitor {
131+ m := namedOverviewMonitor()
132+ m.ToggleNamed("game")
133+ m.ToggleOverview()
134+ return m
135+ }, ActionMoveColumnToWorkspaceDown},
136+ {"fixed maximize kept", func() *Monitor {
137+ m := overviewMonitor()
138+ w := m.Current()
139+ w.Overflow = OverflowFixed
140+ w.FocusID(3)
141+ w.ToggleFullWidth()
142+ m.ToggleOverview()
143+ m.OverviewMove(0, -1)
144+ return m
145+ }, ActionMoveColumnToWorkspaceUp},
146+ }
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ m := tt.setup()
150+ before, ws := m.Layout(), windows(m)
151+ m.Apply(tt.action)
152+ if !slices.Equal(before, m.Layout()) || !slices.EqualFunc(ws, windows(m), slices.Equal) {
153+ t.Fatalf("changed: %v -> %v", ws, windows(m))
154+ }
155+ })
156+ }
157+}
158+
159+// A browsed destination row keeps the moved window focused on close.
160+func TestOverviewMoveDestinationKeepsFocus(t *testing.T) {
161+ m := overviewMonitor()
162+ m.ToggleOverview()
163+ m.OverviewMove(0, 1)
164+ m.OverviewMove(0, -1)
165+ m.OverviewMove(-1, 0)
166+ m.Apply(ActionMoveColumnToWorkspaceDown)
167+ m.ToggleOverview()
168+ if id, _ := m.Workspaces[1].Focused(); id != 2 {
169+ t.Fatalf("destination focus %d, want 2", id)
170+ }
171+}
172+
173+// Closing after a move drops the moved window from the restored maximize
174+// history.
175+func TestOverviewMoveDropsMaximizeHistory(t *testing.T) {
176+ m := overviewMonitor()
177+ w := m.Current()
178+ w.Overflow = OverflowFixed
179+ w.FocusID(3)
180+ w.ToggleFullWidth()
181+ m.ToggleOverview()
182+ m.Apply(ActionMoveColumnToWorkspaceDown)
183+ if w.has(3) {
184+ t.Fatal("3 not moved")
185+ }
186+ m.CancelOverview()
187+ if slices.Contains(w.maximized, 3) {
188+ t.Fatalf("maximized %v keeps a moved window", w.maximized)
189+ }
190+}
191+
192+// A fixed fullscreen row moves its window like the bind outside the
193+// overview: fullscreen ends, and Escape leaves the row on a window still
194+// there.
195+func TestOverviewMoveFromFullscreenRow(t *testing.T) {
196+ for _, follow := range []bool{false, true} {
197+ m := newMonitor("", "")
198+ m.SetOutput(300, 200)
199+ m.SetMaxColumns(4)
200+ for id := WindowID(1); id <= 4; id++ {
201+ m.AddWindow(id)
202+ }
203+ w := m.Current()
204+ w.Overflow = OverflowFixed
205+ w.FocusID(2)
206+ m.ToggleFullscreen()
207+ m.SetFollowMove(follow)
208+ m.ToggleOverview()
209+ if m.overviewTarget() != 2 {
210+ t.Fatalf("follow %v: selection %d", follow, m.overviewTarget())
211+ }
212+ m.Apply(ActionMoveColumnToWorkspaceDown)
213+ if !slices.Equal(w.windows(), []WindowID{1, 3, 4}) || !slices.Equal(m.Workspaces[1].windows(), []WindowID{2}) {
214+ t.Fatalf("follow %v: windows %v", follow, windows(m))
215+ }
216+ if w.fullscreen != 0 || m.Workspaces[1].fullscreen != 0 {
217+ t.Fatalf("follow %v: fullscreen kept", follow)
218+ }
219+ m.CancelOverview()
220+ if id, ok := w.Focused(); !ok || !w.has(id) || m.Current() != w {
221+ t.Fatalf("follow %v: focus %d on workspace %d", follow, id, m.Active)
222+ }
223+ }
224+}
225+
226+// A move from a browsed row back to the opening row keeps the moved window
227+// focused there on Escape, like any destination.
228+func TestOverviewMoveBackToOpeningRow(t *testing.T) {
229+ m := overviewMonitor()
230+ m.ToggleOverview()
231+ m.OverviewMove(0, 1)
232+ m.Apply("move-column-to-workspace 1")
233+ m.CancelOverview()
234+ if id, _ := m.Current().Focused(); m.Current() != m.Workspaces[0] || id != 4 {
235+ t.Fatalf("workspace %d focus %d, want 4 on 1 (%v)", m.Active, id, windows(m))
236+ }
237+}
@@ -0,0 +1,237 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// Move-to-workspace binds move the selected preview, not the focus the
9+// overview opened on, and keep the overview open.
10+func TestOverviewMove(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ action Action
14+ follow bool
15+ // row is the workspace index current after the move.
16+ row int
17+ }{
18+ {"column down", ActionMoveColumnToWorkspaceDown, false, 0},
19+ {"window down", ActionMoveWindowToWorkspaceDown, false, 0},
20+ {"column numbered", "move-column-to-workspace 2", false, 0},
21+ {"window numbered", "move-window-to-workspace 2", false, 0},
22+ {"follows", ActionMoveColumnToWorkspaceDown, true, 1},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ m := overviewMonitor()
27+ m.SetFollowMove(tt.follow)
28+ m.Workspaces[0].FocusID(3)
29+ m.ToggleOverview()
30+ m.OverviewMove(-1, 0)
31+ if m.overviewTarget() != 2 {
32+ t.Fatalf("selection %d, want 2", m.overviewTarget())
33+ }
34+ m.Apply(tt.action)
35+ if !slices.Equal(m.Workspaces[0].windows(), []WindowID{1, 3}) || !slices.Contains(m.Workspaces[1].windows(), 2) {
36+ t.Fatalf("window 2 not moved: %v", windows(m))
37+ }
38+ if !m.ov.open || m.Current() != m.Workspaces[tt.row] {
39+ t.Fatalf("open %v row %d", m.ov.open, m.Active)
40+ }
41+ if tt.follow && m.overviewTarget() != 2 {
42+ t.Fatalf("selection %d did not follow", m.overviewTarget())
43+ }
44+ m.CancelOverview()
45+ if !slices.Contains(m.Workspaces[1].windows(), 2) {
46+ t.Fatal("escape undid the move")
47+ }
48+ if id, _ := m.Workspaces[0].Focused(); m.Current() != m.Workspaces[0] || id != 3 {
49+ t.Fatalf("workspace %d focus %d", m.Active, id)
50+ }
51+ })
52+ }
53+}
54+
55+// The window variant moves only the selected window of a stacked column.
56+func TestOverviewMoveWindowFromStack(t *testing.T) {
57+ m := overviewMonitor()
58+ w := m.Current()
59+ w.FocusID(2)
60+ w.ConsumeOrExpel(-1)
61+ if len(w.Columns) != 2 {
62+ t.Fatalf("columns %v", windows(m))
63+ }
64+ w.FocusID(3)
65+ m.ToggleOverview()
66+ m.OverviewMove(-1, 0)
67+ if m.overviewTarget() != 2 {
68+ t.Fatalf("selection %d", m.overviewTarget())
69+ }
70+ m.Apply(ActionMoveWindowToWorkspaceDown)
71+ if !slices.Equal(w.windows(), []WindowID{1, 3}) || !slices.Contains(m.Workspaces[1].windows(), 2) {
72+ t.Fatalf("windows %v", windows(m))
73+ }
74+}
75+
76+// The selected column moves even when a covering float is the row's focus.
77+func TestOverviewMoveSelectedUnderFloat(t *testing.T) {
78+ m := stackMonitor()
79+ m.ToggleOverview()
80+ m.OverviewMove(0, -1)
81+ id := m.overviewTarget()
82+ if id == 0 || id == 9 {
83+ t.Fatalf("selection %d", id)
84+ }
85+ m.Apply(ActionMoveColumnToWorkspaceDown)
86+ if !slices.Contains(m.Workspaces[1].windows(), id) || m.Workspaces[1].isFloat(9) {
87+ t.Fatalf("moved the wrong window: %v floats %v", windows(m), m.Workspaces[0].Floats)
88+ }
89+ // The provisional stack front went with the moved column.
90+ if m.ov.row != nil || m.ov.selected != 0 {
91+ t.Fatalf("stale selection: row %v selected %d", m.ov.row != nil, m.ov.selected)
92+ }
93+}
94+
95+// A bind with nothing to move or nowhere to go leaves the overview as is.
96+func TestOverviewMoveNoop(t *testing.T) {
97+ tests := []struct {
98+ name string
99+ setup func() *Monitor
100+ action Action
101+ }{
102+ {"stash card", func() *Monitor {
103+ m := pileMonitor()
104+ m.ToggleOverview()
105+ m.OverviewMove(-1, 0)
106+ if m.cardAt(m.Current()) < 0 {
107+ t.Fatal("no stash card selected")
108+ }
109+ return m
110+ }, ActionMoveColumnToWorkspaceDown},
111+ {"hidden small float", func() *Monitor {
112+ m := newMonitor("", "")
113+ m.SetOutput(300, 200)
114+ m.AddFloating(9, 50, 50)
115+ m.ToggleOverview()
116+ return m
117+ }, ActionMoveWindowToWorkspaceDown},
118+ {"up from first", func() *Monitor {
119+ m := overviewMonitor()
120+ m.ToggleOverview()
121+ m.OverviewMove(-1, 0)
122+ return m
123+ }, ActionMoveColumnToWorkspaceUp},
124+ {"to its own row", func() *Monitor {
125+ m := overviewMonitor()
126+ m.ToggleOverview()
127+ m.OverviewMove(-1, 0)
128+ return m
129+ }, "move-column-to-workspace 1"},
130+ {"down from named", func() *Monitor {
131+ m := namedOverviewMonitor()
132+ m.ToggleNamed("game")
133+ m.ToggleOverview()
134+ return m
135+ }, ActionMoveColumnToWorkspaceDown},
136+ {"fixed maximize kept", func() *Monitor {
137+ m := overviewMonitor()
138+ w := m.Current()
139+ w.Overflow = OverflowFixed
140+ w.FocusID(3)
141+ w.ToggleFullWidth()
142+ m.ToggleOverview()
143+ m.OverviewMove(0, -1)
144+ return m
145+ }, ActionMoveColumnToWorkspaceUp},
146+ }
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ m := tt.setup()
150+ before, ws := m.Layout(), windows(m)
151+ m.Apply(tt.action)
152+ if !slices.Equal(before, m.Layout()) || !slices.EqualFunc(ws, windows(m), slices.Equal) {
153+ t.Fatalf("changed: %v -> %v", ws, windows(m))
154+ }
155+ })
156+ }
157+}
158+
159+// A browsed destination row keeps the moved window focused on close.
160+func TestOverviewMoveDestinationKeepsFocus(t *testing.T) {
161+ m := overviewMonitor()
162+ m.ToggleOverview()
163+ m.OverviewMove(0, 1)
164+ m.OverviewMove(0, -1)
165+ m.OverviewMove(-1, 0)
166+ m.Apply(ActionMoveColumnToWorkspaceDown)
167+ m.ToggleOverview()
168+ if id, _ := m.Workspaces[1].Focused(); id != 2 {
169+ t.Fatalf("destination focus %d, want 2", id)
170+ }
171+}
172+
173+// Closing after a move drops the moved window from the restored maximize
174+// history.
175+func TestOverviewMoveDropsMaximizeHistory(t *testing.T) {
176+ m := overviewMonitor()
177+ w := m.Current()
178+ w.Overflow = OverflowFixed
179+ w.FocusID(3)
180+ w.ToggleFullWidth()
181+ m.ToggleOverview()
182+ m.Apply(ActionMoveColumnToWorkspaceDown)
183+ if w.has(3) {
184+ t.Fatal("3 not moved")
185+ }
186+ m.CancelOverview()
187+ if slices.Contains(w.maximized, 3) {
188+ t.Fatalf("maximized %v keeps a moved window", w.maximized)
189+ }
190+}
191+
192+// A fixed fullscreen row moves its window like the bind outside the
193+// overview: fullscreen ends, and Escape leaves the row on a window still
194+// there.
195+func TestOverviewMoveFromFullscreenRow(t *testing.T) {
196+ for _, follow := range []bool{false, true} {
197+ m := newMonitor("", "")
198+ m.SetOutput(300, 200)
199+ m.SetMaxColumns(4)
200+ for id := WindowID(1); id <= 4; id++ {
201+ m.AddWindow(id)
202+ }
203+ w := m.Current()
204+ w.Overflow = OverflowFixed
205+ w.FocusID(2)
206+ m.ToggleFullscreen()
207+ m.SetFollowMove(follow)
208+ m.ToggleOverview()
209+ if m.overviewTarget() != 2 {
210+ t.Fatalf("follow %v: selection %d", follow, m.overviewTarget())
211+ }
212+ m.Apply(ActionMoveColumnToWorkspaceDown)
213+ if !slices.Equal(w.windows(), []WindowID{1, 3, 4}) || !slices.Equal(m.Workspaces[1].windows(), []WindowID{2}) {
214+ t.Fatalf("follow %v: windows %v", follow, windows(m))
215+ }
216+ if w.fullscreen != 0 || m.Workspaces[1].fullscreen != 0 {
217+ t.Fatalf("follow %v: fullscreen kept", follow)
218+ }
219+ m.CancelOverview()
220+ if id, ok := w.Focused(); !ok || !w.has(id) || m.Current() != w {
221+ t.Fatalf("follow %v: focus %d on workspace %d", follow, id, m.Active)
222+ }
223+ }
224+}
225+
226+// A move from a browsed row back to the opening row keeps the moved window
227+// focused there on Escape, like any destination.
228+func TestOverviewMoveBackToOpeningRow(t *testing.T) {
229+ m := overviewMonitor()
230+ m.ToggleOverview()
231+ m.OverviewMove(0, 1)
232+ m.Apply("move-column-to-workspace 1")
233+ m.CancelOverview()
234+ if id, _ := m.Current().Focused(); m.Current() != m.Workspaces[0] || id != 4 {
235+ t.Fatalf("workspace %d focus %d, want 4 on 1 (%v)", m.Active, id, windows(m))
236+ }
237+}
M internal/core/overview_named_test.go +1 -1
@@ -410,7 +410,7 @@ func TestOverviewNamedDividerPublished(t *testing.T) {
410410 func TestOverviewNamedScrollAndSwipe(t *testing.T) {
411411 m := namedOverviewMonitor()
412412 m.ToggleOverview()
413- m.overviewSwipe(ActionFocusWorkspaceDown)
413+ m.overviewFocus(ActionFocusWorkspaceDown)
414414 if m.Current().Name != "dev" {
415415 t.Fatal("swipe did not cross groups")
416416 }
@@ -410,7 +410,7 @@ func TestOverviewNamedDividerPublished(t *testing.T) {
410 func TestOverviewNamedScrollAndSwipe(t *testing.T) {410 func TestOverviewNamedScrollAndSwipe(t *testing.T) {
411 m := namedOverviewMonitor()411 m := namedOverviewMonitor()
412 m.ToggleOverview()412 m.ToggleOverview()
413- m.overviewSwipe(ActionFocusWorkspaceDown)413+ m.overviewFocus(ActionFocusWorkspaceDown)
414 if m.Current().Name != "dev" {414 if m.Current().Name != "dev" {
415 t.Fatal("swipe did not cross groups")415 t.Fatal("swipe did not cross groups")
416 }416 }
M internal/core/overview_regression_test.go +1 -4
@@ -125,11 +125,8 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) {
125125 actions := []Action{
126126 ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
127127 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
128- ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp,
129- ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp,
130- ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft,
128+ ActionToggleStashVisible, ActionConsumeOrExpelLeft,
131129 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
132- "move-column-to-workspace 2", "move-window-to-workspace 2",
133130 }
134131 for _, a := range actions {
135132 t.Run(string(a), func(t *testing.T) {
@@ -125,11 +125,8 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) {
125 actions := []Action{125 actions := []Action{
126 ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,126 ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
127 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,127 ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
128- ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp,128+ ActionToggleStashVisible, ActionConsumeOrExpelLeft,
129- ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp,
130- ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft,
131 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,129 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
132- "move-column-to-workspace 2", "move-window-to-workspace 2",
133 }130 }
134 for _, a := range actions {131 for _, a := range actions {
135 t.Run(string(a), func(t *testing.T) {132 t.Run(string(a), func(t *testing.T) {
M internal/core/overview_stack.go +1 -1
@@ -146,7 +146,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
146146 return i
147147 }
148148 }
149- if i := w.columnOf(m.ov.fromID); w == m.ov.from && i >= 0 && i != w.Focus {
149+ if i := w.columnOf(m.ov.rows[m.ov.from].focus); w == m.ov.from && i >= 0 && i != w.Focus {
150150 return i
151151 }
152152 for i := range w.Columns {
@@ -146,7 +146,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
146 return i146 return i
147 }147 }
148 }148 }
149- if i := w.columnOf(m.ov.fromID); w == m.ov.from && i >= 0 && i != w.Focus {149+ if i := w.columnOf(m.ov.rows[m.ov.from].focus); w == m.ov.from && i >= 0 && i != w.Focus {
150 return i150 return i
151 }151 }
152 for i := range w.Columns {152 for i := range w.Columns {
M internal/core/overview_stack_test.go +21 -0
@@ -277,3 +277,24 @@ func TestOverviewStackNoFloatVeil(t *testing.T) {
277277 t.Fatalf("veil %v in the overview", d)
278278 }
279279 }
280+
281+// l past the last stash card returns to the front card, even with no columns.
282+func TestOverviewPileRightToFloatCard(t *testing.T) {
283+ m := monitor()
284+ m.SetOutput(300, 200)
285+ m.SetBorder(2)
286+ w := m.Current()
287+ m.AddWindow(1)
288+ w.ToggleWindowStash()
289+ w.ToggleStashVisible()
290+ w.AddFloating(9, 300, 200)
291+ m.ToggleOverview()
292+ m.OverviewMove(-1, 0)
293+ if m.card() != 1 {
294+ t.Fatalf("h did not enter the pile: card %d", m.card())
295+ }
296+ m.OverviewMove(1, 0)
297+ if m.card() != 0 || m.overviewTarget() != 9 {
298+ t.Fatalf("l stayed in the pile: card %d target %d", m.card(), m.overviewTarget())
299+ }
300+}
@@ -277,3 +277,24 @@ func TestOverviewStackNoFloatVeil(t *testing.T) {
277 t.Fatalf("veil %v in the overview", d)277 t.Fatalf("veil %v in the overview", d)
278 }278 }
279 }279 }
280+
281+// l past the last stash card returns to the front card, even with no columns.
282+func TestOverviewPileRightToFloatCard(t *testing.T) {
283+ m := monitor()
284+ m.SetOutput(300, 200)
285+ m.SetBorder(2)
286+ w := m.Current()
287+ m.AddWindow(1)
288+ w.ToggleWindowStash()
289+ w.ToggleStashVisible()
290+ w.AddFloating(9, 300, 200)
291+ m.ToggleOverview()
292+ m.OverviewMove(-1, 0)
293+ if m.card() != 1 {
294+ t.Fatalf("h did not enter the pile: card %d", m.card())
295+ }
296+ m.OverviewMove(1, 0)
297+ if m.card() != 0 || m.overviewTarget() != 9 {
298+ t.Fatalf("l stayed in the pile: card %d target %d", m.card(), m.overviewTarget())
299+ }
300+}

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