detached 7d82780

feat(layout): share each cascade band's width like fixed overflow

The columns of a cascade band share its width equally: a lone column
takes the full width until another joins its band, and partial bands
leave no empty cells.
commit
7d8278071c936e8055d9dc5d829fee8c893ffe27
author
brice · · 2026-10-07 14:36 UTC
parent
0e2dc8e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +19 -12
M docs/config.md +1 -1
@@ -190,7 +190,7 @@ 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
@@ -190,7 +190,7 @@ 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
M internal/core/cascade.go +8 -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,12 @@ 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+// cellWidth is the width of the cell of column i: the columns of its band
17+// share the usable width equally, as in fixed overflow, so a lone column
18+// fills it.
19+func (w *Workspace) cellWidth(i int) int {
20+ first, last := w.laneBounds(w.band(i))
21+ k := max(last-first+1, 1)
1822 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
1923 }
2024 func (w *Workspace) cellInBand(b int) int {
@@ -38,11 +42,10 @@ func (w *Workspace) focusBand(dir int) bool {
3842 }
3943 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
4044 g := w.gap()
41- width := w.cellWidth()
4245 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
4346 view := w.View + w.shiftPixels()
4447 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)}
48+ 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)}
4649 if w.Columns[i].FullWidth && !ignoreFullWidth {
4750 r.X = w.Usable.X + g
4851 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,12 @@ 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+// cellWidth is the width of the cell of column i: the columns of its band
17- k := max(w.MaxColumns, 1)17+// share the usable width equally, as in fixed overflow, so a lone column
18+// fills it.
19+func (w *Workspace) cellWidth(i int) int {
20+ first, last := w.laneBounds(w.band(i))
21+ k := max(last-first+1, 1)
18 return max((w.Usable.W-w.gap()*(k+1))/k, 0)22 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
19 }23 }
20 func (w *Workspace) cellInBand(b int) int {24 func (w *Workspace) cellInBand(b int) int {
@@ -38,11 +42,10 @@ func (w *Workspace) focusBand(dir int) bool {
38 }42 }
39 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {43 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
40 g := w.gap()44 g := w.gap()
41- width := w.cellWidth()
42 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]45 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
43 view := w.View + w.shiftPixels()46 view := w.View + w.shiftPixels()
44 for i := range w.Columns {47 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)}48+ 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 {49 if w.Columns[i].FullWidth && !ignoreFullWidth {
47 r.X = w.Usable.X + g50 r.X = w.Usable.X + g
48 r.W = max(w.Usable.W-2*g, 0)51 r.W = max(w.Usable.W-2*g, 0)
M internal/core/cascade_test.go +6 -2
@@ -14,15 +14,19 @@ func cascadeWorkspace(n int) *Workspace {
1414 return w
1515 }
1616
17+// Each band's columns share its width equally, as in fixed overflow: a
18+// lone column fills it, a full band of three has thirds.
1719 func TestCascadeBandsAndReveal(t *testing.T) {
18- for _, n := range []int{0, 1, 3, 4, 7} {
20+ for _, n := range []int{0, 1, 2, 3, 4, 5, 7} {
1921 w := cascadeWorkspace(n)
2022 ps := w.Layout()
2123 if len(ps) != n {
2224 t.Fatalf("n=%d placements=%d", n, len(ps))
2325 }
2426 for i, p := range ps {
25- want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600}
27+ inBand := min(n-i/3*3, 3)
28+ cell := 900 / inBand
29+ want := Rect{X: i % 3 * cell, Y: (i/3)*600 - w.View, W: cell, H: 600}
2630 if p.Rect != want {
2731 t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)
2832 }
@@ -14,15 +14,19 @@ func cascadeWorkspace(n int) *Workspace {
14 return w14 return w
15 }15 }
16 16
17+// Each band's columns share its width equally, as in fixed overflow: a
18+// lone column fills it, a full band of three has thirds.
17 func TestCascadeBandsAndReveal(t *testing.T) {19 func TestCascadeBandsAndReveal(t *testing.T) {
18- for _, n := range []int{0, 1, 3, 4, 7} {20+ for _, n := range []int{0, 1, 2, 3, 4, 5, 7} {
19 w := cascadeWorkspace(n)21 w := cascadeWorkspace(n)
20 ps := w.Layout()22 ps := w.Layout()
21 if len(ps) != n {23 if len(ps) != n {
22 t.Fatalf("n=%d placements=%d", n, len(ps))24 t.Fatalf("n=%d placements=%d", n, len(ps))
23 }25 }
24 for i, p := range ps {26 for i, p := range ps {
25- want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600}27+ inBand := min(n-i/3*3, 3)
28+ cell := 900 / inBand
29+ want := Rect{X: i % 3 * cell, Y: (i/3)*600 - w.View, W: cell, H: 600}
26 if p.Rect != want {30 if p.Rect != want {
27 t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)31 t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)
28 }32 }
M internal/core/layout.go +1 -1
@@ -1390,7 +1390,7 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
13901390 g := w.gap()
13911391 // Fixed overflow never scrolls, so presets would push columns off screen.
13921392 if w.policy().wraps {
1393- return w.cellWidth()
1393+ return w.cellWidth(i)
13941394 }
13951395 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
13961396 if len(w.Columns) == 1 {
@@ -1390,7 +1390,7 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1390 g := w.gap()1390 g := w.gap()
1391 // Fixed overflow never scrolls, so presets would push columns off screen.1391 // Fixed overflow never scrolls, so presets would push columns off screen.
1392 if w.policy().wraps {1392 if w.policy().wraps {
1393- return w.cellWidth()1393+ return w.cellWidth(i)
1394 }1394 }
1395 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {1395 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
1396 if len(w.Columns) == 1 {1396 if len(w.Columns) == 1 {
M internal/core/overview.go +2 -2
@@ -996,7 +996,7 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
996996 // the cards it hides.
997997 r.X = w.cellX(i)
998998 r.Y = g + w.band(i)*w.Usable.H
999- r.W = w.cellWidth()
999+ r.W = w.cellWidth(i)
10001000 if c.FullWidth {
10011001 in := 0
10021002 if i == w.Focus {
@@ -1061,7 +1061,7 @@ func (w *Workspace) bandCardsInset(i int) int {
10611061 // (on the band's edge), like the cards of a stack.
10621062 func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
10631063 g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1064- r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(), H: max(w.Usable.H-2*g, 0)}
1064+ r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
10651065 k := min(max(i-w.Focus, w.Focus-i), 2)
10661066 if i < w.Focus {
10671067 r.X = g + (2-k)*s
@@ -996,7 +996,7 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
996 // the cards it hides.996 // the cards it hides.
997 r.X = w.cellX(i)997 r.X = w.cellX(i)
998 r.Y = g + w.band(i)*w.Usable.H998 r.Y = g + w.band(i)*w.Usable.H
999- r.W = w.cellWidth()999+ r.W = w.cellWidth(i)
1000 if c.FullWidth {1000 if c.FullWidth {
1001 in := 01001 in := 0
1002 if i == w.Focus {1002 if i == w.Focus {
@@ -1061,7 +1061,7 @@ func (w *Workspace) bandCardsInset(i int) int {
1061 // (on the band's edge), like the cards of a stack.1061 // (on the band's edge), like the cards of a stack.
1062 func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {1062 func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1063 g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]1063 g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1064- r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(), H: max(w.Usable.H-2*g, 0)}1064+ r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
1065 k := min(max(i-w.Focus, w.Focus-i), 2)1065 k := min(max(i-w.Focus, w.Focus-i), 2)
1066 if i < w.Focus {1066 if i < w.Focus {
1067 r.X = g + (2-k)*s1067 r.X = g + (2-k)*s
M internal/core/overview_cascade_test.go +1 -1
@@ -107,7 +107,7 @@ func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
107107 order = append(order, i)
108108 }
109109 band := i / 3
110- want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}
110+ want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
111111 switch k := tc.cards[i]; {
112112 case i == tc.max:
113113 want.X, want.W = g+in, w.Usable.W-2*g-2*in
@@ -107,7 +107,7 @@ func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
107 order = append(order, i)107 order = append(order, i)
108 }108 }
109 band := i / 3109 band := i / 3
110- want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}110+ want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
111 switch k := tc.cards[i]; {111 switch k := tc.cards[i]; {
112 case i == tc.max:112 case i == tc.max:
113 want.X, want.W = g+in, w.Usable.W-2*g-2*in113 want.X, want.W = g+in, w.Usable.W-2*g-2*in

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