detached 92e4f5a

Merge pull request 'feat(overview): present cascade bands as dimmed lanes' (#13) from feat/overview-cascade into main

commit
92e4f5a6b4a14fe159242a66ff6afa16c3997b33
author
bnema · · 2026-10-07 07:37 UTC
merge of
0c37670 + 61bb6e0 · diff against the first parent
signature
unsigned
9 files · +1512 -136
M docs/config.md +9 -5
@@ -196,7 +196,11 @@ Left/right focus follows column order across band boundaries. Up/down visits sta
196196
197197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
198198
199-The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
199+In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot.
200+
201+In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
202+
203+Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
200204
201205 ### Actions
202206
@@ -257,7 +261,7 @@ The drop can be on another monitor; focus follows the window. There, the middle
257261
258262 ### Overview
259263
260-`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. 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 previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
264+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. 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, and in a vertical overview up to two cards behind peek above it and passed cards peek below (a Cascade overview fans them left and right). Neighbor workspaces show dimmed stacks. Each monitor has one overview, laid out along the navigation axis of the current workspace's layout: neighbors appear above and below a `scroll` or `fixed` workspace and left and right of a `cascade` one (see [Cascade](#cascade)); stepping to a workspace with another layout turns the 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 rules across the axis (horizontal in a vertical overview, vertical in a side-by-side one) 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 beside it along that axis; in a vertical overview, 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 previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
261265
262266 | Key | Action |
263267 | --- | --- |
@@ -266,11 +270,11 @@ The drop can be on another monitor; focus follows the window. There, the middle
266270 | `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 |
267271 | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order |
268272
269-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-prev/next` also traverse cards before crossing workspace rows, including named ones, except inside a selected stash pile.
273+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-prev/next` also traverse cards before crossing workspace rows, including named ones, except inside a selected stash pile. The keys above describe vertical overviews; in a [Cascade](#cascade) overview they move as described there.
270274
271-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, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. Right (`l`) leaves the pile immediately; left (`h`) stays in the pile. On a stash-only workspace, right deselects the pile so up/down can change workspace. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window.
275+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 (in a Cascade overview the pile extends its workspace's slot to the left): its selected window in front, up to three others behind it, dimmed. In the pile, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. In a vertical overview, right (`l`) leaves the pile immediately, left (`h`) stays in the pile, and on a stash-only workspace right deselects the pile so up/down can change workspace. In a Cascade overview, left from the pile goes to the previous workspace, right returns to the band's first column (or to the next workspace on a stash-only workspace), and up/down browse the pile. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window.
272276
273-A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. Three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries.
277+A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. In a vertical overview, three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. A Cascade overview follows the fingers instead (see [Cascade](#cascade)). The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries.
274278
275279 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.
276280
@@ -196,7 +196,11 @@ Left/right focus follows column order across band boundaries. Up/down visits sta
196 196
197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
198 198
199-The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.199+In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot.
200+
201+In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
202+
203+Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
200 204
201 ### Actions205 ### Actions
202 206
@@ -257,7 +261,7 @@ The drop can be on another monitor; focus follows the window. There, the middle
257 261
258 ### Overview262 ### Overview
259 263
260-`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. 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 previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.264+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. 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, and in a vertical overview up to two cards behind peek above it and passed cards peek below (a Cascade overview fans them left and right). Neighbor workspaces show dimmed stacks. Each monitor has one overview, laid out along the navigation axis of the current workspace's layout: neighbors appear above and below a `scroll` or `fixed` workspace and left and right of a `cascade` one (see [Cascade](#cascade)); stepping to a workspace with another layout turns the 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 rules across the axis (horizontal in a vertical overview, vertical in a side-by-side one) 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 beside it along that axis; in a vertical overview, 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 previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
261 265
262 | Key | Action |266 | Key | Action |
263 | --- | --- |267 | --- | --- |
@@ -266,11 +270,11 @@ The drop can be on another monitor; focus follows the window. There, the middle
266 | `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 |270 | `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 |
267 | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order |271 | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order |
268 272
269-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-prev/next` also traverse cards before crossing workspace rows, including named ones, except inside a selected stash pile.273+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-prev/next` also traverse cards before crossing workspace rows, including named ones, except inside a selected stash pile. The keys above describe vertical overviews; in a [Cascade](#cascade) overview they move as described there.
270 274
271-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, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. Right (`l`) leaves the pile immediately; left (`h`) stays in the pile. On a stash-only workspace, right deselects the pile so up/down can change workspace. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window.275+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 (in a Cascade overview the pile extends its workspace's slot to the left): its selected window in front, up to three others behind it, dimmed. In the pile, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. In a vertical overview, right (`l`) leaves the pile immediately, left (`h`) stays in the pile, and on a stash-only workspace right deselects the pile so up/down can change workspace. In a Cascade overview, left from the pile goes to the previous workspace, right returns to the band's first column (or to the next workspace on a stash-only workspace), and up/down browse the pile. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window.
272 276
273-A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. Three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries.277+A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. In a vertical overview, three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. A Cascade overview follows the fingers instead (see [Cascade](#cascade)). The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries.
274 278
275 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.279 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.
276 280
M internal/core/cascade.go +36 -10
@@ -22,7 +22,7 @@ func (w *Workspace) cellInBand(b int) int {
2222 return min(b*k+w.Focus%k, len(w.Columns)-1)
2323 }
2424 func (w *Workspace) focusBand(dir int) bool {
25- i := w.bandNeighbor(dir)
25+ i := w.laneNeighbor(dir)
2626 if i < 0 {
2727 return false
2828 }
@@ -36,15 +36,6 @@ func (w *Workspace) focusBand(dir int) bool {
3636 w.scroll()
3737 return true
3838 }
39-func (w *Workspace) bandNeighbor(dir int) int {
40- k := max(w.MaxColumns, 1)
41- b := w.band(w.Focus) + dir
42- if b < 0 || b*k >= len(w.Columns) {
43- return -1
44- }
45- return w.cellInBand(b)
46-}
47-
4839 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
4940 g := w.gap()
5041 width := w.cellWidth()
@@ -77,3 +68,38 @@ func (w *Workspace) cascadeFocus(view int) int {
7768 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
7869 return w.cellInBand(b)
7970 }
71+
72+// laneOf is the lane of column i in the overview: its band, or the only
73+// lane of a layout that does not wrap.
74+func (w *Workspace) laneOf(i int) int {
75+ if w.policy().wraps {
76+ return w.band(i)
77+ }
78+ return 0
79+}
80+
81+// laneBounds is the range of columns of a lane, first > last when the lane
82+// does not exist. A layout that does not wrap has the whole row as lane 0.
83+func (w *Workspace) laneBounds(lane int) (first, last int) {
84+ k := len(w.Columns)
85+ if w.policy().wraps {
86+ k = max(w.MaxColumns, 1)
87+ }
88+ first = lane * k
89+ if lane < 0 || first >= len(w.Columns) {
90+ return 0, -1
91+ }
92+ return first, min(first+k, len(w.Columns)) - 1
93+}
94+
95+// laneNeighbor is the closest column to the focus in the lane d lanes away,
96+// the focus's offset in its lane kept; -1 past the first or last lane.
97+func (w *Workspace) laneNeighbor(d int) int {
98+ lane := w.laneOf(w.Focus)
99+ first, _ := w.laneBounds(lane)
100+ nfirst, nlast := w.laneBounds(lane + d)
101+ if nfirst > nlast {
102+ return -1
103+ }
104+ return min(nfirst+w.Focus-first, nlast)
105+}
@@ -22,7 +22,7 @@ func (w *Workspace) cellInBand(b int) int {
22 return min(b*k+w.Focus%k, len(w.Columns)-1)22 return min(b*k+w.Focus%k, len(w.Columns)-1)
23 }23 }
24 func (w *Workspace) focusBand(dir int) bool {24 func (w *Workspace) focusBand(dir int) bool {
25- i := w.bandNeighbor(dir)25+ i := w.laneNeighbor(dir)
26 if i < 0 {26 if i < 0 {
27 return false27 return false
28 }28 }
@@ -36,15 +36,6 @@ func (w *Workspace) focusBand(dir int) bool {
36 w.scroll()36 w.scroll()
37 return true37 return true
38 }38 }
39-func (w *Workspace) bandNeighbor(dir int) int {
40- k := max(w.MaxColumns, 1)
41- b := w.band(w.Focus) + dir
42- if b < 0 || b*k >= len(w.Columns) {
43- return -1
44- }
45- return w.cellInBand(b)
46-}
47-
48 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {39 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
49 g := w.gap()40 g := w.gap()
50 width := w.cellWidth()41 width := w.cellWidth()
@@ -77,3 +68,38 @@ func (w *Workspace) cascadeFocus(view int) int {
77 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))68 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
78 return w.cellInBand(b)69 return w.cellInBand(b)
79 }70 }
71+
72+// laneOf is the lane of column i in the overview: its band, or the only
73+// lane of a layout that does not wrap.
74+func (w *Workspace) laneOf(i int) int {
75+ if w.policy().wraps {
76+ return w.band(i)
77+ }
78+ return 0
79+}
80+
81+// laneBounds is the range of columns of a lane, first > last when the lane
82+// does not exist. A layout that does not wrap has the whole row as lane 0.
83+func (w *Workspace) laneBounds(lane int) (first, last int) {
84+ k := len(w.Columns)
85+ if w.policy().wraps {
86+ k = max(w.MaxColumns, 1)
87+ }
88+ first = lane * k
89+ if lane < 0 || first >= len(w.Columns) {
90+ return 0, -1
91+ }
92+ return first, min(first+k, len(w.Columns)) - 1
93+}
94+
95+// laneNeighbor is the closest column to the focus in the lane d lanes away,
96+// the focus's offset in its lane kept; -1 past the first or last lane.
97+func (w *Workspace) laneNeighbor(d int) int {
98+ lane := w.laneOf(w.Focus)
99+ first, _ := w.laneBounds(lane)
100+ nfirst, nlast := w.laneBounds(lane + d)
101+ if nfirst > nlast {
102+ return -1
103+ }
104+ return min(nfirst+w.Focus-first, nlast)
105+}
M internal/core/cascade_test.go +56 -0
@@ -166,6 +166,62 @@ func TestCascadeGeometryReusesBuffer(t *testing.T) {
166166 if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
167167 t.Fatalf("allocations=%v", n)
168168 }
169+ // The overview's lane-encoded tiles reuse their buffer too.
170+ tiles, _, _ := w.previewTiles()
171+ if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 {
172+ t.Fatalf("preview tile allocations=%v", n)
173+ }
174+}
175+
176+// Lanes are the bands of a cascade workspace and the whole row of any other.
177+func TestLaneGeometry(t *testing.T) {
178+ w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6}
179+ for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} {
180+ if got := w.laneOf(c.i); got != c.lane {
181+ t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane)
182+ }
183+ }
184+ for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} {
185+ if first, last := w.laneBounds(lane); first != want[0] || last != want[1] {
186+ t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want)
187+ }
188+ }
189+ for _, lane := range []int{-1, 3} {
190+ if first, last := w.laneBounds(lane); first <= last {
191+ t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last)
192+ }
193+ }
194+ // The closest column of the adjacent lane keeps its offset, clamped.
195+ for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} {
196+ w.Focus = c.focus
197+ if got := w.laneNeighbor(c.d); got != c.want {
198+ t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want)
199+ }
200+ }
201+
202+ s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3}
203+ s.SetOutput(900, 600)
204+ for i := 1; i <= 5; i++ {
205+ s.AddWindow(WindowID(i))
206+ }
207+ for i := range s.Columns {
208+ if s.laneOf(i) != 0 {
209+ t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i))
210+ }
211+ }
212+ if first, last := s.laneBounds(0); first != 0 || last != 4 {
213+ t.Fatalf("scroll laneBounds(0)=%d,%d", first, last)
214+ }
215+ for _, lane := range []int{-1, 1} {
216+ if first, last := s.laneBounds(lane); first <= last {
217+ t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last)
218+ }
219+ }
220+ for _, d := range []int{-1, 1} {
221+ if got := s.laneNeighbor(d); got != -1 {
222+ t.Fatalf("scroll laneNeighbor(%d)=%d", d, got)
223+ }
224+ }
169225 }
170226
171227 func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
@@ -166,6 +166,62 @@ func TestCascadeGeometryReusesBuffer(t *testing.T) {
166 if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {166 if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
167 t.Fatalf("allocations=%v", n)167 t.Fatalf("allocations=%v", n)
168 }168 }
169+ // The overview's lane-encoded tiles reuse their buffer too.
170+ tiles, _, _ := w.previewTiles()
171+ if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 {
172+ t.Fatalf("preview tile allocations=%v", n)
173+ }
174+}
175+
176+// Lanes are the bands of a cascade workspace and the whole row of any other.
177+func TestLaneGeometry(t *testing.T) {
178+ w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6}
179+ for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} {
180+ if got := w.laneOf(c.i); got != c.lane {
181+ t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane)
182+ }
183+ }
184+ for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} {
185+ if first, last := w.laneBounds(lane); first != want[0] || last != want[1] {
186+ t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want)
187+ }
188+ }
189+ for _, lane := range []int{-1, 3} {
190+ if first, last := w.laneBounds(lane); first <= last {
191+ t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last)
192+ }
193+ }
194+ // The closest column of the adjacent lane keeps its offset, clamped.
195+ for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} {
196+ w.Focus = c.focus
197+ if got := w.laneNeighbor(c.d); got != c.want {
198+ t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want)
199+ }
200+ }
201+
202+ s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3}
203+ s.SetOutput(900, 600)
204+ for i := 1; i <= 5; i++ {
205+ s.AddWindow(WindowID(i))
206+ }
207+ for i := range s.Columns {
208+ if s.laneOf(i) != 0 {
209+ t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i))
210+ }
211+ }
212+ if first, last := s.laneBounds(0); first != 0 || last != 4 {
213+ t.Fatalf("scroll laneBounds(0)=%d,%d", first, last)
214+ }
215+ for _, lane := range []int{-1, 1} {
216+ if first, last := s.laneBounds(lane); first <= last {
217+ t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last)
218+ }
219+ }
220+ for _, d := range []int{-1, 1} {
221+ if got := s.laneNeighbor(d); got != -1 {
222+ t.Fatalf("scroll laneNeighbor(%d)=%d", d, got)
223+ }
224+ }
169 }225 }
170 226
171 func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {227 func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
M internal/core/core.go +11 -0
@@ -287,6 +287,11 @@ func (c *Core) apply(cfg ports.Config) error {
287287 if err != nil {
288288 return err
289289 }
290+ // Reload is cold-path work; unrelated changes keep a scroll or gesture.
291+ axes := make(map[*Monitor]layoutPolicy, len(c.screens))
292+ for _, s := range c.screens {
293+ axes[s.mon] = s.mon.Current().policy()
294+ }
290295 c.cfg = cfg
291296 c.binds = binds
292297 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
@@ -297,6 +302,12 @@ func (c *Core) apply(cfg ports.Config) error {
297302 c.settings(s.mon)
298303 }
299304 c.applyOutputConfig()
305+ for _, s := range c.screens {
306+ old, now := axes[s.mon], s.mon.Current().policy()
307+ if old.content != now.content || old.workspace != now.workspace {
308+ s.mon.ov.dropScroll()
309+ }
310+ }
300311 return nil
301312 }
302313
@@ -287,6 +287,11 @@ func (c *Core) apply(cfg ports.Config) error {
287 if err != nil {287 if err != nil {
288 return err288 return err
289 }289 }
290+ // Reload is cold-path work; unrelated changes keep a scroll or gesture.
291+ axes := make(map[*Monitor]layoutPolicy, len(c.screens))
292+ for _, s := range c.screens {
293+ axes[s.mon] = s.mon.Current().policy()
294+ }
290 c.cfg = cfg295 c.cfg = cfg
291 c.binds = binds296 c.binds = binds
292 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]297 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
@@ -297,6 +302,12 @@ func (c *Core) apply(cfg ports.Config) error {
297 c.settings(s.mon)302 c.settings(s.mon)
298 }303 }
299 c.applyOutputConfig()304 c.applyOutputConfig()
305+ for _, s := range c.screens {
306+ old, now := axes[s.mon], s.mon.Current().policy()
307+ if old.content != now.content || old.workspace != now.workspace {
308+ s.mon.ov.dropScroll()
309+ }
310+ }
300 return nil311 return nil
301 }312 }
302 313
M internal/core/gesture.go +1 -1
@@ -418,7 +418,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
418418 }
419419 } else if p.wraps {
420420 // A content swipe cannot escape the last band to another workspace.
421- if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {
421+ if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
422422 return false
423423 }
424424 }
@@ -418,7 +418,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
418 }418 }
419 } else if p.wraps {419 } else if p.wraps {
420 // A content swipe cannot escape the last band to another workspace.420 // A content swipe cannot escape the last band to another workspace.
421- if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {421+ if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
422 return false422 return false
423 }423 }
424 }424 }
M internal/core/overview.go +381 -73
@@ -31,6 +31,11 @@ type overviewState struct {
3131 scrolled, sideways bool
3232 }
3333
34+// dropScroll forgets the scroll an overview axis accumulated, which belongs
35+// to the old axis once it turns. The finger latch (scrolled) stays: it waits
36+// for the fingers to lift.
37+func (o *overviewState) dropScroll() { o.scrollX, o.scrollY, o.sideways = 0, 0, false }
38+
3439 // rowSnapshot is a row as the overview first showed it: browsing moves its
3540 // focus and scroll, closing puts them back.
3641 type rowSnapshot struct {
@@ -353,6 +358,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
353358 // a named group uses its invocation anchor as the toggle return target, not
354359 // rows merely browsed in the overview.
355360 func (m *Monitor) showOverview(w *Workspace) {
361+ if w.policy().workspace != m.overviewAxis() {
362+ m.ov.dropScroll()
363+ }
356364 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {
357365 m.back = m.Current()
358366 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {
@@ -363,10 +371,120 @@ func (m *Monitor) showOverview(w *Workspace) {
363371 m.selectRow()
364372 }
365373
366-// OverviewMove shares navigation rules across keys, scrolls and swipes.
367-// Vertical moves stay in a selected stash pile; left stays, and right
368-// deselects it, even when the row has no main card.
374+// OverviewMove shares navigation rules across keys, scrolls and swipes. It
375+// is the physical entry point: it maps dx and dy to the overview's moves.
369376 func (m *Monitor) OverviewMove(dx, dy int) {
377+ if m.overviewAxis() == horizontalAxis {
378+ if dy != 0 {
379+ m.overviewLane(dy)
380+ } else if dx != 0 {
381+ m.overviewAcross(dx)
382+ }
383+ return
384+ }
385+ m.overviewMoveVertical(dx, dy)
386+}
387+
388+// overviewWorkspaceStep is a focus-workspace bind. A vertical overview
389+// walks the stack cards before changing workspace (overviewVertical). A
390+// side-by-side one always changes workspace, since its stack fans left and
391+// right. A selected stash card cycles the pile first, in every layout.
392+func (m *Monitor) overviewWorkspaceStep(d int) {
393+ if m.overviewAxis() == verticalAxis {
394+ m.overviewVertical(d)
395+ return
396+ }
397+ if !m.cycleCard(m.Current(), d) {
398+ m.overviewStep(d)
399+ }
400+}
401+
402+// cycleCard brings the stash card d entries from the selected one of w to
403+// the front, stopping at either end. It reports whether a card was selected.
404+func (m *Monitor) cycleCard(w *Workspace, d int) bool {
405+ at := m.cardAt(w)
406+ if at < 0 {
407+ return false
408+ }
409+ m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))
410+ return true
411+}
412+
413+// overviewLane is the vertical move of an overview whose workspaces sit side
414+// by side (cascade): a selected stash card cycles the pile; else the
415+// selection goes to the previous or next lane (band), at the closest column,
416+// stopping at the first and last. Covering floats stack along the horizontal
417+// axis, not this one.
418+func (m *Monitor) overviewLane(dy int) {
419+ w := m.Current()
420+ if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
421+ return
422+ }
423+ if i := w.laneNeighbor(dy); i >= 0 {
424+ m.selectLaneColumn(w, i)
425+ }
426+}
427+
428+// selectLaneColumn selects column i of w.
429+func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
430+ w.selectOverviewColumn(i)
431+ m.ov.selected = w.Columns[i].Windows[w.Columns[i].Focus]
432+ m.ov.selectedAt = i
433+}
434+
435+// overviewAcross is the horizontal move of an overview whose workspaces sit
436+// side by side (cascade): a column inside the selected lane. Past the
437+// lane's first column it goes to the stash, else to the previous workspace;
438+// past the last column, to the next workspace. Covering floats fan along
439+// this axis and are traversed before the stash or a workspace: the card on
440+// the left is the one behind.
441+func (m *Monitor) overviewAcross(dx int) {
442+ w := m.Current()
443+ if m.cardAt(w) >= 0 {
444+ if dx < 0 || len(w.stack()) == 0 {
445+ // Left leaves the pile; a stash-only workspace has nothing
446+ // to its right to select.
447+ m.overviewStep(dx)
448+ return
449+ }
450+ m.ov.card, m.ov.cardOf = 0, nil
451+ m.ov.selected = 0
452+ if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
453+ first, _ := w.laneBounds(w.laneOf(w.Focus))
454+ m.selectLaneColumn(w, first)
455+ }
456+ return
457+ }
458+ if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
459+ m.laneEdge(w, dx)
460+ return
461+ }
462+ first, last := w.laneBounds(w.laneOf(w.Focus))
463+ if next := w.Focus + dx; next >= first && next <= last {
464+ m.selectLaneColumn(w, next)
465+ return
466+ }
467+ m.laneEdge(w, dx)
468+}
469+
470+// laneEdge is a horizontal move off the edge of the selected lane, or off a
471+// card that is not the columns: the stack first, then the stash on the left
472+// or a workspace.
473+func (m *Monitor) laneEdge(w *Workspace, dx int) {
474+ if m.moveStack(-dx) {
475+ return
476+ }
477+ if dx < 0 && len(w.Stash) > 0 {
478+ m.selectCard(w, w.stashAt)
479+ return
480+ }
481+ m.overviewStep(dx)
482+}
483+
484+// overviewMoveVertical is the move of an overview whose workspaces stack
485+// vertically. Vertical moves stay in a selected stash pile; left stays, and
486+// right deselects it, even when the row has no main card.
487+func (m *Monitor) overviewMoveVertical(dx, dy int) {
370488 if dy != 0 {
371489 m.overviewVertical(dy)
372490 } else if dx != 0 {
@@ -375,21 +493,27 @@ func (m *Monitor) OverviewMove(dx, dy int) {
375493 }
376494
377495 func (m *Monitor) overviewVertical(dy int) {
378- w := m.Current()
379- if at := m.cardAt(w); at >= 0 {
380- m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1))
496+ if m.cycleCard(m.Current(), dy) {
381497 return
382498 }
383499 if m.moveStack(-dy) {
384500 return
385501 }
502+ m.overviewStep(dy)
503+}
504+
505+// overviewStep shows the workspace d rows from the current one; it stays
506+// on the first or last row.
507+func (m *Monitor) overviewStep(d int) {
386508 rows := m.overviewWorkspaces()
387- i := indexOf(rows, w) + dy
509+ i := indexOf(rows, m.Current()) + d
388510 if i >= 0 && i < len(rows) {
389511 m.showOverview(rows[i])
390512 }
391513 }
392514
515+// overviewHorizontal is the horizontal move of an overview whose workspaces
516+// stack vertically: it never changes workspace.
393517 func (m *Monitor) overviewHorizontal(dx int) {
394518 w := m.Current()
395519 if m.cardAt(w) >= 0 {
@@ -487,16 +611,37 @@ func (m *Monitor) OverviewPick(id WindowID) {
487611 m.closeOverview()
488612 }
489613
490-// overviewRows centers the selected row with its neighbors above and below.
491-// The numbered/named boundary gets extra spacing and an inactive horizontal
492-// rule. Row widths, stash piles and preview scales remain unchanged.
493-func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
614+// overviewAxis is the axis the overview lays workspaces out along, and the
615+// stacked cards of a row fan along: the navigation axis of the current
616+// workspace's layout.
617+func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace }
618+
619+// overviewRows places the current workspace and its overview neighbors in
620+// slots, in output-local pixels, along the overview axis; each workspace
621+// places its previews in its slot. The numbered/named boundary gets extra
622+// spacing and an inactive rule across the axis.
623+//
624+// Vertical axis: full-width slots, the current one centred; the rule is
625+// horizontal. Horizontal axis: full-height slots as wide as their content,
626+// stash pile and stack peeks included, the current one centred; the rule is
627+// vertical, in the spacing between two slots.
628+func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
494629 cur := m.Current()
495630 u := cur.overviewArea()
496631 gap := max(1, u.H*3/100)
497- hc := cur.rowHeight()
498- y := u.Y + (u.H-hc)/2
499- rows := map[*Workspace]int{cur: y}
632+ axis := m.overviewAxis()
633+ var cs Rect
634+ if axis == horizontalAxis {
635+ // The row stays centred on the area; the pile extends the slot to
636+ // the left, pushing the previous workspace, unless it would leave
637+ // the area.
638+ pile, row := cur.pileWidth(), cur.rowWidth()
639+ cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H}
640+ } else {
641+ hc := cur.rowHeight()
642+ cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}
643+ }
644+ rows := map[*Workspace]Rect{cur: cs}
500645 var dividers []ports.Separator
501646 list := m.overviewWorkspaces()
502647 i := indexOf(list, cur)
@@ -510,33 +655,66 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
510655 if boundary {
511656 spacing *= 2
512657 }
513- top := y + hc
514- if next < i {
515- rows[w] = y - spacing - w.rowHeight()
516- top = y - spacing
517- } else {
518- rows[w] = y + hc + spacing
519- }
658+ slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing)
659+ rows[w] = slot
520660 if boundary {
521- margin := u.W * 4 / 100
522- dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}})
661+ dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2))
523662 }
524663 }
525664 return rows, dividers
526665 }
527666
667+// neighborSlot is the slot of w, the neighbor before or after the current
668+// slot cs along axis, spacing away; gapAt is where the spacing begins.
669+func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) {
670+ if axis == horizontalAxis {
671+ slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H}
672+ if before {
673+ gapAt = cs.X - spacing
674+ slot.X = gapAt - slot.W
675+ } else {
676+ gapAt = cs.X + cs.W
677+ slot.X = gapAt + spacing
678+ }
679+ return slot, gapAt
680+ }
681+ h := w.rowHeight()
682+ slot = Rect{X: u.X, W: u.W, H: h}
683+ if before {
684+ gapAt = cs.Y - spacing
685+ slot.Y = gapAt - h
686+ } else {
687+ gapAt = cs.Y + cs.H
688+ slot.Y = gapAt + spacing
689+ }
690+ return slot, gapAt
691+}
692+
693+// overviewDivider is the inactive rule across axis at pos, inset from the
694+// edges of u.
695+func overviewDivider(axis layoutAxis, u Rect, pos int) ports.Separator {
696+ if axis == horizontalAxis {
697+ return ports.Separator{Rect: Rect{X: pos, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}}
698+ }
699+ margin := u.W * 4 / 100
700+ return ports.Separator{Rect: Rect{X: u.X + margin, Y: pos, W: u.W - 2*margin, H: 1}}
701+}
702+
528703 // overviewLayout places the current workspace and its overview neighbors,
529-// each with its full-width stash pile and covering-float stack. Other rows
704+// each with its stash pile and covering-float stack. Other rows
530705 // stay hidden; numbered and named workspaces share the same preview geometry.
531706 func (m *Monitor) overviewLayout(result []Placement) []Placement {
532707 cur := m.Current()
533708 rows, _ := m.overviewRows()
709+ axis := m.overviewAxis()
534710 for w := range m.all() {
535- ry, shown := rows[w]
711+ slot, shown := rows[w]
536712 if shown {
537713 at := m.cardAt(w)
538- result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)
539- result = append(result, w.pile(ry, w != cur, at)...)
714+ result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
715+ if len(w.Stash) > 0 {
716+ result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...)
717+ }
540718 }
541719 for _, f := range w.Floats {
542720 if _, drawn := w.itemOf(f.ID); !shown || !drawn {
@@ -637,11 +815,11 @@ func (w *Workspace) cardStep() int {
637815 return min(overviewCardStep, a.W/100, a.H/50)
638816 }
639817
640-// pile places the stash of w as cards on the left of the row whose top is
641-// at y: entry front (the stash selection when -1) in front, the next ones
642-// behind it, dimmed, the rest hidden. Only a front card of the current row
643-// (dim false) with front set is selected.
644-func (w *Workspace) pile(y int, dim bool, front int) []Placement {
818+// pile places the stash of w as cards on the left of band (see pileBand):
819+// entry front (the stash selection when -1) in
820+// front, the next ones behind it, dimmed, the rest hidden. Only a front card
821+// of the current row (dim false) with front set is selected.
822+func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
645823 n := len(w.Stash)
646824 if n == 0 {
647825 return nil
@@ -658,7 +836,7 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
658836 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
659837 // Stack rows reserve two peek steps above the front card. Align the
660838 // stash with that front band, not with the top of its envelope.
661- x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2
839+ x0, y0 := band.X+u.W/100, band.Y+(rowH-ch)/2
662840 cards := make([][]Placement, n)
663841 for i := range w.Stash {
664842 cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}
@@ -672,19 +850,48 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
672850 // overviewZoom is the scale that fits every column of w in the usable
673851 // width, within the overview bounds.
674852 func (w *Workspace) overviewZoom() float64 {
853+ z, _ := w.zoomSpan()
854+ return z
855+}
856+
857+// zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
858+// cascade workspace spans one screen whatever its band count.
859+func (w *Workspace) zoomSpan() (float64, int) {
675860 // Only the span is read: the tiles go in the zoom scratch, dead on return.
676861 var span int
677862 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
678863 if span <= 0 {
679- return overviewMaxZoom
864+ return overviewMaxZoom, span
680865 }
681866 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
682867 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
683- if len(w.stack()) > 1 {
868+ if w.stacked() {
684869 // Reserve two clear peeks on either side of the front row.
685870 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
686871 }
687- return z
872+ return z, span
873+}
874+
875+// slotWidth is the width of the slot of w in a horizontal overview: its
876+// stash pile and its row.
877+func (w *Workspace) slotWidth() int {
878+ return w.pileWidth() + w.rowWidth()
879+}
880+
881+// rowWidth is the width of the row of w in a horizontal overview: what its
882+// tiles take (at most what the pile leaves of the area), plus the room for
883+// the covering floats peeking on either side of the front card.
884+func (w *Workspace) rowWidth() int {
885+ z, span := w.zoomSpan()
886+ if span <= 0 {
887+ // An empty workspace keeps the room of a screen.
888+ span = w.Usable.W
889+ }
890+ width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth())
891+ if w.stacked() {
892+ width += 4 * w.peekStep()
893+ }
894+ return width
688895 }
689896
690897 // peekStep is the shared step for a stack row and its front-card inset.
@@ -692,20 +899,38 @@ func (w *Workspace) peekStep() int {
692899 return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep)))
693900 }
694901
695-// frontRowY skips the space reserved for two cards peeking above the front.
696-// A single-card row starts at y, as before.
697-func (w *Workspace) frontRowY(y int) int {
698- if len(w.stack()) > 1 {
699- return y + 2*w.peekStep()
902+// frontSlot is the part of slot the front card of w takes: slot shifted
903+// along axis past the room reserved for two cards peeking before it, and
904+// (along a horizontal axis, where the slot is as wide as its cards) without
905+// the room of the two after it. A single-card row keeps slot.
906+func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {
907+ if w.stacked() {
908+ p := w.peekStep()
909+ axis.offset(&slot, 2*p)
910+ if axis == horizontalAxis {
911+ slot.W -= 4 * p
912+ }
700913 }
701- return y
914+ return slot
915+}
916+
917+// pileBand is the band the stash pile of w aligns with in its slot. A
918+// vertical overview aligns it with the front card (see frontSlot). A
919+// horizontal one puts it level with the selected lane, left of the whole
920+// stack of cards.
921+func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect {
922+ if axis == verticalAxis {
923+ return w.frontSlot(axis, slot)
924+ }
925+ slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2
926+ return slot
702927 }
703928
704929 // rowHeight includes the largest possible stack envelope (two peeks above
705930 // and two below). Neighbor rows use the same measure as the current row.
706931 func (w *Workspace) rowHeight() int {
707932 h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
708- if len(w.stack()) > 1 {
933+ if w.stacked() {
709934 h += 4 * w.peekStep()
710935 }
711936 return h
@@ -745,13 +970,18 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
745970 rects = w.colBuf
746971 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
747972 }
748- maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
973+ maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
749974 for i, c := range w.Columns {
750975 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
751976 if w.policy().wraps {
752- width := w.cellWidth()
753- r.X = w.band(i)*w.Usable.W + w.cellX(i)
754- r.W = width
977+ // The band is the lane: it is encoded in Y, one screen apart.
978+ // A maximized column fills its band as on screen.
979+ r.X = w.cellX(i)
980+ r.Y = g + w.band(i)*w.Usable.H
981+ r.W = w.cellWidth()
982+ if c.FullWidth {
983+ r.X, r.W = g, max(w.Usable.W-2*g, 0)
984+ }
755985 } else if w.Overflow == OverflowFixed {
756986 r = rects[i]
757987 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
@@ -759,10 +989,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
759989 // A fullscreen column shows at the width it had in the row.
760990 r.W = max(w.Usable.W-2*g, 0)
761991 }
762- if maximized && i != w.Focus {
763- // FullWidth hides other columns on screen. Their old buffers
764- // cannot fit their current spiral rects; show only the maximized
765- // column until navigation restores the normal layout.
992+ if maximized && i != w.Focus && w.sameBand(i, w.Focus) {
993+ // FullWidth hides the other columns of its band on screen.
994+ // Their old buffers cannot fit their current rects; show only
995+ // the maximized column until navigation restores the layout.
766996 for _, id := range c.Windows {
767997 tiles = append(tiles, Placement{ID: id, Hidden: true})
768998 }
@@ -772,52 +1002,92 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
7721002 for j, t := range w.rowBuf {
7731003 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
7741004 }
775- if w.Overflow != OverflowFixed {
1005+ if w.Overflow != OverflowFixed && !w.policy().wraps {
7761006 span = max(span, r.X+r.W+g)
7771007 }
7781008 if i == w.Focus {
7791009 sel = r
7801010 }
7811011 }
1012+ if w.policy().wraps && len(w.Columns) > 0 {
1013+ span = w.Usable.W
1014+ }
7821015 return tiles, span, sel
7831016 }
7841017
785-// previewRow scales the tiles of w into a row whose top is at y, centred
786-// in the usable width, or scrolled so the focused column shows when the
1018+// previewRow scales the tiles of w into a row placed in slot, centred
1019+// in the slot's width, or scrolled so the focused column shows when the
7871020 // row is wider, right of the stash pile. Dimmed rows (neighbor
7881021 // workspaces) have no focus; neither has a row whose pile has it (lit
7891022 // false).
790-func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {
1023+func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Placement {
7911024 tiles, span, sel := w.previewTiles()
792- return w.previewRowTiles(y, dim, lit, tiles, span, sel)
1025+ return w.previewRowTiles(axis, slot, dim, lit, w.policy().wraps, tiles, span, sel)
7931026 }
7941027
795-func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
796- u := w.overviewArea()
1028+// previewRowTiles scales tiles, laid out unscrolled over span with sel the
1029+// focused one, into slot of an overview along axis. Placement stays inside
1030+// slot.
1031+//
1032+// With lanes set (the tiles of a cascade workspace), the tiles carry their
1033+// band in Y, one Usable.H apart. The band of sel is the selected lane,
1034+// centred across the axis; the other bands sit a lane away from it, dimmed,
1035+// and are hidden when they leave the area. A workspace whose lanes run along
1036+// the overview axis shows only its selected lane. In a horizontal overview,
1037+// tiles entirely outside the area are hidden.
1038+func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement {
7971039 z := w.overviewZoom()
7981040 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
7991041 width := scale(span)
1042+ u := w.overviewArea()
1043+ top := slot.Y
1044+ if axis == horizontalAxis {
1045+ top = slot.Y + (slot.H-scale(w.Usable.H))/2
1046+ }
8001047 // Rows stay centred on the output like rows without a stash; the pile
8011048 // only pushes a row right when they would overlap.
802- x := max(u.X+(u.W-width)/2, u.X+w.pileWidth())
8031049 left := w.pileWidth()
804- u.X, u.W = u.X+left, u.W-left
805- if width > u.W {
806- x = u.X + u.W/2 - scale(sel.X+sel.W/2)
807- x = min(max(x, u.X+u.W-width), u.X)
1050+ x := max(slot.X+(slot.W-width)/2, slot.X+left)
1051+ ux, uw := slot.X+left, slot.W-left
1052+ if width > uw {
1053+ x = ux + uw/2 - scale(sel.X+sel.W/2)
1054+ x = min(max(x, ux+uw-width), ux)
1055+ }
1056+ stride := w.policy().content.span(w.Usable)
1057+ lanes = lanes && stride > 0
1058+ // Lanes along the overview axis would overlap the neighbor workspaces.
1059+ otherLanes := w.policy().content != axis
1060+ laneStep := scale(stride) + max(1, u.H*3/100)
1061+ selLane := 0
1062+ if lanes {
1063+ selLane = max(sel.Y, 0) / stride
8081064 }
8091065 for i := range tiles {
8101066 if tiles[i].Hidden {
8111067 continue
8121068 }
8131069 r := tiles[i].Rect
814- x0, y0 := scale(r.X), scale(r.Y)
815- tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}
1070+ lane, ly := 0, r.Y
1071+ if lanes {
1072+ l := r.Y / stride
1073+ lane, ly = l-selLane, r.Y-l*stride
1074+ }
1075+ x0, y0 := scale(r.X), scale(ly)
1076+ tiles[i].Rect = Rect{X: x + x0, Y: top + y0 + lane*laneStep, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0}
8161077 tiles[i].Preview = z
817- if dim {
1078+ if dim || lane != 0 {
8181079 tiles[i].peeking(1)
8191080 }
820- tiles[i].Focused = tiles[i].Focused && !dim && lit
1081+ tiles[i].Focused = tiles[i].Focused && !dim && lit && lane == 0
1082+ out := false
1083+ if axis == horizontalAxis {
1084+ // A row wider than its slot scrolls: what leaves the slot
1085+ // would sit on the neighbor workspaces.
1086+ out = tiles[i].Rect.X < ux || tiles[i].Rect.X+tiles[i].Rect.W > ux+uw
1087+ }
1088+ if lane != 0 && !otherLanes || out || (lanes || axis == horizontalAxis) && !tiles[i].Rect.Overlaps(u) {
1089+ tiles[i].Hidden, tiles[i].Preview, tiles[i].Focused = true, 0, false
1090+ }
8211091 }
8221092 return tiles
8231093 }
@@ -921,10 +1191,14 @@ func (m *Monitor) overviewFocus(a Action) bool {
9211191 m.OverviewMove(-1, 0)
9221192 case ActionFocusColumnRight:
9231193 m.OverviewMove(1, 0)
924- case ActionFocusWindowUp, ActionFocusWorkspacePrev:
1194+ case ActionFocusWindowUp:
9251195 m.OverviewMove(0, -1)
926- case ActionFocusWindowDown, ActionFocusWorkspaceNext:
1196+ case ActionFocusWindowDown:
9271197 m.OverviewMove(0, 1)
1198+ case ActionFocusWorkspacePrev:
1199+ m.overviewWorkspaceStep(-1)
1200+ case ActionFocusWorkspaceNext:
1201+ m.overviewWorkspaceStep(1)
9281202 default:
9291203 return false
9301204 }
@@ -984,20 +1258,43 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
9841258 add(&m.ov.scrollY, a.Vertical)
9851259 }
9861260 add(&m.ov.scrollX, a.Horizontal)
1261+ // A step that turns the overview returns, so the axis holds for the loop.
1262+ axis := m.overviewAxis()
1263+ cascade := axis == horizontalAxis
9871264 for {
9881265 switch {
9891266 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
990- m.OverviewMove(0, sign(m.ov.scrollY))
991- m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep
1267+ d := sign(m.ov.scrollY)
1268+ // The step leaves before the move: a move that turns the
1269+ // overview drops the scroll left (showOverview).
1270+ m.ov.scrollY -= float64(d) * overviewScrollStep
9921271 m.ov.scrollX = 0
1272+ if cascade && !finger && m.cardAt(m.Current()) < 0 {
1273+ m.overviewStep(d)
1274+ } else {
1275+ m.OverviewMove(0, d)
1276+ }
1277+ if m.overviewTurned(axis, finger) {
1278+ return true
1279+ }
9931280 if finger {
9941281 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
9951282 return true
9961283 }
9971284 case math.Abs(m.ov.scrollX) >= stepX:
998- m.OverviewMove(sign(m.ov.scrollX), 0)
999- m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX
1285+ d := sign(m.ov.scrollX)
1286+ if cascade && finger {
1287+ // One workspace per gesture, even over a stash card.
1288+ m.overviewStep(d)
1289+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1290+ return true
1291+ }
1292+ m.ov.scrollX -= float64(d) * stepX
10001293 m.ov.scrollY = 0
1294+ m.OverviewMove(d, 0)
1295+ if m.overviewTurned(axis, finger) {
1296+ return true
1297+ }
10011298 if finger {
10021299 m.ov.sideways, stepX = true, overviewScrollRepeat
10031300 }
@@ -1008,6 +1305,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
10081305 }
10091306 }
10101307
1308+// overviewTurned reports whether a scroll step turned the overview from the
1309+// axis before. A finger scroll then waits for the fingers to lift, so it
1310+// cannot bounce back.
1311+func (m *Monitor) overviewTurned(before layoutAxis, finger bool) bool {
1312+ if m.overviewAxis() == before {
1313+ return false
1314+ }
1315+ m.ov.scrolled = m.ov.scrolled || finger
1316+ return true
1317+}
1318+
10111319 // scrollStop ends a scroll on every monitor: the fingers lifted, maybe
10121320 // after the pointer took the focus to another output, so the next scroll
10131321 // starts from zero wherever it goes.
@@ -31,6 +31,11 @@ type overviewState struct {
31 scrolled, sideways bool31 scrolled, sideways bool
32 }32 }
33 33
34+// dropScroll forgets the scroll an overview axis accumulated, which belongs
35+// to the old axis once it turns. The finger latch (scrolled) stays: it waits
36+// for the fingers to lift.
37+func (o *overviewState) dropScroll() { o.scrollX, o.scrollY, o.sideways = 0, 0, false }
38+
34 // rowSnapshot is a row as the overview first showed it: browsing moves its39 // rowSnapshot is a row as the overview first showed it: browsing moves its
35 // focus and scroll, closing puts them back.40 // focus and scroll, closing puts them back.
36 type rowSnapshot struct {41 type rowSnapshot struct {
@@ -353,6 +358,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
353 // a named group uses its invocation anchor as the toggle return target, not358 // a named group uses its invocation anchor as the toggle return target, not
354 // rows merely browsed in the overview.359 // rows merely browsed in the overview.
355 func (m *Monitor) showOverview(w *Workspace) {360 func (m *Monitor) showOverview(w *Workspace) {
361+ if w.policy().workspace != m.overviewAxis() {
362+ m.ov.dropScroll()
363+ }
356 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {364 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {
357 m.back = m.Current()365 m.back = m.Current()
358 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {366 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {
@@ -363,10 +371,120 @@ func (m *Monitor) showOverview(w *Workspace) {
363 m.selectRow()371 m.selectRow()
364 }372 }
365 373
366-// OverviewMove shares navigation rules across keys, scrolls and swipes.374+// OverviewMove shares navigation rules across keys, scrolls and swipes. It
367-// Vertical moves stay in a selected stash pile; left stays, and right375+// is the physical entry point: it maps dx and dy to the overview's moves.
368-// deselects it, even when the row has no main card.
369 func (m *Monitor) OverviewMove(dx, dy int) {376 func (m *Monitor) OverviewMove(dx, dy int) {
377+ if m.overviewAxis() == horizontalAxis {
378+ if dy != 0 {
379+ m.overviewLane(dy)
380+ } else if dx != 0 {
381+ m.overviewAcross(dx)
382+ }
383+ return
384+ }
385+ m.overviewMoveVertical(dx, dy)
386+}
387+
388+// overviewWorkspaceStep is a focus-workspace bind. A vertical overview
389+// walks the stack cards before changing workspace (overviewVertical). A
390+// side-by-side one always changes workspace, since its stack fans left and
391+// right. A selected stash card cycles the pile first, in every layout.
392+func (m *Monitor) overviewWorkspaceStep(d int) {
393+ if m.overviewAxis() == verticalAxis {
394+ m.overviewVertical(d)
395+ return
396+ }
397+ if !m.cycleCard(m.Current(), d) {
398+ m.overviewStep(d)
399+ }
400+}
401+
402+// cycleCard brings the stash card d entries from the selected one of w to
403+// the front, stopping at either end. It reports whether a card was selected.
404+func (m *Monitor) cycleCard(w *Workspace, d int) bool {
405+ at := m.cardAt(w)
406+ if at < 0 {
407+ return false
408+ }
409+ m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))
410+ return true
411+}
412+
413+// overviewLane is the vertical move of an overview whose workspaces sit side
414+// by side (cascade): a selected stash card cycles the pile; else the
415+// selection goes to the previous or next lane (band), at the closest column,
416+// stopping at the first and last. Covering floats stack along the horizontal
417+// axis, not this one.
418+func (m *Monitor) overviewLane(dy int) {
419+ w := m.Current()
420+ if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
421+ return
422+ }
423+ if i := w.laneNeighbor(dy); i >= 0 {
424+ m.selectLaneColumn(w, i)
425+ }
426+}
427+
428+// selectLaneColumn selects column i of w.
429+func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
430+ w.selectOverviewColumn(i)
431+ m.ov.selected = w.Columns[i].Windows[w.Columns[i].Focus]
432+ m.ov.selectedAt = i
433+}
434+
435+// overviewAcross is the horizontal move of an overview whose workspaces sit
436+// side by side (cascade): a column inside the selected lane. Past the
437+// lane's first column it goes to the stash, else to the previous workspace;
438+// past the last column, to the next workspace. Covering floats fan along
439+// this axis and are traversed before the stash or a workspace: the card on
440+// the left is the one behind.
441+func (m *Monitor) overviewAcross(dx int) {
442+ w := m.Current()
443+ if m.cardAt(w) >= 0 {
444+ if dx < 0 || len(w.stack()) == 0 {
445+ // Left leaves the pile; a stash-only workspace has nothing
446+ // to its right to select.
447+ m.overviewStep(dx)
448+ return
449+ }
450+ m.ov.card, m.ov.cardOf = 0, nil
451+ m.ov.selected = 0
452+ if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
453+ first, _ := w.laneBounds(w.laneOf(w.Focus))
454+ m.selectLaneColumn(w, first)
455+ }
456+ return
457+ }
458+ if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
459+ m.laneEdge(w, dx)
460+ return
461+ }
462+ first, last := w.laneBounds(w.laneOf(w.Focus))
463+ if next := w.Focus + dx; next >= first && next <= last {
464+ m.selectLaneColumn(w, next)
465+ return
466+ }
467+ m.laneEdge(w, dx)
468+}
469+
470+// laneEdge is a horizontal move off the edge of the selected lane, or off a
471+// card that is not the columns: the stack first, then the stash on the left
472+// or a workspace.
473+func (m *Monitor) laneEdge(w *Workspace, dx int) {
474+ if m.moveStack(-dx) {
475+ return
476+ }
477+ if dx < 0 && len(w.Stash) > 0 {
478+ m.selectCard(w, w.stashAt)
479+ return
480+ }
481+ m.overviewStep(dx)
482+}
483+
484+// overviewMoveVertical is the move of an overview whose workspaces stack
485+// vertically. Vertical moves stay in a selected stash pile; left stays, and
486+// right deselects it, even when the row has no main card.
487+func (m *Monitor) overviewMoveVertical(dx, dy int) {
370 if dy != 0 {488 if dy != 0 {
371 m.overviewVertical(dy)489 m.overviewVertical(dy)
372 } else if dx != 0 {490 } else if dx != 0 {
@@ -375,21 +493,27 @@ func (m *Monitor) OverviewMove(dx, dy int) {
375 }493 }
376 494
377 func (m *Monitor) overviewVertical(dy int) {495 func (m *Monitor) overviewVertical(dy int) {
378- w := m.Current()496+ if m.cycleCard(m.Current(), dy) {
379- if at := m.cardAt(w); at >= 0 {
380- m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1))
381 return497 return
382 }498 }
383 if m.moveStack(-dy) {499 if m.moveStack(-dy) {
384 return500 return
385 }501 }
502+ m.overviewStep(dy)
503+}
504+
505+// overviewStep shows the workspace d rows from the current one; it stays
506+// on the first or last row.
507+func (m *Monitor) overviewStep(d int) {
386 rows := m.overviewWorkspaces()508 rows := m.overviewWorkspaces()
387- i := indexOf(rows, w) + dy509+ i := indexOf(rows, m.Current()) + d
388 if i >= 0 && i < len(rows) {510 if i >= 0 && i < len(rows) {
389 m.showOverview(rows[i])511 m.showOverview(rows[i])
390 }512 }
391 }513 }
392 514
515+// overviewHorizontal is the horizontal move of an overview whose workspaces
516+// stack vertically: it never changes workspace.
393 func (m *Monitor) overviewHorizontal(dx int) {517 func (m *Monitor) overviewHorizontal(dx int) {
394 w := m.Current()518 w := m.Current()
395 if m.cardAt(w) >= 0 {519 if m.cardAt(w) >= 0 {
@@ -487,16 +611,37 @@ func (m *Monitor) OverviewPick(id WindowID) {
487 m.closeOverview()611 m.closeOverview()
488 }612 }
489 613
490-// overviewRows centers the selected row with its neighbors above and below.614+// overviewAxis is the axis the overview lays workspaces out along, and the
491-// The numbered/named boundary gets extra spacing and an inactive horizontal615+// stacked cards of a row fan along: the navigation axis of the current
492-// rule. Row widths, stash piles and preview scales remain unchanged.616+// workspace's layout.
493-func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {617+func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace }
618+
619+// overviewRows places the current workspace and its overview neighbors in
620+// slots, in output-local pixels, along the overview axis; each workspace
621+// places its previews in its slot. The numbered/named boundary gets extra
622+// spacing and an inactive rule across the axis.
623+//
624+// Vertical axis: full-width slots, the current one centred; the rule is
625+// horizontal. Horizontal axis: full-height slots as wide as their content,
626+// stash pile and stack peeks included, the current one centred; the rule is
627+// vertical, in the spacing between two slots.
628+func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
494 cur := m.Current()629 cur := m.Current()
495 u := cur.overviewArea()630 u := cur.overviewArea()
496 gap := max(1, u.H*3/100)631 gap := max(1, u.H*3/100)
497- hc := cur.rowHeight()632+ axis := m.overviewAxis()
498- y := u.Y + (u.H-hc)/2633+ var cs Rect
499- rows := map[*Workspace]int{cur: y}634+ if axis == horizontalAxis {
635+ // The row stays centred on the area; the pile extends the slot to
636+ // the left, pushing the previous workspace, unless it would leave
637+ // the area.
638+ pile, row := cur.pileWidth(), cur.rowWidth()
639+ cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H}
640+ } else {
641+ hc := cur.rowHeight()
642+ cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}
643+ }
644+ rows := map[*Workspace]Rect{cur: cs}
500 var dividers []ports.Separator645 var dividers []ports.Separator
501 list := m.overviewWorkspaces()646 list := m.overviewWorkspaces()
502 i := indexOf(list, cur)647 i := indexOf(list, cur)
@@ -510,33 +655,66 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
510 if boundary {655 if boundary {
511 spacing *= 2656 spacing *= 2
512 }657 }
513- top := y + hc658+ slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing)
514- if next < i {659+ rows[w] = slot
515- rows[w] = y - spacing - w.rowHeight()
516- top = y - spacing
517- } else {
518- rows[w] = y + hc + spacing
519- }
520 if boundary {660 if boundary {
521- margin := u.W * 4 / 100661+ dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2))
522- dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}})
523 }662 }
524 }663 }
525 return rows, dividers664 return rows, dividers
526 }665 }
527 666
667+// neighborSlot is the slot of w, the neighbor before or after the current
668+// slot cs along axis, spacing away; gapAt is where the spacing begins.
669+func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) {
670+ if axis == horizontalAxis {
671+ slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H}
672+ if before {
673+ gapAt = cs.X - spacing
674+ slot.X = gapAt - slot.W
675+ } else {
676+ gapAt = cs.X + cs.W
677+ slot.X = gapAt + spacing
678+ }
679+ return slot, gapAt
680+ }
681+ h := w.rowHeight()
682+ slot = Rect{X: u.X, W: u.W, H: h}
683+ if before {
684+ gapAt = cs.Y - spacing
685+ slot.Y = gapAt - h
686+ } else {
687+ gapAt = cs.Y + cs.H
688+ slot.Y = gapAt + spacing
689+ }
690+ return slot, gapAt
691+}
692+
693+// overviewDivider is the inactive rule across axis at pos, inset from the
694+// edges of u.
695+func overviewDivider(axis layoutAxis, u Rect, pos int) ports.Separator {
696+ if axis == horizontalAxis {
697+ return ports.Separator{Rect: Rect{X: pos, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}}
698+ }
699+ margin := u.W * 4 / 100
700+ return ports.Separator{Rect: Rect{X: u.X + margin, Y: pos, W: u.W - 2*margin, H: 1}}
701+}
702+
528 // overviewLayout places the current workspace and its overview neighbors,703 // overviewLayout places the current workspace and its overview neighbors,
529-// each with its full-width stash pile and covering-float stack. Other rows704+// each with its stash pile and covering-float stack. Other rows
530 // stay hidden; numbered and named workspaces share the same preview geometry.705 // stay hidden; numbered and named workspaces share the same preview geometry.
531 func (m *Monitor) overviewLayout(result []Placement) []Placement {706 func (m *Monitor) overviewLayout(result []Placement) []Placement {
532 cur := m.Current()707 cur := m.Current()
533 rows, _ := m.overviewRows()708 rows, _ := m.overviewRows()
709+ axis := m.overviewAxis()
534 for w := range m.all() {710 for w := range m.all() {
535- ry, shown := rows[w]711+ slot, shown := rows[w]
536 if shown {712 if shown {
537 at := m.cardAt(w)713 at := m.cardAt(w)
538- result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)714+ result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
539- result = append(result, w.pile(ry, w != cur, at)...)715+ if len(w.Stash) > 0 {
716+ result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...)
717+ }
540 }718 }
541 for _, f := range w.Floats {719 for _, f := range w.Floats {
542 if _, drawn := w.itemOf(f.ID); !shown || !drawn {720 if _, drawn := w.itemOf(f.ID); !shown || !drawn {
@@ -637,11 +815,11 @@ func (w *Workspace) cardStep() int {
637 return min(overviewCardStep, a.W/100, a.H/50)815 return min(overviewCardStep, a.W/100, a.H/50)
638 }816 }
639 817
640-// pile places the stash of w as cards on the left of the row whose top is818+// pile places the stash of w as cards on the left of band (see pileBand):
641-// at y: entry front (the stash selection when -1) in front, the next ones819+// entry front (the stash selection when -1) in
642-// behind it, dimmed, the rest hidden. Only a front card of the current row820+// front, the next ones behind it, dimmed, the rest hidden. Only a front card
643-// (dim false) with front set is selected.821+// of the current row (dim false) with front set is selected.
644-func (w *Workspace) pile(y int, dim bool, front int) []Placement {822+func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
645 n := len(w.Stash)823 n := len(w.Stash)
646 if n == 0 {824 if n == 0 {
647 return nil825 return nil
@@ -658,7 +836,7 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
658 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))836 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
659 // Stack rows reserve two peek steps above the front card. Align the837 // Stack rows reserve two peek steps above the front card. Align the
660 // stash with that front band, not with the top of its envelope.838 // stash with that front band, not with the top of its envelope.
661- x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2839+ x0, y0 := band.X+u.W/100, band.Y+(rowH-ch)/2
662 cards := make([][]Placement, n)840 cards := make([][]Placement, n)
663 for i := range w.Stash {841 for i := range w.Stash {
664 cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}842 cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}
@@ -672,19 +850,48 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
672 // overviewZoom is the scale that fits every column of w in the usable850 // overviewZoom is the scale that fits every column of w in the usable
673 // width, within the overview bounds.851 // width, within the overview bounds.
674 func (w *Workspace) overviewZoom() float64 {852 func (w *Workspace) overviewZoom() float64 {
853+ z, _ := w.zoomSpan()
854+ return z
855+}
856+
857+// zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
858+// cascade workspace spans one screen whatever its band count.
859+func (w *Workspace) zoomSpan() (float64, int) {
675 // Only the span is read: the tiles go in the zoom scratch, dead on return.860 // Only the span is read: the tiles go in the zoom scratch, dead on return.
676 var span int861 var span int
677 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])862 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
678 if span <= 0 {863 if span <= 0 {
679- return overviewMaxZoom864+ return overviewMaxZoom, span
680 }865 }
681 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96866 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
682 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)867 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
683- if len(w.stack()) > 1 {868+ if w.stacked() {
684 // Reserve two clear peeks on either side of the front row.869 // Reserve two clear peeks on either side of the front row.
685 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))870 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
686 }871 }
687- return z872+ return z, span
873+}
874+
875+// slotWidth is the width of the slot of w in a horizontal overview: its
876+// stash pile and its row.
877+func (w *Workspace) slotWidth() int {
878+ return w.pileWidth() + w.rowWidth()
879+}
880+
881+// rowWidth is the width of the row of w in a horizontal overview: what its
882+// tiles take (at most what the pile leaves of the area), plus the room for
883+// the covering floats peeking on either side of the front card.
884+func (w *Workspace) rowWidth() int {
885+ z, span := w.zoomSpan()
886+ if span <= 0 {
887+ // An empty workspace keeps the room of a screen.
888+ span = w.Usable.W
889+ }
890+ width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth())
891+ if w.stacked() {
892+ width += 4 * w.peekStep()
893+ }
894+ return width
688 }895 }
689 896
690 // peekStep is the shared step for a stack row and its front-card inset.897 // peekStep is the shared step for a stack row and its front-card inset.
@@ -692,20 +899,38 @@ func (w *Workspace) peekStep() int {
692 return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep)))899 return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep)))
693 }900 }
694 901
695-// frontRowY skips the space reserved for two cards peeking above the front.902+// frontSlot is the part of slot the front card of w takes: slot shifted
696-// A single-card row starts at y, as before.903+// along axis past the room reserved for two cards peeking before it, and
697-func (w *Workspace) frontRowY(y int) int {904+// (along a horizontal axis, where the slot is as wide as its cards) without
698- if len(w.stack()) > 1 {905+// the room of the two after it. A single-card row keeps slot.
699- return y + 2*w.peekStep()906+func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {
907+ if w.stacked() {
908+ p := w.peekStep()
909+ axis.offset(&slot, 2*p)
910+ if axis == horizontalAxis {
911+ slot.W -= 4 * p
912+ }
700 }913 }
701- return y914+ return slot
915+}
916+
917+// pileBand is the band the stash pile of w aligns with in its slot. A
918+// vertical overview aligns it with the front card (see frontSlot). A
919+// horizontal one puts it level with the selected lane, left of the whole
920+// stack of cards.
921+func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect {
922+ if axis == verticalAxis {
923+ return w.frontSlot(axis, slot)
924+ }
925+ slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2
926+ return slot
702 }927 }
703 928
704 // rowHeight includes the largest possible stack envelope (two peeks above929 // rowHeight includes the largest possible stack envelope (two peeks above
705 // and two below). Neighbor rows use the same measure as the current row.930 // and two below). Neighbor rows use the same measure as the current row.
706 func (w *Workspace) rowHeight() int {931 func (w *Workspace) rowHeight() int {
707 h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))932 h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
708- if len(w.stack()) > 1 {933+ if w.stacked() {
709 h += 4 * w.peekStep()934 h += 4 * w.peekStep()
710 }935 }
711 return h936 return h
@@ -745,13 +970,18 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
745 rects = w.colBuf970 rects = w.colBuf
746 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.971 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
747 }972 }
748- maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth973+ maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
749 for i, c := range w.Columns {974 for i, c := range w.Columns {
750 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}975 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
751 if w.policy().wraps {976 if w.policy().wraps {
752- width := w.cellWidth()977+ // The band is the lane: it is encoded in Y, one screen apart.
753- r.X = w.band(i)*w.Usable.W + w.cellX(i)978+ // A maximized column fills its band as on screen.
754- r.W = width979+ r.X = w.cellX(i)
980+ r.Y = g + w.band(i)*w.Usable.H
981+ r.W = w.cellWidth()
982+ if c.FullWidth {
983+ r.X, r.W = g, max(w.Usable.W-2*g, 0)
984+ }
755 } else if w.Overflow == OverflowFixed {985 } else if w.Overflow == OverflowFixed {
756 r = rects[i]986 r = rects[i]
757 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y987 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
@@ -759,10 +989,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
759 // A fullscreen column shows at the width it had in the row.989 // A fullscreen column shows at the width it had in the row.
760 r.W = max(w.Usable.W-2*g, 0)990 r.W = max(w.Usable.W-2*g, 0)
761 }991 }
762- if maximized && i != w.Focus {992+ if maximized && i != w.Focus && w.sameBand(i, w.Focus) {
763- // FullWidth hides other columns on screen. Their old buffers993+ // FullWidth hides the other columns of its band on screen.
764- // cannot fit their current spiral rects; show only the maximized994+ // Their old buffers cannot fit their current rects; show only
765- // column until navigation restores the normal layout.995+ // the maximized column until navigation restores the layout.
766 for _, id := range c.Windows {996 for _, id := range c.Windows {
767 tiles = append(tiles, Placement{ID: id, Hidden: true})997 tiles = append(tiles, Placement{ID: id, Hidden: true})
768 }998 }
@@ -772,52 +1002,92 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
772 for j, t := range w.rowBuf {1002 for j, t := range w.rowBuf {
773 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})1003 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
774 }1004 }
775- if w.Overflow != OverflowFixed {1005+ if w.Overflow != OverflowFixed && !w.policy().wraps {
776 span = max(span, r.X+r.W+g)1006 span = max(span, r.X+r.W+g)
777 }1007 }
778 if i == w.Focus {1008 if i == w.Focus {
779 sel = r1009 sel = r
780 }1010 }
781 }1011 }
1012+ if w.policy().wraps && len(w.Columns) > 0 {
1013+ span = w.Usable.W
1014+ }
782 return tiles, span, sel1015 return tiles, span, sel
783 }1016 }
784 1017
785-// previewRow scales the tiles of w into a row whose top is at y, centred1018+// previewRow scales the tiles of w into a row placed in slot, centred
786-// in the usable width, or scrolled so the focused column shows when the1019+// in the slot's width, or scrolled so the focused column shows when the
787 // row is wider, right of the stash pile. Dimmed rows (neighbor1020 // row is wider, right of the stash pile. Dimmed rows (neighbor
788 // workspaces) have no focus; neither has a row whose pile has it (lit1021 // workspaces) have no focus; neither has a row whose pile has it (lit
789 // false).1022 // false).
790-func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {1023+func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Placement {
791 tiles, span, sel := w.previewTiles()1024 tiles, span, sel := w.previewTiles()
792- return w.previewRowTiles(y, dim, lit, tiles, span, sel)1025+ return w.previewRowTiles(axis, slot, dim, lit, w.policy().wraps, tiles, span, sel)
793 }1026 }
794 1027
795-func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {1028+// previewRowTiles scales tiles, laid out unscrolled over span with sel the
796- u := w.overviewArea()1029+// focused one, into slot of an overview along axis. Placement stays inside
1030+// slot.
1031+//
1032+// With lanes set (the tiles of a cascade workspace), the tiles carry their
1033+// band in Y, one Usable.H apart. The band of sel is the selected lane,
1034+// centred across the axis; the other bands sit a lane away from it, dimmed,
1035+// and are hidden when they leave the area. A workspace whose lanes run along
1036+// the overview axis shows only its selected lane. In a horizontal overview,
1037+// tiles entirely outside the area are hidden.
1038+func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement {
797 z := w.overviewZoom()1039 z := w.overviewZoom()
798 scale := func(v int) int { return int(math.Round(float64(v) * z)) }1040 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
799 width := scale(span)1041 width := scale(span)
1042+ u := w.overviewArea()
1043+ top := slot.Y
1044+ if axis == horizontalAxis {
1045+ top = slot.Y + (slot.H-scale(w.Usable.H))/2
1046+ }
800 // Rows stay centred on the output like rows without a stash; the pile1047 // Rows stay centred on the output like rows without a stash; the pile
801 // only pushes a row right when they would overlap.1048 // only pushes a row right when they would overlap.
802- x := max(u.X+(u.W-width)/2, u.X+w.pileWidth())
803 left := w.pileWidth()1049 left := w.pileWidth()
804- u.X, u.W = u.X+left, u.W-left1050+ x := max(slot.X+(slot.W-width)/2, slot.X+left)
805- if width > u.W {1051+ ux, uw := slot.X+left, slot.W-left
806- x = u.X + u.W/2 - scale(sel.X+sel.W/2)1052+ if width > uw {
807- x = min(max(x, u.X+u.W-width), u.X)1053+ x = ux + uw/2 - scale(sel.X+sel.W/2)
1054+ x = min(max(x, ux+uw-width), ux)
1055+ }
1056+ stride := w.policy().content.span(w.Usable)
1057+ lanes = lanes && stride > 0
1058+ // Lanes along the overview axis would overlap the neighbor workspaces.
1059+ otherLanes := w.policy().content != axis
1060+ laneStep := scale(stride) + max(1, u.H*3/100)
1061+ selLane := 0
1062+ if lanes {
1063+ selLane = max(sel.Y, 0) / stride
808 }1064 }
809 for i := range tiles {1065 for i := range tiles {
810 if tiles[i].Hidden {1066 if tiles[i].Hidden {
811 continue1067 continue
812 }1068 }
813 r := tiles[i].Rect1069 r := tiles[i].Rect
814- x0, y0 := scale(r.X), scale(r.Y)1070+ lane, ly := 0, r.Y
815- tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}1071+ if lanes {
1072+ l := r.Y / stride
1073+ lane, ly = l-selLane, r.Y-l*stride
1074+ }
1075+ x0, y0 := scale(r.X), scale(ly)
1076+ tiles[i].Rect = Rect{X: x + x0, Y: top + y0 + lane*laneStep, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0}
816 tiles[i].Preview = z1077 tiles[i].Preview = z
817- if dim {1078+ if dim || lane != 0 {
818 tiles[i].peeking(1)1079 tiles[i].peeking(1)
819 }1080 }
820- tiles[i].Focused = tiles[i].Focused && !dim && lit1081+ tiles[i].Focused = tiles[i].Focused && !dim && lit && lane == 0
1082+ out := false
1083+ if axis == horizontalAxis {
1084+ // A row wider than its slot scrolls: what leaves the slot
1085+ // would sit on the neighbor workspaces.
1086+ out = tiles[i].Rect.X < ux || tiles[i].Rect.X+tiles[i].Rect.W > ux+uw
1087+ }
1088+ if lane != 0 && !otherLanes || out || (lanes || axis == horizontalAxis) && !tiles[i].Rect.Overlaps(u) {
1089+ tiles[i].Hidden, tiles[i].Preview, tiles[i].Focused = true, 0, false
1090+ }
821 }1091 }
822 return tiles1092 return tiles
823 }1093 }
@@ -921,10 +1191,14 @@ func (m *Monitor) overviewFocus(a Action) bool {
921 m.OverviewMove(-1, 0)1191 m.OverviewMove(-1, 0)
922 case ActionFocusColumnRight:1192 case ActionFocusColumnRight:
923 m.OverviewMove(1, 0)1193 m.OverviewMove(1, 0)
924- case ActionFocusWindowUp, ActionFocusWorkspacePrev:1194+ case ActionFocusWindowUp:
925 m.OverviewMove(0, -1)1195 m.OverviewMove(0, -1)
926- case ActionFocusWindowDown, ActionFocusWorkspaceNext:1196+ case ActionFocusWindowDown:
927 m.OverviewMove(0, 1)1197 m.OverviewMove(0, 1)
1198+ case ActionFocusWorkspacePrev:
1199+ m.overviewWorkspaceStep(-1)
1200+ case ActionFocusWorkspaceNext:
1201+ m.overviewWorkspaceStep(1)
928 default:1202 default:
929 return false1203 return false
930 }1204 }
@@ -984,20 +1258,43 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
984 add(&m.ov.scrollY, a.Vertical)1258 add(&m.ov.scrollY, a.Vertical)
985 }1259 }
986 add(&m.ov.scrollX, a.Horizontal)1260 add(&m.ov.scrollX, a.Horizontal)
1261+ // A step that turns the overview returns, so the axis holds for the loop.
1262+ axis := m.overviewAxis()
1263+ cascade := axis == horizontalAxis
987 for {1264 for {
988 switch {1265 switch {
989 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):1266 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
990- m.OverviewMove(0, sign(m.ov.scrollY))1267+ d := sign(m.ov.scrollY)
991- m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep1268+ // The step leaves before the move: a move that turns the
1269+ // overview drops the scroll left (showOverview).
1270+ m.ov.scrollY -= float64(d) * overviewScrollStep
992 m.ov.scrollX = 01271 m.ov.scrollX = 0
1272+ if cascade && !finger && m.cardAt(m.Current()) < 0 {
1273+ m.overviewStep(d)
1274+ } else {
1275+ m.OverviewMove(0, d)
1276+ }
1277+ if m.overviewTurned(axis, finger) {
1278+ return true
1279+ }
993 if finger {1280 if finger {
994 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1281 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
995 return true1282 return true
996 }1283 }
997 case math.Abs(m.ov.scrollX) >= stepX:1284 case math.Abs(m.ov.scrollX) >= stepX:
998- m.OverviewMove(sign(m.ov.scrollX), 0)1285+ d := sign(m.ov.scrollX)
999- m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX1286+ if cascade && finger {
1287+ // One workspace per gesture, even over a stash card.
1288+ m.overviewStep(d)
1289+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1290+ return true
1291+ }
1292+ m.ov.scrollX -= float64(d) * stepX
1000 m.ov.scrollY = 01293 m.ov.scrollY = 0
1294+ m.OverviewMove(d, 0)
1295+ if m.overviewTurned(axis, finger) {
1296+ return true
1297+ }
1001 if finger {1298 if finger {
1002 m.ov.sideways, stepX = true, overviewScrollRepeat1299 m.ov.sideways, stepX = true, overviewScrollRepeat
1003 }1300 }
@@ -1008,6 +1305,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1008 }1305 }
1009 }1306 }
1010 1307
1308+// overviewTurned reports whether a scroll step turned the overview from the
1309+// axis before. A finger scroll then waits for the fingers to lift, so it
1310+// cannot bounce back.
1311+func (m *Monitor) overviewTurned(before layoutAxis, finger bool) bool {
1312+ if m.overviewAxis() == before {
1313+ return false
1314+ }
1315+ m.ov.scrolled = m.ov.scrolled || finger
1316+ return true
1317+}
1318+
1011 // scrollStop ends a scroll on every monitor: the fingers lifted, maybe1319 // scrollStop ends a scroll on every monitor: the fingers lifted, maybe
1012 // after the pointer took the focus to another output, so the next scroll1320 // after the pointer took the focus to another output, so the next scroll
1013 // starts from zero wherever it goes.1321 // starts from zero wherever it goes.
A internal/core/overview_cascade_test.go +894 -0
@@ -0,0 +1,894 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// cascadeOverview is overviewMonitor with workspace 2 (current) a cascade of
12+// seven columns, 4..10, in three bands of three (the last holds 10 alone),
13+// the focus on column 8 (band 2, middle). Workspace 1 (columns 1..3) is a
14+// scroll workspace on its left, workspace 3 (column 30) on its right.
15+func cascadeOverview() *Monitor {
16+ m := overviewMonitor()
17+ m.SetBorder(2)
18+ m.Focus(1)
19+ w := m.Current()
20+ w.Overflow = OverflowCascade
21+ for id := WindowID(5); id <= 10; id++ {
22+ w.AddWindow(id)
23+ }
24+ m.Focus(2)
25+ m.AddWindow(30)
26+ m.Focus(1)
27+ w.FocusID(8)
28+ return m
29+}
30+
31+// withStash stashes windows 40..40+n-1 of the current workspace, with the
32+// focus back on column 8.
33+func withStash(m *Monitor, n int) {
34+ w := m.Current()
35+ for id := WindowID(40); id < WindowID(40+n); id++ {
36+ w.AddWindow(id)
37+ w.FocusID(id)
38+ w.ToggleWindowStash()
39+ }
40+ w.FocusID(8)
41+}
42+
43+// cascadeCore is a Core on one output with workspace 0 holding window 1,
44+// workspace 1 (current) a cascade of windows 2..6 in bands of two ({2,3}
45+// {4,5} {6}), workspace 2 window 30, and window 2 focused.
46+func cascadeCore(t *testing.T) (*Core, *Monitor) {
47+ t.Helper()
48+ c, _ := indicatorCore(t)
49+ m := c.cur().mon
50+ m.AddWindow(1)
51+ m.Focus(1)
52+ w := m.Current()
53+ w.Overflow = OverflowCascade
54+ for id := WindowID(2); id <= 6; id++ {
55+ w.AddWindow(id)
56+ }
57+ m.normalize()
58+ m.Focus(2)
59+ m.AddWindow(30)
60+ m.Focus(1)
61+ w.FocusID(2)
62+ return c, m
63+}
64+
65+func centreY(r Rect) int { return r.Y + r.H/2 }
66+
67+func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 }
68+
69+func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused }
70+
71+// T1: the zoom does not depend on the number of bands.
72+func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
73+ if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom {
74+ t.Fatalf("zoom with one band %v, with three %v", one, three)
75+ }
76+}
77+
78+// A maximized column fills its band in the preview as on screen, and hides
79+// only the other columns of its band.
80+func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) {
81+ // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
82+ for _, tc := range []struct {
83+ n, max int
84+ hidden []bool
85+ }{
86+ {n: 1, max: 0, hidden: []bool{false}},
87+ {n: 4, max: 0, hidden: []bool{false, true, true, false}},
88+ {n: 4, max: 3, hidden: []bool{false, false, false, false}},
89+ } {
90+ w := cascadeWorkspace(tc.n)
91+ w.Focus = tc.max
92+ w.maximize(tc.max)
93+ tiles, _, _ := w.previewTiles()
94+ g, h := w.gap(), w.Usable.H-2*w.gap()
95+ for i, p := range tiles {
96+ if p.Hidden != tc.hidden[i] {
97+ t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i])
98+ }
99+ if p.Hidden {
100+ continue
101+ }
102+ band := i / 3
103+ want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}
104+ if i == tc.max {
105+ want.X, want.W = g, w.Usable.W-2*g
106+ }
107+ if p.Rect != want {
108+ t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want)
109+ }
110+ }
111+ }
112+}
113+
114+// T2: the selected band is the centre lane: undimmed, centred, selected tile
115+// focused.
116+func TestOverviewCascadeCentreLane(t *testing.T) {
117+ m := cascadeOverview()
118+ m.ToggleOverview()
119+ ps := m.Layout()
120+ u := m.Current().overviewArea()
121+ for _, id := range []WindowID{7, 8, 9} {
122+ p := previewOf(t, ps, id)
123+ if p.Hidden || p.Peek || p.Preview != overviewMaxZoom || p.Focused != (id == 8) {
124+ t.Fatalf("band tile %d: %+v", id, p)
125+ }
126+ if !within1(centreY(p.Rect), u.Y+u.H/2) {
127+ t.Fatalf("band tile %d centre %d, area centre %d", id, centreY(p.Rect), u.Y+u.H/2)
128+ }
129+ }
130+}
131+
132+// T3: the other bands are dimmed lanes a screen (and a gap) away; those
133+// outside the area are hidden.
134+func TestOverviewCascadeOtherLanes(t *testing.T) {
135+ m := cascadeOverview()
136+ m.ToggleOverview()
137+ ps := m.Layout()
138+ sel := previewOf(t, ps, 8).Rect
139+ above, below := previewOf(t, ps, 5), previewOf(t, ps, 10)
140+ if above.Hidden || !above.Peek || above.Focused || below.Hidden || !below.Peek || below.Focused {
141+ t.Fatalf("adjacent bands: above %+v, below %+v", above, below)
142+ }
143+ // The lanes sit evenly around the selected one, apart from it (a gap),
144+ // and the area still shows part of each.
145+ up, down := sel.Y-above.Rect.Y, below.Rect.Y-sel.Y
146+ if !within1(up, down) {
147+ t.Fatalf("lanes not evenly spaced: %d above, %d below", up, down)
148+ }
149+ if gap := up - sel.H; gap <= 0 {
150+ t.Fatalf("lane above overlaps the selected one: %v over %v", above.Rect, sel)
151+ }
152+ u := m.Current().overviewArea()
153+ if above.Rect.Y+above.Rect.H <= u.Y || below.Rect.Y >= u.Y+u.H {
154+ t.Fatalf("adjacent lanes not visible in %v: %v, %v", u, above.Rect, below.Rect)
155+ }
156+
157+ // Five bands (13 columns): the ones two lanes away are off the area.
158+ m = cascadeOverview()
159+ w := m.Current()
160+ for id := WindowID(11); id <= 16; id++ {
161+ w.AddWindow(id)
162+ }
163+ w.FocusID(8)
164+ m.ToggleOverview()
165+ // Columns 4..16: bands {4,5,6} {7,8,9} {10,11,12} {13,14,15} {16}.
166+ w.FocusID(11)
167+ ps = m.Layout()
168+ for _, id := range []WindowID{4, 5, 6, 16} {
169+ if p := previewOf(t, ps, id); !hiddenOff(p) {
170+ t.Fatalf("band two lanes away %d not hidden: %+v", id, p)
171+ }
172+ }
173+ for _, id := range []WindowID{7, 13} {
174+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
175+ t.Fatalf("adjacent band %d: %+v", id, p)
176+ }
177+ }
178+}
179+
180+// T4: neighbor workspaces sit left and right of the current slot, dimmed,
181+// their selected lane level with the centre lane.
182+func TestOverviewCascadeNeighbors(t *testing.T) {
183+ m := cascadeOverview()
184+ m.ToggleOverview()
185+ rows, _ := m.overviewRows()
186+ cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]]
187+ if left.X+left.W > cur.X || right.X < cur.X+cur.W {
188+ t.Fatalf("slots overlap: %v %v %v", left, cur, right)
189+ }
190+ ps := m.Layout()
191+ centre := centreY(previewOf(t, ps, 8).Rect)
192+ shown := 0
193+ for _, n := range []struct {
194+ id WindowID
195+ side int
196+ }{{1, -1}, {2, -1}, {3, -1}, {30, 1}} {
197+ id, side := n.id, n.side
198+ p := previewOf(t, ps, id)
199+ if p.Hidden {
200+ continue
201+ }
202+ shown++
203+ if !p.Peek || p.Focused || p.Preview == 0 {
204+ t.Fatalf("neighbor %d not dimmed: %+v", id, p)
205+ }
206+ if side < 0 && p.Rect.X+p.Rect.W > cur.X || side > 0 && p.Rect.X < cur.X+cur.W {
207+ t.Fatalf("neighbor %d overlaps the current slot %v: %+v", id, cur, p.Rect)
208+ }
209+ if !within1(centreY(p.Rect), centre) {
210+ t.Fatalf("neighbor %d centre %d, centre lane %d", id, centreY(p.Rect), centre)
211+ }
212+ }
213+ if shown < 2 {
214+ t.Fatalf("only %d neighbor tiles drawn", shown)
215+ }
216+ for _, p := range ps {
217+ if p.Hidden || p.Peek {
218+ continue
219+ }
220+ if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > cur.X+cur.W {
221+ t.Fatalf("current tile %d outside its slot %v: %+v", p.ID, cur, p.Rect)
222+ }
223+ }
224+}
225+
226+// T5: lanes along the overview axis would overlap the other workspaces: a
227+// cascade neighbor of a scroll workspace shows its selected band only.
228+func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
229+ m := cascadeOverview()
230+ m.Focus(0)
231+ m.ToggleOverview()
232+ if m.overviewAxis() != verticalAxis {
233+ t.Fatal("setup: scroll overview is not vertical")
234+ }
235+ ps := m.Layout()
236+ for _, id := range []WindowID{4, 5, 6} {
237+ if p := previewOf(t, ps, id); !hiddenOff(p) {
238+ t.Fatalf("band %d of the neighbor drawn: %+v", id, p)
239+ }
240+ }
241+ if p := previewOf(t, ps, 8); p.Hidden {
242+ // The neighbor's selected column is 8.
243+ t.Fatalf("selected band hidden: %+v", p)
244+ }
245+ for _, id := range []WindowID{7, 9} {
246+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
247+ t.Fatalf("selected band tile %d: %+v", id, p)
248+ }
249+ }
250+ if p := previewOf(t, ps, 10); !hiddenOff(p) {
251+ t.Fatalf("band 3 drawn: %+v", p)
252+ }
253+}
254+
255+// T6: the stash pile is inside the current slot, left of the band; the
256+// previous workspace stays clear of it.
257+func TestOverviewCascadeStashInSlot(t *testing.T) {
258+ m := cascadeOverview()
259+ withStash(m, 2)
260+ m.ToggleOverview()
261+ rows, _ := m.overviewRows()
262+ cur, prev := rows[m.Current()], rows[m.Workspaces[0]]
263+ ps := m.Layout()
264+ left := cur.X + cur.W
265+ for _, id := range []WindowID{7, 8, 9} {
266+ left = min(left, previewOf(t, ps, id).Rect.X)
267+ }
268+ for _, id := range []WindowID{40, 41} {
269+ p := previewOf(t, ps, id)
270+ if p.Hidden || p.Preview != overviewCardZoom {
271+ t.Fatalf("pile card %d: %+v", id, p)
272+ }
273+ if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left {
274+ t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect)
275+ }
276+ // 41 is the front card of the pile.
277+ if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) {
278+ t.Fatalf("pile not level with the selected lane: %+v", p.Rect)
279+ }
280+ }
281+ // The pile does not shift the band: its row stays centred on the area.
282+ u := m.Current().overviewArea()
283+ lo, hi := previewOf(t, ps, 7).Rect, previewOf(t, ps, 9).Rect
284+ if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) {
285+ t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2)
286+ }
287+ for _, id := range []WindowID{1, 2, 3} {
288+ if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X {
289+ t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect)
290+ }
291+ }
292+ if prev.X+prev.W > cur.X {
293+ t.Fatalf("slots overlap: %v %v", prev, cur)
294+ }
295+}
296+
297+type cascadeDriver struct {
298+ name string
299+ move func(m *Monitor, dx, dy int)
300+}
301+
302+func keyDriver(left, right, up, down string) func(m *Monitor, dx, dy int) {
303+ return func(m *Monitor, dx, dy int) {
304+ name := map[[2]int]string{{-1, 0}: left, {1, 0}: right, {0, -1}: up, {0, 1}: down}[[2]int{dx, dy}]
305+ m.overviewKey(ports.KeyEvent{Keysym: name, Pressed: true})
306+ }
307+}
308+
309+var cascadeDrivers = []cascadeDriver{
310+ {"move", func(m *Monitor, dx, dy int) { m.OverviewMove(dx, dy) }},
311+ {"hjkl", keyDriver("h", "l", "k", "j")},
312+ {"arrows", keyDriver("Left", "Right", "Up", "Down")},
313+}
314+
315+func focusedID(m *Monitor) WindowID {
316+ id, _ := m.Focused()
317+ return id
318+}
319+
320+// T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
321+// for the stash or the neighbor workspaces; Escape restores everything.
322+func TestOverviewCascadeKeys(t *testing.T) {
323+ for _, d := range cascadeDrivers {
324+ t.Run(d.name, func(t *testing.T) {
325+ m := cascadeOverview()
326+ w, prev, next := m.Current(), m.Workspaces[0], m.Workspaces[2]
327+ w.FocusID(5)
328+ m.ToggleOverview()
329+ sel := func() WindowID { return w.Columns[w.Focus].Windows[0] }
330+ d.move(m, 0, 1)
331+ if sel() != 8 {
332+ t.Fatalf("down: %d, want 8", sel())
333+ }
334+ d.move(m, 0, 1)
335+ d.move(m, 0, 1)
336+ if m.Current() != w || sel() != 10 {
337+ t.Fatalf("down at the last band: %d on the current workspace %v", sel(), m.Current() == w)
338+ }
339+ d.move(m, 0, -1)
340+ if sel() != 7 {
341+ t.Fatalf("up: %d, want 7", sel())
342+ }
343+ d.move(m, 0, -1)
344+ d.move(m, 0, -1)
345+ if m.Current() != w || sel() != 4 {
346+ t.Fatalf("up at the first band: %d", sel())
347+ }
348+ d.move(m, 1, 0)
349+ d.move(m, 1, 0)
350+ if sel() != 6 || m.Current() != w {
351+ t.Fatalf("right in the band: %d", sel())
352+ }
353+ d.move(m, 1, 0)
354+ if m.Current() != next {
355+ t.Fatal("right past the last column did not reach the next workspace")
356+ }
357+ m.CancelOverview()
358+ if m.Current() != w || focusedID(m) != 5 {
359+ t.Fatalf("cancel left %d", focusedID(m))
360+ }
361+
362+ m.ToggleOverview()
363+ d.move(m, -1, 0)
364+ if sel() != 4 || m.Current() != w {
365+ t.Fatal("left within the band")
366+ }
367+ d.move(m, -1, 0)
368+ if m.Current() != prev {
369+ t.Fatal("left from the first column did not reach the previous workspace")
370+ }
371+ m.CancelOverview()
372+
373+ // With a stash: left from the first column enters it, right
374+ // comes back to the band's first column, left twice goes to
375+ // the previous workspace.
376+ withStash(m, 2)
377+ w.FocusID(7)
378+ m.ToggleOverview()
379+ d.move(m, -1, 0)
380+ if m.cardAt(w) < 0 {
381+ t.Fatal("left from the first column did not select the stash")
382+ }
383+ d.move(m, 1, 0)
384+ if m.cardAt(w) >= 0 || sel() != 7 {
385+ t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())
386+ }
387+ d.move(m, -1, 0)
388+ d.move(m, -1, 0)
389+ if m.Current() != prev {
390+ t.Fatal("left from the stash did not reach the previous workspace")
391+ }
392+ m.CancelOverview()
393+ if m.Current() != w || focusedID(m) != 7 {
394+ t.Fatalf("cancel left %d", focusedID(m))
395+ }
396+ })
397+ }
398+}
399+
400+// T7b: with a stash card selected, up/down and the three-finger vertical
401+// swipe cycle the pile as on main; confirming shows the picked window.
402+func TestOverviewCascadeStashPile(t *testing.T) {
403+ for _, d := range cascadeDrivers {
404+ t.Run(d.name, func(t *testing.T) {
405+ m := cascadeOverview()
406+ withStash(m, 3)
407+ w := m.Current()
408+ w.FocusID(7)
409+ m.ToggleOverview()
410+ d.move(m, -1, 0)
411+ if m.cardAt(w) != w.stashAt {
412+ t.Fatalf("entered at %d", m.cardAt(w))
413+ }
414+ at := m.cardAt(w)
415+ d.move(m, 0, -1)
416+ if m.Current() != w || m.cardAt(w) != at-1 {
417+ t.Fatalf("up: card %d, was %d", m.cardAt(w), at)
418+ }
419+ d.move(m, 0, 1)
420+ d.move(m, 0, 1)
421+ d.move(m, 0, 1)
422+ if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 {
423+ t.Fatalf("down: card %d", m.cardAt(w))
424+ }
425+ d.move(m, 0, -1)
426+ picked := w.Stash[m.cardAt(w)].ID
427+ m.ToggleOverview()
428+ if focusedID(m) != picked || w.stashHidden {
429+ t.Fatalf("confirm on %d, want %d (hidden %v)", focusedID(m), picked, w.stashHidden)
430+ }
431+ })
432+ }
433+
434+ c, _, sc, _ := stashRig(t, true)
435+ m := sc.mon
436+ w := m.Current()
437+ w.Overflow = OverflowCascade
438+ m.Focus(1)
439+ m.AddWindow(50)
440+ m.Focus(0)
441+ m.ToggleOverview()
442+ if m.cardAt(w) < 0 {
443+ t.Fatal("setup: no card selected")
444+ }
445+ at, before := m.cardAt(w), stashIDs(w)
446+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
447+ for range 3 {
448+ c.swipeUpdate(ports.SwipeUpdate{DY: -40})
449+ }
450+ c.swipeEnd(ports.SwipeEnd{})
451+ if m.Current() != w || m.cardAt(w) != at-1 {
452+ t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at)
453+ }
454+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
455+ for range 3 {
456+ c.swipeUpdate(ports.SwipeUpdate{DX: 40})
457+ }
458+ c.swipeEnd(ports.SwipeEnd{})
459+ // A horizontal three-finger swipe is a workspace step, even over a
460+ // stash card.
461+ if m.Current() != m.Workspaces[1] {
462+ t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current()))
463+ }
464+ if got := stashIDs(w); !slices.Equal(got, before) {
465+ t.Fatal("stash reordered")
466+ }
467+}
468+
469+// T7c: three fingers follow the physical axes, once per gesture.
470+func TestOverviewCascadeThreeFinger(t *testing.T) {
471+ c, m := cascadeCore(t)
472+ w := m.Current()
473+ m.ToggleOverview()
474+ swipe := func(dx, dy float64) {
475+ t.Helper()
476+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
477+ for range 4 {
478+ c.swipeUpdate(ports.SwipeUpdate{DX: dx, DY: dy})
479+ }
480+ c.swipeEnd(ports.SwipeEnd{})
481+ }
482+ swipe(0, 40)
483+ if m.Current() != w || w.band(w.Focus) != 1 {
484+ t.Fatalf("one swipe down: band %d", w.band(w.Focus))
485+ }
486+ swipe(0, -40)
487+ if w.band(w.Focus) != 0 {
488+ t.Fatalf("one swipe up: band %d", w.band(w.Focus))
489+ }
490+ swipe(40, 0)
491+ if m.Current() != m.Workspaces[2] {
492+ t.Fatalf("one swipe right: workspace %d", indexOf(m.Workspaces, m.Current()))
493+ }
494+ // Onto a scroll workspace and back: the vertical swipe is the scroll
495+ // overview's workspace step; neither bounces during the gesture.
496+ swipe(0, -40)
497+ if m.Current() != w {
498+ t.Fatalf("swipe up from the scroll workspace: workspace %d", indexOf(m.Workspaces, m.Current()))
499+ }
500+ swipe(-40, 0)
501+ if m.Current() != m.Workspaces[0] {
502+ t.Fatalf("one swipe left: workspace %d", indexOf(m.Workspaces, m.Current()))
503+ }
504+}
505+
506+// T8: a named boundary of a cascade overview is a vertical rule strictly
507+// between the two slots.
508+func TestOverviewCascadeNamedDivider(t *testing.T) {
509+ m := namedOverviewMonitor()
510+ m.Workspaces[1].Overflow = OverflowCascade
511+ m.ToggleOverview()
512+ rows, dividers := m.overviewRows()
513+ if len(dividers) != 1 {
514+ t.Fatalf("dividers: %+v", dividers)
515+ }
516+ d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")]
517+ if d.W != 1 || d.H <= 1 || dividers[0].Active {
518+ t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])
519+ }
520+ if d.X < cur.X+cur.W || d.X+d.W > dev.X {
521+ t.Fatalf("divider %v not between %v and %v", d, cur, dev)
522+ }
523+}
524+
525+// T10: covering floats fan along the horizontal axis: left goes deeper in the
526+// stack (the cards behind show on the left), right comes back to the front.
527+func TestOverviewCascadeCoveringFloat(t *testing.T) {
528+ for _, d := range cascadeDrivers {
529+ t.Run(d.name, func(t *testing.T) {
530+ m := cascadeOverview()
531+ m.Current().AddFloating(90, 300, 200)
532+ w := m.Current()
533+ w.FocusID(10)
534+ m.ToggleOverview()
535+ if m.stackFront(w).kind != stackColumns {
536+ t.Fatal("setup: columns not in front")
537+ }
538+ d.move(m, 0, -1) // columns: up is a band
539+ d.move(m, -1, 0)
540+ if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
541+ t.Fatalf("left from the first column: front %+v", f)
542+ }
543+ if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused {
544+ t.Fatalf("float not in front: %+v", p)
545+ }
546+ d.move(m, 0, 1) // a float front takes no band move
547+ if f := m.stackFront(w); f.kind != stackFloat {
548+ t.Fatalf("down left the float: %+v", f)
549+ }
550+ d.move(m, 1, 0)
551+ if m.stackFront(w).kind != stackColumns || m.Current() != w {
552+ t.Fatalf("right from the float: front %+v", m.stackFront(w))
553+ }
554+
555+ // The float in front of the columns: they fan to its left.
556+ m.CancelOverview()
557+ w.FocusID(90)
558+ m.ToggleOverview()
559+ if f := m.stackFront(w); f.kind != stackFloat {
560+ t.Fatalf("setup: front %+v", f)
561+ }
562+ behind := previewOf(t, m.Layout(), 10)
563+ if !behind.Peek {
564+ t.Fatalf("columns not behind the float: %+v", behind)
565+ }
566+ d.move(m, -1, 0)
567+ if m.stackFront(w).kind != stackColumns || m.Current() != w {
568+ t.Fatalf("left from the front float: front %+v", m.stackFront(w))
569+ }
570+ // The cards behind fan to the left: coming to the front, the
571+ // columns move right by one peek step.
572+ front := previewOf(t, m.Layout(), 10)
573+ if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() {
574+ t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep())
575+ }
576+ d.move(m, 1, 0)
577+ if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
578+ t.Fatalf("right from the columns behind the float: front %+v", f)
579+ }
580+ })
581+ }
582+}
583+
584+// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
585+func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
586+ m := cascadeOverview()
587+ w := m.Current()
588+ w.FocusID(8)
589+ m.ToggleFullscreen()
590+ if !w.pinned() {
591+ t.Fatal("setup: not pinned")
592+ }
593+ m.ToggleOverview()
594+ ps := m.Layout()
595+ fs := previewOf(t, ps, 8)
596+ if !fs.Fullscreen || fs.Hidden {
597+ t.Fatalf("fullscreen tile: %+v", fs)
598+ }
599+ for _, id := range []WindowID{7, 9} {
600+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
601+ t.Fatalf("band tile %d: %+v", id, p)
602+ }
603+ }
604+ u := w.overviewArea()
605+ if !within1(centreY(fs.Rect), u.Y+u.H/2) {
606+ t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect)
607+ }
608+}
609+
610+// T11: the focus binds keep their meaning in a cascade overview.
611+func TestOverviewCascadeFocusBinds(t *testing.T) {
612+ m := cascadeOverview()
613+ w := m.Current()
614+ m.ToggleOverview()
615+ if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w {
616+ t.Fatalf("focus-window-down: band %d", w.band(w.Focus))
617+ }
618+ if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 {
619+ t.Fatalf("focus-window-up: band %d", w.band(w.Focus))
620+ }
621+ m.overviewFocus(ActionFocusWorkspaceNext)
622+ if m.Current() != m.Workspaces[2] {
623+ t.Fatal("focus-workspace-next did not change workspace")
624+ }
625+ m.overviewFocus(ActionFocusWorkspacePrev)
626+ if m.Current() != w {
627+ t.Fatal("focus-workspace-prev did not come back")
628+ }
629+ m.overviewFocus(ActionFocusWorkspacePrev)
630+ if m.Current() != m.Workspaces[0] {
631+ t.Fatal("focus-workspace-prev did not change workspace")
632+ }
633+ m.CancelOverview()
634+
635+ // A selected stash card keeps the pile cycle.
636+ withStash(m, 3)
637+ w.FocusID(7)
638+ m.ToggleOverview()
639+ m.OverviewMove(-1, 0)
640+ at := m.cardAt(w)
641+ m.overviewFocus(ActionFocusWorkspacePrev)
642+ if m.Current() != w || m.cardAt(w) != at-1 {
643+ t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at)
644+ }
645+ m.overviewFocus(ActionFocusWorkspaceNext)
646+ if m.Current() != w || m.cardAt(w) != at {
647+ t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w))
648+ }
649+}
650+
651+func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
652+ m := overviewMonitor()
653+ m.SetOverflow(OverflowFixed)
654+ m.Current().Overflow = OverflowCascade
655+ from := m.Current()
656+ m.ToggleOverview()
657+ rows, _ := m.overviewRows()
658+ if rows[m.Workspaces[1]].X <= rows[from].X {
659+ t.Fatal("cascade neighbor not horizontal")
660+ }
661+ m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
662+ if m.Current() == from {
663+ t.Fatal("horizontal gesture did not change workspace")
664+ }
665+ if m.Current().Overflow != OverflowFixed {
666+ t.Fatal("destination lost fixed policy")
667+ }
668+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled {
669+ t.Fatalf("scroll state after the axis change: %+v", m.ov)
670+ }
671+ m.CancelOverview()
672+ if m.Current() != from {
673+ t.Fatal("cancel lost source workspace")
674+ }
675+}
676+
677+// Showing a workspace with another overview axis drops the scroll leftovers
678+// of the old one.
679+func TestOverviewAxisChangeResetsScroll(t *testing.T) {
680+ m := cascadeOverview()
681+ m.ToggleOverview()
682+ m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true
683+ m.showOverview(m.Workspaces[2])
684+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled {
685+ t.Fatalf("scroll kept or latch dropped: %+v", m.ov)
686+ }
687+ m.ov.scrollX = 20
688+ m.showOverview(m.Workspaces[0])
689+ if m.ov.scrollX != 20 {
690+ t.Fatal("scroll reset between workspaces of the same axis")
691+ }
692+}
693+
694+// A reload that changes the current workspace's axes drops the scroll state;
695+// one that does not, keeps it.
696+func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) {
697+ c, _ := indicatorCore(t)
698+ m := c.cur().mon
699+ m.AddWindow(1)
700+ m.ToggleOverview()
701+ w := m.Current()
702+ set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true }
703+ set()
704+ cfg := c.cfg
705+ cfg.Layout.Gaps++
706+ if err := c.apply(cfg); err != nil {
707+ t.Fatal(err)
708+ }
709+ if m.ov.scrollX != 20 || !m.ov.scrolled {
710+ t.Fatalf("unrelated reload reset the scroll: %+v", m.ov)
711+ }
712+ cfg.Layout.Overflow = string(OverflowCascade)
713+ if err := c.apply(cfg); err != nil {
714+ t.Fatal(err)
715+ }
716+ if m.Current().policy().workspace != horizontalAxis {
717+ t.Fatal("setup: not cascade")
718+ }
719+ // The reload keeps the overview open on its selection, and cancelling
720+ // still gives the original focus back.
721+ if id, _ := w.Focused(); !m.ov.open || id != 1 {
722+ t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id)
723+ }
724+ // The scroll is dropped, but not the latch that waits for the fingers
725+ // to lift.
726+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways {
727+ t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov)
728+ }
729+ set()
730+ cfg.Layout.Overflow = string(OverflowFixed)
731+ if err := c.apply(cfg); err != nil {
732+ t.Fatal(err)
733+ }
734+ if m.ov.scrollX != 0 || !m.ov.scrolled {
735+ t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov)
736+ }
737+ m.CancelOverview()
738+ if id, _ := w.Focused(); id != 1 {
739+ t.Fatalf("cancel left the focus on %d", id)
740+ }
741+}
742+
743+// The finger latch outlives an axis change: a gesture that turned the
744+// overview, then was turned back by a key, does not step again.
745+func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) {
746+ c, m := cascadeCore(t)
747+ w := m.Current()
748+ m.ToggleOverview()
749+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
750+ c.swipeUpdate(ports.SwipeUpdate{DX: 70})
751+ if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis {
752+ t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current()))
753+ }
754+ // Back to the cascade workspace by a key, with the fingers still down.
755+ m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true})
756+ if m.Current() != w || m.overviewAxis() != horizontalAxis {
757+ t.Fatal("setup: the key did not turn the overview back")
758+ }
759+ for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} {
760+ c.swipeUpdate(u)
761+ }
762+ c.swipeEnd(ports.SwipeEnd{})
763+ if m.Current() != w || w.Focus != 0 {
764+ t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus)
765+ }
766+ // The next gesture steps again.
767+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
768+ c.swipeUpdate(ports.SwipeUpdate{DY: 70})
769+ c.swipeEnd(ports.SwipeEnd{})
770+ if w.band(w.Focus) != 1 {
771+ t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus))
772+ }
773+}
774+
775+// A stash-only cascade workspace has no columns to select: right from its
776+// pile goes straight to the next workspace.
777+func TestOverviewCascadeStashOnlyRight(t *testing.T) {
778+ m := overviewMonitor()
779+ m.Focus(1)
780+ w := m.Current()
781+ w.Overflow = OverflowCascade
782+ w.ToggleWindowStash()
783+ m.Focus(2)
784+ m.AddWindow(30)
785+ m.Focus(1)
786+ m.ToggleOverview()
787+ if len(w.Columns) != 0 || m.cardAt(w) < 0 {
788+ t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w))
789+ }
790+ m.OverviewMove(1, 0)
791+ if m.Current() != m.Workspaces[2] {
792+ t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
793+ }
794+ m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back
795+ if m.Current() != w || m.cardAt(w) < 0 {
796+ t.Fatal("setup: back on the stash-only workspace without its card")
797+ }
798+ m.OverviewMove(-1, 0)
799+ if m.Current() != m.Workspaces[0] {
800+ t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
801+ }
802+}
803+
804+// A cascade neighbor beside a cascade workspace draws its other lanes
805+// dimmed, its selected lane level with the centre lane.
806+func TestOverviewCascadeNeighborLanes(t *testing.T) {
807+ m := cascadeOverview()
808+ prev := m.Workspaces[0]
809+ prev.Overflow = OverflowCascade
810+ for id := WindowID(60); id <= 65; id++ {
811+ prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected
812+ }
813+ m.ToggleOverview()
814+ ps := m.Layout()
815+ centre := centreY(previewOf(t, ps, 8).Rect)
816+ sel := previewOf(t, ps, 65)
817+ if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) {
818+ t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre)
819+ }
820+ above := previewOf(t, ps, 62)
821+ if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y {
822+ t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect)
823+ }
824+ if p := previewOf(t, ps, 3); !hiddenOff(p) {
825+ t.Fatalf("lane two away in the neighbor: %+v", p)
826+ }
827+}
828+
829+// A scroll neighbor wider than its slot scrolls inside it: what leaves the
830+// slot is hidden, so it never sits on the current slot.
831+func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) {
832+ m := cascadeOverview()
833+ prev := m.Workspaces[0]
834+ for id := WindowID(60); id <= 67; id++ {
835+ prev.AddWindow(id)
836+ }
837+ m.ToggleOverview()
838+ rows, _ := m.overviewRows()
839+ cur, slot := rows[m.Current()], rows[prev]
840+ hidden, shown := 0, 0
841+ for _, p := range m.Layout() {
842+ if !prev.has(p.ID) {
843+ continue
844+ }
845+ if p.Hidden {
846+ hidden++
847+ continue
848+ }
849+ shown++
850+ if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X {
851+ t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect)
852+ }
853+ }
854+ if hidden == 0 || shown == 0 {
855+ t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden)
856+ }
857+}
858+
859+// A wheel and a continuous scroll in a cascade overview: vertical changes
860+// workspace (and does not bounce back on a workspace with another axis),
861+// horizontal moves along the band.
862+func TestOverviewCascadeScroll(t *testing.T) {
863+ vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120}
864+ for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} {
865+ t.Run(fmt.Sprint(source), func(t *testing.T) {
866+ m := cascadeOverview()
867+ w := m.Current()
868+ m.ToggleOverview()
869+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
870+ if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
871+ t.Fatalf("vertical: workspace %d, band %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), w.band(w.Focus), m.ov.scrollX, m.ov.scrollY)
872+ }
873+
874+ m = cascadeOverview()
875+ w = m.Current()
876+ w.FocusID(7)
877+ m.ToggleOverview()
878+ m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical})
879+ if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 {
880+ t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0])
881+ }
882+
883+ // Onto a workspace with another axis: one step, no bounce.
884+ m = overviewMonitor()
885+ m.SetOverflow(OverflowFixed)
886+ m.Workspaces[1].Overflow = OverflowCascade
887+ m.ToggleOverview()
888+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
889+ if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
890+ t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY)
891+ }
892+ })
893+ }
894+}
@@ -0,0 +1,894 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// cascadeOverview is overviewMonitor with workspace 2 (current) a cascade of
12+// seven columns, 4..10, in three bands of three (the last holds 10 alone),
13+// the focus on column 8 (band 2, middle). Workspace 1 (columns 1..3) is a
14+// scroll workspace on its left, workspace 3 (column 30) on its right.
15+func cascadeOverview() *Monitor {
16+ m := overviewMonitor()
17+ m.SetBorder(2)
18+ m.Focus(1)
19+ w := m.Current()
20+ w.Overflow = OverflowCascade
21+ for id := WindowID(5); id <= 10; id++ {
22+ w.AddWindow(id)
23+ }
24+ m.Focus(2)
25+ m.AddWindow(30)
26+ m.Focus(1)
27+ w.FocusID(8)
28+ return m
29+}
30+
31+// withStash stashes windows 40..40+n-1 of the current workspace, with the
32+// focus back on column 8.
33+func withStash(m *Monitor, n int) {
34+ w := m.Current()
35+ for id := WindowID(40); id < WindowID(40+n); id++ {
36+ w.AddWindow(id)
37+ w.FocusID(id)
38+ w.ToggleWindowStash()
39+ }
40+ w.FocusID(8)
41+}
42+
43+// cascadeCore is a Core on one output with workspace 0 holding window 1,
44+// workspace 1 (current) a cascade of windows 2..6 in bands of two ({2,3}
45+// {4,5} {6}), workspace 2 window 30, and window 2 focused.
46+func cascadeCore(t *testing.T) (*Core, *Monitor) {
47+ t.Helper()
48+ c, _ := indicatorCore(t)
49+ m := c.cur().mon
50+ m.AddWindow(1)
51+ m.Focus(1)
52+ w := m.Current()
53+ w.Overflow = OverflowCascade
54+ for id := WindowID(2); id <= 6; id++ {
55+ w.AddWindow(id)
56+ }
57+ m.normalize()
58+ m.Focus(2)
59+ m.AddWindow(30)
60+ m.Focus(1)
61+ w.FocusID(2)
62+ return c, m
63+}
64+
65+func centreY(r Rect) int { return r.Y + r.H/2 }
66+
67+func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 }
68+
69+func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused }
70+
71+// T1: the zoom does not depend on the number of bands.
72+func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
73+ if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom {
74+ t.Fatalf("zoom with one band %v, with three %v", one, three)
75+ }
76+}
77+
78+// A maximized column fills its band in the preview as on screen, and hides
79+// only the other columns of its band.
80+func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) {
81+ // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
82+ for _, tc := range []struct {
83+ n, max int
84+ hidden []bool
85+ }{
86+ {n: 1, max: 0, hidden: []bool{false}},
87+ {n: 4, max: 0, hidden: []bool{false, true, true, false}},
88+ {n: 4, max: 3, hidden: []bool{false, false, false, false}},
89+ } {
90+ w := cascadeWorkspace(tc.n)
91+ w.Focus = tc.max
92+ w.maximize(tc.max)
93+ tiles, _, _ := w.previewTiles()
94+ g, h := w.gap(), w.Usable.H-2*w.gap()
95+ for i, p := range tiles {
96+ if p.Hidden != tc.hidden[i] {
97+ t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i])
98+ }
99+ if p.Hidden {
100+ continue
101+ }
102+ band := i / 3
103+ want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}
104+ if i == tc.max {
105+ want.X, want.W = g, w.Usable.W-2*g
106+ }
107+ if p.Rect != want {
108+ t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want)
109+ }
110+ }
111+ }
112+}
113+
114+// T2: the selected band is the centre lane: undimmed, centred, selected tile
115+// focused.
116+func TestOverviewCascadeCentreLane(t *testing.T) {
117+ m := cascadeOverview()
118+ m.ToggleOverview()
119+ ps := m.Layout()
120+ u := m.Current().overviewArea()
121+ for _, id := range []WindowID{7, 8, 9} {
122+ p := previewOf(t, ps, id)
123+ if p.Hidden || p.Peek || p.Preview != overviewMaxZoom || p.Focused != (id == 8) {
124+ t.Fatalf("band tile %d: %+v", id, p)
125+ }
126+ if !within1(centreY(p.Rect), u.Y+u.H/2) {
127+ t.Fatalf("band tile %d centre %d, area centre %d", id, centreY(p.Rect), u.Y+u.H/2)
128+ }
129+ }
130+}
131+
132+// T3: the other bands are dimmed lanes a screen (and a gap) away; those
133+// outside the area are hidden.
134+func TestOverviewCascadeOtherLanes(t *testing.T) {
135+ m := cascadeOverview()
136+ m.ToggleOverview()
137+ ps := m.Layout()
138+ sel := previewOf(t, ps, 8).Rect
139+ above, below := previewOf(t, ps, 5), previewOf(t, ps, 10)
140+ if above.Hidden || !above.Peek || above.Focused || below.Hidden || !below.Peek || below.Focused {
141+ t.Fatalf("adjacent bands: above %+v, below %+v", above, below)
142+ }
143+ // The lanes sit evenly around the selected one, apart from it (a gap),
144+ // and the area still shows part of each.
145+ up, down := sel.Y-above.Rect.Y, below.Rect.Y-sel.Y
146+ if !within1(up, down) {
147+ t.Fatalf("lanes not evenly spaced: %d above, %d below", up, down)
148+ }
149+ if gap := up - sel.H; gap <= 0 {
150+ t.Fatalf("lane above overlaps the selected one: %v over %v", above.Rect, sel)
151+ }
152+ u := m.Current().overviewArea()
153+ if above.Rect.Y+above.Rect.H <= u.Y || below.Rect.Y >= u.Y+u.H {
154+ t.Fatalf("adjacent lanes not visible in %v: %v, %v", u, above.Rect, below.Rect)
155+ }
156+
157+ // Five bands (13 columns): the ones two lanes away are off the area.
158+ m = cascadeOverview()
159+ w := m.Current()
160+ for id := WindowID(11); id <= 16; id++ {
161+ w.AddWindow(id)
162+ }
163+ w.FocusID(8)
164+ m.ToggleOverview()
165+ // Columns 4..16: bands {4,5,6} {7,8,9} {10,11,12} {13,14,15} {16}.
166+ w.FocusID(11)
167+ ps = m.Layout()
168+ for _, id := range []WindowID{4, 5, 6, 16} {
169+ if p := previewOf(t, ps, id); !hiddenOff(p) {
170+ t.Fatalf("band two lanes away %d not hidden: %+v", id, p)
171+ }
172+ }
173+ for _, id := range []WindowID{7, 13} {
174+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
175+ t.Fatalf("adjacent band %d: %+v", id, p)
176+ }
177+ }
178+}
179+
180+// T4: neighbor workspaces sit left and right of the current slot, dimmed,
181+// their selected lane level with the centre lane.
182+func TestOverviewCascadeNeighbors(t *testing.T) {
183+ m := cascadeOverview()
184+ m.ToggleOverview()
185+ rows, _ := m.overviewRows()
186+ cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]]
187+ if left.X+left.W > cur.X || right.X < cur.X+cur.W {
188+ t.Fatalf("slots overlap: %v %v %v", left, cur, right)
189+ }
190+ ps := m.Layout()
191+ centre := centreY(previewOf(t, ps, 8).Rect)
192+ shown := 0
193+ for _, n := range []struct {
194+ id WindowID
195+ side int
196+ }{{1, -1}, {2, -1}, {3, -1}, {30, 1}} {
197+ id, side := n.id, n.side
198+ p := previewOf(t, ps, id)
199+ if p.Hidden {
200+ continue
201+ }
202+ shown++
203+ if !p.Peek || p.Focused || p.Preview == 0 {
204+ t.Fatalf("neighbor %d not dimmed: %+v", id, p)
205+ }
206+ if side < 0 && p.Rect.X+p.Rect.W > cur.X || side > 0 && p.Rect.X < cur.X+cur.W {
207+ t.Fatalf("neighbor %d overlaps the current slot %v: %+v", id, cur, p.Rect)
208+ }
209+ if !within1(centreY(p.Rect), centre) {
210+ t.Fatalf("neighbor %d centre %d, centre lane %d", id, centreY(p.Rect), centre)
211+ }
212+ }
213+ if shown < 2 {
214+ t.Fatalf("only %d neighbor tiles drawn", shown)
215+ }
216+ for _, p := range ps {
217+ if p.Hidden || p.Peek {
218+ continue
219+ }
220+ if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > cur.X+cur.W {
221+ t.Fatalf("current tile %d outside its slot %v: %+v", p.ID, cur, p.Rect)
222+ }
223+ }
224+}
225+
226+// T5: lanes along the overview axis would overlap the other workspaces: a
227+// cascade neighbor of a scroll workspace shows its selected band only.
228+func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
229+ m := cascadeOverview()
230+ m.Focus(0)
231+ m.ToggleOverview()
232+ if m.overviewAxis() != verticalAxis {
233+ t.Fatal("setup: scroll overview is not vertical")
234+ }
235+ ps := m.Layout()
236+ for _, id := range []WindowID{4, 5, 6} {
237+ if p := previewOf(t, ps, id); !hiddenOff(p) {
238+ t.Fatalf("band %d of the neighbor drawn: %+v", id, p)
239+ }
240+ }
241+ if p := previewOf(t, ps, 8); p.Hidden {
242+ // The neighbor's selected column is 8.
243+ t.Fatalf("selected band hidden: %+v", p)
244+ }
245+ for _, id := range []WindowID{7, 9} {
246+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
247+ t.Fatalf("selected band tile %d: %+v", id, p)
248+ }
249+ }
250+ if p := previewOf(t, ps, 10); !hiddenOff(p) {
251+ t.Fatalf("band 3 drawn: %+v", p)
252+ }
253+}
254+
255+// T6: the stash pile is inside the current slot, left of the band; the
256+// previous workspace stays clear of it.
257+func TestOverviewCascadeStashInSlot(t *testing.T) {
258+ m := cascadeOverview()
259+ withStash(m, 2)
260+ m.ToggleOverview()
261+ rows, _ := m.overviewRows()
262+ cur, prev := rows[m.Current()], rows[m.Workspaces[0]]
263+ ps := m.Layout()
264+ left := cur.X + cur.W
265+ for _, id := range []WindowID{7, 8, 9} {
266+ left = min(left, previewOf(t, ps, id).Rect.X)
267+ }
268+ for _, id := range []WindowID{40, 41} {
269+ p := previewOf(t, ps, id)
270+ if p.Hidden || p.Preview != overviewCardZoom {
271+ t.Fatalf("pile card %d: %+v", id, p)
272+ }
273+ if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left {
274+ t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect)
275+ }
276+ // 41 is the front card of the pile.
277+ if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) {
278+ t.Fatalf("pile not level with the selected lane: %+v", p.Rect)
279+ }
280+ }
281+ // The pile does not shift the band: its row stays centred on the area.
282+ u := m.Current().overviewArea()
283+ lo, hi := previewOf(t, ps, 7).Rect, previewOf(t, ps, 9).Rect
284+ if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) {
285+ t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2)
286+ }
287+ for _, id := range []WindowID{1, 2, 3} {
288+ if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X {
289+ t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect)
290+ }
291+ }
292+ if prev.X+prev.W > cur.X {
293+ t.Fatalf("slots overlap: %v %v", prev, cur)
294+ }
295+}
296+
297+type cascadeDriver struct {
298+ name string
299+ move func(m *Monitor, dx, dy int)
300+}
301+
302+func keyDriver(left, right, up, down string) func(m *Monitor, dx, dy int) {
303+ return func(m *Monitor, dx, dy int) {
304+ name := map[[2]int]string{{-1, 0}: left, {1, 0}: right, {0, -1}: up, {0, 1}: down}[[2]int{dx, dy}]
305+ m.overviewKey(ports.KeyEvent{Keysym: name, Pressed: true})
306+ }
307+}
308+
309+var cascadeDrivers = []cascadeDriver{
310+ {"move", func(m *Monitor, dx, dy int) { m.OverviewMove(dx, dy) }},
311+ {"hjkl", keyDriver("h", "l", "k", "j")},
312+ {"arrows", keyDriver("Left", "Right", "Up", "Down")},
313+}
314+
315+func focusedID(m *Monitor) WindowID {
316+ id, _ := m.Focused()
317+ return id
318+}
319+
320+// T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
321+// for the stash or the neighbor workspaces; Escape restores everything.
322+func TestOverviewCascadeKeys(t *testing.T) {
323+ for _, d := range cascadeDrivers {
324+ t.Run(d.name, func(t *testing.T) {
325+ m := cascadeOverview()
326+ w, prev, next := m.Current(), m.Workspaces[0], m.Workspaces[2]
327+ w.FocusID(5)
328+ m.ToggleOverview()
329+ sel := func() WindowID { return w.Columns[w.Focus].Windows[0] }
330+ d.move(m, 0, 1)
331+ if sel() != 8 {
332+ t.Fatalf("down: %d, want 8", sel())
333+ }
334+ d.move(m, 0, 1)
335+ d.move(m, 0, 1)
336+ if m.Current() != w || sel() != 10 {
337+ t.Fatalf("down at the last band: %d on the current workspace %v", sel(), m.Current() == w)
338+ }
339+ d.move(m, 0, -1)
340+ if sel() != 7 {
341+ t.Fatalf("up: %d, want 7", sel())
342+ }
343+ d.move(m, 0, -1)
344+ d.move(m, 0, -1)
345+ if m.Current() != w || sel() != 4 {
346+ t.Fatalf("up at the first band: %d", sel())
347+ }
348+ d.move(m, 1, 0)
349+ d.move(m, 1, 0)
350+ if sel() != 6 || m.Current() != w {
351+ t.Fatalf("right in the band: %d", sel())
352+ }
353+ d.move(m, 1, 0)
354+ if m.Current() != next {
355+ t.Fatal("right past the last column did not reach the next workspace")
356+ }
357+ m.CancelOverview()
358+ if m.Current() != w || focusedID(m) != 5 {
359+ t.Fatalf("cancel left %d", focusedID(m))
360+ }
361+
362+ m.ToggleOverview()
363+ d.move(m, -1, 0)
364+ if sel() != 4 || m.Current() != w {
365+ t.Fatal("left within the band")
366+ }
367+ d.move(m, -1, 0)
368+ if m.Current() != prev {
369+ t.Fatal("left from the first column did not reach the previous workspace")
370+ }
371+ m.CancelOverview()
372+
373+ // With a stash: left from the first column enters it, right
374+ // comes back to the band's first column, left twice goes to
375+ // the previous workspace.
376+ withStash(m, 2)
377+ w.FocusID(7)
378+ m.ToggleOverview()
379+ d.move(m, -1, 0)
380+ if m.cardAt(w) < 0 {
381+ t.Fatal("left from the first column did not select the stash")
382+ }
383+ d.move(m, 1, 0)
384+ if m.cardAt(w) >= 0 || sel() != 7 {
385+ t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())
386+ }
387+ d.move(m, -1, 0)
388+ d.move(m, -1, 0)
389+ if m.Current() != prev {
390+ t.Fatal("left from the stash did not reach the previous workspace")
391+ }
392+ m.CancelOverview()
393+ if m.Current() != w || focusedID(m) != 7 {
394+ t.Fatalf("cancel left %d", focusedID(m))
395+ }
396+ })
397+ }
398+}
399+
400+// T7b: with a stash card selected, up/down and the three-finger vertical
401+// swipe cycle the pile as on main; confirming shows the picked window.
402+func TestOverviewCascadeStashPile(t *testing.T) {
403+ for _, d := range cascadeDrivers {
404+ t.Run(d.name, func(t *testing.T) {
405+ m := cascadeOverview()
406+ withStash(m, 3)
407+ w := m.Current()
408+ w.FocusID(7)
409+ m.ToggleOverview()
410+ d.move(m, -1, 0)
411+ if m.cardAt(w) != w.stashAt {
412+ t.Fatalf("entered at %d", m.cardAt(w))
413+ }
414+ at := m.cardAt(w)
415+ d.move(m, 0, -1)
416+ if m.Current() != w || m.cardAt(w) != at-1 {
417+ t.Fatalf("up: card %d, was %d", m.cardAt(w), at)
418+ }
419+ d.move(m, 0, 1)
420+ d.move(m, 0, 1)
421+ d.move(m, 0, 1)
422+ if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 {
423+ t.Fatalf("down: card %d", m.cardAt(w))
424+ }
425+ d.move(m, 0, -1)
426+ picked := w.Stash[m.cardAt(w)].ID
427+ m.ToggleOverview()
428+ if focusedID(m) != picked || w.stashHidden {
429+ t.Fatalf("confirm on %d, want %d (hidden %v)", focusedID(m), picked, w.stashHidden)
430+ }
431+ })
432+ }
433+
434+ c, _, sc, _ := stashRig(t, true)
435+ m := sc.mon
436+ w := m.Current()
437+ w.Overflow = OverflowCascade
438+ m.Focus(1)
439+ m.AddWindow(50)
440+ m.Focus(0)
441+ m.ToggleOverview()
442+ if m.cardAt(w) < 0 {
443+ t.Fatal("setup: no card selected")
444+ }
445+ at, before := m.cardAt(w), stashIDs(w)
446+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
447+ for range 3 {
448+ c.swipeUpdate(ports.SwipeUpdate{DY: -40})
449+ }
450+ c.swipeEnd(ports.SwipeEnd{})
451+ if m.Current() != w || m.cardAt(w) != at-1 {
452+ t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at)
453+ }
454+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
455+ for range 3 {
456+ c.swipeUpdate(ports.SwipeUpdate{DX: 40})
457+ }
458+ c.swipeEnd(ports.SwipeEnd{})
459+ // A horizontal three-finger swipe is a workspace step, even over a
460+ // stash card.
461+ if m.Current() != m.Workspaces[1] {
462+ t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current()))
463+ }
464+ if got := stashIDs(w); !slices.Equal(got, before) {
465+ t.Fatal("stash reordered")
466+ }
467+}
468+
469+// T7c: three fingers follow the physical axes, once per gesture.
470+func TestOverviewCascadeThreeFinger(t *testing.T) {
471+ c, m := cascadeCore(t)
472+ w := m.Current()
473+ m.ToggleOverview()
474+ swipe := func(dx, dy float64) {
475+ t.Helper()
476+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
477+ for range 4 {
478+ c.swipeUpdate(ports.SwipeUpdate{DX: dx, DY: dy})
479+ }
480+ c.swipeEnd(ports.SwipeEnd{})
481+ }
482+ swipe(0, 40)
483+ if m.Current() != w || w.band(w.Focus) != 1 {
484+ t.Fatalf("one swipe down: band %d", w.band(w.Focus))
485+ }
486+ swipe(0, -40)
487+ if w.band(w.Focus) != 0 {
488+ t.Fatalf("one swipe up: band %d", w.band(w.Focus))
489+ }
490+ swipe(40, 0)
491+ if m.Current() != m.Workspaces[2] {
492+ t.Fatalf("one swipe right: workspace %d", indexOf(m.Workspaces, m.Current()))
493+ }
494+ // Onto a scroll workspace and back: the vertical swipe is the scroll
495+ // overview's workspace step; neither bounces during the gesture.
496+ swipe(0, -40)
497+ if m.Current() != w {
498+ t.Fatalf("swipe up from the scroll workspace: workspace %d", indexOf(m.Workspaces, m.Current()))
499+ }
500+ swipe(-40, 0)
501+ if m.Current() != m.Workspaces[0] {
502+ t.Fatalf("one swipe left: workspace %d", indexOf(m.Workspaces, m.Current()))
503+ }
504+}
505+
506+// T8: a named boundary of a cascade overview is a vertical rule strictly
507+// between the two slots.
508+func TestOverviewCascadeNamedDivider(t *testing.T) {
509+ m := namedOverviewMonitor()
510+ m.Workspaces[1].Overflow = OverflowCascade
511+ m.ToggleOverview()
512+ rows, dividers := m.overviewRows()
513+ if len(dividers) != 1 {
514+ t.Fatalf("dividers: %+v", dividers)
515+ }
516+ d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")]
517+ if d.W != 1 || d.H <= 1 || dividers[0].Active {
518+ t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])
519+ }
520+ if d.X < cur.X+cur.W || d.X+d.W > dev.X {
521+ t.Fatalf("divider %v not between %v and %v", d, cur, dev)
522+ }
523+}
524+
525+// T10: covering floats fan along the horizontal axis: left goes deeper in the
526+// stack (the cards behind show on the left), right comes back to the front.
527+func TestOverviewCascadeCoveringFloat(t *testing.T) {
528+ for _, d := range cascadeDrivers {
529+ t.Run(d.name, func(t *testing.T) {
530+ m := cascadeOverview()
531+ m.Current().AddFloating(90, 300, 200)
532+ w := m.Current()
533+ w.FocusID(10)
534+ m.ToggleOverview()
535+ if m.stackFront(w).kind != stackColumns {
536+ t.Fatal("setup: columns not in front")
537+ }
538+ d.move(m, 0, -1) // columns: up is a band
539+ d.move(m, -1, 0)
540+ if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
541+ t.Fatalf("left from the first column: front %+v", f)
542+ }
543+ if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused {
544+ t.Fatalf("float not in front: %+v", p)
545+ }
546+ d.move(m, 0, 1) // a float front takes no band move
547+ if f := m.stackFront(w); f.kind != stackFloat {
548+ t.Fatalf("down left the float: %+v", f)
549+ }
550+ d.move(m, 1, 0)
551+ if m.stackFront(w).kind != stackColumns || m.Current() != w {
552+ t.Fatalf("right from the float: front %+v", m.stackFront(w))
553+ }
554+
555+ // The float in front of the columns: they fan to its left.
556+ m.CancelOverview()
557+ w.FocusID(90)
558+ m.ToggleOverview()
559+ if f := m.stackFront(w); f.kind != stackFloat {
560+ t.Fatalf("setup: front %+v", f)
561+ }
562+ behind := previewOf(t, m.Layout(), 10)
563+ if !behind.Peek {
564+ t.Fatalf("columns not behind the float: %+v", behind)
565+ }
566+ d.move(m, -1, 0)
567+ if m.stackFront(w).kind != stackColumns || m.Current() != w {
568+ t.Fatalf("left from the front float: front %+v", m.stackFront(w))
569+ }
570+ // The cards behind fan to the left: coming to the front, the
571+ // columns move right by one peek step.
572+ front := previewOf(t, m.Layout(), 10)
573+ if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() {
574+ t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep())
575+ }
576+ d.move(m, 1, 0)
577+ if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
578+ t.Fatalf("right from the columns behind the float: front %+v", f)
579+ }
580+ })
581+ }
582+}
583+
584+// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
585+func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
586+ m := cascadeOverview()
587+ w := m.Current()
588+ w.FocusID(8)
589+ m.ToggleFullscreen()
590+ if !w.pinned() {
591+ t.Fatal("setup: not pinned")
592+ }
593+ m.ToggleOverview()
594+ ps := m.Layout()
595+ fs := previewOf(t, ps, 8)
596+ if !fs.Fullscreen || fs.Hidden {
597+ t.Fatalf("fullscreen tile: %+v", fs)
598+ }
599+ for _, id := range []WindowID{7, 9} {
600+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
601+ t.Fatalf("band tile %d: %+v", id, p)
602+ }
603+ }
604+ u := w.overviewArea()
605+ if !within1(centreY(fs.Rect), u.Y+u.H/2) {
606+ t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect)
607+ }
608+}
609+
610+// T11: the focus binds keep their meaning in a cascade overview.
611+func TestOverviewCascadeFocusBinds(t *testing.T) {
612+ m := cascadeOverview()
613+ w := m.Current()
614+ m.ToggleOverview()
615+ if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w {
616+ t.Fatalf("focus-window-down: band %d", w.band(w.Focus))
617+ }
618+ if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 {
619+ t.Fatalf("focus-window-up: band %d", w.band(w.Focus))
620+ }
621+ m.overviewFocus(ActionFocusWorkspaceNext)
622+ if m.Current() != m.Workspaces[2] {
623+ t.Fatal("focus-workspace-next did not change workspace")
624+ }
625+ m.overviewFocus(ActionFocusWorkspacePrev)
626+ if m.Current() != w {
627+ t.Fatal("focus-workspace-prev did not come back")
628+ }
629+ m.overviewFocus(ActionFocusWorkspacePrev)
630+ if m.Current() != m.Workspaces[0] {
631+ t.Fatal("focus-workspace-prev did not change workspace")
632+ }
633+ m.CancelOverview()
634+
635+ // A selected stash card keeps the pile cycle.
636+ withStash(m, 3)
637+ w.FocusID(7)
638+ m.ToggleOverview()
639+ m.OverviewMove(-1, 0)
640+ at := m.cardAt(w)
641+ m.overviewFocus(ActionFocusWorkspacePrev)
642+ if m.Current() != w || m.cardAt(w) != at-1 {
643+ t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at)
644+ }
645+ m.overviewFocus(ActionFocusWorkspaceNext)
646+ if m.Current() != w || m.cardAt(w) != at {
647+ t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w))
648+ }
649+}
650+
651+func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
652+ m := overviewMonitor()
653+ m.SetOverflow(OverflowFixed)
654+ m.Current().Overflow = OverflowCascade
655+ from := m.Current()
656+ m.ToggleOverview()
657+ rows, _ := m.overviewRows()
658+ if rows[m.Workspaces[1]].X <= rows[from].X {
659+ t.Fatal("cascade neighbor not horizontal")
660+ }
661+ m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
662+ if m.Current() == from {
663+ t.Fatal("horizontal gesture did not change workspace")
664+ }
665+ if m.Current().Overflow != OverflowFixed {
666+ t.Fatal("destination lost fixed policy")
667+ }
668+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled {
669+ t.Fatalf("scroll state after the axis change: %+v", m.ov)
670+ }
671+ m.CancelOverview()
672+ if m.Current() != from {
673+ t.Fatal("cancel lost source workspace")
674+ }
675+}
676+
677+// Showing a workspace with another overview axis drops the scroll leftovers
678+// of the old one.
679+func TestOverviewAxisChangeResetsScroll(t *testing.T) {
680+ m := cascadeOverview()
681+ m.ToggleOverview()
682+ m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true
683+ m.showOverview(m.Workspaces[2])
684+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled {
685+ t.Fatalf("scroll kept or latch dropped: %+v", m.ov)
686+ }
687+ m.ov.scrollX = 20
688+ m.showOverview(m.Workspaces[0])
689+ if m.ov.scrollX != 20 {
690+ t.Fatal("scroll reset between workspaces of the same axis")
691+ }
692+}
693+
694+// A reload that changes the current workspace's axes drops the scroll state;
695+// one that does not, keeps it.
696+func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) {
697+ c, _ := indicatorCore(t)
698+ m := c.cur().mon
699+ m.AddWindow(1)
700+ m.ToggleOverview()
701+ w := m.Current()
702+ set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true }
703+ set()
704+ cfg := c.cfg
705+ cfg.Layout.Gaps++
706+ if err := c.apply(cfg); err != nil {
707+ t.Fatal(err)
708+ }
709+ if m.ov.scrollX != 20 || !m.ov.scrolled {
710+ t.Fatalf("unrelated reload reset the scroll: %+v", m.ov)
711+ }
712+ cfg.Layout.Overflow = string(OverflowCascade)
713+ if err := c.apply(cfg); err != nil {
714+ t.Fatal(err)
715+ }
716+ if m.Current().policy().workspace != horizontalAxis {
717+ t.Fatal("setup: not cascade")
718+ }
719+ // The reload keeps the overview open on its selection, and cancelling
720+ // still gives the original focus back.
721+ if id, _ := w.Focused(); !m.ov.open || id != 1 {
722+ t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id)
723+ }
724+ // The scroll is dropped, but not the latch that waits for the fingers
725+ // to lift.
726+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways {
727+ t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov)
728+ }
729+ set()
730+ cfg.Layout.Overflow = string(OverflowFixed)
731+ if err := c.apply(cfg); err != nil {
732+ t.Fatal(err)
733+ }
734+ if m.ov.scrollX != 0 || !m.ov.scrolled {
735+ t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov)
736+ }
737+ m.CancelOverview()
738+ if id, _ := w.Focused(); id != 1 {
739+ t.Fatalf("cancel left the focus on %d", id)
740+ }
741+}
742+
743+// The finger latch outlives an axis change: a gesture that turned the
744+// overview, then was turned back by a key, does not step again.
745+func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) {
746+ c, m := cascadeCore(t)
747+ w := m.Current()
748+ m.ToggleOverview()
749+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
750+ c.swipeUpdate(ports.SwipeUpdate{DX: 70})
751+ if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis {
752+ t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current()))
753+ }
754+ // Back to the cascade workspace by a key, with the fingers still down.
755+ m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true})
756+ if m.Current() != w || m.overviewAxis() != horizontalAxis {
757+ t.Fatal("setup: the key did not turn the overview back")
758+ }
759+ for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} {
760+ c.swipeUpdate(u)
761+ }
762+ c.swipeEnd(ports.SwipeEnd{})
763+ if m.Current() != w || w.Focus != 0 {
764+ t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus)
765+ }
766+ // The next gesture steps again.
767+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
768+ c.swipeUpdate(ports.SwipeUpdate{DY: 70})
769+ c.swipeEnd(ports.SwipeEnd{})
770+ if w.band(w.Focus) != 1 {
771+ t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus))
772+ }
773+}
774+
775+// A stash-only cascade workspace has no columns to select: right from its
776+// pile goes straight to the next workspace.
777+func TestOverviewCascadeStashOnlyRight(t *testing.T) {
778+ m := overviewMonitor()
779+ m.Focus(1)
780+ w := m.Current()
781+ w.Overflow = OverflowCascade
782+ w.ToggleWindowStash()
783+ m.Focus(2)
784+ m.AddWindow(30)
785+ m.Focus(1)
786+ m.ToggleOverview()
787+ if len(w.Columns) != 0 || m.cardAt(w) < 0 {
788+ t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w))
789+ }
790+ m.OverviewMove(1, 0)
791+ if m.Current() != m.Workspaces[2] {
792+ t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
793+ }
794+ m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back
795+ if m.Current() != w || m.cardAt(w) < 0 {
796+ t.Fatal("setup: back on the stash-only workspace without its card")
797+ }
798+ m.OverviewMove(-1, 0)
799+ if m.Current() != m.Workspaces[0] {
800+ t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
801+ }
802+}
803+
804+// A cascade neighbor beside a cascade workspace draws its other lanes
805+// dimmed, its selected lane level with the centre lane.
806+func TestOverviewCascadeNeighborLanes(t *testing.T) {
807+ m := cascadeOverview()
808+ prev := m.Workspaces[0]
809+ prev.Overflow = OverflowCascade
810+ for id := WindowID(60); id <= 65; id++ {
811+ prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected
812+ }
813+ m.ToggleOverview()
814+ ps := m.Layout()
815+ centre := centreY(previewOf(t, ps, 8).Rect)
816+ sel := previewOf(t, ps, 65)
817+ if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) {
818+ t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre)
819+ }
820+ above := previewOf(t, ps, 62)
821+ if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y {
822+ t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect)
823+ }
824+ if p := previewOf(t, ps, 3); !hiddenOff(p) {
825+ t.Fatalf("lane two away in the neighbor: %+v", p)
826+ }
827+}
828+
829+// A scroll neighbor wider than its slot scrolls inside it: what leaves the
830+// slot is hidden, so it never sits on the current slot.
831+func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) {
832+ m := cascadeOverview()
833+ prev := m.Workspaces[0]
834+ for id := WindowID(60); id <= 67; id++ {
835+ prev.AddWindow(id)
836+ }
837+ m.ToggleOverview()
838+ rows, _ := m.overviewRows()
839+ cur, slot := rows[m.Current()], rows[prev]
840+ hidden, shown := 0, 0
841+ for _, p := range m.Layout() {
842+ if !prev.has(p.ID) {
843+ continue
844+ }
845+ if p.Hidden {
846+ hidden++
847+ continue
848+ }
849+ shown++
850+ if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X {
851+ t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect)
852+ }
853+ }
854+ if hidden == 0 || shown == 0 {
855+ t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden)
856+ }
857+}
858+
859+// A wheel and a continuous scroll in a cascade overview: vertical changes
860+// workspace (and does not bounce back on a workspace with another axis),
861+// horizontal moves along the band.
862+func TestOverviewCascadeScroll(t *testing.T) {
863+ vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120}
864+ for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} {
865+ t.Run(fmt.Sprint(source), func(t *testing.T) {
866+ m := cascadeOverview()
867+ w := m.Current()
868+ m.ToggleOverview()
869+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
870+ if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
871+ t.Fatalf("vertical: workspace %d, band %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), w.band(w.Focus), m.ov.scrollX, m.ov.scrollY)
872+ }
873+
874+ m = cascadeOverview()
875+ w = m.Current()
876+ w.FocusID(7)
877+ m.ToggleOverview()
878+ m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical})
879+ if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 {
880+ t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0])
881+ }
882+
883+ // Onto a workspace with another axis: one step, no bounce.
884+ m = overviewMonitor()
885+ m.SetOverflow(OverflowFixed)
886+ m.Workspaces[1].Overflow = OverflowCascade
887+ m.ToggleOverview()
888+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
889+ if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
890+ t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY)
891+ }
892+ })
893+ }
894+}
M internal/core/overview_stack.go +69 -28
@@ -42,6 +42,35 @@ func (w *Workspace) columnOf(id WindowID) int {
4242 // stashed one is its card in the pile.
4343 func (w *Workspace) stack() []stackItem {
4444 var items []stackItem
45+ w.walkStack(func(item stackItem) bool {
46+ items = append(items, item)
47+ return true
48+ })
49+ return items
50+}
51+
52+// stacked reports whether the stack has several cards, without building it:
53+// the render path asks for every workspace of every frame.
54+func (w *Workspace) stacked() bool {
55+ _, n := w.stackHead()
56+ return n > 1
57+}
58+
59+// stackHead is the first item of the stack and the number of items, counted
60+// up to 2, without building the stack.
61+func (w *Workspace) stackHead() (first stackItem, n int) {
62+ w.walkStack(func(item stackItem) bool {
63+ if n == 0 {
64+ first = item
65+ }
66+ n++
67+ return n < 2
68+ })
69+ return first, n
70+}
71+
72+// walkStack yields the items of stack in order until yield returns false.
73+func (w *Workspace) walkStack(yield func(stackItem) bool) {
4574 c, columns := WindowID(0), len(w.Columns) > 0
4675 if w.pinned() {
4776 c = w.cover()
@@ -49,33 +78,38 @@ func (w *Workspace) stack() []stackItem {
4978 switch {
5079 case c == 0, w.stashIndex(c) >= 0:
5180 case w.floatIndex(c) >= 0:
52- items = append(items, stackItem{stackFloat, c})
81+ if !yield(stackItem{stackFloat, c}) {
82+ return
83+ }
5384 case w.overviewMaximized():
54- items = append(items, stackItem{stackColumn, c})
85+ if !yield(stackItem{stackColumn, c}) {
86+ return
87+ }
5588 default:
5689 // One card of all columns, its own marked fullscreen.
57- items = append(items, stackItem{kind: stackColumns})
90+ if !yield(stackItem{kind: stackColumns}) {
91+ return
92+ }
5893 columns = false
5994 }
6095 for i := len(w.Floats) - 1; i >= 0; i-- {
6196 f := w.Floats[i]
62- if !f.below && w.coversFloat(f) {
63- items = append(items, stackItem{stackFloat, f.ID})
97+ if !f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) {
98+ return
6499 }
65100 }
66- if (c == 0 || w.isFloat(c)) && w.overviewMaximized() {
67- items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})
101+ if (c == 0 || w.isFloat(c)) && w.overviewMaximized() && !yield(stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) {
102+ return
68103 }
69- if columns {
70- items = append(items, stackItem{kind: stackColumns})
104+ if columns && !yield(stackItem{kind: stackColumns}) {
105+ return
71106 }
72107 for i := len(w.Floats) - 1; i >= 0; i-- {
73108 f := w.Floats[i]
74- if f.below && w.coversFloat(f) {
75- items = append(items, stackItem{stackFloat, f.ID})
109+ if f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) {
110+ return
76111 }
77112 }
78- return items
79113 }
80114
81115 // itemOf resolves a clicked tile to the card currently containing it.
@@ -234,10 +268,10 @@ func (w *Workspace) apply(item stackItem, selected WindowID) {
234268
235269 // previewItem draws a group using screen geometry and a single card at its
236270 // configured size. A prior maximized column retains its full-width buffer.
237-func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool) []Placement {
271+func (m *Monitor) previewItem(w *Workspace, item stackItem, axis layoutAxis, slot Rect, dim, lit bool) []Placement {
238272 if item.kind == stackColumns {
239273 if !w.overviewMaximized() {
240- return w.previewRow(y, dim, lit)
274+ return w.previewRow(axis, slot, dim, lit)
241275 }
242276 // The hidden columns fill the screen as if the maximized column,
243277 // shown on its own card, were not there. In particular, an earlier
@@ -260,20 +294,20 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
260294 }
261295 sel := rects[selected]
262296 sel.X -= w.Usable.X
263- return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel)
297+ return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, sel)
264298 }
265299 g := w.gap()
266300 if item.kind == stackFloat && item.id == w.cover() {
267301 // A fullscreen float shows at the size of a fullscreen column.
268302 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
269- return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)
303+ return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)
270304 }
271305 if item.kind == stackFloat {
272306 f := w.Floats[w.floatIndex(item.id)]
273307 r := w.floatRect(f)
274308 r.X -= w.Usable.X
275309 r.Y -= w.Usable.Y
276- return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)
310+ return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)
277311 }
278312 i := w.columnOf(item.id)
279313 if i < 0 {
@@ -288,7 +322,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
288322 for j, t := range w.rowBuf {
289323 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
290324 }
291- return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
325+ return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, r)
292326 }
293327
294328 // hiddenColumnRects lays out the columns behind the maximized one with the
@@ -301,27 +335,34 @@ func (w *Workspace) hiddenColumnRects() []Rect {
301335 return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{})
302336 }
303337
304-// stackRow draws up to two cards on each side of the provisional front.
305-func (m *Monitor) stackRow(w *Workspace, y int, dim, lit bool) []Placement {
306- items := w.stack()
307- if len(items) == 0 {
308- return w.previewRow(y, dim, lit)
338+// stackRow draws up to two cards on each side of the provisional front,
339+// fanned along axis.
340+func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement {
341+ first, n := w.stackHead()
342+ if n == 0 {
343+ return w.previewRow(axis, slot, dim, lit)
309344 }
310- if len(items) == 1 {
311- return m.previewItem(w, items[0], y, dim, lit)
345+ if n == 1 {
346+ return m.previewItem(w, first, axis, slot, dim, lit)
312347 }
348+ items := w.stack()
313349 at := 0
314350 if w == m.Current() {
315351 at = slices.Index(items, m.stackFront(w))
316352 }
317353 cards := make([][]Placement, len(items))
318354 step := w.peekStep()
355+ // along is the offset of d steps on the overview axis.
356+ along := func(d int) (int, int) {
357+ var r Rect
358+ axis.offset(&r, d*step)
359+ return r.X, r.Y
360+ }
319361 for i, item := range items {
320- cards[i] = m.previewItem(w, item, w.frontRowY(y), dim, lit)
362+ cards[i] = m.previewItem(w, item, axis, w.frontSlot(axis, slot), dim, lit)
321363 }
322364
323365 // fan's behind side advances through the linear stack.
324366 return fan(cards, at, 2, 2, false,
325- func(d int) (int, int) { return 0, -d * step },
326- func(d int) (int, int) { return 0, d * step }, dim, lit)
367+ func(d int) (int, int) { return along(-d) }, along, dim, lit)
327368 }
@@ -42,6 +42,35 @@ func (w *Workspace) columnOf(id WindowID) int {
42 // stashed one is its card in the pile.42 // stashed one is its card in the pile.
43 func (w *Workspace) stack() []stackItem {43 func (w *Workspace) stack() []stackItem {
44 var items []stackItem44 var items []stackItem
45+ w.walkStack(func(item stackItem) bool {
46+ items = append(items, item)
47+ return true
48+ })
49+ return items
50+}
51+
52+// stacked reports whether the stack has several cards, without building it:
53+// the render path asks for every workspace of every frame.
54+func (w *Workspace) stacked() bool {
55+ _, n := w.stackHead()
56+ return n > 1
57+}
58+
59+// stackHead is the first item of the stack and the number of items, counted
60+// up to 2, without building the stack.
61+func (w *Workspace) stackHead() (first stackItem, n int) {
62+ w.walkStack(func(item stackItem) bool {
63+ if n == 0 {
64+ first = item
65+ }
66+ n++
67+ return n < 2
68+ })
69+ return first, n
70+}
71+
72+// walkStack yields the items of stack in order until yield returns false.
73+func (w *Workspace) walkStack(yield func(stackItem) bool) {
45 c, columns := WindowID(0), len(w.Columns) > 074 c, columns := WindowID(0), len(w.Columns) > 0
46 if w.pinned() {75 if w.pinned() {
47 c = w.cover()76 c = w.cover()
@@ -49,33 +78,38 @@ func (w *Workspace) stack() []stackItem {
49 switch {78 switch {
50 case c == 0, w.stashIndex(c) >= 0:79 case c == 0, w.stashIndex(c) >= 0:
51 case w.floatIndex(c) >= 0:80 case w.floatIndex(c) >= 0:
52- items = append(items, stackItem{stackFloat, c})81+ if !yield(stackItem{stackFloat, c}) {
82+ return
83+ }
53 case w.overviewMaximized():84 case w.overviewMaximized():
54- items = append(items, stackItem{stackColumn, c})85+ if !yield(stackItem{stackColumn, c}) {
86+ return
87+ }
55 default:88 default:
56 // One card of all columns, its own marked fullscreen.89 // One card of all columns, its own marked fullscreen.
57- items = append(items, stackItem{kind: stackColumns})90+ if !yield(stackItem{kind: stackColumns}) {
91+ return
92+ }
58 columns = false93 columns = false
59 }94 }
60 for i := len(w.Floats) - 1; i >= 0; i-- {95 for i := len(w.Floats) - 1; i >= 0; i-- {
61 f := w.Floats[i]96 f := w.Floats[i]
62- if !f.below && w.coversFloat(f) {97+ if !f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) {
63- items = append(items, stackItem{stackFloat, f.ID})98+ return
64 }99 }
65 }100 }
66- if (c == 0 || w.isFloat(c)) && w.overviewMaximized() {101+ if (c == 0 || w.isFloat(c)) && w.overviewMaximized() && !yield(stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) {
67- items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})102+ return
68 }103 }
69- if columns {104+ if columns && !yield(stackItem{kind: stackColumns}) {
70- items = append(items, stackItem{kind: stackColumns})105+ return
71 }106 }
72 for i := len(w.Floats) - 1; i >= 0; i-- {107 for i := len(w.Floats) - 1; i >= 0; i-- {
73 f := w.Floats[i]108 f := w.Floats[i]
74- if f.below && w.coversFloat(f) {109+ if f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) {
75- items = append(items, stackItem{stackFloat, f.ID})110+ return
76 }111 }
77 }112 }
78- return items
79 }113 }
80 114
81 // itemOf resolves a clicked tile to the card currently containing it.115 // itemOf resolves a clicked tile to the card currently containing it.
@@ -234,10 +268,10 @@ func (w *Workspace) apply(item stackItem, selected WindowID) {
234 268
235 // previewItem draws a group using screen geometry and a single card at its269 // previewItem draws a group using screen geometry and a single card at its
236 // configured size. A prior maximized column retains its full-width buffer.270 // configured size. A prior maximized column retains its full-width buffer.
237-func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool) []Placement {271+func (m *Monitor) previewItem(w *Workspace, item stackItem, axis layoutAxis, slot Rect, dim, lit bool) []Placement {
238 if item.kind == stackColumns {272 if item.kind == stackColumns {
239 if !w.overviewMaximized() {273 if !w.overviewMaximized() {
240- return w.previewRow(y, dim, lit)274+ return w.previewRow(axis, slot, dim, lit)
241 }275 }
242 // The hidden columns fill the screen as if the maximized column,276 // The hidden columns fill the screen as if the maximized column,
243 // shown on its own card, were not there. In particular, an earlier277 // shown on its own card, were not there. In particular, an earlier
@@ -260,20 +294,20 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
260 }294 }
261 sel := rects[selected]295 sel := rects[selected]
262 sel.X -= w.Usable.X296 sel.X -= w.Usable.X
263- return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel)297+ return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, sel)
264 }298 }
265 g := w.gap()299 g := w.gap()
266 if item.kind == stackFloat && item.id == w.cover() {300 if item.kind == stackFloat && item.id == w.cover() {
267 // A fullscreen float shows at the size of a fullscreen column.301 // A fullscreen float shows at the size of a fullscreen column.
268 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}302 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
269- return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)303+ return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)
270 }304 }
271 if item.kind == stackFloat {305 if item.kind == stackFloat {
272 f := w.Floats[w.floatIndex(item.id)]306 f := w.Floats[w.floatIndex(item.id)]
273 r := w.floatRect(f)307 r := w.floatRect(f)
274 r.X -= w.Usable.X308 r.X -= w.Usable.X
275 r.Y -= w.Usable.Y309 r.Y -= w.Usable.Y
276- return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)310+ return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)
277 }311 }
278 i := w.columnOf(item.id)312 i := w.columnOf(item.id)
279 if i < 0 {313 if i < 0 {
@@ -288,7 +322,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
288 for j, t := range w.rowBuf {322 for j, t := range w.rowBuf {
289 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})323 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
290 }324 }
291- return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)325+ return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, r)
292 }326 }
293 327
294 // hiddenColumnRects lays out the columns behind the maximized one with the328 // hiddenColumnRects lays out the columns behind the maximized one with the
@@ -301,27 +335,34 @@ func (w *Workspace) hiddenColumnRects() []Rect {
301 return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{})335 return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{})
302 }336 }
303 337
304-// stackRow draws up to two cards on each side of the provisional front.338+// stackRow draws up to two cards on each side of the provisional front,
305-func (m *Monitor) stackRow(w *Workspace, y int, dim, lit bool) []Placement {339+// fanned along axis.
306- items := w.stack()340+func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement {
307- if len(items) == 0 {341+ first, n := w.stackHead()
308- return w.previewRow(y, dim, lit)342+ if n == 0 {
343+ return w.previewRow(axis, slot, dim, lit)
309 }344 }
310- if len(items) == 1 {345+ if n == 1 {
311- return m.previewItem(w, items[0], y, dim, lit)346+ return m.previewItem(w, first, axis, slot, dim, lit)
312 }347 }
348+ items := w.stack()
313 at := 0349 at := 0
314 if w == m.Current() {350 if w == m.Current() {
315 at = slices.Index(items, m.stackFront(w))351 at = slices.Index(items, m.stackFront(w))
316 }352 }
317 cards := make([][]Placement, len(items))353 cards := make([][]Placement, len(items))
318 step := w.peekStep()354 step := w.peekStep()
355+ // along is the offset of d steps on the overview axis.
356+ along := func(d int) (int, int) {
357+ var r Rect
358+ axis.offset(&r, d*step)
359+ return r.X, r.Y
360+ }
319 for i, item := range items {361 for i, item := range items {
320- cards[i] = m.previewItem(w, item, w.frontRowY(y), dim, lit)362+ cards[i] = m.previewItem(w, item, axis, w.frontSlot(axis, slot), dim, lit)
321 }363 }
322 364
323 // fan's behind side advances through the linear stack.365 // fan's behind side advances through the linear stack.
324 return fan(cards, at, 2, 2, false,366 return fan(cards, at, 2, 2, false,
325- func(d int) (int, int) { return 0, -d * step },367+ func(d int) (int, int) { return along(-d) }, along, dim, lit)
326- func(d int) (int, int) { return 0, d * step }, dim, lit)
327 }368 }
M internal/core/publish_alloc_test.go +55 -19
@@ -187,26 +187,62 @@ func TestPublishStashSlideAllocations(t *testing.T) {
187187
188188 // TestPublishOverviewAllocations pins the cost of publishing while the
189189 // overview opens: its cards fly from the full rects (scale and fade motions
190-// on every card), which guards the reuse of Core.overviewReal.
190+// on every card), which guards the reuse of Core.overviewReal. The current
191+// workspace is scroll or cascade, alone or between two workspaces with
192+// windows (their slots and stacks are laid out every frame too).
191193 func TestPublishOverviewAllocations(t *testing.T) {
192- c := publishRig(t, 1)
193- sc := c.cur()
194- now := time.Now()
195- shots := c.snapshot(now)
196- sc.mon.ToggleOverview()
197- c.transition(shots, now)
198- step := publishStep(t, c, nil)
199- step()
200- if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() {
201- t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating())
202- }
203- n := testing.AllocsPerRun(50, step)
204- t.Logf("overview allocs per step = %v", n)
205- if len(sc.rects) == 0 {
206- t.Fatal("card motions settled during the measure")
207- }
208- if n > publishOverviewAllocBudget {
209- t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
194+ for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} {
195+ for _, neighbors := range []bool{false, true} {
196+ name := string(overflow) + "/alone"
197+ if neighbors {
198+ name = string(overflow) + "/neighbors"
199+ }
200+ t.Run(name, func(t *testing.T) {
201+ c := publishRig(t, 1)
202+ sc := c.cur()
203+ m := sc.mon
204+ if neighbors {
205+ // Windows 1..6 are on workspace 0: move to 1 with a
206+ // workspace on each side.
207+ m.Focus(1)
208+ for id := WindowID(10); id < 16; id++ {
209+ m.AddWindow(id)
210+ }
211+ m.Focus(2)
212+ for id := WindowID(20); id < 26; id++ {
213+ m.AddWindow(id)
214+ }
215+ m.Focus(1)
216+ }
217+ m.Current().Overflow = overflow
218+ sc.arrange()
219+ c.refreshShown()
220+ now := time.Now()
221+ shots := c.snapshot(now)
222+ m.ToggleOverview()
223+ c.transition(shots, now)
224+ step := publishStep(t, c, nil)
225+ step()
226+ want, slots := 6, 1
227+ if neighbors {
228+ want, slots = 18, 3
229+ }
230+ if rows, _ := m.overviewRows(); len(rows) != slots {
231+ t.Fatalf("setup: %d overview slots, want %d", len(rows), slots)
232+ }
233+ if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() {
234+ t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating())
235+ }
236+ n := testing.AllocsPerRun(50, step)
237+ t.Logf("%s overview allocs per step = %v", name, n)
238+ if len(sc.rects) == 0 {
239+ t.Fatal("card motions settled during the measure")
240+ }
241+ if n > publishOverviewAllocBudget {
242+ t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
243+ }
244+ })
245+ }
210246 }
211247 }
212248
@@ -187,26 +187,62 @@ func TestPublishStashSlideAllocations(t *testing.T) {
187 187
188 // TestPublishOverviewAllocations pins the cost of publishing while the188 // TestPublishOverviewAllocations pins the cost of publishing while the
189 // overview opens: its cards fly from the full rects (scale and fade motions189 // overview opens: its cards fly from the full rects (scale and fade motions
190-// on every card), which guards the reuse of Core.overviewReal.190+// on every card), which guards the reuse of Core.overviewReal. The current
191+// workspace is scroll or cascade, alone or between two workspaces with
192+// windows (their slots and stacks are laid out every frame too).
191 func TestPublishOverviewAllocations(t *testing.T) {193 func TestPublishOverviewAllocations(t *testing.T) {
192- c := publishRig(t, 1)194+ for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} {
193- sc := c.cur()195+ for _, neighbors := range []bool{false, true} {
194- now := time.Now()196+ name := string(overflow) + "/alone"
195- shots := c.snapshot(now)197+ if neighbors {
196- sc.mon.ToggleOverview()198+ name = string(overflow) + "/neighbors"
197- c.transition(shots, now)199+ }
198- step := publishStep(t, c, nil)200+ t.Run(name, func(t *testing.T) {
199- step()201+ c := publishRig(t, 1)
200- if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() {202+ sc := c.cur()
201- t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating())203+ m := sc.mon
202- }204+ if neighbors {
203- n := testing.AllocsPerRun(50, step)205+ // Windows 1..6 are on workspace 0: move to 1 with a
204- t.Logf("overview allocs per step = %v", n)206+ // workspace on each side.
205- if len(sc.rects) == 0 {207+ m.Focus(1)
206- t.Fatal("card motions settled during the measure")208+ for id := WindowID(10); id < 16; id++ {
207- }209+ m.AddWindow(id)
208- if n > publishOverviewAllocBudget {210+ }
209- t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)211+ m.Focus(2)
212+ for id := WindowID(20); id < 26; id++ {
213+ m.AddWindow(id)
214+ }
215+ m.Focus(1)
216+ }
217+ m.Current().Overflow = overflow
218+ sc.arrange()
219+ c.refreshShown()
220+ now := time.Now()
221+ shots := c.snapshot(now)
222+ m.ToggleOverview()
223+ c.transition(shots, now)
224+ step := publishStep(t, c, nil)
225+ step()
226+ want, slots := 6, 1
227+ if neighbors {
228+ want, slots = 18, 3
229+ }
230+ if rows, _ := m.overviewRows(); len(rows) != slots {
231+ t.Fatalf("setup: %d overview slots, want %d", len(rows), slots)
232+ }
233+ if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() {
234+ t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating())
235+ }
236+ n := testing.AllocsPerRun(50, step)
237+ t.Logf("%s overview allocs per step = %v", name, n)
238+ if len(sc.rects) == 0 {
239+ t.Fatal("card motions settled during the measure")
240+ }
241+ if n > publishOverviewAllocBudget {
242+ t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
243+ }
244+ })
245+ }
210 }246 }
211 }247 }
212 248

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