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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 190 | 190 | ||
| 191 | 191 | ### 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 | 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 | ||
| @@ -190,7 +190,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 190 | 190 | ||||
| 191 | ### Cascade | 191 | ### 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" | |||
| 5 | 5 | func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) } | |
| 6 | 6 | func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) } | |
| 7 | 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 | 10 | func (w *Workspace) cascadeBands() (n, spacing int) { | |
| 11 | 11 | if len(w.Columns) == 0 || w.Usable.H <= 0 { | |
| @@ -13,8 +13,12 @@ func (w *Workspace) cascadeBands() (n, spacing int) { | |||
| 13 | 13 | } | |
| 14 | 14 | return w.band(len(w.Columns)-1) + 1, w.Usable.H | |
| 15 | 15 | } | |
| 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) | |
| 18 | 22 | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | |
| 19 | 23 | } | |
| 20 | 24 | func (w *Workspace) cellInBand(b int) int { | |
| @@ -38,11 +42,10 @@ func (w *Workspace) focusBand(dir int) bool { | |||
| 38 | 42 | } | |
| 39 | 43 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |
| 40 | 44 | g := w.gap() | |
| 41 | - | width := w.cellWidth() | |
| 42 | 45 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 43 | 46 | view := w.View + w.shiftPixels() | |
| 44 | 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 | 49 | if w.Columns[i].FullWidth && !ignoreFullWidth { | |
| 47 | 50 | r.X = w.Usable.X + g | |
| 48 | 51 | 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.H | 14 | 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 + g | 50 | 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 { | |||
| 14 | 14 | 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 | 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 | 21 | w := cascadeWorkspace(n) | |
| 20 | 22 | ps := w.Layout() | |
| 21 | 23 | if len(ps) != n { | |
| 22 | 24 | t.Fatalf("n=%d placements=%d", n, len(ps)) | |
| 23 | 25 | } | |
| 24 | 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 | 30 | if p.Rect != want { | |
| 27 | 31 | t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want) | |
| 28 | 32 | } | |
| @@ -14,15 +14,19 @@ func cascadeWorkspace(n int) *Workspace { | |||||
| 14 | return w | 14 | 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 { | |||
| 1390 | 1390 | g := w.gap() | |
| 1391 | 1391 | // Fixed overflow never scrolls, so presets would push columns off screen. | |
| 1392 | 1392 | if w.policy().wraps { | |
| 1393 | - | return w.cellWidth() | |
| 1393 | + | return w.cellWidth(i) | |
| 1394 | 1394 | } | |
| 1395 | 1395 | if w.Columns[i].Width == (Width{}) || w.policy().equalCells { | |
| 1396 | 1396 | 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) | |||
| 996 | 996 | // the cards it hides. | |
| 997 | 997 | r.X = w.cellX(i) | |
| 998 | 998 | r.Y = g + w.band(i)*w.Usable.H | |
| 999 | - | r.W = w.cellWidth() | |
| 999 | + | r.W = w.cellWidth(i) | |
| 1000 | 1000 | if c.FullWidth { | |
| 1001 | 1001 | in := 0 | |
| 1002 | 1002 | if i == w.Focus { | |
| @@ -1061,7 +1061,7 @@ func (w *Workspace) bandCardsInset(i int) int { | |||
| 1061 | 1061 | // (on the band's edge), like the cards of a stack. | |
| 1062 | 1062 | func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement { | |
| 1063 | 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 | 1065 | k := min(max(i-w.Focus, w.Focus-i), 2) | |
| 1066 | 1066 | if i < w.Focus { | |
| 1067 | 1067 | 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.H | 998 | 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 := 0 | 1001 | 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)*s | 1067 | r.X = g + (2-k)*s | ||
M internal/core/overview_cascade_test.go +1 -1
| @@ -107,7 +107,7 @@ func TestOverviewCascadeMaximizedColumnCards(t *testing.T) { | |||
| 107 | 107 | order = append(order, i) | |
| 108 | 108 | } | |
| 109 | 109 | 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 | 111 | switch k := tc.cards[i]; { | |
| 112 | 112 | case i == tc.max: | |
| 113 | 113 | 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 / 3 | 109 | 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*in | 113 | want.X, want.W = g+in, w.Usable.W-2*g-2*in | ||