detached 76cf032

Merge pull request 'feat(overview): show cascade columns hidden by a maximized column as cards' (#14) from feat/overview-cascade-hidden-card into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/14
commit
76cf03262cc6ff4bdf5c89ae9dc08e7eebe3cbfb
author
bnema · · 2026-10-07 16:05 UTC
merge of
92e4f5a + e6e645b · diff against the first parent
signature
unsigned
12 files · +587 -79
M docs/config.md +4 -4
@@ -47,7 +47,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4747 | `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |
4848 | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) |
4949 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
50-| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
50+| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone outside panels (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
5151 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
5252 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
5353 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |
@@ -190,15 +190,15 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
190190
191191 ### Cascade
192192
193-Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill equal-width cells, up to `max-columns` per band, then continue in a fresh band below. Incomplete bands keep empty cells. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
193+Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
194194
195195 Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
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-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.
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. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back.
200200
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.
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. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. 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.
202202
203203 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
204204
@@ -47,7 +47,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
47 | `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |47 | `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |
48 | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) |48 | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) |
49 | `focus.follow-move` | `off` | Follow a column moved to another workspace |49 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
50-| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |50+| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone outside panels (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
51 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |51 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
52 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |52 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
53 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |53 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |
@@ -190,15 +190,15 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
190 190
191 ### Cascade191 ### Cascade
192 192
193-Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill equal-width cells, up to `max-columns` per band, then continue in a fresh band below. Incomplete bands keep empty cells. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.193+Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
194 194
195 Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.195 Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
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-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.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. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back.
200 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.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. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. 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 202
203 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.203 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
204 204
M internal/core/cascade.go +15 -5
@@ -5,7 +5,7 @@ import "slices"
55 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
66 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
77 func (w *Workspace) cellX(i int) int {
8- return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap())
8+ return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth(i)+w.gap())
99 }
1010 func (w *Workspace) cascadeBands() (n, spacing int) {
1111 if len(w.Columns) == 0 || w.Usable.H <= 0 {
@@ -13,8 +13,19 @@ func (w *Workspace) cascadeBands() (n, spacing int) {
1313 }
1414 return w.band(len(w.Columns)-1) + 1, w.Usable.H
1515 }
16-func (w *Workspace) cellWidth() int {
17- k := max(w.MaxColumns, 1)
16+
17+// cellWidth is the width of the cell of column i: the columns of its band
18+// share the usable width equally, as in fixed overflow, so a lone column
19+// fills it.
20+func (w *Workspace) cellWidth(i int) int {
21+ first, last := w.laneBounds(w.band(i))
22+ return w.equalShare(last - first + 1)
23+}
24+
25+// equalShare is the width of each of k columns sharing the usable width
26+// after gaps; a remainder under k pixels stays empty.
27+func (w *Workspace) equalShare(k int) int {
28+ k = max(k, 1)
1829 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
1930 }
2031 func (w *Workspace) cellInBand(b int) int {
@@ -38,11 +49,10 @@ func (w *Workspace) focusBand(dir int) bool {
3849 }
3950 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
4051 g := w.gap()
41- width := w.cellWidth()
4252 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
4353 view := w.View + w.shiftPixels()
4454 for i := range w.Columns {
45- r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}
55+ r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
4656 if w.Columns[i].FullWidth && !ignoreFullWidth {
4757 r.X = w.Usable.X + g
4858 r.W = max(w.Usable.W-2*g, 0)
@@ -5,7 +5,7 @@ import "slices"
5 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }5 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
6 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }6 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7 func (w *Workspace) cellX(i int) int {7 func (w *Workspace) cellX(i int) int {
8- return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap())8+ return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth(i)+w.gap())
9 }9 }
10 func (w *Workspace) cascadeBands() (n, spacing int) {10 func (w *Workspace) cascadeBands() (n, spacing int) {
11 if len(w.Columns) == 0 || w.Usable.H <= 0 {11 if len(w.Columns) == 0 || w.Usable.H <= 0 {
@@ -13,8 +13,19 @@ func (w *Workspace) cascadeBands() (n, spacing int) {
13 }13 }
14 return w.band(len(w.Columns)-1) + 1, w.Usable.H14 return w.band(len(w.Columns)-1) + 1, w.Usable.H
15 }15 }
16-func (w *Workspace) cellWidth() int {16+
17- k := max(w.MaxColumns, 1)17+// cellWidth is the width of the cell of column i: the columns of its band
18+// share the usable width equally, as in fixed overflow, so a lone column
19+// fills it.
20+func (w *Workspace) cellWidth(i int) int {
21+ first, last := w.laneBounds(w.band(i))
22+ return w.equalShare(last - first + 1)
23+}
24+
25+// equalShare is the width of each of k columns sharing the usable width
26+// after gaps; a remainder under k pixels stays empty.
27+func (w *Workspace) equalShare(k int) int {
28+ k = max(k, 1)
18 return max((w.Usable.W-w.gap()*(k+1))/k, 0)29 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
19 }30 }
20 func (w *Workspace) cellInBand(b int) int {31 func (w *Workspace) cellInBand(b int) int {
@@ -38,11 +49,10 @@ func (w *Workspace) focusBand(dir int) bool {
38 }49 }
39 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {50 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
40 g := w.gap()51 g := w.gap()
41- width := w.cellWidth()
42 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]52 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
43 view := w.View + w.shiftPixels()53 view := w.View + w.shiftPixels()
44 for i := range w.Columns {54 for i := range w.Columns {
45- r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}55+ r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
46 if w.Columns[i].FullWidth && !ignoreFullWidth {56 if w.Columns[i].FullWidth && !ignoreFullWidth {
47 r.X = w.Usable.X + g57 r.X = w.Usable.X + g
48 r.W = max(w.Usable.W-2*g, 0)58 r.W = max(w.Usable.W-2*g, 0)
M internal/core/cascade_test.go +80 -2
@@ -3,6 +3,8 @@ package core
33 import (
44 "slices"
55 "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
68 )
79
810 func cascadeWorkspace(n int) *Workspace {
@@ -14,15 +16,91 @@ func cascadeWorkspace(n int) *Workspace {
1416 return w
1517 }
1618
19+// With gaps and a width that does not divide evenly, the columns of a
20+// partial band share what the gaps leave, and the configured width follows
21+// the band as it fills.
22+func TestCascadeBandWidthWithGaps(t *testing.T) {
23+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
24+ w.SetOutput(1000, 600)
25+ w.SetGaps(8)
26+ w.AddWindow(1)
27+ if got := w.columnWidth(0); got != 1000-2*8 {
28+ t.Fatalf("lone column width %d, want %d", got, 1000-2*8)
29+ }
30+ w.AddWindow(2)
31+ cell := (1000 - 3*8) / 2
32+ for i, x := range []int{8, 8 + cell + 8} {
33+ r := w.columnRectsFor(false)[i]
34+ if r.X != x || r.W != cell || w.columnWidth(i) != cell {
35+ t.Fatalf("column %d rect %v width %d, want X %d W %d", i, r, w.columnWidth(i), x, cell)
36+ }
37+ }
38+}
39+
40+// A partial last band after a full one shares the band width with gaps:
41+// each band divides by its own column count.
42+func TestCascadePartialLastBandWithGaps(t *testing.T) {
43+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
44+ w.SetOutput(1000, 600)
45+ w.SetGaps(8)
46+ for id := WindowID(1); id <= 5; id++ {
47+ w.AddWindow(id)
48+ }
49+ third, half := (1000-4*8)/3, (1000-3*8)/2
50+ rects := w.columnRectsFor(false)
51+ for i, want := range []struct{ x, w int }{
52+ {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third},
53+ {8, half}, {8 + half + 8, half},
54+ } {
55+ if r := rects[i]; r.X != want.x || r.W != want.w {
56+ t.Fatalf("column %d rect %v, want X %d W %d", i, r, want.x, want.w)
57+ }
58+ }
59+ if rects[3].Y-rects[0].Y != 600 {
60+ t.Fatalf("band spacing %d, want 600", rects[3].Y-rects[0].Y)
61+ }
62+}
63+
64+// A side panel moves and narrows every band: cells start at the usable
65+// area's X and share its width.
66+func TestCascadeSidePanel(t *testing.T) {
67+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2}
68+ w.SetOutput(1000, 600)
69+ w.SetUsable(Rect{X: 100, W: 900, H: 600})
70+ for id := WindowID(1); id <= 3; id++ {
71+ w.AddWindow(id)
72+ }
73+ rects := w.columnRectsFor(false)
74+ for i, want := range []Rect{
75+ {X: 100, Y: 0, W: 450, H: 600},
76+ {X: 550, Y: 0, W: 450, H: 600},
77+ {X: 100, Y: 600, W: 900, H: 600},
78+ } {
79+ if rects[i].X != want.X || rects[i].W != want.W || rects[i].Y-rects[0].Y != want.Y {
80+ t.Fatalf("column %d rect %v, want %v", i, rects[i], want)
81+ }
82+ }
83+ // The panel edge at x=100 is no shared line: column 1 has only its
84+ // right neighbor.
85+ w.FocusID(1)
86+ if p := previewOf(t, w.Layout(), 1); p.Neighbors != ports.SideRight {
87+ t.Fatalf("neighbors %04b, want right only", p.Neighbors)
88+ }
89+}
90+
91+// Each band's columns share its width equally, as in fixed overflow: a
92+// lone column fills it, a full band of three has thirds.
1793 func TestCascadeBandsAndReveal(t *testing.T) {
18- for _, n := range []int{0, 1, 3, 4, 7} {
94+ for _, n := range []int{0, 1, 2, 3, 4, 5, 7} {
1995 w := cascadeWorkspace(n)
2096 ps := w.Layout()
2197 if len(ps) != n {
2298 t.Fatalf("n=%d placements=%d", n, len(ps))
2399 }
24100 for i, p := range ps {
25- want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600}
101+ inBand := min(n-i/3*3, 3)
102+ cell := 900 / inBand
103+ want := Rect{X: i % 3 * cell, Y: (i/3)*600 - w.View, W: cell, H: 600}
26104 if p.Rect != want {
27105 t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)
28106 }
@@ -3,6 +3,8 @@ package core
3 import (3 import (
4 "slices"4 "slices"
5 "testing"5 "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
6 )8 )
7 9
8 func cascadeWorkspace(n int) *Workspace {10 func cascadeWorkspace(n int) *Workspace {
@@ -14,15 +16,91 @@ func cascadeWorkspace(n int) *Workspace {
14 return w16 return w
15 }17 }
16 18
19+// With gaps and a width that does not divide evenly, the columns of a
20+// partial band share what the gaps leave, and the configured width follows
21+// the band as it fills.
22+func TestCascadeBandWidthWithGaps(t *testing.T) {
23+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
24+ w.SetOutput(1000, 600)
25+ w.SetGaps(8)
26+ w.AddWindow(1)
27+ if got := w.columnWidth(0); got != 1000-2*8 {
28+ t.Fatalf("lone column width %d, want %d", got, 1000-2*8)
29+ }
30+ w.AddWindow(2)
31+ cell := (1000 - 3*8) / 2
32+ for i, x := range []int{8, 8 + cell + 8} {
33+ r := w.columnRectsFor(false)[i]
34+ if r.X != x || r.W != cell || w.columnWidth(i) != cell {
35+ t.Fatalf("column %d rect %v width %d, want X %d W %d", i, r, w.columnWidth(i), x, cell)
36+ }
37+ }
38+}
39+
40+// A partial last band after a full one shares the band width with gaps:
41+// each band divides by its own column count.
42+func TestCascadePartialLastBandWithGaps(t *testing.T) {
43+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
44+ w.SetOutput(1000, 600)
45+ w.SetGaps(8)
46+ for id := WindowID(1); id <= 5; id++ {
47+ w.AddWindow(id)
48+ }
49+ third, half := (1000-4*8)/3, (1000-3*8)/2
50+ rects := w.columnRectsFor(false)
51+ for i, want := range []struct{ x, w int }{
52+ {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third},
53+ {8, half}, {8 + half + 8, half},
54+ } {
55+ if r := rects[i]; r.X != want.x || r.W != want.w {
56+ t.Fatalf("column %d rect %v, want X %d W %d", i, r, want.x, want.w)
57+ }
58+ }
59+ if rects[3].Y-rects[0].Y != 600 {
60+ t.Fatalf("band spacing %d, want 600", rects[3].Y-rects[0].Y)
61+ }
62+}
63+
64+// A side panel moves and narrows every band: cells start at the usable
65+// area's X and share its width.
66+func TestCascadeSidePanel(t *testing.T) {
67+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2}
68+ w.SetOutput(1000, 600)
69+ w.SetUsable(Rect{X: 100, W: 900, H: 600})
70+ for id := WindowID(1); id <= 3; id++ {
71+ w.AddWindow(id)
72+ }
73+ rects := w.columnRectsFor(false)
74+ for i, want := range []Rect{
75+ {X: 100, Y: 0, W: 450, H: 600},
76+ {X: 550, Y: 0, W: 450, H: 600},
77+ {X: 100, Y: 600, W: 900, H: 600},
78+ } {
79+ if rects[i].X != want.X || rects[i].W != want.W || rects[i].Y-rects[0].Y != want.Y {
80+ t.Fatalf("column %d rect %v, want %v", i, rects[i], want)
81+ }
82+ }
83+ // The panel edge at x=100 is no shared line: column 1 has only its
84+ // right neighbor.
85+ w.FocusID(1)
86+ if p := previewOf(t, w.Layout(), 1); p.Neighbors != ports.SideRight {
87+ t.Fatalf("neighbors %04b, want right only", p.Neighbors)
88+ }
89+}
90+
91+// Each band's columns share its width equally, as in fixed overflow: a
92+// lone column fills it, a full band of three has thirds.
17 func TestCascadeBandsAndReveal(t *testing.T) {93 func TestCascadeBandsAndReveal(t *testing.T) {
18- for _, n := range []int{0, 1, 3, 4, 7} {94+ for _, n := range []int{0, 1, 2, 3, 4, 5, 7} {
19 w := cascadeWorkspace(n)95 w := cascadeWorkspace(n)
20 ps := w.Layout()96 ps := w.Layout()
21 if len(ps) != n {97 if len(ps) != n {
22 t.Fatalf("n=%d placements=%d", n, len(ps))98 t.Fatalf("n=%d placements=%d", n, len(ps))
23 }99 }
24 for i, p := range ps {100 for i, p := range ps {
25- want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600}101+ inBand := min(n-i/3*3, 3)
102+ cell := 900 / inBand
103+ want := Rect{X: i % 3 * cell, Y: (i/3)*600 - w.View, W: cell, H: 600}
26 if p.Rect != want {104 if p.Rect != want {
27 t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)105 t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)
28 }106 }
M internal/core/core.go +2 -1
@@ -632,7 +632,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
632632 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
633633 }
634634 // A window alone on screen needs no pulse to show it has the focus.
635- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
635+ // A tile under a panel (the next cascade band) does not count.
636+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1
636637 for k, p := range layout {
637638 ps := settled[k]
638639 // Only the focused output has an activated window.
@@ -632,7 +632,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
632 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)632 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
633 }633 }
634 // A window alone on screen needs no pulse to show it has the focus.634 // A window alone on screen needs no pulse to show it has the focus.
635- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1635+ // A tile under a panel (the next cascade band) does not count.
636+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1
636 for k, p := range layout {637 for k, p := range layout {
637 ps := settled[k]638 ps := settled[k]
638 // Only the focused output has an activated window.639 // Only the focused output has an activated window.
M internal/core/layout.go +24 -21
@@ -122,7 +122,7 @@ type Placement struct {
122122 // Preview is the scale of an overview preview: its buffer is drawn
123123 // that much smaller in Rect, the client keeps its size. 0 otherwise.
124124 Preview float64
125- // Neighbors are the sides touching a visible tile on this output.
125+ // Neighbors are the sides touching a tile visible in the usable area.
126126 // Inset reserves room for drawn lines: all sides of a float; for
127127 // tiles without gaps, only the right and bottom shared sides.
128128 Neighbors, Inset ports.Sides
@@ -1052,6 +1052,13 @@ func (w *Workspace) maximize(i int) {
10521052 }
10531053 }
10541054
1055+// hiddenByMaximized reports whether the maximized focused column hides
1056+// column i on screen: in equal-cell layouts it hides the other columns of
1057+// its band (the whole row in fixed overflow).
1058+func (w *Workspace) hiddenByMaximized(i int) bool {
1059+ return w.policy().equalCells && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1060+}
1061+
10551062 // unmaximize clears the focused column in scroll mode, or all maximized
10561063 // columns in fixed mode (including after Escape changed focus).
10571064 func (w *Workspace) unmaximize() {
@@ -1383,15 +1390,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
13831390 g := w.gap()
13841391 // Fixed overflow never scrolls, so presets would push columns off screen.
13851392 if w.policy().wraps {
1386- return w.cellWidth()
1393+ return w.cellWidth(i)
13871394 }
13881395 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
1389- if len(w.Columns) == 1 {
1390- return max(w.Usable.W-2*g, 0)
1391- }
1392- // Equal shares of the width left after gaps; a remainder under k pixels stays empty.
1393- k := min(len(w.Columns), max(w.MaxColumns, 1))
1394- return max((w.Usable.W-g*(k+1))/k, 0)
1396+ return w.equalShare(min(len(w.Columns), max(w.MaxColumns, 1)))
13951397 }
13961398 return w.Columns[i].Width.Resolve(w.Usable.W, g)
13971399 }
@@ -1485,7 +1487,7 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
14851487 // right; the two strips share the last cell.
14861488 func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
14871489 g := w.gap()
1488- cell := max((w.Usable.W-g*(k+1))/k, 0)
1490+ cell := w.equalShare(k)
14891491 wide := (k-1)*cell + (k-2)*g
14901492 rest := max(w.Usable.W-2*g-wide-g, 0)
14911493 before, after := w.Columns[:e], w.Columns[e+1:]
@@ -1637,8 +1639,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
16371639 full := w.fullscreen == id && id != 0
16381640 // Fixed overflow cannot scroll to other columns while one fills
16391641 // the view. Keep them in place, but out of the scene.
1640- maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1641- hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
1642+ hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || w.hiddenByMaximized(i)
16421643 if full {
16431644 // Scroll mode aligns the view on the column; fixed never scrolls.
16441645 r = w.Output
@@ -1652,7 +1653,9 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
16521653 tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
16531654 }
16541655 }
1655- setVisibleNeighbors(tiles, gap, w.Output)
1656+ // Lines are shared within the usable area: a tile under a panel (the
1657+ // next cascade band starts right below a bottom one) is no neighbor.
1658+ setVisibleNeighbors(tiles, gap, w.Usable)
16561659 // tiles lives in the workspace's scratch until they are copied below.
16571660 w.tileBuf = tiles
16581661 // The column/stash group sits between demoted covering floats and
@@ -1707,11 +1710,11 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
17071710 }
17081711
17091712 // setVisibleNeighbors reserves client space only for shared lines that
1710-// are actually on this output. A scrolled-off or hidden tile cannot give a
1711-// lone visible tile a border (or shrink its client).
1712-func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {
1713+// are actually in the usable area. A scrolled-off, hidden or under-panel
1714+// tile cannot give a lone visible tile a border (or shrink its client).
1715+func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
17131716 visible := func(p Placement) bool {
1714- return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(output)
1717+ return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(area)
17151718 }
17161719 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
17171720 for i := range tiles {
@@ -1725,15 +1728,15 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {
17251728 continue
17261729 }
17271730 b, r := tiles[j].Rect, a.Rect
1728- // Only count a shared edge strictly within this output.
1731+ // Only count a shared edge strictly within the area.
17291732 switch {
1730- case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > output.X && r.X+r.W < output.X+output.W:
1733+ case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > area.X && r.X+r.W < area.X+area.W:
17311734 a.Neighbors |= ports.SideRight
1732- case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > output.X && r.X < output.X+output.W:
1735+ case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > area.X && r.X < area.X+area.W:
17331736 a.Neighbors |= ports.SideLeft
1734- case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > output.Y && r.Y+r.H < output.Y+output.H:
1737+ case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > area.Y && r.Y+r.H < area.Y+area.H:
17351738 a.Neighbors |= ports.SideBottom
1736- case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > output.Y && r.Y < output.Y+output.H:
1739+ case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > area.Y && r.Y < area.Y+area.H:
17371740 a.Neighbors |= ports.SideTop
17381741 }
17391742 }
@@ -122,7 +122,7 @@ type Placement struct {
122 // Preview is the scale of an overview preview: its buffer is drawn122 // Preview is the scale of an overview preview: its buffer is drawn
123 // that much smaller in Rect, the client keeps its size. 0 otherwise.123 // that much smaller in Rect, the client keeps its size. 0 otherwise.
124 Preview float64124 Preview float64
125- // Neighbors are the sides touching a visible tile on this output.125+ // Neighbors are the sides touching a tile visible in the usable area.
126 // Inset reserves room for drawn lines: all sides of a float; for126 // Inset reserves room for drawn lines: all sides of a float; for
127 // tiles without gaps, only the right and bottom shared sides.127 // tiles without gaps, only the right and bottom shared sides.
128 Neighbors, Inset ports.Sides128 Neighbors, Inset ports.Sides
@@ -1052,6 +1052,13 @@ func (w *Workspace) maximize(i int) {
1052 }1052 }
1053 }1053 }
1054 1054
1055+// hiddenByMaximized reports whether the maximized focused column hides
1056+// column i on screen: in equal-cell layouts it hides the other columns of
1057+// its band (the whole row in fixed overflow).
1058+func (w *Workspace) hiddenByMaximized(i int) bool {
1059+ return w.policy().equalCells && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1060+}
1061+
1055 // unmaximize clears the focused column in scroll mode, or all maximized1062 // unmaximize clears the focused column in scroll mode, or all maximized
1056 // columns in fixed mode (including after Escape changed focus).1063 // columns in fixed mode (including after Escape changed focus).
1057 func (w *Workspace) unmaximize() {1064 func (w *Workspace) unmaximize() {
@@ -1383,15 +1390,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1383 g := w.gap()1390 g := w.gap()
1384 // Fixed overflow never scrolls, so presets would push columns off screen.1391 // Fixed overflow never scrolls, so presets would push columns off screen.
1385 if w.policy().wraps {1392 if w.policy().wraps {
1386- return w.cellWidth()1393+ return w.cellWidth(i)
1387 }1394 }
1388 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {1395 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
1389- if len(w.Columns) == 1 {1396+ return w.equalShare(min(len(w.Columns), max(w.MaxColumns, 1)))
1390- return max(w.Usable.W-2*g, 0)
1391- }
1392- // Equal shares of the width left after gaps; a remainder under k pixels stays empty.
1393- k := min(len(w.Columns), max(w.MaxColumns, 1))
1394- return max((w.Usable.W-g*(k+1))/k, 0)
1395 }1397 }
1396 return w.Columns[i].Width.Resolve(w.Usable.W, g)1398 return w.Columns[i].Width.Resolve(w.Usable.W, g)
1397 }1399 }
@@ -1485,7 +1487,7 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1485 // right; the two strips share the last cell.1487 // right; the two strips share the last cell.
1486 func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {1488 func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
1487 g := w.gap()1489 g := w.gap()
1488- cell := max((w.Usable.W-g*(k+1))/k, 0)1490+ cell := w.equalShare(k)
1489 wide := (k-1)*cell + (k-2)*g1491 wide := (k-1)*cell + (k-2)*g
1490 rest := max(w.Usable.W-2*g-wide-g, 0)1492 rest := max(w.Usable.W-2*g-wide-g, 0)
1491 before, after := w.Columns[:e], w.Columns[e+1:]1493 before, after := w.Columns[:e], w.Columns[e+1:]
@@ -1637,8 +1639,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1637 full := w.fullscreen == id && id != 01639 full := w.fullscreen == id && id != 0
1638 // Fixed overflow cannot scroll to other columns while one fills1640 // Fixed overflow cannot scroll to other columns while one fills
1639 // the view. Keep them in place, but out of the scene.1641 // the view. Keep them in place, but out of the scene.
1640- maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)1642+ hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || w.hiddenByMaximized(i)
1641- hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
1642 if full {1643 if full {
1643 // Scroll mode aligns the view on the column; fixed never scrolls.1644 // Scroll mode aligns the view on the column; fixed never scrolls.
1644 r = w.Output1645 r = w.Output
@@ -1652,7 +1653,9 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1652 tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})1653 tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
1653 }1654 }
1654 }1655 }
1655- setVisibleNeighbors(tiles, gap, w.Output)1656+ // Lines are shared within the usable area: a tile under a panel (the
1657+ // next cascade band starts right below a bottom one) is no neighbor.
1658+ setVisibleNeighbors(tiles, gap, w.Usable)
1656 // tiles lives in the workspace's scratch until they are copied below.1659 // tiles lives in the workspace's scratch until they are copied below.
1657 w.tileBuf = tiles1660 w.tileBuf = tiles
1658 // The column/stash group sits between demoted covering floats and1661 // The column/stash group sits between demoted covering floats and
@@ -1707,11 +1710,11 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1707 }1710 }
1708 1711
1709 // setVisibleNeighbors reserves client space only for shared lines that1712 // setVisibleNeighbors reserves client space only for shared lines that
1710-// are actually on this output. A scrolled-off or hidden tile cannot give a1713+// are actually in the usable area. A scrolled-off, hidden or under-panel
1711-// lone visible tile a border (or shrink its client).1714+// tile cannot give a lone visible tile a border (or shrink its client).
1712-func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {1715+func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
1713 visible := func(p Placement) bool {1716 visible := func(p Placement) bool {
1714- return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(output)1717+ return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(area)
1715 }1718 }
1716 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }1719 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
1717 for i := range tiles {1720 for i := range tiles {
@@ -1725,15 +1728,15 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {
1725 continue1728 continue
1726 }1729 }
1727 b, r := tiles[j].Rect, a.Rect1730 b, r := tiles[j].Rect, a.Rect
1728- // Only count a shared edge strictly within this output.1731+ // Only count a shared edge strictly within the area.
1729 switch {1732 switch {
1730- case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > output.X && r.X+r.W < output.X+output.W:1733+ case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > area.X && r.X+r.W < area.X+area.W:
1731 a.Neighbors |= ports.SideRight1734 a.Neighbors |= ports.SideRight
1732- case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > output.X && r.X < output.X+output.W:1735+ case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > area.X && r.X < area.X+area.W:
1733 a.Neighbors |= ports.SideLeft1736 a.Neighbors |= ports.SideLeft
1734- case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > output.Y && r.Y+r.H < output.Y+output.H:1737+ case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > area.Y && r.Y+r.H < area.Y+area.H:
1735 a.Neighbors |= ports.SideBottom1738 a.Neighbors |= ports.SideBottom
1736- case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > output.Y && r.Y < output.Y+output.H:1739+ case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > area.Y && r.Y < area.Y+area.H:
1737 a.Neighbors |= ports.SideTop1740 a.Neighbors |= ports.SideTop
1738 }1741 }
1739 }1742 }
M internal/core/overview.go +95 -16
@@ -450,8 +450,12 @@ func (m *Monitor) overviewAcross(dx int) {
450450 m.ov.card, m.ov.cardOf = 0, nil
451451 m.ov.selected = 0
452452 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
453- first, _ := w.laneBounds(w.laneOf(w.Focus))
454- m.selectLaneColumn(w, first)
453+ // The front column: a maximized one keeps its cards behind.
454+ i := w.Focus
455+ if !w.hasBandCards() {
456+ i, _ = w.laneBounds(w.laneOf(w.Focus))
457+ }
458+ m.selectLaneColumn(w, i)
455459 }
456460 return
457461 }
@@ -596,16 +600,18 @@ func (m *Monitor) OverviewPick(id WindowID) {
596600 } else if item, ok := w.itemOf(id); ok {
597601 m.setFront(w, item)
598602 if i := w.columnOf(id); i >= 0 {
599- if item.kind == stackColumns && !w.overviewMaximized() {
600- w.selectOverviewColumn(i)
601- }
602- m.ov.selected = id
603+ // The clicked window first: a maximization moving to its
604+ // column records it in the maximize history.
603605 for j, v := range w.Columns[i].Windows {
604606 if v == id {
605607 w.Columns[i].Focus = j
606608 break
607609 }
608610 }
611+ if item.kind == stackColumns && !w.overviewMaximized() {
612+ w.selectOverviewColumn(i)
613+ }
614+ m.ov.selected = id
609615 }
610616 }
611617 m.closeOverview()
@@ -970,17 +976,38 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
970976 rects = w.colBuf
971977 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
972978 }
973- maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
979+ if w.hasBandCards() {
980+ // The columns the maximized one hides are cards behind it,
981+ // farthest first so the nearest paint over them.
982+ first, last := w.laneBounds(w.laneOf(w.Focus))
983+ for d := last - first; d > 0; d-- {
984+ if i := w.Focus - d; i >= first {
985+ tiles = w.appendBandCard(tiles, i)
986+ }
987+ if i := w.Focus + d; i <= last {
988+ tiles = w.appendBandCard(tiles, i)
989+ }
990+ }
991+ }
974992 for i, c := range w.Columns {
975993 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
976994 if w.policy().wraps {
995+ if w.hiddenByMaximized(i) {
996+ continue // A card, above.
997+ }
977998 // The band is the lane: it is encoded in Y, one screen apart.
978- // A maximized column fills its band as on screen.
999+ // A maximized column fills its band as on screen, inset for
1000+ // the cards it hides.
9791001 r.X = w.cellX(i)
9801002 r.Y = g + w.band(i)*w.Usable.H
981- r.W = w.cellWidth()
1003+ r.W = w.cellWidth(i)
9821004 if c.FullWidth {
983- r.X, r.W = g, max(w.Usable.W-2*g, 0)
1005+ in := 0
1006+ if i == w.Focus && w.hasBandCards() {
1007+ // Only the focused column has cards behind it.
1008+ in = 2 * w.bandCardStep()
1009+ }
1010+ r.X, r.W = g+in, max(w.Usable.W-2*g-2*in, 0)
9841011 }
9851012 } else if w.Overflow == OverflowFixed {
9861013 r = rects[i]
@@ -989,10 +1016,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
9891016 // A fullscreen column shows at the width it had in the row.
9901017 r.W = max(w.Usable.W-2*g, 0)
9911018 }
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.
1019+ if w.hiddenByMaximized(i) {
1020+ // Fixed overflow shows the hidden columns on their own card
1021+ // (previewItem). Their old buffers cannot fit their current
1022+ // rects; show only the maximized column here.
9961023 for _, id := range c.Windows {
9971024 tiles = append(tiles, Placement{ID: id, Hidden: true})
9981025 }
@@ -1015,6 +1042,46 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
10151042 return tiles, span, sel
10161043 }
10171044
1045+// bandCardStep is the unscaled step between the cards of a cascade band
1046+// whose maximized column hides the others: the stack peek step.
1047+func (w *Workspace) bandCardStep() int {
1048+ return max(1, int(math.Round(float64(w.Usable.H)*overviewPeekStep)))
1049+}
1050+
1051+// hasBandCards reports whether the focused column of a cascade is
1052+// maximized and hides other columns of its band: the overview shows them
1053+// as cards behind it, the maximized column inset by two card steps on
1054+// each side.
1055+func (w *Workspace) hasBandCards() bool {
1056+ if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth {
1057+ return false
1058+ }
1059+ first, last := w.laneBounds(w.laneOf(w.Focus))
1060+ return first < last
1061+}
1062+
1063+// appendBandCard appends hidden column i of a cascade band as a dimmed card
1064+// at its cell size behind the maximized column, on its side: the nearest
1065+// column one step out from the maximized one, the next ones two steps out
1066+// (on the band's edge), like the cards of a stack.
1067+func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1068+ g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1069+ r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
1070+ k := min(max(i-w.Focus, w.Focus-i), 2)
1071+ if i < w.Focus {
1072+ r.X = g + (2-k)*s
1073+ } else {
1074+ r.X = w.Usable.W - g - (2-k)*s - r.W
1075+ }
1076+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
1077+ for j, t := range w.rowBuf {
1078+ p := Placement{ID: c.Windows[j], Rect: t}
1079+ p.peeking(1)
1080+ tiles = append(tiles, p)
1081+ }
1082+ return tiles
1083+}
1084+
10181085 // previewRow scales the tiles of w into a row placed in slot, centred
10191086 // in the slot's width, or scrolled so the focused column shows when the
10201087 // row is wider, right of the stash pile. Dimmed rows (neighbor
@@ -1284,8 +1351,14 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
12841351 case math.Abs(m.ov.scrollX) >= stepX:
12851352 d := sign(m.ov.scrollX)
12861353 if cascade && finger {
1287- // One workspace per gesture, even over a stash card.
1288- m.overviewStep(d)
1354+ // One step per gesture: a card behind a maximized
1355+ // column first, like the arrows, else one workspace,
1356+ // even over a stash card.
1357+ if m.bandCardsAt(m.Current()) {
1358+ m.OverviewMove(d, 0)
1359+ } else {
1360+ m.overviewStep(d)
1361+ }
12891362 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
12901363 return true
12911364 }
@@ -1305,6 +1378,12 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
13051378 }
13061379 }
13071380
1381+// bandCardsAt reports whether the selection of w is a maximized cascade
1382+// column hiding cards of its band: not a stash card nor a covering float.
1383+func (m *Monitor) bandCardsAt(w *Workspace) bool {
1384+ return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns
1385+}
1386+
13081387 // overviewTurned reports whether a scroll step turned the overview from the
13091388 // axis before. A finger scroll then waits for the fingers to lift, so it
13101389 // cannot bounce back.
@@ -450,8 +450,12 @@ func (m *Monitor) overviewAcross(dx int) {
450 m.ov.card, m.ov.cardOf = 0, nil450 m.ov.card, m.ov.cardOf = 0, nil
451 m.ov.selected = 0451 m.ov.selected = 0
452 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {452 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
453- first, _ := w.laneBounds(w.laneOf(w.Focus))453+ // The front column: a maximized one keeps its cards behind.
454- m.selectLaneColumn(w, first)454+ i := w.Focus
455+ if !w.hasBandCards() {
456+ i, _ = w.laneBounds(w.laneOf(w.Focus))
457+ }
458+ m.selectLaneColumn(w, i)
455 }459 }
456 return460 return
457 }461 }
@@ -596,16 +600,18 @@ func (m *Monitor) OverviewPick(id WindowID) {
596 } else if item, ok := w.itemOf(id); ok {600 } else if item, ok := w.itemOf(id); ok {
597 m.setFront(w, item)601 m.setFront(w, item)
598 if i := w.columnOf(id); i >= 0 {602 if i := w.columnOf(id); i >= 0 {
599- if item.kind == stackColumns && !w.overviewMaximized() {603+ // The clicked window first: a maximization moving to its
600- w.selectOverviewColumn(i)604+ // column records it in the maximize history.
601- }
602- m.ov.selected = id
603 for j, v := range w.Columns[i].Windows {605 for j, v := range w.Columns[i].Windows {
604 if v == id {606 if v == id {
605 w.Columns[i].Focus = j607 w.Columns[i].Focus = j
606 break608 break
607 }609 }
608 }610 }
611+ if item.kind == stackColumns && !w.overviewMaximized() {
612+ w.selectOverviewColumn(i)
613+ }
614+ m.ov.selected = id
609 }615 }
610 }616 }
611 m.closeOverview()617 m.closeOverview()
@@ -970,17 +976,38 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
970 rects = w.colBuf976 rects = w.colBuf
971 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.977 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
972 }978 }
973- maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth979+ if w.hasBandCards() {
980+ // The columns the maximized one hides are cards behind it,
981+ // farthest first so the nearest paint over them.
982+ first, last := w.laneBounds(w.laneOf(w.Focus))
983+ for d := last - first; d > 0; d-- {
984+ if i := w.Focus - d; i >= first {
985+ tiles = w.appendBandCard(tiles, i)
986+ }
987+ if i := w.Focus + d; i <= last {
988+ tiles = w.appendBandCard(tiles, i)
989+ }
990+ }
991+ }
974 for i, c := range w.Columns {992 for i, c := range w.Columns {
975 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}993 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
976 if w.policy().wraps {994 if w.policy().wraps {
995+ if w.hiddenByMaximized(i) {
996+ continue // A card, above.
997+ }
977 // The band is the lane: it is encoded in Y, one screen apart.998 // The band is the lane: it is encoded in Y, one screen apart.
978- // A maximized column fills its band as on screen.999+ // A maximized column fills its band as on screen, inset for
1000+ // the cards it hides.
979 r.X = w.cellX(i)1001 r.X = w.cellX(i)
980 r.Y = g + w.band(i)*w.Usable.H1002 r.Y = g + w.band(i)*w.Usable.H
981- r.W = w.cellWidth()1003+ r.W = w.cellWidth(i)
982 if c.FullWidth {1004 if c.FullWidth {
983- r.X, r.W = g, max(w.Usable.W-2*g, 0)1005+ in := 0
1006+ if i == w.Focus && w.hasBandCards() {
1007+ // Only the focused column has cards behind it.
1008+ in = 2 * w.bandCardStep()
1009+ }
1010+ r.X, r.W = g+in, max(w.Usable.W-2*g-2*in, 0)
984 }1011 }
985 } else if w.Overflow == OverflowFixed {1012 } else if w.Overflow == OverflowFixed {
986 r = rects[i]1013 r = rects[i]
@@ -989,10 +1016,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
989 // A fullscreen column shows at the width it had in the row.1016 // A fullscreen column shows at the width it had in the row.
990 r.W = max(w.Usable.W-2*g, 0)1017 r.W = max(w.Usable.W-2*g, 0)
991 }1018 }
992- if maximized && i != w.Focus && w.sameBand(i, w.Focus) {1019+ if w.hiddenByMaximized(i) {
993- // FullWidth hides the other columns of its band on screen.1020+ // Fixed overflow shows the hidden columns on their own card
994- // Their old buffers cannot fit their current rects; show only1021+ // (previewItem). Their old buffers cannot fit their current
995- // the maximized column until navigation restores the layout.1022+ // rects; show only the maximized column here.
996 for _, id := range c.Windows {1023 for _, id := range c.Windows {
997 tiles = append(tiles, Placement{ID: id, Hidden: true})1024 tiles = append(tiles, Placement{ID: id, Hidden: true})
998 }1025 }
@@ -1015,6 +1042,46 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
1015 return tiles, span, sel1042 return tiles, span, sel
1016 }1043 }
1017 1044
1045+// bandCardStep is the unscaled step between the cards of a cascade band
1046+// whose maximized column hides the others: the stack peek step.
1047+func (w *Workspace) bandCardStep() int {
1048+ return max(1, int(math.Round(float64(w.Usable.H)*overviewPeekStep)))
1049+}
1050+
1051+// hasBandCards reports whether the focused column of a cascade is
1052+// maximized and hides other columns of its band: the overview shows them
1053+// as cards behind it, the maximized column inset by two card steps on
1054+// each side.
1055+func (w *Workspace) hasBandCards() bool {
1056+ if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth {
1057+ return false
1058+ }
1059+ first, last := w.laneBounds(w.laneOf(w.Focus))
1060+ return first < last
1061+}
1062+
1063+// appendBandCard appends hidden column i of a cascade band as a dimmed card
1064+// at its cell size behind the maximized column, on its side: the nearest
1065+// column one step out from the maximized one, the next ones two steps out
1066+// (on the band's edge), like the cards of a stack.
1067+func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1068+ g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1069+ r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
1070+ k := min(max(i-w.Focus, w.Focus-i), 2)
1071+ if i < w.Focus {
1072+ r.X = g + (2-k)*s
1073+ } else {
1074+ r.X = w.Usable.W - g - (2-k)*s - r.W
1075+ }
1076+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
1077+ for j, t := range w.rowBuf {
1078+ p := Placement{ID: c.Windows[j], Rect: t}
1079+ p.peeking(1)
1080+ tiles = append(tiles, p)
1081+ }
1082+ return tiles
1083+}
1084+
1018 // previewRow scales the tiles of w into a row placed in slot, centred1085 // 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 the1086 // in the slot's width, or scrolled so the focused column shows when the
1020 // row is wider, right of the stash pile. Dimmed rows (neighbor1087 // row is wider, right of the stash pile. Dimmed rows (neighbor
@@ -1284,8 +1351,14 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1284 case math.Abs(m.ov.scrollX) >= stepX:1351 case math.Abs(m.ov.scrollX) >= stepX:
1285 d := sign(m.ov.scrollX)1352 d := sign(m.ov.scrollX)
1286 if cascade && finger {1353 if cascade && finger {
1287- // One workspace per gesture, even over a stash card.1354+ // One step per gesture: a card behind a maximized
1288- m.overviewStep(d)1355+ // column first, like the arrows, else one workspace,
1356+ // even over a stash card.
1357+ if m.bandCardsAt(m.Current()) {
1358+ m.OverviewMove(d, 0)
1359+ } else {
1360+ m.overviewStep(d)
1361+ }
1289 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1362 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1290 return true1363 return true
1291 }1364 }
@@ -1305,6 +1378,12 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1305 }1378 }
1306 }1379 }
1307 1380
1381+// bandCardsAt reports whether the selection of w is a maximized cascade
1382+// column hiding cards of its band: not a stash card nor a covering float.
1383+func (m *Monitor) bandCardsAt(w *Workspace) bool {
1384+ return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns
1385+}
1386+
1308 // overviewTurned reports whether a scroll step turned the overview from the1387 // 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 it1388 // axis before. A finger scroll then waits for the fingers to lift, so it
1310 // cannot bounce back.1389 // cannot bounce back.
M internal/core/overview_cascade_test.go +285 -20
@@ -75,39 +75,171 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
7575 }
7676 }
7777
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) {
78+// A maximized column fills its band in the preview, inset for the columns
79+// it hides: they are dimmed cards at their cell size behind it, on their
80+// side, the nearest one step out, the farther ones two (the band's edge).
81+// Other bands keep their cells.
82+func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
8183 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
8284 for _, tc := range []struct {
8385 n, max int
84- hidden []bool
86+ cards []int // per column: 0 not a card, -k/+k k steps left/right
87+ order []int // paint order of band 0's columns
8588 }{
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+ {n: 1, max: 0, cards: []int{0}, order: []int{0}},
90+ {n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}},
91+ {n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}},
92+ {n: 4, max: 3, cards: []int{0, 0, 0, 0}},
93+ // Partial bands: the card has its band's half width.
94+ {n: 2, max: 0, cards: []int{0, 1}, order: []int{1, 0}},
95+ {n: 5, max: 4, cards: []int{0, 0, 0, -1, 0}},
8996 } {
9097 w := cascadeWorkspace(tc.n)
9198 w.Focus = tc.max
9299 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
100+ tiles, _, sel := w.previewTiles()
101+ g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
102+ in := 0
103+ if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
104+ in = 2 * s
105+ }
106+ var order []int
107+ for _, p := range tiles {
108+ i := int(p.ID) - 1
109+ if i/3 == 0 && tc.order != nil {
110+ order = append(order, i)
101111 }
102112 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
113+ want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
114+ switch k := tc.cards[i]; {
115+ case i == tc.max:
116+ want.X, want.W = g+in, w.Usable.W-2*g-2*in
117+ case k < 0:
118+ want.X = g + (2+k)*s
119+ case k > 0:
120+ want.X = w.Usable.W - g - (2-k)*s - want.W
121+ }
122+ if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
123+ t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
124+ }
125+ // The 900-wide band split among its columns, no gaps.
126+ if inBand := min(tc.n-band*3, 3); i != tc.max && p.Rect.W != 900/inBand {
127+ t.Fatalf("n=%d max=%d column %d width %d, want %d", tc.n, tc.max, i, p.Rect.W, 900/inBand)
106128 }
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)
129+ if i == tc.max && sel != want {
130+ t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want)
109131 }
110132 }
133+ if tc.order != nil && !slices.Equal(order, tc.order) {
134+ t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order)
135+ }
136+ }
137+}
138+
139+// Moving onto a card behind a maximized column hands it the maximization;
140+// Return keeps it there, Escape gives it back. Moving to another band
141+// unmaximizes as before.
142+func TestOverviewCascadeMaximizedCardSelection(t *testing.T) {
143+ for _, d := range cascadeDrivers {
144+ t.Run(d.name, func(t *testing.T) {
145+ m := cascadeOverview()
146+ w := m.Current()
147+ w.FocusID(9) // band 2 holds 7, 8, 9
148+ w.ToggleFullWidth()
149+ m.ToggleOverview()
150+ for _, id := range []WindowID{7, 8} {
151+ if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
152+ t.Fatalf("card %d: %+v", id, p)
153+ }
154+ }
155+ d.move(m, -1, 0)
156+ if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
157+ t.Fatalf("left: focus %d, maximization not moved", focusedID(m))
158+ }
159+ if p := previewOf(t, m.Layout(), 9); !p.Peek {
160+ t.Fatalf("former maximized column not a card: %+v", p)
161+ }
162+ m.CancelOverview()
163+ if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth {
164+ t.Fatalf("escape: focus %d, maximization not restored", focusedID(m))
165+ }
166+
167+ m.ToggleOverview()
168+ d.move(m, -1, 0)
169+ d.move(m, -1, 0)
170+ m.ToggleOverview() // return
171+ if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
172+ t.Fatalf("return: focus %d, maximization not on 7", focusedID(m))
173+ }
174+
175+ m.ToggleOverview()
176+ d.move(m, 0, -1)
177+ if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) {
178+ t.Fatalf("up: focus %d, a column still maximized", focusedID(m))
179+ }
180+ })
181+ }
182+}
183+
184+// A click on a window of a card's column records that window in the
185+// maximize history, not the column's former focus.
186+func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) {
187+ m := cascadeOverview()
188+ w := m.Current()
189+ // Column 8 stacks 8 over 50, 8 focused.
190+ w.Columns[w.columnOf(8)].Windows = append(w.Columns[w.columnOf(8)].Windows, 50)
191+ w.FocusID(7)
192+ w.ToggleFullWidth()
193+ m.ToggleOverview()
194+ m.OverviewPick(50)
195+ if len(w.maximized) == 0 || w.maximized[0] != 50 || focusedID(m) != 50 {
196+ t.Fatalf("history %v, focus %d; want 50 first", w.maximized, focusedID(m))
197+ }
198+}
199+
200+// Only the focused maximized column is inset for cards: a FullWidth column
201+// elsewhere fills its band.
202+func TestOverviewCascadeUnfocusedMaximizedNotInset(t *testing.T) {
203+ w := cascadeWorkspace(6)
204+ w.SetGaps(8)
205+ w.Focus = 4
206+ w.maximize(4)
207+ w.Columns[0].FullWidth = true
208+ tiles, _, _ := w.previewTiles()
209+ g := w.gap()
210+ p := previewOf(t, tiles, 1)
211+ if p.Hidden || p.Rect.X != g || p.Rect.W != w.Usable.W-2*g {
212+ t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, g, w.Usable.W-2*g)
213+ }
214+}
215+
216+// A click on a card behind a maximized cascade column hands it the
217+// maximization.
218+func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
219+ m := cascadeOverview()
220+ w := m.Current()
221+ w.FocusID(7)
222+ w.ToggleFullWidth()
223+ m.ToggleOverview()
224+ // Each card's strip right of the maximized column hits it: 8 next to
225+ // it, 9 at the band's edge.
226+ ps := m.Layout()
227+ front, near, far := previewOf(t, ps, 7).Rect, previewOf(t, ps, 8).Rect, previewOf(t, ps, 9).Rect
228+ for _, c := range []struct {
229+ x0, x1 int
230+ want WindowID
231+ }{{front.X + front.W, near.X + near.W, 8}, {near.X + near.W, far.X + far.W, 9}} {
232+ x := (c.x0 + c.x1) / 2
233+ if c.x0 >= c.x1 {
234+ t.Fatalf("no strip for %d: front %v near %v far %v", c.want, front, near, far)
235+ }
236+ if got := overviewIn(ps, float64(x), float64(centreY(far))); got != c.want {
237+ t.Fatalf("strip at x=%d hits %d, want %d", x, got, c.want)
238+ }
239+ }
240+ m.OverviewPick(9)
241+ if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth {
242+ t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m))
111243 }
112244 }
113245
@@ -503,6 +635,139 @@ func TestOverviewCascadeThreeFinger(t *testing.T) {
503635 }
504636 }
505637
638+// Three fingers sideways walk the cards behind a maximized column one per
639+// gesture, like the arrows, then change workspace past the band's edge.
640+func TestOverviewCascadeThreeFingerMaximizedCards(t *testing.T) {
641+ c, m := cascadeCore(t)
642+ w := m.Current()
643+ w.ToggleFullWidth() // Column 2 hides 3, the other column of its band.
644+ m.ToggleOverview()
645+ swipe := func(dx float64) {
646+ t.Helper()
647+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
648+ for range 4 {
649+ c.swipeUpdate(ports.SwipeUpdate{DX: dx})
650+ }
651+ c.swipeEnd(ports.SwipeEnd{})
652+ }
653+ swipe(40)
654+ if m.Current() != w || m.overviewTarget() != 3 || !w.Columns[w.Focus].FullWidth {
655+ t.Fatalf("swipe right: workspace %d, target %d, want maximized 3", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
656+ }
657+ swipe(-40)
658+ if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
659+ t.Fatalf("swipe left: workspace %d, target %d, want maximized 2", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
660+ }
661+ swipe(40)
662+ swipe(40)
663+ if m.Current() != m.Workspaces[2] {
664+ t.Fatalf("swipe right past the band: workspace %d", indexOf(m.Workspaces, m.Current()))
665+ }
666+
667+ // A covering float in front: the swipe changes workspace, as without
668+ // a maximized column.
669+ m.CancelOverview()
670+ w.AddFloating(90, 300, 200)
671+ m.ToggleOverview()
672+ if f := m.stackFront(w); f.kind != stackFloat || !w.Columns[w.Focus].FullWidth {
673+ t.Fatalf("setup: front %+v", f)
674+ }
675+ swipe(-40)
676+ if m.Current() != m.Workspaces[0] {
677+ t.Fatalf("swipe left over a float: workspace %d", indexOf(m.Workspaces, m.Current()))
678+ }
679+}
680+
681+// Left from the first column of a band whose maximized column hides it:
682+// the arrows and three fingers walk the cards, then enter the stash, or
683+// change workspace without one.
684+func TestOverviewCascadeMaximizedLeftEdge(t *testing.T) {
685+ for _, stash := range []bool{false, true} {
686+ c, m := cascadeCore(t)
687+ w := m.Current()
688+ if stash {
689+ w.AddWindow(40)
690+ w.FocusID(40)
691+ w.ToggleWindowStash()
692+ }
693+ w.FocusID(3) // Band {2, 3}: 3 hides 2.
694+ w.ToggleFullWidth()
695+ m.ToggleOverview()
696+ swipe := func() {
697+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
698+ for range 4 {
699+ c.swipeUpdate(ports.SwipeUpdate{DX: -40})
700+ }
701+ c.swipeEnd(ports.SwipeEnd{})
702+ }
703+ swipe()
704+ if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
705+ t.Fatalf("stash %v: first swipe: target %d", stash, m.overviewTarget())
706+ }
707+ swipe()
708+ if stash {
709+ if m.Current() != w || m.cardAt(w) < 0 {
710+ t.Fatalf("past the band: workspace %d, card %d; want the stash", indexOf(m.Workspaces, m.Current()), m.cardAt(w))
711+ }
712+ } else if m.Current() != m.Workspaces[0] {
713+ t.Fatalf("past the band: workspace %d, want 0", indexOf(m.Workspaces, m.Current()))
714+ }
715+ }
716+}
717+
718+// With four or more columns in a band, the cards three or more columns
719+// away sit under the two-step card: arrows still reach every one, in
720+// order, each taking the maximization.
721+func TestOverviewCascadeFarCardsByArrows(t *testing.T) {
722+ m := newMonitor("", "")
723+ m.SetOutput(1200, 600)
724+ m.SetMaxColumns(5)
725+ w := m.Current()
726+ w.Overflow = OverflowCascade
727+ for id := WindowID(1); id <= 5; id++ {
728+ m.AddWindow(id)
729+ }
730+ w.FocusID(1)
731+ w.ToggleFullWidth()
732+ m.ToggleOverview()
733+ ps := m.Layout()
734+ edge := previewOf(t, ps, 3).Rect
735+ for _, id := range []WindowID{4, 5} {
736+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek || p.Rect.X != edge.X {
737+ t.Fatalf("far card %d: %+v, want a card under 3 at x %d", id, p, edge.X)
738+ }
739+ }
740+ for _, want := range []WindowID{2, 3, 4, 5} {
741+ m.OverviewMove(1, 0)
742+ if m.Current() != w || m.overviewTarget() != want || !w.Columns[w.Focus].FullWidth {
743+ t.Fatalf("right: target %d, want maximized %d", m.overviewTarget(), want)
744+ }
745+ }
746+ m.CancelOverview()
747+ if focusedID(m) != 1 || !w.Columns[w.Focus].FullWidth {
748+ t.Fatalf("escape: focus %d, want maximized 1", focusedID(m))
749+ }
750+}
751+
752+// Right from a stash card lands on the maximized column, which keeps its
753+// maximization, not on the first card of its band.
754+func TestOverviewCascadeStashToMaximized(t *testing.T) {
755+ m := cascadeOverview()
756+ withStash(m, 1)
757+ w := m.Current()
758+ w.FocusID(9)
759+ w.ToggleFullWidth()
760+ m.ToggleOverview()
761+ m.selectCard(w, w.stashAt) // As a click on the pile does.
762+ if m.cardAt(w) < 0 {
763+ t.Fatalf("setup: no stash card, target %d", m.overviewTarget())
764+ }
765+ m.OverviewMove(1, 0)
766+ if m.overviewTarget() != 9 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 9 {
767+ t.Fatalf("target %d, maximized %v; want 9", m.overviewTarget(), w.maximized)
768+ }
769+}
770+
506771 // T8: a named boundary of a cascade overview is a vertical rule strictly
507772 // between the two slots.
508773 func TestOverviewCascadeNamedDivider(t *testing.T) {
@@ -75,39 +75,171 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
75 }75 }
76 }76 }
77 77
78-// A maximized column fills its band in the preview as on screen, and hides78+// A maximized column fills its band in the preview, inset for the columns
79-// only the other columns of its band.79+// it hides: they are dimmed cards at their cell size behind it, on their
80-func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) {80+// side, the nearest one step out, the farther ones two (the band's edge).
81+// Other bands keep their cells.
82+func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
81 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.83 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
82 for _, tc := range []struct {84 for _, tc := range []struct {
83 n, max int85 n, max int
84- hidden []bool86+ cards []int // per column: 0 not a card, -k/+k k steps left/right
87+ order []int // paint order of band 0's columns
85 }{88 }{
86- {n: 1, max: 0, hidden: []bool{false}},89+ {n: 1, max: 0, cards: []int{0}, order: []int{0}},
87- {n: 4, max: 0, hidden: []bool{false, true, true, false}},90+ {n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}},
88- {n: 4, max: 3, hidden: []bool{false, false, false, false}},91+ {n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}},
92+ {n: 4, max: 3, cards: []int{0, 0, 0, 0}},
93+ // Partial bands: the card has its band's half width.
94+ {n: 2, max: 0, cards: []int{0, 1}, order: []int{1, 0}},
95+ {n: 5, max: 4, cards: []int{0, 0, 0, -1, 0}},
89 } {96 } {
90 w := cascadeWorkspace(tc.n)97 w := cascadeWorkspace(tc.n)
91 w.Focus = tc.max98 w.Focus = tc.max
92 w.maximize(tc.max)99 w.maximize(tc.max)
93- tiles, _, _ := w.previewTiles()100+ tiles, _, sel := w.previewTiles()
94- g, h := w.gap(), w.Usable.H-2*w.gap()101+ g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
95- for i, p := range tiles {102+ in := 0
96- if p.Hidden != tc.hidden[i] {103+ if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
97- t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i])104+ in = 2 * s
98- }105+ }
99- if p.Hidden {106+ var order []int
100- continue107+ for _, p := range tiles {
108+ i := int(p.ID) - 1
109+ if i/3 == 0 && tc.order != nil {
110+ order = append(order, i)
101 }111 }
102 band := i / 3112 band := i / 3
103- want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}113+ want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
104- if i == tc.max {114+ switch k := tc.cards[i]; {
105- want.X, want.W = g, w.Usable.W-2*g115+ case i == tc.max:
116+ want.X, want.W = g+in, w.Usable.W-2*g-2*in
117+ case k < 0:
118+ want.X = g + (2+k)*s
119+ case k > 0:
120+ want.X = w.Usable.W - g - (2-k)*s - want.W
121+ }
122+ if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
123+ t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
124+ }
125+ // The 900-wide band split among its columns, no gaps.
126+ if inBand := min(tc.n-band*3, 3); i != tc.max && p.Rect.W != 900/inBand {
127+ t.Fatalf("n=%d max=%d column %d width %d, want %d", tc.n, tc.max, i, p.Rect.W, 900/inBand)
106 }128 }
107- if p.Rect != want {129+ if i == tc.max && sel != want {
108- t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want)130+ t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want)
109 }131 }
110 }132 }
133+ if tc.order != nil && !slices.Equal(order, tc.order) {
134+ t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order)
135+ }
136+ }
137+}
138+
139+// Moving onto a card behind a maximized column hands it the maximization;
140+// Return keeps it there, Escape gives it back. Moving to another band
141+// unmaximizes as before.
142+func TestOverviewCascadeMaximizedCardSelection(t *testing.T) {
143+ for _, d := range cascadeDrivers {
144+ t.Run(d.name, func(t *testing.T) {
145+ m := cascadeOverview()
146+ w := m.Current()
147+ w.FocusID(9) // band 2 holds 7, 8, 9
148+ w.ToggleFullWidth()
149+ m.ToggleOverview()
150+ for _, id := range []WindowID{7, 8} {
151+ if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
152+ t.Fatalf("card %d: %+v", id, p)
153+ }
154+ }
155+ d.move(m, -1, 0)
156+ if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
157+ t.Fatalf("left: focus %d, maximization not moved", focusedID(m))
158+ }
159+ if p := previewOf(t, m.Layout(), 9); !p.Peek {
160+ t.Fatalf("former maximized column not a card: %+v", p)
161+ }
162+ m.CancelOverview()
163+ if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth {
164+ t.Fatalf("escape: focus %d, maximization not restored", focusedID(m))
165+ }
166+
167+ m.ToggleOverview()
168+ d.move(m, -1, 0)
169+ d.move(m, -1, 0)
170+ m.ToggleOverview() // return
171+ if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
172+ t.Fatalf("return: focus %d, maximization not on 7", focusedID(m))
173+ }
174+
175+ m.ToggleOverview()
176+ d.move(m, 0, -1)
177+ if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) {
178+ t.Fatalf("up: focus %d, a column still maximized", focusedID(m))
179+ }
180+ })
181+ }
182+}
183+
184+// A click on a window of a card's column records that window in the
185+// maximize history, not the column's former focus.
186+func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) {
187+ m := cascadeOverview()
188+ w := m.Current()
189+ // Column 8 stacks 8 over 50, 8 focused.
190+ w.Columns[w.columnOf(8)].Windows = append(w.Columns[w.columnOf(8)].Windows, 50)
191+ w.FocusID(7)
192+ w.ToggleFullWidth()
193+ m.ToggleOverview()
194+ m.OverviewPick(50)
195+ if len(w.maximized) == 0 || w.maximized[0] != 50 || focusedID(m) != 50 {
196+ t.Fatalf("history %v, focus %d; want 50 first", w.maximized, focusedID(m))
197+ }
198+}
199+
200+// Only the focused maximized column is inset for cards: a FullWidth column
201+// elsewhere fills its band.
202+func TestOverviewCascadeUnfocusedMaximizedNotInset(t *testing.T) {
203+ w := cascadeWorkspace(6)
204+ w.SetGaps(8)
205+ w.Focus = 4
206+ w.maximize(4)
207+ w.Columns[0].FullWidth = true
208+ tiles, _, _ := w.previewTiles()
209+ g := w.gap()
210+ p := previewOf(t, tiles, 1)
211+ if p.Hidden || p.Rect.X != g || p.Rect.W != w.Usable.W-2*g {
212+ t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, g, w.Usable.W-2*g)
213+ }
214+}
215+
216+// A click on a card behind a maximized cascade column hands it the
217+// maximization.
218+func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
219+ m := cascadeOverview()
220+ w := m.Current()
221+ w.FocusID(7)
222+ w.ToggleFullWidth()
223+ m.ToggleOverview()
224+ // Each card's strip right of the maximized column hits it: 8 next to
225+ // it, 9 at the band's edge.
226+ ps := m.Layout()
227+ front, near, far := previewOf(t, ps, 7).Rect, previewOf(t, ps, 8).Rect, previewOf(t, ps, 9).Rect
228+ for _, c := range []struct {
229+ x0, x1 int
230+ want WindowID
231+ }{{front.X + front.W, near.X + near.W, 8}, {near.X + near.W, far.X + far.W, 9}} {
232+ x := (c.x0 + c.x1) / 2
233+ if c.x0 >= c.x1 {
234+ t.Fatalf("no strip for %d: front %v near %v far %v", c.want, front, near, far)
235+ }
236+ if got := overviewIn(ps, float64(x), float64(centreY(far))); got != c.want {
237+ t.Fatalf("strip at x=%d hits %d, want %d", x, got, c.want)
238+ }
239+ }
240+ m.OverviewPick(9)
241+ if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth {
242+ t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m))
111 }243 }
112 }244 }
113 245
@@ -503,6 +635,139 @@ func TestOverviewCascadeThreeFinger(t *testing.T) {
503 }635 }
504 }636 }
505 637
638+// Three fingers sideways walk the cards behind a maximized column one per
639+// gesture, like the arrows, then change workspace past the band's edge.
640+func TestOverviewCascadeThreeFingerMaximizedCards(t *testing.T) {
641+ c, m := cascadeCore(t)
642+ w := m.Current()
643+ w.ToggleFullWidth() // Column 2 hides 3, the other column of its band.
644+ m.ToggleOverview()
645+ swipe := func(dx float64) {
646+ t.Helper()
647+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
648+ for range 4 {
649+ c.swipeUpdate(ports.SwipeUpdate{DX: dx})
650+ }
651+ c.swipeEnd(ports.SwipeEnd{})
652+ }
653+ swipe(40)
654+ if m.Current() != w || m.overviewTarget() != 3 || !w.Columns[w.Focus].FullWidth {
655+ t.Fatalf("swipe right: workspace %d, target %d, want maximized 3", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
656+ }
657+ swipe(-40)
658+ if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
659+ t.Fatalf("swipe left: workspace %d, target %d, want maximized 2", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
660+ }
661+ swipe(40)
662+ swipe(40)
663+ if m.Current() != m.Workspaces[2] {
664+ t.Fatalf("swipe right past the band: workspace %d", indexOf(m.Workspaces, m.Current()))
665+ }
666+
667+ // A covering float in front: the swipe changes workspace, as without
668+ // a maximized column.
669+ m.CancelOverview()
670+ w.AddFloating(90, 300, 200)
671+ m.ToggleOverview()
672+ if f := m.stackFront(w); f.kind != stackFloat || !w.Columns[w.Focus].FullWidth {
673+ t.Fatalf("setup: front %+v", f)
674+ }
675+ swipe(-40)
676+ if m.Current() != m.Workspaces[0] {
677+ t.Fatalf("swipe left over a float: workspace %d", indexOf(m.Workspaces, m.Current()))
678+ }
679+}
680+
681+// Left from the first column of a band whose maximized column hides it:
682+// the arrows and three fingers walk the cards, then enter the stash, or
683+// change workspace without one.
684+func TestOverviewCascadeMaximizedLeftEdge(t *testing.T) {
685+ for _, stash := range []bool{false, true} {
686+ c, m := cascadeCore(t)
687+ w := m.Current()
688+ if stash {
689+ w.AddWindow(40)
690+ w.FocusID(40)
691+ w.ToggleWindowStash()
692+ }
693+ w.FocusID(3) // Band {2, 3}: 3 hides 2.
694+ w.ToggleFullWidth()
695+ m.ToggleOverview()
696+ swipe := func() {
697+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
698+ for range 4 {
699+ c.swipeUpdate(ports.SwipeUpdate{DX: -40})
700+ }
701+ c.swipeEnd(ports.SwipeEnd{})
702+ }
703+ swipe()
704+ if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
705+ t.Fatalf("stash %v: first swipe: target %d", stash, m.overviewTarget())
706+ }
707+ swipe()
708+ if stash {
709+ if m.Current() != w || m.cardAt(w) < 0 {
710+ t.Fatalf("past the band: workspace %d, card %d; want the stash", indexOf(m.Workspaces, m.Current()), m.cardAt(w))
711+ }
712+ } else if m.Current() != m.Workspaces[0] {
713+ t.Fatalf("past the band: workspace %d, want 0", indexOf(m.Workspaces, m.Current()))
714+ }
715+ }
716+}
717+
718+// With four or more columns in a band, the cards three or more columns
719+// away sit under the two-step card: arrows still reach every one, in
720+// order, each taking the maximization.
721+func TestOverviewCascadeFarCardsByArrows(t *testing.T) {
722+ m := newMonitor("", "")
723+ m.SetOutput(1200, 600)
724+ m.SetMaxColumns(5)
725+ w := m.Current()
726+ w.Overflow = OverflowCascade
727+ for id := WindowID(1); id <= 5; id++ {
728+ m.AddWindow(id)
729+ }
730+ w.FocusID(1)
731+ w.ToggleFullWidth()
732+ m.ToggleOverview()
733+ ps := m.Layout()
734+ edge := previewOf(t, ps, 3).Rect
735+ for _, id := range []WindowID{4, 5} {
736+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek || p.Rect.X != edge.X {
737+ t.Fatalf("far card %d: %+v, want a card under 3 at x %d", id, p, edge.X)
738+ }
739+ }
740+ for _, want := range []WindowID{2, 3, 4, 5} {
741+ m.OverviewMove(1, 0)
742+ if m.Current() != w || m.overviewTarget() != want || !w.Columns[w.Focus].FullWidth {
743+ t.Fatalf("right: target %d, want maximized %d", m.overviewTarget(), want)
744+ }
745+ }
746+ m.CancelOverview()
747+ if focusedID(m) != 1 || !w.Columns[w.Focus].FullWidth {
748+ t.Fatalf("escape: focus %d, want maximized 1", focusedID(m))
749+ }
750+}
751+
752+// Right from a stash card lands on the maximized column, which keeps its
753+// maximization, not on the first card of its band.
754+func TestOverviewCascadeStashToMaximized(t *testing.T) {
755+ m := cascadeOverview()
756+ withStash(m, 1)
757+ w := m.Current()
758+ w.FocusID(9)
759+ w.ToggleFullWidth()
760+ m.ToggleOverview()
761+ m.selectCard(w, w.stashAt) // As a click on the pile does.
762+ if m.cardAt(w) < 0 {
763+ t.Fatalf("setup: no stash card, target %d", m.overviewTarget())
764+ }
765+ m.OverviewMove(1, 0)
766+ if m.overviewTarget() != 9 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 9 {
767+ t.Fatalf("target %d, maximized %v; want 9", m.overviewTarget(), w.maximized)
768+ }
769+}
770+
506 // T8: a named boundary of a cascade overview is a vertical rule strictly771 // T8: a named boundary of a cascade overview is a vertical rule strictly
507 // between the two slots.772 // between the two slots.
508 func TestOverviewCascadeNamedDivider(t *testing.T) {773 func TestOverviewCascadeNamedDivider(t *testing.T) {
M internal/core/overview_stack.go +5 -1
@@ -214,7 +214,11 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
214214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
215215 func (w *Workspace) selectOverviewColumn(i int) {
216216 w.floatFocus, w.stashFocus = false, false
217- if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
217+ if w.policy().wraps && w.hiddenByMaximized(i) {
218+ // A card behind a maximized cascade column takes the
219+ // maximization; Escape gives it back (restoreRow).
220+ w.transferMaximization(i)
221+ } else if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
218222 w.unmaximize()
219223 }
220224 w.Focus = i
@@ -214,7 +214,11 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
215 func (w *Workspace) selectOverviewColumn(i int) {215 func (w *Workspace) selectOverviewColumn(i int) {
216 w.floatFocus, w.stashFocus = false, false216 w.floatFocus, w.stashFocus = false, false
217- if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {217+ if w.policy().wraps && w.hiddenByMaximized(i) {
218+ // A card behind a maximized cascade column takes the
219+ // maximization; Escape gives it back (restoreRow).
220+ w.transferMaximization(i)
221+ } else if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
218 w.unmaximize()222 w.unmaximize()
219 }223 }
220 w.Focus = i224 w.Focus = i
M internal/core/pulse.go +4 -4
@@ -15,7 +15,7 @@ import (
1515 // the window that pulsed last does not pulse again within pulseCooldown
1616 // (switching back after another window held the focus is a new change).
1717 // Fast switching therefore shows nothing. Fullscreen windows, a window alone
18-// on screen (e.g. a maximized column), overview previews, a window focused as
18+// in the usable area (e.g. a maximized column), overview previews, a window focused as
1919 // it maps and a protected session never pulse.
2020
2121 const (
@@ -151,11 +151,11 @@ func (m *Monitor) settled() bool {
151151 return true
152152 }
153153
154-// visibleCount is the number of windows of layout shown in frame.
155-func visibleCount(layout []Placement, frame Rect) int {
154+// visibleCount is the number of windows of layout shown in area.
155+func visibleCount(layout []Placement, area Rect) int {
156156 n := 0
157157 for _, p := range layout {
158- if p.Preview == 0 && onScreen(p, frame) {
158+ if p.Preview == 0 && onScreen(p, area) {
159159 n++
160160 }
161161 }
@@ -15,7 +15,7 @@ import (
15 // the window that pulsed last does not pulse again within pulseCooldown15 // the window that pulsed last does not pulse again within pulseCooldown
16 // (switching back after another window held the focus is a new change).16 // (switching back after another window held the focus is a new change).
17 // Fast switching therefore shows nothing. Fullscreen windows, a window alone17 // Fast switching therefore shows nothing. Fullscreen windows, a window alone
18-// on screen (e.g. a maximized column), overview previews, a window focused as18+// in the usable area (e.g. a maximized column), overview previews, a window focused as
19 // it maps and a protected session never pulse.19 // it maps and a protected session never pulse.
20 20
21 const (21 const (
@@ -151,11 +151,11 @@ func (m *Monitor) settled() bool {
151 return true151 return true
152 }152 }
153 153
154-// visibleCount is the number of windows of layout shown in frame.154+// visibleCount is the number of windows of layout shown in area.
155-func visibleCount(layout []Placement, frame Rect) int {155+func visibleCount(layout []Placement, area Rect) int {
156 n := 0156 n := 0
157 for _, p := range layout {157 for _, p := range layout {
158- if p.Preview == 0 && onScreen(p, frame) {158+ if p.Preview == 0 && onScreen(p, area) {
159 n++159 n++
160 }160 }
161 }161 }
M internal/core/pulse_test.go +16 -0
@@ -185,6 +185,22 @@ func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
185185 }
186186 }
187187
188+// A cascade window alone in its band above a bottom panel is alone on
189+// screen: the next band, under the panel, does not count.
190+func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) {
191+ c, ic := pulseCore(t)
192+ m := c.cur().mon
193+ m.SetOverflow(OverflowCascade)
194+ m.SetMaxColumns(1)
195+ m.SetUsable(Rect{W: 300, H: 180})
196+ m.Current().FocusID(1)
197+ indicatorScene(t, c)
198+ ic.now = ic.now.Add(pulseSettle)
199+ if c.pulseTick() || c.animating() {
200+ t.Fatal("a window alone in its band above a panel pulses")
201+ }
202+}
203+
188204 // A swipe, its landing slide or a transition still shows the neighbours: the
189205 // pulse waits for the layout to settle, then decides on it.
190206 func TestFocusPulseWaitsForSlide(t *testing.T) {
@@ -185,6 +185,22 @@ func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
185 }185 }
186 }186 }
187 187
188+// A cascade window alone in its band above a bottom panel is alone on
189+// screen: the next band, under the panel, does not count.
190+func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) {
191+ c, ic := pulseCore(t)
192+ m := c.cur().mon
193+ m.SetOverflow(OverflowCascade)
194+ m.SetMaxColumns(1)
195+ m.SetUsable(Rect{W: 300, H: 180})
196+ m.Current().FocusID(1)
197+ indicatorScene(t, c)
198+ ic.now = ic.now.Add(pulseSettle)
199+ if c.pulseTick() || c.animating() {
200+ t.Fatal("a window alone in its band above a panel pulses")
201+ }
202+}
203+
188 // A swipe, its landing slide or a transition still shows the neighbours: the204 // A swipe, its landing slide or a transition still shows the neighbours: the
189 // pulse waits for the layout to settle, then decides on it.205 // pulse waits for the layout to settle, then decides on it.
190 func TestFocusPulseWaitsForSlide(t *testing.T) {206 func TestFocusPulseWaitsForSlide(t *testing.T) {
M internal/core/separators.go +8 -5
@@ -8,10 +8,11 @@ import "github.com/bnema/neferwl/internal/ports"
88 // sides of the focused tile, including corners. With exactly two visible
99 // tiles and no gaps, each lights its own half of the shared line (left/top
1010 // first). With gaps, each tile owns its neighbor-facing lines and lights
11-// them whole. Hidden, scrolled-off, other-workspace and other-output tiles
12-// do not count. Lines at output edges are omitted. Floating windows differ
13-// from tmux tiles: each gets its own full border, drawn with that window
14-// (Separator.Window), even when it is the only visible window. Only the
11+// them whole. Hidden, scrolled-off, under-panel, other-workspace and
12+// other-output tiles do not count, nor do tiles that share no line. Lines
13+// at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles:
14+// each gets its own full border, drawn with that window (Separator.Window),
15+// even when it is the only visible window. Only the
1516 // focused output's focused window can light lines; keyboard grabs by popups
1617 // and layers do not change the underlying window's visual focus.
1718
@@ -54,7 +55,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
5455 if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
5556 continue
5657 }
57- if !p.Floating {
58+ // Tiles sharing a line: Neighbors only counts tiles visible in
59+ // the usable area, not those under a panel.
60+ if !p.Floating && p.Neighbors != 0 {
5861 tiles++
5962 }
6063 for _, s := range sides {
@@ -8,10 +8,11 @@ import "github.com/bnema/neferwl/internal/ports"
8 // sides of the focused tile, including corners. With exactly two visible8 // sides of the focused tile, including corners. With exactly two visible
9 // tiles and no gaps, each lights its own half of the shared line (left/top9 // tiles and no gaps, each lights its own half of the shared line (left/top
10 // first). With gaps, each tile owns its neighbor-facing lines and lights10 // first). With gaps, each tile owns its neighbor-facing lines and lights
11-// them whole. Hidden, scrolled-off, other-workspace and other-output tiles11+// them whole. Hidden, scrolled-off, under-panel, other-workspace and
12-// do not count. Lines at output edges are omitted. Floating windows differ12+// other-output tiles do not count, nor do tiles that share no line. Lines
13-// from tmux tiles: each gets its own full border, drawn with that window13+// at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles:
14-// (Separator.Window), even when it is the only visible window. Only the14+// each gets its own full border, drawn with that window (Separator.Window),
15+// even when it is the only visible window. Only the
15 // focused output's focused window can light lines; keyboard grabs by popups16 // focused output's focused window can light lines; keyboard grabs by popups
16 // and layers do not change the underlying window's visual focus.17 // and layers do not change the underlying window's visual focus.
17 18
@@ -54,7 +55,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
54 if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {55 if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
55 continue56 continue
56 }57 }
57- if !p.Floating {58+ // Tiles sharing a line: Neighbors only counts tiles visible in
59+ // the usable area, not those under a panel.
60+ if !p.Floating && p.Neighbors != 0 {
58 tiles++61 tiles++
59 }62 }
60 for _, s := range sides {63 for _, s := range sides {
M internal/core/separators_test.go +49 -0
@@ -147,6 +147,55 @@ func TestSeparatorsScroll(t *testing.T) {
147147 }
148148 }
149149
150+// A scroll column scrolled under a side panel is no neighbor: the column
151+// next to it in the usable area gets no line on that side.
152+// With a gap, the edge the two share lies inside the usable area: only
153+// the tile under the panel being out of it keeps the line away.
154+func TestSeparatorsScrollUnderSidePanel(t *testing.T) {
155+ for _, gap := range []int{0, 8} {
156+ ps := []Placement{
157+ {ID: 1, Rect: Rect{X: 0, W: 100, H: 600}},
158+ {ID: 2, Rect: Rect{X: 100 + gap, W: 400, H: 600}, Focused: true},
159+ {ID: 3, Rect: Rect{X: 500 + 2*gap, W: 380, H: 600}},
160+ }
161+ setVisibleNeighbors(ps, gap, Rect{X: 100, W: 900, H: 600})
162+ if ps[0].Neighbors != 0 {
163+ t.Fatalf("gap %d: tile under the panel: neighbors %04b, want none", gap, ps[0].Neighbors)
164+ }
165+ if ps[1].Neighbors != ports.SideRight {
166+ t.Fatalf("gap %d: tile at the panel: neighbors %04b, want right only", gap, ps[1].Neighbors)
167+ }
168+ }
169+}
170+
171+// A cascade band above a bottom panel: the next band starts under the
172+// panel, so the focused tile gets no line along the panel, only the one it
173+// shares with its band neighbor.
174+func TestSeparatorsCascadeBottomPanel(t *testing.T) {
175+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2}
176+ w.SetOutput(900, 600)
177+ w.SetUsable(Rect{W: 900, H: 580})
178+ for id := WindowID(1); id <= 3; id++ {
179+ w.AddWindow(id)
180+ }
181+ w.FocusID(1)
182+ ps := w.Layout()
183+ if p := previewOf(t, ps, 1); p.Neighbors != ports.SideRight {
184+ t.Fatalf("neighbors %04b, want right only", p.Neighbors)
185+ }
186+ seps := separators(ps, 2, 0, w.Output, true)
187+ if got := colorAt(seps, 200, 578); got != "" {
188+ t.Fatalf("line along the panel: %v", seps)
189+ }
190+ // Two tiles on screen: each lights its half of the shared line.
191+ if got := colorAt(seps, 449, 100); got != "lit" {
192+ t.Fatalf("shared line (449,100) = %q; %v", got, seps)
193+ }
194+ if got := colorAt(seps, 449, 400); got != "gray" {
195+ t.Fatalf("shared line (449,400) = %q; %v", got, seps)
196+ }
197+}
198+
150199 // With gaps each tile has its own line: the focused one lights it whole.
151200 func TestSeparatorsGaps(t *testing.T) {
152201 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
@@ -147,6 +147,55 @@ func TestSeparatorsScroll(t *testing.T) {
147 }147 }
148 }148 }
149 149
150+// A scroll column scrolled under a side panel is no neighbor: the column
151+// next to it in the usable area gets no line on that side.
152+// With a gap, the edge the two share lies inside the usable area: only
153+// the tile under the panel being out of it keeps the line away.
154+func TestSeparatorsScrollUnderSidePanel(t *testing.T) {
155+ for _, gap := range []int{0, 8} {
156+ ps := []Placement{
157+ {ID: 1, Rect: Rect{X: 0, W: 100, H: 600}},
158+ {ID: 2, Rect: Rect{X: 100 + gap, W: 400, H: 600}, Focused: true},
159+ {ID: 3, Rect: Rect{X: 500 + 2*gap, W: 380, H: 600}},
160+ }
161+ setVisibleNeighbors(ps, gap, Rect{X: 100, W: 900, H: 600})
162+ if ps[0].Neighbors != 0 {
163+ t.Fatalf("gap %d: tile under the panel: neighbors %04b, want none", gap, ps[0].Neighbors)
164+ }
165+ if ps[1].Neighbors != ports.SideRight {
166+ t.Fatalf("gap %d: tile at the panel: neighbors %04b, want right only", gap, ps[1].Neighbors)
167+ }
168+ }
169+}
170+
171+// A cascade band above a bottom panel: the next band starts under the
172+// panel, so the focused tile gets no line along the panel, only the one it
173+// shares with its band neighbor.
174+func TestSeparatorsCascadeBottomPanel(t *testing.T) {
175+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2}
176+ w.SetOutput(900, 600)
177+ w.SetUsable(Rect{W: 900, H: 580})
178+ for id := WindowID(1); id <= 3; id++ {
179+ w.AddWindow(id)
180+ }
181+ w.FocusID(1)
182+ ps := w.Layout()
183+ if p := previewOf(t, ps, 1); p.Neighbors != ports.SideRight {
184+ t.Fatalf("neighbors %04b, want right only", p.Neighbors)
185+ }
186+ seps := separators(ps, 2, 0, w.Output, true)
187+ if got := colorAt(seps, 200, 578); got != "" {
188+ t.Fatalf("line along the panel: %v", seps)
189+ }
190+ // Two tiles on screen: each lights its half of the shared line.
191+ if got := colorAt(seps, 449, 100); got != "lit" {
192+ t.Fatalf("shared line (449,100) = %q; %v", got, seps)
193+ }
194+ if got := colorAt(seps, 449, 400); got != "gray" {
195+ t.Fatalf("shared line (449,400) = %q; %v", got, seps)
196+ }
197+}
198+
150 // With gaps each tile has its own line: the focused one lights it whole.199 // With gaps each tile has its own line: the focused one lights it whole.
151 func TestSeparatorsGaps(t *testing.T) {200 func TestSeparatorsGaps(t *testing.T) {
152 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}201 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}

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