fix(core): move a cascade column past a maximized band
move-window-up/down swapped a maximized column with a one-slot column, which repacked the bands so the column stayed in place. Columns taking a different number of slots now pass the whole adjacent band. Cascade rects walk the bands once, and the focus offset helper reuses laneNeighbor.
- commit
- d4955b90b13ca092983ad3337621db80f86e6486
- author
- brice · · 2026-10-13 13:34 UTC
- parent
- ba5aae9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +66 -25
M internal/core/cascade.go +23 -22
| @@ -59,8 +59,6 @@ func (w *Workspace) bandBounds(b int) (first, last int) { | |||
| 59 | 59 | return first, last | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | - | func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) } | |
| 63 | - | ||
| 64 | 62 | // cellX is the x of column i in its lane, from the usable area's left edge. | |
| 65 | 63 | func (w *Workspace) cellX(i int) int { | |
| 66 | 64 | first, last := w.laneBounds(w.laneOf(i)) | |
| @@ -145,14 +143,6 @@ func (w *Workspace) equalShare(k int) int { | |||
| 145 | 143 | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | |
| 146 | 144 | } | |
| 147 | 145 | ||
| 148 | - | // cellInBand is the column of band b at the focus's offset in its band, | |
| 149 | - | // clamped to the band. | |
| 150 | - | func (w *Workspace) cellInBand(b int) int { | |
| 151 | - | first, _ := w.bandBounds(w.band(w.Focus)) | |
| 152 | - | nfirst, nlast := w.bandBounds(b) | |
| 153 | - | return min(nfirst+w.Focus-first, nlast) | |
| 154 | - | } | |
| 155 | - | ||
| 156 | 146 | // focusBand focuses the column in the band dir away; a maximized column | |
| 157 | 147 | // keeps its own band, so it stays maximized. | |
| 158 | 148 | func (w *Workspace) focusBand(dir int) bool { | |
| @@ -173,11 +163,7 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 173 | 163 | g := w.gap() | |
| 174 | 164 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 175 | 165 | view := w.View + w.shiftPixels() | |
| 176 | - | for lane := 0; ; lane++ { | |
| 177 | - | first, last := w.laneBounds(lane) | |
| 178 | - | if first > last { | |
| 179 | - | break | |
| 180 | - | } | |
| 166 | + | w.walkBands(func(lane, first, last int) bool { | |
| 181 | 167 | S, u, sum := w.laneStats(first, last) | |
| 182 | 168 | x := g | |
| 183 | 169 | for i := first; i <= last; i++ { | |
| @@ -190,7 +176,8 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 190 | 176 | rects[i] = r | |
| 191 | 177 | x += cw + g | |
| 192 | 178 | } | |
| 193 | - | } | |
| 179 | + | return true | |
| 180 | + | }) | |
| 194 | 181 | return rects | |
| 195 | 182 | } | |
| 196 | 183 | ||
| @@ -208,7 +195,7 @@ func (w *Workspace) cascadeFocus(view int) int { | |||
| 208 | 195 | return 0 | |
| 209 | 196 | } | |
| 210 | 197 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | |
| 211 | - | return w.cellInBand(b) | |
| 198 | + | return w.laneNeighbor(b - w.band(w.Focus)) | |
| 212 | 199 | } | |
| 213 | 200 | ||
| 214 | 201 | // laneOf is the lane of column i in the overview: its band, or the only | |
| @@ -236,20 +223,34 @@ func (w *Workspace) laneBounds(lane int) (first, last int) { | |||
| 236 | 223 | // focused column goes, is past the workspace or in another cascade band: | |
| 237 | 224 | // the move then goes to the neighbor monitor first. | |
| 238 | 225 | func (w *Workspace) leavesBand(i int) bool { | |
| 239 | - | return i < 0 || i >= len(w.Columns) || !w.sameBand(i, w.Focus) | |
| 226 | + | return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus) | |
| 240 | 227 | } | |
| 241 | 228 | ||
| 242 | 229 | // moveBand swaps the focused cascade column with the column in the band dir | |
| 243 | 230 | // away where focus-window-up/down goes; focus follows it. Past the first | |
| 244 | - | // or last band nothing moves. Columns keep their maximization: bands pack | |
| 245 | - | // again around them. | |
| 231 | + | // or last band nothing moves. Columns keep their maximization: when the two | |
| 232 | + | // take a different number of slots, a swap would repack the bands around | |
| 233 | + | // them, so the focused column passes the whole adjacent band instead. | |
| 246 | 234 | func (w *Workspace) moveBand(dir int) { | |
| 247 | 235 | i := w.laneNeighbor(dir) | |
| 248 | 236 | if i < 0 { | |
| 249 | 237 | return | |
| 250 | 238 | } | |
| 251 | - | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] | |
| 252 | - | w.Focus = i | |
| 239 | + | if w.bandSlots(w.Focus) == w.bandSlots(i) { | |
| 240 | + | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] | |
| 241 | + | w.Focus = i | |
| 242 | + | w.scroll() | |
| 243 | + | return | |
| 244 | + | } | |
| 245 | + | first, last := w.bandBounds(w.band(i)) | |
| 246 | + | col := w.Columns[w.Focus] | |
| 247 | + | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |
| 248 | + | at := first // dir < 0: before the band above. | |
| 249 | + | if dir > 0 { | |
| 250 | + | at = last // After the band below, one index lower once removed. | |
| 251 | + | } | |
| 252 | + | w.Columns = slices.Insert(w.Columns, at, col) | |
| 253 | + | w.Focus = at | |
| 253 | 254 | w.scroll() | |
| 254 | 255 | } | |
| 255 | 256 | ||
| @@ -59,8 +59,6 @@ func (w *Workspace) bandBounds(b int) (first, last int) { | |||||
| 59 | return first, last | 59 | return first, last | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | - | func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) } | |||
| 63 | - | ||||
| 64 | // cellX is the x of column i in its lane, from the usable area's left edge. | 62 | // cellX is the x of column i in its lane, from the usable area's left edge. | ||
| 65 | func (w *Workspace) cellX(i int) int { | 63 | func (w *Workspace) cellX(i int) int { | ||
| 66 | first, last := w.laneBounds(w.laneOf(i)) | 64 | first, last := w.laneBounds(w.laneOf(i)) | ||
| @@ -145,14 +143,6 @@ func (w *Workspace) equalShare(k int) int { | |||||
| 145 | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | 143 | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | ||
| 146 | } | 144 | } | ||
| 147 | 145 | ||||
| 148 | - | // cellInBand is the column of band b at the focus's offset in its band, | |||
| 149 | - | // clamped to the band. | |||
| 150 | - | func (w *Workspace) cellInBand(b int) int { | |||
| 151 | - | first, _ := w.bandBounds(w.band(w.Focus)) | |||
| 152 | - | nfirst, nlast := w.bandBounds(b) | |||
| 153 | - | return min(nfirst+w.Focus-first, nlast) | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | // focusBand focuses the column in the band dir away; a maximized column | 146 | // focusBand focuses the column in the band dir away; a maximized column | ||
| 157 | // keeps its own band, so it stays maximized. | 147 | // keeps its own band, so it stays maximized. | ||
| 158 | func (w *Workspace) focusBand(dir int) bool { | 148 | func (w *Workspace) focusBand(dir int) bool { | ||
| @@ -173,11 +163,7 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||||
| 173 | g := w.gap() | 163 | g := w.gap() | ||
| 174 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | 164 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | ||
| 175 | view := w.View + w.shiftPixels() | 165 | view := w.View + w.shiftPixels() | ||
| 176 | - | for lane := 0; ; lane++ { | 166 | + | w.walkBands(func(lane, first, last int) bool { |
| 177 | - | first, last := w.laneBounds(lane) | |||
| 178 | - | if first > last { | |||
| 179 | - | break | |||
| 180 | - | } | |||
| 181 | S, u, sum := w.laneStats(first, last) | 167 | S, u, sum := w.laneStats(first, last) | ||
| 182 | x := g | 168 | x := g | ||
| 183 | for i := first; i <= last; i++ { | 169 | for i := first; i <= last; i++ { | ||
| @@ -190,7 +176,8 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||||
| 190 | rects[i] = r | 176 | rects[i] = r | ||
| 191 | x += cw + g | 177 | x += cw + g | ||
| 192 | } | 178 | } | ||
| 193 | - | } | 179 | + | return true |
| 180 | + | }) | |||
| 194 | return rects | 181 | return rects | ||
| 195 | } | 182 | } | ||
| 196 | 183 | ||||
| @@ -208,7 +195,7 @@ func (w *Workspace) cascadeFocus(view int) int { | |||||
| 208 | return 0 | 195 | return 0 | ||
| 209 | } | 196 | } | ||
| 210 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | 197 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | ||
| 211 | - | return w.cellInBand(b) | 198 | + | return w.laneNeighbor(b - w.band(w.Focus)) |
| 212 | } | 199 | } | ||
| 213 | 200 | ||||
| 214 | // laneOf is the lane of column i in the overview: its band, or the only | 201 | // laneOf is the lane of column i in the overview: its band, or the only | ||
| @@ -236,20 +223,34 @@ func (w *Workspace) laneBounds(lane int) (first, last int) { | |||||
| 236 | // focused column goes, is past the workspace or in another cascade band: | 223 | // focused column goes, is past the workspace or in another cascade band: | ||
| 237 | // the move then goes to the neighbor monitor first. | 224 | // the move then goes to the neighbor monitor first. | ||
| 238 | func (w *Workspace) leavesBand(i int) bool { | 225 | func (w *Workspace) leavesBand(i int) bool { | ||
| 239 | - | return i < 0 || i >= len(w.Columns) || !w.sameBand(i, w.Focus) | 226 | + | return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus) |
| 240 | } | 227 | } | ||
| 241 | 228 | ||||
| 242 | // moveBand swaps the focused cascade column with the column in the band dir | 229 | // moveBand swaps the focused cascade column with the column in the band dir | ||
| 243 | // away where focus-window-up/down goes; focus follows it. Past the first | 230 | // away where focus-window-up/down goes; focus follows it. Past the first | ||
| 244 | - | // or last band nothing moves. Columns keep their maximization: bands pack | 231 | + | // or last band nothing moves. Columns keep their maximization: when the two |
| 245 | - | // again around them. | 232 | + | // take a different number of slots, a swap would repack the bands around |
| 233 | + | // them, so the focused column passes the whole adjacent band instead. | |||
| 246 | func (w *Workspace) moveBand(dir int) { | 234 | func (w *Workspace) moveBand(dir int) { | ||
| 247 | i := w.laneNeighbor(dir) | 235 | i := w.laneNeighbor(dir) | ||
| 248 | if i < 0 { | 236 | if i < 0 { | ||
| 249 | return | 237 | return | ||
| 250 | } | 238 | } | ||
| 251 | - | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] | 239 | + | if w.bandSlots(w.Focus) == w.bandSlots(i) { |
| 252 | - | w.Focus = i | 240 | + | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] |
| 241 | + | w.Focus = i | |||
| 242 | + | w.scroll() | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | first, last := w.bandBounds(w.band(i)) | |||
| 246 | + | col := w.Columns[w.Focus] | |||
| 247 | + | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |||
| 248 | + | at := first // dir < 0: before the band above. | |||
| 249 | + | if dir > 0 { | |||
| 250 | + | at = last // After the band below, one index lower once removed. | |||
| 251 | + | } | |||
| 252 | + | w.Columns = slices.Insert(w.Columns, at, col) | |||
| 253 | + | w.Focus = at | |||
| 253 | w.scroll() | 254 | w.scroll() | ||
| 254 | } | 255 | } | ||
| 255 | 256 | ||||
M internal/core/cascade_moves_test.go +41 -0
| @@ -202,6 +202,47 @@ func TestCascadeMoveWindowKeepsMaximization(t *testing.T) { | |||
| 202 | 202 | } | |
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | + | // A column moving to or from a maximized column's band passes the whole | |
| 206 | + | // adjacent band. Bands follow column order, so a maximized column leaving | |
| 207 | + | // lets the bands around it pack together: {1} and {3 4} join. | |
| 208 | + | // Repeating the move never leaves the column stuck. | |
| 209 | + | func TestCascadeMoveWindowAcrossMaximizedBand(t *testing.T) { | |
| 210 | + | for _, tc := range []struct { | |
| 211 | + | name string | |
| 212 | + | max, focus WindowID | |
| 213 | + | action Action | |
| 214 | + | want []WindowID | |
| 215 | + | band int | |
| 216 | + | }{ | |
| 217 | + | // Bands {1} {2*} {3 4}. | |
| 218 | + | {"maximized down", 2, 2, ActionMoveWindowDown, []WindowID{1, 3, 4, 2}, 1}, | |
| 219 | + | {"maximized up", 2, 2, ActionMoveWindowUp, []WindowID{2, 1, 3, 4}, 0}, | |
| 220 | + | // Bands {1 2 3} {4*} {5}. | |
| 221 | + | {"into maximized below", 4, 3, ActionMoveWindowDown, []WindowID{1, 2, 4, 3, 5}, 2}, | |
| 222 | + | {"into maximized above", 4, 5, ActionMoveWindowUp, []WindowID{1, 2, 3, 5, 4}, 1}, | |
| 223 | + | } { | |
| 224 | + | t.Run(tc.name, func(t *testing.T) { | |
| 225 | + | n := 5 | |
| 226 | + | if tc.max == 2 { | |
| 227 | + | n = 4 | |
| 228 | + | } | |
| 229 | + | c, w := cascadeMovesCore(t, n, 3, false) | |
| 230 | + | w.FocusID(tc.max) | |
| 231 | + | w.ToggleFullWidth() | |
| 232 | + | w.FocusID(tc.focus) | |
| 233 | + | c.applyAction(tc.action) | |
| 234 | + | wantColumns(t, w, tc.want...) | |
| 235 | + | wantFocus(t, c, "M", tc.focus) | |
| 236 | + | if got := w.band(w.Focus); got != tc.band { | |
| 237 | + | t.Fatalf("band %d, want %d", got, tc.band) | |
| 238 | + | } | |
| 239 | + | if !w.Columns[w.columnOf(tc.max)].FullWidth { | |
| 240 | + | t.Fatal("maximization lost") | |
| 241 | + | } | |
| 242 | + | }) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 205 | 246 | // A fullscreen tile at its band edge leaves for the neighbor monitor and | |
| 206 | 247 | // stays fullscreen, as a tile there does. | |
| 207 | 248 | func TestCascadeFullscreenBandEdgeFocusesMonitor(t *testing.T) { | |
| @@ -202,6 +202,47 @@ func TestCascadeMoveWindowKeepsMaximization(t *testing.T) { | |||||
| 202 | } | 202 | } | ||
| 203 | } | 203 | } | ||
| 204 | 204 | ||||
| 205 | + | // A column moving to or from a maximized column's band passes the whole | |||
| 206 | + | // adjacent band. Bands follow column order, so a maximized column leaving | |||
| 207 | + | // lets the bands around it pack together: {1} and {3 4} join. | |||
| 208 | + | // Repeating the move never leaves the column stuck. | |||
| 209 | + | func TestCascadeMoveWindowAcrossMaximizedBand(t *testing.T) { | |||
| 210 | + | for _, tc := range []struct { | |||
| 211 | + | name string | |||
| 212 | + | max, focus WindowID | |||
| 213 | + | action Action | |||
| 214 | + | want []WindowID | |||
| 215 | + | band int | |||
| 216 | + | }{ | |||
| 217 | + | // Bands {1} {2*} {3 4}. | |||
| 218 | + | {"maximized down", 2, 2, ActionMoveWindowDown, []WindowID{1, 3, 4, 2}, 1}, | |||
| 219 | + | {"maximized up", 2, 2, ActionMoveWindowUp, []WindowID{2, 1, 3, 4}, 0}, | |||
| 220 | + | // Bands {1 2 3} {4*} {5}. | |||
| 221 | + | {"into maximized below", 4, 3, ActionMoveWindowDown, []WindowID{1, 2, 4, 3, 5}, 2}, | |||
| 222 | + | {"into maximized above", 4, 5, ActionMoveWindowUp, []WindowID{1, 2, 3, 5, 4}, 1}, | |||
| 223 | + | } { | |||
| 224 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 225 | + | n := 5 | |||
| 226 | + | if tc.max == 2 { | |||
| 227 | + | n = 4 | |||
| 228 | + | } | |||
| 229 | + | c, w := cascadeMovesCore(t, n, 3, false) | |||
| 230 | + | w.FocusID(tc.max) | |||
| 231 | + | w.ToggleFullWidth() | |||
| 232 | + | w.FocusID(tc.focus) | |||
| 233 | + | c.applyAction(tc.action) | |||
| 234 | + | wantColumns(t, w, tc.want...) | |||
| 235 | + | wantFocus(t, c, "M", tc.focus) | |||
| 236 | + | if got := w.band(w.Focus); got != tc.band { | |||
| 237 | + | t.Fatalf("band %d, want %d", got, tc.band) | |||
| 238 | + | } | |||
| 239 | + | if !w.Columns[w.columnOf(tc.max)].FullWidth { | |||
| 240 | + | t.Fatal("maximization lost") | |||
| 241 | + | } | |||
| 242 | + | }) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 205 | // A fullscreen tile at its band edge leaves for the neighbor monitor and | 246 | // A fullscreen tile at its band edge leaves for the neighbor monitor and | ||
| 206 | // stays fullscreen, as a tile there does. | 247 | // stays fullscreen, as a tile there does. | ||
| 207 | func TestCascadeFullscreenBandEdgeFocusesMonitor(t *testing.T) { | 248 | func TestCascadeFullscreenBandEdgeFocusesMonitor(t *testing.T) { | ||
M internal/core/overview.go +1 -2
| @@ -860,8 +860,7 @@ func (w *Workspace) overviewZoom() float64 { | |||
| 860 | 860 | } | |
| 861 | 861 | ||
| 862 | 862 | // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A | |
| 863 | - | // cascade workspace spans one screen whatever its band count, plus the room | |
| 864 | - | // of the cards a maximized column hides. | |
| 863 | + | // cascade workspace spans one screen whatever its band count. | |
| 865 | 864 | func (w *Workspace) zoomSpan() (float64, int) { | |
| 866 | 865 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | |
| 867 | 866 | var span int | |
| @@ -860,8 +860,7 @@ func (w *Workspace) overviewZoom() float64 { | |||||
| 860 | } | 860 | } | ||
| 861 | 861 | ||||
| 862 | // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A | 862 | // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A | ||
| 863 | - | // cascade workspace spans one screen whatever its band count, plus the room | 863 | + | // cascade workspace spans one screen whatever its band count. |
| 864 | - | // of the cards a maximized column hides. | |||
| 865 | func (w *Workspace) zoomSpan() (float64, int) { | 864 | func (w *Workspace) zoomSpan() (float64, int) { | ||
| 866 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | 865 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | ||
| 867 | var span int | 866 | var span int | ||
M internal/core/overview_cascade_test.go +1 -1
| @@ -468,7 +468,7 @@ func TestOverviewCascadeThreeFinger(t *testing.T) { | |||
| 468 | 468 | } | |
| 469 | 469 | ||
| 470 | 470 | // Right from a stash card lands on the maximized column, which keeps its | |
| 471 | - | // maximization, not on the first card of its band. | |
| 471 | + | // maximization: it is alone in its band. | |
| 472 | 472 | func TestOverviewCascadeStashToMaximized(t *testing.T) { | |
| 473 | 473 | m := cascadeOverview() | |
| 474 | 474 | withStash(m, 1) | |
| @@ -468,7 +468,7 @@ func TestOverviewCascadeThreeFinger(t *testing.T) { | |||||
| 468 | } | 468 | } | ||
| 469 | 469 | ||||
| 470 | // Right from a stash card lands on the maximized column, which keeps its | 470 | // Right from a stash card lands on the maximized column, which keeps its | ||
| 471 | - | // maximization, not on the first card of its band. | 471 | + | // maximization: it is alone in its band. |
| 472 | func TestOverviewCascadeStashToMaximized(t *testing.T) { | 472 | func TestOverviewCascadeStashToMaximized(t *testing.T) { | ||
| 473 | m := cascadeOverview() | 473 | m := cascadeOverview() | ||
| 474 | withStash(m, 1) | 474 | withStash(m, 1) | ||