detached d4955b9

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
5959 return first, last
6060 }
6161
62-func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
63-
6462 // cellX is the x of column i in its lane, from the usable area's left edge.
6563 func (w *Workspace) cellX(i int) int {
6664 first, last := w.laneBounds(w.laneOf(i))
@@ -145,14 +143,6 @@ func (w *Workspace) equalShare(k int) int {
145143 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
146144 }
147145
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-
156146 // focusBand focuses the column in the band dir away; a maximized column
157147 // keeps its own band, so it stays maximized.
158148 func (w *Workspace) focusBand(dir int) bool {
@@ -173,11 +163,7 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
173163 g := w.gap()
174164 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
175165 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 {
181167 S, u, sum := w.laneStats(first, last)
182168 x := g
183169 for i := first; i <= last; i++ {
@@ -190,7 +176,8 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
190176 rects[i] = r
191177 x += cw + g
192178 }
193- }
179+ return true
180+ })
194181 return rects
195182 }
196183
@@ -208,7 +195,7 @@ func (w *Workspace) cascadeFocus(view int) int {
208195 return 0
209196 }
210197 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))
212199 }
213200
214201 // 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) {
236223 // focused column goes, is past the workspace or in another cascade band:
237224 // the move then goes to the neighbor monitor first.
238225 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)
240227 }
241228
242229 // moveBand swaps the focused cascade column with the column in the band dir
243230 // 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.
246234 func (w *Workspace) moveBand(dir int) {
247235 i := w.laneNeighbor(dir)
248236 if i < 0 {
249237 return
250238 }
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
253254 w.scroll()
254255 }
255256
@@ -59,8 +59,6 @@ func (w *Workspace) bandBounds(b int) (first, last int) {
59 return first, last59 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 column146 // 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 := g168 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] = r176 rects[i] = r
191 x += cw + g177 x += cw + g
192 }178 }
193- }179+ return true
180+ })
194 return rects181 return rects
195 }182 }
196 183
@@ -208,7 +195,7 @@ func (w *Workspace) cascadeFocus(view int) int {
208 return 0195 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 only201 // 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 dir229 // 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 first230 // away where focus-window-up/down goes; focus follows it. Past the first
244-// or last band nothing moves. Columns keep their maximization: bands pack231+// 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 return237 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 = i240+ 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) {
202202 }
203203 }
204204
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+
205246 // A fullscreen tile at its band edge leaves for the neighbor monitor and
206247 // stays fullscreen, as a tile there does.
207248 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 and246 // 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 {
860860 }
861861
862862 // 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.
865864 func (w *Workspace) zoomSpan() (float64, int) {
866865 // Only the span is read: the tiles go in the zoom scratch, dead on return.
867866 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. A862 // 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 room863+// 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 int866 var span int
M internal/core/overview_cascade_test.go +1 -1
@@ -468,7 +468,7 @@ func TestOverviewCascadeThreeFinger(t *testing.T) {
468468 }
469469
470470 // 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.
472472 func TestOverviewCascadeStashToMaximized(t *testing.T) {
473473 m := cascadeOverview()
474474 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 its470 // 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)

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