detached 458251f

Merge pull request 'feat(core): keep cascade bands explicit' (#60) from feat/cascade-explicit-bands into main

commit
458251f4beb24b34815aa9a39a4b591188e28571
author
bnema · · 2026-10-16 18:33 UTC
merge of
ef6bf5d + f4b2d68 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.9.0
14 files · +918 -174
M docs/config.md +8 -3
@@ -240,11 +240,16 @@ With `keyboard.cmd = alt` or `ctrl`, the defaults that would combine `cmd` with
240240
241241 ### Cascade
242242
243-The default layout. Columns fill the screen side by side up to `max-columns`, then continue in a new band below. Each band fills the screen height.
243+The default layout. Columns sit side by side in bands of up to `max-columns`, stacked one below the other. Each band fills the screen height and keeps its columns until you move them.
244244
245-- Left/right focus moves within a band, then to the neighbor workspace.
245+- Left/right focus moves within a band, then to the neighbor monitor, then to the neighbor workspace.
246+- `move-column-left/right` stays in the band; at its edge the column goes to the neighbor monitor, if any.
246247 - Up/down moves through the windows of a column, then to the band above or below.
247-- A maximized column gets a band of its own.
248+- `move-window-up/down` past the column's top or bottom sends the whole column to the band above or below. It joins that band when it has room, else it gets a new band between the two.
249+- A new window joins the focused band when it has room, else it gets a new band right below.
250+- `cycle-column-width` in a full band makes room: the rightmost column goes to the band below (from the last column, the leftmost goes to the band above). The end of the cycle brings it back.
251+- `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back.
252+- A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
248253
249254 ### Column widths
250255
@@ -240,11 +240,16 @@ With `keyboard.cmd = alt` or `ctrl`, the defaults that would combine `cmd` with
240 240
241 ### Cascade241 ### Cascade
242 242
243-The default layout. Columns fill the screen side by side up to `max-columns`, then continue in a new band below. Each band fills the screen height.243+The default layout. Columns sit side by side in bands of up to `max-columns`, stacked one below the other. Each band fills the screen height and keeps its columns until you move them.
244 244
245-- Left/right focus moves within a band, then to the neighbor workspace.245+- Left/right focus moves within a band, then to the neighbor monitor, then to the neighbor workspace.
246+- `move-column-left/right` stays in the band; at its edge the column goes to the neighbor monitor, if any.
246 - Up/down moves through the windows of a column, then to the band above or below.247 - Up/down moves through the windows of a column, then to the band above or below.
247-- A maximized column gets a band of its own.248+- `move-window-up/down` past the column's top or bottom sends the whole column to the band above or below. It joins that band when it has room, else it gets a new band between the two.
249+- A new window joins the focused band when it has room, else it gets a new band right below.
250+- `cycle-column-width` in a full band makes room: the rightmost column goes to the band below (from the last column, the leftmost goes to the band above). The end of the cycle brings it back.
251+- `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back.
252+- A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
248 253
249 ### Column widths254 ### Column widths
250 255
M internal/core/action.go +9 -8
@@ -285,7 +285,8 @@ func (c *Core) applyAction(a Action) Effect {
285285 case ActionMoveColumnLeft, ActionMoveColumnRight:
286286 // Past the edge of the workspace or cascade band the column moves to
287287 // the neighbor screen, on the side facing this one, and focus
288- // follows it. With no screen there, a cascade column crosses bands.
288+ // follows it. With no screen there, a cascade column stays in its
289+ // band.
289290 w := c.cur().mon.Current()
290291 dir, d := 1, dirRight
291292 if a == ActionMoveColumnLeft {
@@ -313,19 +314,19 @@ func (c *Core) applyAction(a Action) Effect {
313314 }
314315
315316 // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the
316-// content edge, in order: to the occupied workspace on that side when the
317-// workspace axis is horizontal; else to the neighbor monitor; else to the
318-// trailing empty workspace. It reports whether focus moved.
317+// content edge, in order: to the neighbor monitor on that side; else to the
318+// occupied workspace on that side when the workspace axis is horizontal;
319+// else to the trailing empty workspace. It reports whether focus moved.
319320 func (c *Core) edgeFocus(dir int, d direction) bool {
320321 m := c.cur().mon
321- if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
322- m.Focus(to)
323- return true
324- }
325322 if i := c.neighbor(d); i >= 0 {
326323 c.focusScreen = i
327324 return true
328325 }
326+ if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
327+ m.Focus(to)
328+ return true
329+ }
329330 return m.stepWorkspace(horizontalAxis, dir)
330331 }
331332
@@ -285,7 +285,8 @@ func (c *Core) applyAction(a Action) Effect {
285 case ActionMoveColumnLeft, ActionMoveColumnRight:285 case ActionMoveColumnLeft, ActionMoveColumnRight:
286 // Past the edge of the workspace or cascade band the column moves to286 // Past the edge of the workspace or cascade band the column moves to
287 // the neighbor screen, on the side facing this one, and focus287 // the neighbor screen, on the side facing this one, and focus
288- // follows it. With no screen there, a cascade column crosses bands.288+ // follows it. With no screen there, a cascade column stays in its
289+ // band.
289 w := c.cur().mon.Current()290 w := c.cur().mon.Current()
290 dir, d := 1, dirRight291 dir, d := 1, dirRight
291 if a == ActionMoveColumnLeft {292 if a == ActionMoveColumnLeft {
@@ -313,19 +314,19 @@ func (c *Core) applyAction(a Action) Effect {
313 }314 }
314 315
315 // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the316 // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the
316-// content edge, in order: to the occupied workspace on that side when the317+// content edge, in order: to the neighbor monitor on that side; else to the
317-// workspace axis is horizontal; else to the neighbor monitor; else to the318+// occupied workspace on that side when the workspace axis is horizontal;
318-// trailing empty workspace. It reports whether focus moved.319+// else to the trailing empty workspace. It reports whether focus moved.
319 func (c *Core) edgeFocus(dir int, d direction) bool {320 func (c *Core) edgeFocus(dir int, d direction) bool {
320 m := c.cur().mon321 m := c.cur().mon
321- if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
322- m.Focus(to)
323- return true
324- }
325 if i := c.neighbor(d); i >= 0 {322 if i := c.neighbor(d); i >= 0 {
326 c.focusScreen = i323 c.focusScreen = i
327 return true324 return true
328 }325 }
326+ if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
327+ m.Focus(to)
328+ return true
329+ }
329 return m.stepWorkspace(horizontalAxis, dir)330 return m.stepWorkspace(horizontalAxis, dir)
330 }331 }
331 332
M internal/core/cascade.go +275 -36
@@ -2,39 +2,55 @@ package core
22
33 import "slices"
44
5-// Cascade bands pack columns in order: a band ends when the next column
6-// would take it past MaxColumns slots. A column takes one slot and a
7-// maximized one all of them, so it sits alone in its band. Cells only
8-// share a band's width (laneWidth); they never move a column to a band.
5+// Cascade bands are explicit: Column.BandStart marks the first column of
6+// each band, and moves keep the other bands as they are. A band also ends
7+// where it would pass MaxColumns columns (max-columns shrank, or a column
8+// was inserted into a full band), and a maximized column sits alone in its
9+// band. scroll freezes those ends into BandStart, so the bands stay when
10+// max-columns grows again or the column is unmaximized. Cells only share
11+// a band's width (laneWidth); they never move a column to a band.
912
10-// bandSlots is the number of slots column i takes in its band.
11-func (w *Workspace) bandSlots(i int) int {
12- if w.Columns[i].FullWidth {
13- return max(w.MaxColumns, 1)
13+// startsBand reports whether column i starts a band, n being the count of
14+// columns in the band before it.
15+func (w *Workspace) startsBand(i, n int) bool {
16+ if i == 0 {
17+ return true
1418 }
15- return 1
19+ c := w.Columns
20+ return c[i].BandStart || n >= max(w.MaxColumns, 1) || c[i].FullWidth || c[i-1].FullWidth
1621 }
1722
1823 // walkBands calls f with each band and its column range, in order, until f
1924 // returns false.
2025 func (w *Workspace) walkBands(f func(b, first, last int) bool) {
21- k := max(w.MaxColumns, 1)
22- b, first, sum := 0, 0, 0
26+ b, first, n := 0, 0, 0
2327 for i := range w.Columns {
24- c := w.bandSlots(i)
25- if i > first && sum+c > k {
28+ if i > first && w.startsBand(i, n) {
2629 if !f(b, first, i-1) {
2730 return
2831 }
29- b, first, sum = b+1, i, 0
32+ b, first, n = b+1, i, 0
3033 }
31- sum += c
34+ n++
3235 }
3336 if len(w.Columns) > 0 {
3437 f(b, first, len(w.Columns)-1)
3538 }
3639 }
3740
41+// markBands freezes the band starts into BandStart.
42+func (w *Workspace) markBands() {
43+ n := 0
44+ for i := range w.Columns {
45+ s := w.startsBand(i, n)
46+ w.Columns[i].BandStart = s
47+ if s {
48+ n = 0
49+ }
50+ n++
51+ }
52+}
53+
3854 // band is the cascade band of column i.
3955 func (w *Workspace) band(i int) (band int) {
4056 w.walkBands(func(b, _, last int) bool {
@@ -226,32 +242,17 @@ func (w *Workspace) leavesBand(i int) bool {
226242 return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus)
227243 }
228244
229-// moveBand swaps the focused cascade column with the column in the band dir
230-// away where focus-window-up/down goes; focus follows it. Past the first
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.
245+// moveBand moves the focused cascade column to the band dir away
246+// (move-window-up/down past its column), keeping its offset in the band;
247+// focus follows it. See shiftBand.
234248 func (w *Workspace) moveBand(dir int) {
235- i := w.laneNeighbor(dir)
236- if i < 0 {
249+ if !w.policy().wraps || len(w.Columns) == 0 {
237250 return
238251 }
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
252+ first, _ := w.bandBounds(w.band(w.Focus))
253+ if w.shiftBand(w.Focus, w.Focus, dir, w.Focus-first) {
242254 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.
251255 }
252- w.Columns = slices.Insert(w.Columns, at, col)
253- w.Focus = at
254- w.scroll()
255256 }
256257
257258 // laneNeighbor is the closest column to the focus in the lane d lanes away,
@@ -265,3 +266,241 @@ func (w *Workspace) laneNeighbor(d int) int {
265266 }
266267 return min(nfirst+w.Focus-first, nlast)
267268 }
269+
270+// bandList copies the cascade columns into their bands.
271+func (w *Workspace) bandList() [][]Column {
272+ var bands [][]Column
273+ w.walkBands(func(_, first, last int) bool {
274+ bands = append(bands, slices.Clone(w.Columns[first:last+1]))
275+ return true
276+ })
277+ return bands
278+}
279+
280+// setBands stores bands as the columns, empty bands dropped, and focuses
281+// the column holding window focus.
282+func (w *Workspace) setBands(bands [][]Column, focus WindowID) {
283+ cols := w.Columns[:0]
284+ for _, b := range bands {
285+ for i, c := range b {
286+ c.BandStart = i == 0
287+ cols = append(cols, c)
288+ }
289+ }
290+ w.Columns = cols
291+ if i := w.columnOf(focus); i >= 0 {
292+ w.Focus = i
293+ }
294+}
295+
296+// fits reports whether cols can join band b: it has room for them and
297+// neither side holds a maximized column, which keeps a band to itself.
298+func (w *Workspace) fits(bands [][]Column, b int, cols []Column) bool {
299+ if b < 0 || b >= len(bands) || len(bands[b])+len(cols) > max(w.MaxColumns, 1) {
300+ return false
301+ }
302+ full := func(c Column) bool { return c.FullWidth }
303+ return !slices.ContainsFunc(bands[b], full) && !slices.ContainsFunc(cols, full)
304+}
305+
306+// sendBand moves cols, already taken out of band b, to the band dir away:
307+// they join it at index at (clamped) when they fit, else they make a new
308+// band between the two, or past the outer band. When cols were alone in
309+// band b, it goes away and a band that cannot take them is passed: the
310+// new band goes on its far side, so repeated moves always progress.
311+func (w *Workspace) sendBand(bands [][]Column, b, dir, at int, cols []Column) [][]Column {
312+ t, nb := b+dir, b // A new band between b and t, before b going up.
313+ if dir > 0 {
314+ nb = b + 1
315+ }
316+ if len(bands[b]) == 0 {
317+ bands = slices.Delete(bands, b, b+1)
318+ t, nb = b-1, b-1 // Going up: before band t.
319+ if dir > 0 {
320+ t, nb = b, b+1 // Going down: after band t.
321+ }
322+ }
323+ if w.fits(bands, t, cols) {
324+ at = min(max(at, 0), len(bands[t]))
325+ bands[t] = slices.Insert(bands[t], at, cols...)
326+ return bands
327+ }
328+ return slices.Insert(bands, min(max(nb, 0), len(bands)), slices.Clone(cols))
329+}
330+
331+// shiftBand moves the columns lo..hi of one cascade band to the band dir
332+// away (see sendBand), at offset at in it. It reports whether they moved.
333+func (w *Workspace) shiftBand(lo, hi, dir, at int) bool {
334+ if len(w.Columns) == 0 || lo < 0 || hi >= len(w.Columns) || lo > hi {
335+ return false
336+ }
337+ b := w.band(lo)
338+ if w.band(hi) != b {
339+ return false
340+ }
341+ first, last := w.bandBounds(b)
342+ if lo == first && hi == last && (b+dir < 0 || b+dir > w.band(len(w.Columns)-1)) {
343+ return false // Alone at the outer band: nothing to cross.
344+ }
345+ id, _ := w.Focused()
346+ bands := w.bandList()
347+ cols := slices.Clone(bands[b][lo-first : hi-first+1])
348+ bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)
349+ w.setBands(w.sendBand(bands, b, dir, at, cols), id)
350+ return true
351+}
352+
353+// columnIDs lists the windows of each column, in order: its identity for
354+// restoreBand. The column is the one headed by the first of them still
355+// heading a column, so it is found after its first window closed or its
356+// stack changed order, and a window that left it for a column of its own
357+// comes after the one that stayed first.
358+func columnIDs(cols []Column) [][]WindowID {
359+ ids := make([][]WindowID, len(cols))
360+ for i, c := range cols {
361+ ids[i] = slices.Clone(c.Windows)
362+ }
363+ return ids
364+}
365+
366+// isolateBand gives the focused cascade column a band of its own: the
367+// columns on its left go to the band above, the ones on its right to the
368+// band below (sendBand), and the column remembers them for restoreBand.
369+func (w *Workspace) isolateBand() {
370+ b := w.band(w.Focus)
371+ first, last := w.bandBounds(b)
372+ if first == last {
373+ return
374+ }
375+ id, _ := w.Focused()
376+ bands := w.bandList()
377+ p := w.Focus - first
378+ left := slices.Clone(bands[b][:p])
379+ right := slices.Clone(bands[b][p+1:])
380+ c := bands[b][p]
381+ // Both lists keep the band's left-to-right order. Earlier pushes
382+ // (cycle-column-width) took the far ends of the band, so the new ones
383+ // were nearer the column.
384+ c.pushedUp = append(c.pushedUp, columnIDs(left)...)
385+ c.pushedDown = append(columnIDs(right), c.pushedDown...)
386+ bands[b] = []Column{c}
387+ if len(right) > 0 {
388+ bands = w.sendBand(bands, b, 1, 0, right)
389+ }
390+ if len(left) > 0 {
391+ bands = w.sendBand(bands, b, -1, max(w.MaxColumns, 1), left)
392+ }
393+ w.setBands(bands, id)
394+}
395+
396+// pushNeighbor makes room in the focused column's full cascade band for a
397+// wider column: the column at the far end of the band leaves it. The
398+// rightmost goes to the band below; from the last column, the leftmost
399+// goes to the band above, at its right end. The column remembers it.
400+func (w *Workspace) pushNeighbor() {
401+ first, last := w.bandBounds(w.band(w.Focus))
402+ if first == last {
403+ return
404+ }
405+ id, _ := w.Focused()
406+ c := &w.Columns[w.Focus]
407+ if w.Focus < last {
408+ c.pushedDown = append(c.pushedDown, columnIDs(w.Columns[last:last+1])...)
409+ w.shiftBand(last, last, 1, 0)
410+ } else {
411+ c.pushedUp = append(columnIDs(w.Columns[first:first+1]), c.pushedUp...)
412+ w.shiftBand(first, first, -1, max(w.MaxColumns, 1))
413+ }
414+ w.Focus = w.columnOf(id)
415+}
416+
417+// restoreBand brings back the columns isolateBand or pushNeighbor sent
418+// away, those still in the adjacent band, as many as the band has room
419+// for, nearest to the focused column first: the ones sent up at the start
420+// of the band, the ones sent down at its end, in their former order. The
421+// others stay where they are. It reports whether the column remembered
422+// any.
423+func (w *Workspace) restoreBand() bool {
424+ c := &w.Columns[w.Focus]
425+ up, down := c.pushedUp, c.pushedDown
426+ c.pushedUp, c.pushedDown = nil, nil
427+ if len(up) == 0 && len(down) == 0 {
428+ return false
429+ }
430+ id, _ := w.Focused()
431+ bands := w.bandList()
432+ b := w.band(w.Focus)
433+ room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
434+ // take removes from band nb the remembered columns found there, at
435+ // most room of them, in the order of ids walked from the focused
436+ // column outward (rev: ids end nearest).
437+ take := func(nb int, ids [][]WindowID, rev bool) []Column {
438+ if nb < 0 || nb >= len(bands) {
439+ return nil
440+ }
441+ var got []Column
442+ for k := range ids {
443+ if room == 0 {
444+ break
445+ }
446+ e := ids[k]
447+ if rev {
448+ e = ids[len(ids)-1-k]
449+ }
450+ // The column is named across the workspace first: one that
451+ // moved farther away stays there, with no stand-in.
452+ head := slices.IndexFunc(e, func(v WindowID) bool {
453+ return slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Windows[0] == v })
454+ })
455+ if head < 0 {
456+ continue
457+ }
458+ i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
459+ if i < 0 {
460+ continue
461+ }
462+ got = append(got, bands[nb][i])
463+ bands[nb] = slices.Delete(bands[nb], i, i+1)
464+ room--
465+ }
466+ if rev {
467+ slices.Reverse(got)
468+ }
469+ return got
470+ }
471+ above := take(b-1, up, true)
472+ below := take(b+1, down, false)
473+ bands[b] = slices.Concat(above, bands[b], below)
474+ w.setBands(bands, id)
475+ return true
476+}
477+
478+// spawnIndex is where a new cascade column goes: after the focused band,
479+// which it joins when it has room; otherwise it starts a band of its own
480+// between it and the next (newBand).
481+func (w *Workspace) spawnIndex() (at int, newBand bool) {
482+ if len(w.Columns) == 0 {
483+ return 0, true
484+ }
485+ b := w.band(w.Focus)
486+ first, last := w.bandBounds(b)
487+ bands := [][]Column{w.Columns[first : last+1]}
488+ return last + 1, !w.fits(bands, 0, []Column{{}})
489+}
490+
491+// deleteColumn removes column i; a band it started keeps its start.
492+func (w *Workspace) deleteColumn(i int) {
493+ if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
494+ w.Columns[i+1].BandStart = true
495+ }
496+ w.Columns = slices.Delete(w.Columns, i, i+1)
497+}
498+
499+// forgetBands drops the cascade band starts and pushed columns: the bands
500+// pack again in column order.
501+func (w *Workspace) forgetBands() {
502+ for i := range w.Columns {
503+ c := &w.Columns[i]
504+ c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil
505+ }
506+}
@@ -2,39 +2,55 @@ package core
2 2
3 import "slices"3 import "slices"
4 4
5-// Cascade bands pack columns in order: a band ends when the next column5+// Cascade bands are explicit: Column.BandStart marks the first column of
6-// would take it past MaxColumns slots. A column takes one slot and a6+// each band, and moves keep the other bands as they are. A band also ends
7-// maximized one all of them, so it sits alone in its band. Cells only7+// where it would pass MaxColumns columns (max-columns shrank, or a column
8-// share a band's width (laneWidth); they never move a column to a band.8+// was inserted into a full band), and a maximized column sits alone in its
9+// band. scroll freezes those ends into BandStart, so the bands stay when
10+// max-columns grows again or the column is unmaximized. Cells only share
11+// a band's width (laneWidth); they never move a column to a band.
9 12
10-// bandSlots is the number of slots column i takes in its band.13+// startsBand reports whether column i starts a band, n being the count of
11-func (w *Workspace) bandSlots(i int) int {14+// columns in the band before it.
12- if w.Columns[i].FullWidth {15+func (w *Workspace) startsBand(i, n int) bool {
13- return max(w.MaxColumns, 1)16+ if i == 0 {
17+ return true
14 }18 }
15- return 119+ c := w.Columns
20+ return c[i].BandStart || n >= max(w.MaxColumns, 1) || c[i].FullWidth || c[i-1].FullWidth
16 }21 }
17 22
18 // walkBands calls f with each band and its column range, in order, until f23 // walkBands calls f with each band and its column range, in order, until f
19 // returns false.24 // returns false.
20 func (w *Workspace) walkBands(f func(b, first, last int) bool) {25 func (w *Workspace) walkBands(f func(b, first, last int) bool) {
21- k := max(w.MaxColumns, 1)26+ b, first, n := 0, 0, 0
22- b, first, sum := 0, 0, 0
23 for i := range w.Columns {27 for i := range w.Columns {
24- c := w.bandSlots(i)28+ if i > first && w.startsBand(i, n) {
25- if i > first && sum+c > k {
26 if !f(b, first, i-1) {29 if !f(b, first, i-1) {
27 return30 return
28 }31 }
29- b, first, sum = b+1, i, 032+ b, first, n = b+1, i, 0
30 }33 }
31- sum += c34+ n++
32 }35 }
33 if len(w.Columns) > 0 {36 if len(w.Columns) > 0 {
34 f(b, first, len(w.Columns)-1)37 f(b, first, len(w.Columns)-1)
35 }38 }
36 }39 }
37 40
41+// markBands freezes the band starts into BandStart.
42+func (w *Workspace) markBands() {
43+ n := 0
44+ for i := range w.Columns {
45+ s := w.startsBand(i, n)
46+ w.Columns[i].BandStart = s
47+ if s {
48+ n = 0
49+ }
50+ n++
51+ }
52+}
53+
38 // band is the cascade band of column i.54 // band is the cascade band of column i.
39 func (w *Workspace) band(i int) (band int) {55 func (w *Workspace) band(i int) (band int) {
40 w.walkBands(func(b, _, last int) bool {56 w.walkBands(func(b, _, last int) bool {
@@ -226,32 +242,17 @@ func (w *Workspace) leavesBand(i int) bool {
226 return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus)242 return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus)
227 }243 }
228 244
229-// moveBand swaps the focused cascade column with the column in the band dir245+// moveBand moves the focused cascade column to the band dir away
230-// away where focus-window-up/down goes; focus follows it. Past the first246+// (move-window-up/down past its column), keeping its offset in the band;
231-// or last band nothing moves. Columns keep their maximization: when the two247+// focus follows it. See shiftBand.
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.
234 func (w *Workspace) moveBand(dir int) {248 func (w *Workspace) moveBand(dir int) {
235- i := w.laneNeighbor(dir)249+ if !w.policy().wraps || len(w.Columns) == 0 {
236- if i < 0 {
237 return250 return
238 }251 }
239- if w.bandSlots(w.Focus) == w.bandSlots(i) {252+ first, _ := w.bandBounds(w.band(w.Focus))
240- w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus]253+ if w.shiftBand(w.Focus, w.Focus, dir, w.Focus-first) {
241- w.Focus = i
242 w.scroll()254 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 }255 }
252- w.Columns = slices.Insert(w.Columns, at, col)
253- w.Focus = at
254- w.scroll()
255 }256 }
256 257
257 // laneNeighbor is the closest column to the focus in the lane d lanes away,258 // laneNeighbor is the closest column to the focus in the lane d lanes away,
@@ -265,3 +266,241 @@ func (w *Workspace) laneNeighbor(d int) int {
265 }266 }
266 return min(nfirst+w.Focus-first, nlast)267 return min(nfirst+w.Focus-first, nlast)
267 }268 }
269+
270+// bandList copies the cascade columns into their bands.
271+func (w *Workspace) bandList() [][]Column {
272+ var bands [][]Column
273+ w.walkBands(func(_, first, last int) bool {
274+ bands = append(bands, slices.Clone(w.Columns[first:last+1]))
275+ return true
276+ })
277+ return bands
278+}
279+
280+// setBands stores bands as the columns, empty bands dropped, and focuses
281+// the column holding window focus.
282+func (w *Workspace) setBands(bands [][]Column, focus WindowID) {
283+ cols := w.Columns[:0]
284+ for _, b := range bands {
285+ for i, c := range b {
286+ c.BandStart = i == 0
287+ cols = append(cols, c)
288+ }
289+ }
290+ w.Columns = cols
291+ if i := w.columnOf(focus); i >= 0 {
292+ w.Focus = i
293+ }
294+}
295+
296+// fits reports whether cols can join band b: it has room for them and
297+// neither side holds a maximized column, which keeps a band to itself.
298+func (w *Workspace) fits(bands [][]Column, b int, cols []Column) bool {
299+ if b < 0 || b >= len(bands) || len(bands[b])+len(cols) > max(w.MaxColumns, 1) {
300+ return false
301+ }
302+ full := func(c Column) bool { return c.FullWidth }
303+ return !slices.ContainsFunc(bands[b], full) && !slices.ContainsFunc(cols, full)
304+}
305+
306+// sendBand moves cols, already taken out of band b, to the band dir away:
307+// they join it at index at (clamped) when they fit, else they make a new
308+// band between the two, or past the outer band. When cols were alone in
309+// band b, it goes away and a band that cannot take them is passed: the
310+// new band goes on its far side, so repeated moves always progress.
311+func (w *Workspace) sendBand(bands [][]Column, b, dir, at int, cols []Column) [][]Column {
312+ t, nb := b+dir, b // A new band between b and t, before b going up.
313+ if dir > 0 {
314+ nb = b + 1
315+ }
316+ if len(bands[b]) == 0 {
317+ bands = slices.Delete(bands, b, b+1)
318+ t, nb = b-1, b-1 // Going up: before band t.
319+ if dir > 0 {
320+ t, nb = b, b+1 // Going down: after band t.
321+ }
322+ }
323+ if w.fits(bands, t, cols) {
324+ at = min(max(at, 0), len(bands[t]))
325+ bands[t] = slices.Insert(bands[t], at, cols...)
326+ return bands
327+ }
328+ return slices.Insert(bands, min(max(nb, 0), len(bands)), slices.Clone(cols))
329+}
330+
331+// shiftBand moves the columns lo..hi of one cascade band to the band dir
332+// away (see sendBand), at offset at in it. It reports whether they moved.
333+func (w *Workspace) shiftBand(lo, hi, dir, at int) bool {
334+ if len(w.Columns) == 0 || lo < 0 || hi >= len(w.Columns) || lo > hi {
335+ return false
336+ }
337+ b := w.band(lo)
338+ if w.band(hi) != b {
339+ return false
340+ }
341+ first, last := w.bandBounds(b)
342+ if lo == first && hi == last && (b+dir < 0 || b+dir > w.band(len(w.Columns)-1)) {
343+ return false // Alone at the outer band: nothing to cross.
344+ }
345+ id, _ := w.Focused()
346+ bands := w.bandList()
347+ cols := slices.Clone(bands[b][lo-first : hi-first+1])
348+ bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)
349+ w.setBands(w.sendBand(bands, b, dir, at, cols), id)
350+ return true
351+}
352+
353+// columnIDs lists the windows of each column, in order: its identity for
354+// restoreBand. The column is the one headed by the first of them still
355+// heading a column, so it is found after its first window closed or its
356+// stack changed order, and a window that left it for a column of its own
357+// comes after the one that stayed first.
358+func columnIDs(cols []Column) [][]WindowID {
359+ ids := make([][]WindowID, len(cols))
360+ for i, c := range cols {
361+ ids[i] = slices.Clone(c.Windows)
362+ }
363+ return ids
364+}
365+
366+// isolateBand gives the focused cascade column a band of its own: the
367+// columns on its left go to the band above, the ones on its right to the
368+// band below (sendBand), and the column remembers them for restoreBand.
369+func (w *Workspace) isolateBand() {
370+ b := w.band(w.Focus)
371+ first, last := w.bandBounds(b)
372+ if first == last {
373+ return
374+ }
375+ id, _ := w.Focused()
376+ bands := w.bandList()
377+ p := w.Focus - first
378+ left := slices.Clone(bands[b][:p])
379+ right := slices.Clone(bands[b][p+1:])
380+ c := bands[b][p]
381+ // Both lists keep the band's left-to-right order. Earlier pushes
382+ // (cycle-column-width) took the far ends of the band, so the new ones
383+ // were nearer the column.
384+ c.pushedUp = append(c.pushedUp, columnIDs(left)...)
385+ c.pushedDown = append(columnIDs(right), c.pushedDown...)
386+ bands[b] = []Column{c}
387+ if len(right) > 0 {
388+ bands = w.sendBand(bands, b, 1, 0, right)
389+ }
390+ if len(left) > 0 {
391+ bands = w.sendBand(bands, b, -1, max(w.MaxColumns, 1), left)
392+ }
393+ w.setBands(bands, id)
394+}
395+
396+// pushNeighbor makes room in the focused column's full cascade band for a
397+// wider column: the column at the far end of the band leaves it. The
398+// rightmost goes to the band below; from the last column, the leftmost
399+// goes to the band above, at its right end. The column remembers it.
400+func (w *Workspace) pushNeighbor() {
401+ first, last := w.bandBounds(w.band(w.Focus))
402+ if first == last {
403+ return
404+ }
405+ id, _ := w.Focused()
406+ c := &w.Columns[w.Focus]
407+ if w.Focus < last {
408+ c.pushedDown = append(c.pushedDown, columnIDs(w.Columns[last:last+1])...)
409+ w.shiftBand(last, last, 1, 0)
410+ } else {
411+ c.pushedUp = append(columnIDs(w.Columns[first:first+1]), c.pushedUp...)
412+ w.shiftBand(first, first, -1, max(w.MaxColumns, 1))
413+ }
414+ w.Focus = w.columnOf(id)
415+}
416+
417+// restoreBand brings back the columns isolateBand or pushNeighbor sent
418+// away, those still in the adjacent band, as many as the band has room
419+// for, nearest to the focused column first: the ones sent up at the start
420+// of the band, the ones sent down at its end, in their former order. The
421+// others stay where they are. It reports whether the column remembered
422+// any.
423+func (w *Workspace) restoreBand() bool {
424+ c := &w.Columns[w.Focus]
425+ up, down := c.pushedUp, c.pushedDown
426+ c.pushedUp, c.pushedDown = nil, nil
427+ if len(up) == 0 && len(down) == 0 {
428+ return false
429+ }
430+ id, _ := w.Focused()
431+ bands := w.bandList()
432+ b := w.band(w.Focus)
433+ room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
434+ // take removes from band nb the remembered columns found there, at
435+ // most room of them, in the order of ids walked from the focused
436+ // column outward (rev: ids end nearest).
437+ take := func(nb int, ids [][]WindowID, rev bool) []Column {
438+ if nb < 0 || nb >= len(bands) {
439+ return nil
440+ }
441+ var got []Column
442+ for k := range ids {
443+ if room == 0 {
444+ break
445+ }
446+ e := ids[k]
447+ if rev {
448+ e = ids[len(ids)-1-k]
449+ }
450+ // The column is named across the workspace first: one that
451+ // moved farther away stays there, with no stand-in.
452+ head := slices.IndexFunc(e, func(v WindowID) bool {
453+ return slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Windows[0] == v })
454+ })
455+ if head < 0 {
456+ continue
457+ }
458+ i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
459+ if i < 0 {
460+ continue
461+ }
462+ got = append(got, bands[nb][i])
463+ bands[nb] = slices.Delete(bands[nb], i, i+1)
464+ room--
465+ }
466+ if rev {
467+ slices.Reverse(got)
468+ }
469+ return got
470+ }
471+ above := take(b-1, up, true)
472+ below := take(b+1, down, false)
473+ bands[b] = slices.Concat(above, bands[b], below)
474+ w.setBands(bands, id)
475+ return true
476+}
477+
478+// spawnIndex is where a new cascade column goes: after the focused band,
479+// which it joins when it has room; otherwise it starts a band of its own
480+// between it and the next (newBand).
481+func (w *Workspace) spawnIndex() (at int, newBand bool) {
482+ if len(w.Columns) == 0 {
483+ return 0, true
484+ }
485+ b := w.band(w.Focus)
486+ first, last := w.bandBounds(b)
487+ bands := [][]Column{w.Columns[first : last+1]}
488+ return last + 1, !w.fits(bands, 0, []Column{{}})
489+}
490+
491+// deleteColumn removes column i; a band it started keeps its start.
492+func (w *Workspace) deleteColumn(i int) {
493+ if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
494+ w.Columns[i+1].BandStart = true
495+ }
496+ w.Columns = slices.Delete(w.Columns, i, i+1)
497+}
498+
499+// forgetBands drops the cascade band starts and pushed columns: the bands
500+// pack again in column order.
501+func (w *Workspace) forgetBands() {
502+ for i := range w.Columns {
503+ c := &w.Columns[i]
504+ c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil
505+ }
506+}
A internal/core/cascade_bands_test.go +336 -0
@@ -0,0 +1,336 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// cycle-column-width in a full band pushes the column at the far end of
9+// the band out: the rightmost down, or the leftmost up from the last
10+// column; the end of the cycle brings it back.
11+func TestCascadeCyclePushesNeighbor(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ n, k int
15+ focus WindowID
16+ pushed [][]WindowID
17+ }{
18+ {"middle pushes rightmost down", 3, 3, 2, [][]WindowID{{1, 2}, {3}}},
19+ {"first pushes rightmost down", 3, 3, 1, [][]WindowID{{1, 2}, {3}}},
20+ {"last pushes leftmost up", 3, 3, 3, [][]WindowID{{1}, {2, 3}}},
21+ {"last of four pushes leftmost up", 4, 4, 4, [][]WindowID{{1}, {2, 3, 4}}},
22+ {"second of four pushes rightmost down", 4, 4, 2, [][]WindowID{{1, 2, 3}, {4}}},
23+ {"joins the band below", 5, 3, 2, [][]WindowID{{1, 2}, {3, 4, 5}}},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) {
26+ _, w := cascadeMovesCore(t, tc.n, tc.k, false)
27+ w.FocusID(tc.focus)
28+ before := bandIDs(w)
29+ w.Apply(ActionCycleColumnWidth)
30+ wantBands(t, w, tc.pushed...)
31+ if id, _ := w.Focused(); id != tc.focus {
32+ t.Fatalf("focus %d, want %d", id, tc.focus)
33+ }
34+ if got := w.Columns[w.Focus].Cells; got != 2 {
35+ t.Fatalf("focused cells %d, want 2", got)
36+ }
37+ w.Apply(ActionCycleColumnWidth)
38+ wantBands(t, w, before...)
39+ for _, c := range w.Columns {
40+ if c.Cells != 0 {
41+ t.Fatalf("cells not back to equal: %+v", w.Columns)
42+ }
43+ }
44+ })
45+ }
46+}
47+
48+// A pushed column that moved meanwhile stays where it is.
49+func TestCascadeCycleRestoreSkipsMovedColumn(t *testing.T) {
50+ c, w := cascadeMovesCore(t, 3, 3, false)
51+ w.FocusID(2)
52+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
53+ w.FocusID(3)
54+ c.applyAction(ActionMoveWindowUp) // {3 1 2}: 3 is back by hand.
55+ w.FocusID(2)
56+ w.Apply(ActionCycleColumnWidth)
57+ wantBands(t, w, []WindowID{3, 1, 2})
58+ if got := cellsOf(w); !slices.Equal(got, []int{0, 0, 0}) {
59+ t.Fatalf("cells %v, want equal", got)
60+ }
61+}
62+
63+// maximize-column isolates the column: the left ones go up, the right ones
64+// down, together; maximizing again forms the band back.
65+func TestCascadeMaximizeSplitsAndRestoresBand(t *testing.T) {
66+ for _, tc := range []struct {
67+ name string
68+ n, k int
69+ focus WindowID
70+ split [][]WindowID
71+ }{
72+ {"middle", 3, 3, 2, [][]WindowID{{1}, {2}, {3}}},
73+ {"first", 3, 3, 1, [][]WindowID{{1}, {2, 3}}},
74+ {"left side moves together", 4, 4, 3, [][]WindowID{{1, 2}, {3}, {4}}},
75+ {"left joins the band above", 5, 3, 5, [][]WindowID{{1, 2, 3}, {4}, {5}}},
76+ } {
77+ t.Run(tc.name, func(t *testing.T) {
78+ _, w := cascadeMovesCore(t, tc.n, tc.k, false)
79+ w.FocusID(tc.focus)
80+ before := bandIDs(w)
81+ w.ToggleFullWidth()
82+ wantBands(t, w, tc.split...)
83+ if !w.Columns[w.Focus].FullWidth {
84+ t.Fatal("not maximized")
85+ }
86+ w.ToggleFullWidth()
87+ wantBands(t, w, before...)
88+ if w.Columns[w.Focus].FullWidth {
89+ t.Fatal("still maximized")
90+ }
91+ })
92+ }
93+}
94+
95+// Columns on the left that fit the band above join it at its end.
96+func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) {
97+ c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
98+ w.FocusID(2)
99+ c.applyAction(ActionMoveWindowDown) // {1 3} {4 2 5}
100+ w.FocusID(2)
101+ w.ToggleFullWidth()
102+ wantBands(t, w, []WindowID{1, 3, 4}, []WindowID{2}, []WindowID{5})
103+ w.ToggleFullWidth()
104+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
105+}
106+
107+// No column is hidden by a maximized cascade column: focusing each band
108+// shows its windows.
109+func TestCascadeMaximizeHidesNothing(t *testing.T) {
110+ _, w := cascadeMovesCore(t, 3, 3, false)
111+ w.FocusID(2)
112+ w.ToggleFullWidth() // {1} {2*} {3}
113+ for id := WindowID(1); id <= 3; id++ {
114+ w.FocusID(id)
115+ var found bool
116+ for _, p := range w.Layout() {
117+ if p.ID == id {
118+ found = true
119+ if p.Hidden || p.Rect.W <= 0 || p.Rect.H <= 0 {
120+ t.Fatalf("window %d not shown: %+v", id, p)
121+ }
122+ }
123+ }
124+ if !found {
125+ t.Fatalf("no placement for window %d", id)
126+ }
127+ }
128+}
129+
130+// A new window joins the focused band when it has room, else it starts a
131+// band of its own right below it; other bands do not move.
132+func TestCascadeSpawnInFocusedBand(t *testing.T) {
133+ _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
134+ w.FocusID(4)
135+ w.AddWindow(6)
136+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
137+ w.FocusID(2)
138+ w.AddWindow(7)
139+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
140+ if id, _ := w.Focused(); id != 7 {
141+ t.Fatalf("focus %d, want 7", id)
142+ }
143+ w.AddWindow(8)
144+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7, 8}, []WindowID{4, 5, 6})
145+}
146+
147+// A band keeps its columns: closing one leaves the others in place, and a
148+// band left empty goes away.
149+func TestCascadeCloseKeepsBands(t *testing.T) {
150+ c, w := cascadeMovesCore(t, 5, 3, false)
151+ w.FocusID(5)
152+ c.applyAction(ActionMoveWindowDown) // {1 2 3} {4} {5}
153+ w.RemoveWindow(2)
154+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4}, []WindowID{5})
155+ w.RemoveWindow(4)
156+ wantBands(t, w, []WindowID{1, 3}, []WindowID{5})
157+}
158+
159+// A smaller max-columns sends the extra columns of a band to a new band
160+// right below it; a larger one keeps the bands.
161+func TestCascadeMaxColumnsShrinkSplitsBand(t *testing.T) {
162+ _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
163+ w.SetMaxColumns(2)
164+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
165+ w.SetMaxColumns(3)
166+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
167+}
168+
169+// Switching to cascade from another overflow packs the columns in order.
170+func TestCascadeOverflowSwitchRepacks(t *testing.T) {
171+ m := newMonitor("", "")
172+ m.SetOutput(900, 600)
173+ m.SetMaxColumns(3)
174+ m.SetOverflow(OverflowCascade)
175+ for id := WindowID(1); id <= 4; id++ {
176+ m.AddWindow(id)
177+ }
178+ w := m.Current()
179+ w.FocusID(2)
180+ w.ToggleFullWidth() // {1} {2*} {3 4}
181+ m.SetOverflow(OverflowScroll)
182+ m.SetOverflow(OverflowCascade)
183+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4})
184+}
185+
186+// Moves inside a band keep the band starts in place.
187+func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) {
188+ c, w := cascadeMovesCore(t, 5, 3, false)
189+ w.FocusID(4)
190+ c.applyAction(ActionMoveColumnRight)
191+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4})
192+}
193+
194+// Review regressions: push memory survives a maximize in between, finds a
195+// column whose first window closed, and leaves columns without room in
196+// place.
197+func TestCascadeRestoreBandMemory(t *testing.T) {
198+ t.Run("cycle then maximize", func(t *testing.T) {
199+ _, w := cascadeMovesCore(t, 3, 3, false)
200+ w.FocusID(2)
201+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
202+ w.ToggleFullWidth() // {1} {2*} {3}
203+ w.ToggleFullWidth() // {1 2 3}
204+ wantBands(t, w, []WindowID{1, 2, 3})
205+ })
206+ t.Run("cycle then maximize from either end keeps the order", func(t *testing.T) {
207+ for _, focus := range []WindowID{1, 3} {
208+ _, w := cascadeMovesCore(t, 3, 3, false)
209+ w.FocusID(focus)
210+ w.Apply(ActionCycleColumnWidth)
211+ w.ToggleFullWidth()
212+ w.ToggleFullWidth()
213+ wantBands(t, w, []WindowID{1, 2, 3})
214+ }
215+ })
216+ t.Run("a window that left the pushed column does not bring its new column", func(t *testing.T) {
217+ _, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
218+ w.FocusID(4)
219+ w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
220+ w.FocusID(2)
221+ w.ToggleFullWidth() // {1} {2*} {3/4 5}
222+ w.stackWindow(4, 5) // {1} {2*} {3 5/4}
223+ w.FocusID(5)
224+ w.MoveColumn(-1) // {1} {2*} {5/4 3}
225+ w.FocusID(2)
226+ w.ToggleFullWidth()
227+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5})
228+ })
229+ t.Run("a window expelled from the pushed column stays", func(t *testing.T) {
230+ _, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
231+ w.FocusID(4)
232+ w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
233+ w.FocusID(2)
234+ w.ToggleFullWidth() // {1} {2*} {3/4 5}
235+ w.FocusID(4)
236+ w.ConsumeOrExpel(-1) // {1} {2*} {4 3 5}
237+ w.FocusID(2)
238+ w.ToggleFullWidth()
239+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5})
240+ })
241+ t.Run("a pushed column moved away has no stand-in", func(t *testing.T) {
242+ c, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
243+ w.FocusID(4)
244+ w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
245+ w.FocusID(2)
246+ w.ToggleFullWidth() // {1} {2*} {3/4 5}
247+ w.FocusID(4)
248+ w.ConsumeOrExpel(-1) // {1} {2*} {4 3 5}
249+ w.FocusID(3)
250+ c.applyAction(ActionMoveWindowDown) // {1} {2*} {4 5} {3}
251+ w.FocusID(2)
252+ w.ToggleFullWidth()
253+ wantBands(t, w, []WindowID{1, 2}, []WindowID{4, 5}, []WindowID{3})
254+ })
255+ t.Run("first window of a pushed column closed", func(t *testing.T) {
256+ _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
257+ w.FocusID(4)
258+ w.ConsumeOrExpel(-1) // {1 2 3/4}
259+ w.FocusID(2)
260+ w.ToggleFullWidth() // {1} {2*} {3/4}
261+ w.RemoveWindow(3)
262+ w.FocusID(2)
263+ w.ToggleFullWidth()
264+ wantBands(t, w, []WindowID{1, 2, 4})
265+ })
266+ t.Run("no room keeps the other band as it is", func(t *testing.T) {
267+ c, w := cascadeMovesCore(t, 3, 3, false)
268+ w.FocusID(2)
269+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
270+ w.AddWindow(6) // {1 2 6} {3}
271+ w.FocusID(3)
272+ w.AddWindow(5) // {1 2 6} {3 5}
273+ w.AddWindow(4) // {1 2 6} {3 5 4}
274+ c.applyAction(ActionMoveColumnLeft)
275+ c.applyAction(ActionMoveColumnLeft) // {1 2 6} {4 3 5}
276+ w.FocusID(2)
277+ w.Apply(ActionCycleColumnWidth) // End of the cycle: 3 has no room.
278+ wantBands(t, w, []WindowID{1, 2, 6}, []WindowID{4, 3, 5})
279+ if got := cellsOf(w)[:3]; !slices.Equal(got, []int{0, 0, 0}) {
280+ t.Fatalf("cells %v, want equal", got)
281+ }
282+ })
283+}
284+
285+// Quiet placement (window rules) and slot windows join the focused band
286+// like a new window, keeping the focus.
287+func TestCascadeQuietAndSlotPlacement(t *testing.T) {
288+ _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
289+ w.FocusID(4)
290+ w.addColumnQuiet(Column{Windows: []WindowID{6}})
291+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
292+ w.FocusID(2)
293+ w.addColumnQuiet(Column{Windows: []WindowID{7}})
294+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
295+ if id, _ := w.Focused(); id != 2 {
296+ t.Fatalf("focus %d, want 2", id)
297+ }
298+
299+ _, w = cascadeMovesCore(t, 2, 3, false) // {1 2}
300+ w.AddSlotWindow(9, 1, 0)
301+ wantBands(t, w, []WindowID{9, 1, 2})
302+ if id, _ := w.Focused(); id != 2 {
303+ t.Fatalf("focus %d, want 2", id)
304+ }
305+}
306+
307+// Reapplying the config with a named max-columns above the default keeps
308+// the named workspace's bands.
309+func TestCascadeNamedMaxColumnsKeepsBands(t *testing.T) {
310+ m := newMonitor("", "")
311+ m.SetOutput(900, 600)
312+ m.SetOverflow(OverflowCascade)
313+ m.SetMaxColumns(3)
314+ m.SetNamed([]NamedWorkspace{{Name: "wide", MaxColumns: 5}})
315+ m.ToggleNamed("wide")
316+ w := m.Current()
317+ for id := WindowID(1); id <= 5; id++ {
318+ w.AddWindow(id)
319+ }
320+ wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
321+ m.SetMaxColumns(3)
322+ wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
323+}
324+
325+func bandIDs(w *Workspace) [][]WindowID {
326+ var out [][]WindowID
327+ w.walkBands(func(_, first, last int) bool {
328+ var b []WindowID
329+ for _, c := range w.Columns[first : last+1] {
330+ b = append(b, c.Windows[0])
331+ }
332+ out = append(out, slices.Clone(b))
333+ return true
334+ })
335+ return out
336+}
@@ -0,0 +1,336 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// cycle-column-width in a full band pushes the column at the far end of
9+// the band out: the rightmost down, or the leftmost up from the last
10+// column; the end of the cycle brings it back.
11+func TestCascadeCyclePushesNeighbor(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ n, k int
15+ focus WindowID
16+ pushed [][]WindowID
17+ }{
18+ {"middle pushes rightmost down", 3, 3, 2, [][]WindowID{{1, 2}, {3}}},
19+ {"first pushes rightmost down", 3, 3, 1, [][]WindowID{{1, 2}, {3}}},
20+ {"last pushes leftmost up", 3, 3, 3, [][]WindowID{{1}, {2, 3}}},
21+ {"last of four pushes leftmost up", 4, 4, 4, [][]WindowID{{1}, {2, 3, 4}}},
22+ {"second of four pushes rightmost down", 4, 4, 2, [][]WindowID{{1, 2, 3}, {4}}},
23+ {"joins the band below", 5, 3, 2, [][]WindowID{{1, 2}, {3, 4, 5}}},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) {
26+ _, w := cascadeMovesCore(t, tc.n, tc.k, false)
27+ w.FocusID(tc.focus)
28+ before := bandIDs(w)
29+ w.Apply(ActionCycleColumnWidth)
30+ wantBands(t, w, tc.pushed...)
31+ if id, _ := w.Focused(); id != tc.focus {
32+ t.Fatalf("focus %d, want %d", id, tc.focus)
33+ }
34+ if got := w.Columns[w.Focus].Cells; got != 2 {
35+ t.Fatalf("focused cells %d, want 2", got)
36+ }
37+ w.Apply(ActionCycleColumnWidth)
38+ wantBands(t, w, before...)
39+ for _, c := range w.Columns {
40+ if c.Cells != 0 {
41+ t.Fatalf("cells not back to equal: %+v", w.Columns)
42+ }
43+ }
44+ })
45+ }
46+}
47+
48+// A pushed column that moved meanwhile stays where it is.
49+func TestCascadeCycleRestoreSkipsMovedColumn(t *testing.T) {
50+ c, w := cascadeMovesCore(t, 3, 3, false)
51+ w.FocusID(2)
52+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
53+ w.FocusID(3)
54+ c.applyAction(ActionMoveWindowUp) // {3 1 2}: 3 is back by hand.
55+ w.FocusID(2)
56+ w.Apply(ActionCycleColumnWidth)
57+ wantBands(t, w, []WindowID{3, 1, 2})
58+ if got := cellsOf(w); !slices.Equal(got, []int{0, 0, 0}) {
59+ t.Fatalf("cells %v, want equal", got)
60+ }
61+}
62+
63+// maximize-column isolates the column: the left ones go up, the right ones
64+// down, together; maximizing again forms the band back.
65+func TestCascadeMaximizeSplitsAndRestoresBand(t *testing.T) {
66+ for _, tc := range []struct {
67+ name string
68+ n, k int
69+ focus WindowID
70+ split [][]WindowID
71+ }{
72+ {"middle", 3, 3, 2, [][]WindowID{{1}, {2}, {3}}},
73+ {"first", 3, 3, 1, [][]WindowID{{1}, {2, 3}}},
74+ {"left side moves together", 4, 4, 3, [][]WindowID{{1, 2}, {3}, {4}}},
75+ {"left joins the band above", 5, 3, 5, [][]WindowID{{1, 2, 3}, {4}, {5}}},
76+ } {
77+ t.Run(tc.name, func(t *testing.T) {
78+ _, w := cascadeMovesCore(t, tc.n, tc.k, false)
79+ w.FocusID(tc.focus)
80+ before := bandIDs(w)
81+ w.ToggleFullWidth()
82+ wantBands(t, w, tc.split...)
83+ if !w.Columns[w.Focus].FullWidth {
84+ t.Fatal("not maximized")
85+ }
86+ w.ToggleFullWidth()
87+ wantBands(t, w, before...)
88+ if w.Columns[w.Focus].FullWidth {
89+ t.Fatal("still maximized")
90+ }
91+ })
92+ }
93+}
94+
95+// Columns on the left that fit the band above join it at its end.
96+func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) {
97+ c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
98+ w.FocusID(2)
99+ c.applyAction(ActionMoveWindowDown) // {1 3} {4 2 5}
100+ w.FocusID(2)
101+ w.ToggleFullWidth()
102+ wantBands(t, w, []WindowID{1, 3, 4}, []WindowID{2}, []WindowID{5})
103+ w.ToggleFullWidth()
104+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
105+}
106+
107+// No column is hidden by a maximized cascade column: focusing each band
108+// shows its windows.
109+func TestCascadeMaximizeHidesNothing(t *testing.T) {
110+ _, w := cascadeMovesCore(t, 3, 3, false)
111+ w.FocusID(2)
112+ w.ToggleFullWidth() // {1} {2*} {3}
113+ for id := WindowID(1); id <= 3; id++ {
114+ w.FocusID(id)
115+ var found bool
116+ for _, p := range w.Layout() {
117+ if p.ID == id {
118+ found = true
119+ if p.Hidden || p.Rect.W <= 0 || p.Rect.H <= 0 {
120+ t.Fatalf("window %d not shown: %+v", id, p)
121+ }
122+ }
123+ }
124+ if !found {
125+ t.Fatalf("no placement for window %d", id)
126+ }
127+ }
128+}
129+
130+// A new window joins the focused band when it has room, else it starts a
131+// band of its own right below it; other bands do not move.
132+func TestCascadeSpawnInFocusedBand(t *testing.T) {
133+ _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
134+ w.FocusID(4)
135+ w.AddWindow(6)
136+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
137+ w.FocusID(2)
138+ w.AddWindow(7)
139+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
140+ if id, _ := w.Focused(); id != 7 {
141+ t.Fatalf("focus %d, want 7", id)
142+ }
143+ w.AddWindow(8)
144+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7, 8}, []WindowID{4, 5, 6})
145+}
146+
147+// A band keeps its columns: closing one leaves the others in place, and a
148+// band left empty goes away.
149+func TestCascadeCloseKeepsBands(t *testing.T) {
150+ c, w := cascadeMovesCore(t, 5, 3, false)
151+ w.FocusID(5)
152+ c.applyAction(ActionMoveWindowDown) // {1 2 3} {4} {5}
153+ w.RemoveWindow(2)
154+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4}, []WindowID{5})
155+ w.RemoveWindow(4)
156+ wantBands(t, w, []WindowID{1, 3}, []WindowID{5})
157+}
158+
159+// A smaller max-columns sends the extra columns of a band to a new band
160+// right below it; a larger one keeps the bands.
161+func TestCascadeMaxColumnsShrinkSplitsBand(t *testing.T) {
162+ _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
163+ w.SetMaxColumns(2)
164+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
165+ w.SetMaxColumns(3)
166+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
167+}
168+
169+// Switching to cascade from another overflow packs the columns in order.
170+func TestCascadeOverflowSwitchRepacks(t *testing.T) {
171+ m := newMonitor("", "")
172+ m.SetOutput(900, 600)
173+ m.SetMaxColumns(3)
174+ m.SetOverflow(OverflowCascade)
175+ for id := WindowID(1); id <= 4; id++ {
176+ m.AddWindow(id)
177+ }
178+ w := m.Current()
179+ w.FocusID(2)
180+ w.ToggleFullWidth() // {1} {2*} {3 4}
181+ m.SetOverflow(OverflowScroll)
182+ m.SetOverflow(OverflowCascade)
183+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4})
184+}
185+
186+// Moves inside a band keep the band starts in place.
187+func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) {
188+ c, w := cascadeMovesCore(t, 5, 3, false)
189+ w.FocusID(4)
190+ c.applyAction(ActionMoveColumnRight)
191+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4})
192+}
193+
194+// Review regressions: push memory survives a maximize in between, finds a
195+// column whose first window closed, and leaves columns without room in
196+// place.
197+func TestCascadeRestoreBandMemory(t *testing.T) {
198+ t.Run("cycle then maximize", func(t *testing.T) {
199+ _, w := cascadeMovesCore(t, 3, 3, false)
200+ w.FocusID(2)
201+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
202+ w.ToggleFullWidth() // {1} {2*} {3}
203+ w.ToggleFullWidth() // {1 2 3}
204+ wantBands(t, w, []WindowID{1, 2, 3})
205+ })
206+ t.Run("cycle then maximize from either end keeps the order", func(t *testing.T) {
207+ for _, focus := range []WindowID{1, 3} {
208+ _, w := cascadeMovesCore(t, 3, 3, false)
209+ w.FocusID(focus)
210+ w.Apply(ActionCycleColumnWidth)
211+ w.ToggleFullWidth()
212+ w.ToggleFullWidth()
213+ wantBands(t, w, []WindowID{1, 2, 3})
214+ }
215+ })
216+ t.Run("a window that left the pushed column does not bring its new column", func(t *testing.T) {
217+ _, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
218+ w.FocusID(4)
219+ w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
220+ w.FocusID(2)
221+ w.ToggleFullWidth() // {1} {2*} {3/4 5}
222+ w.stackWindow(4, 5) // {1} {2*} {3 5/4}
223+ w.FocusID(5)
224+ w.MoveColumn(-1) // {1} {2*} {5/4 3}
225+ w.FocusID(2)
226+ w.ToggleFullWidth()
227+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5})
228+ })
229+ t.Run("a window expelled from the pushed column stays", func(t *testing.T) {
230+ _, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
231+ w.FocusID(4)
232+ w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
233+ w.FocusID(2)
234+ w.ToggleFullWidth() // {1} {2*} {3/4 5}
235+ w.FocusID(4)
236+ w.ConsumeOrExpel(-1) // {1} {2*} {4 3 5}
237+ w.FocusID(2)
238+ w.ToggleFullWidth()
239+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5})
240+ })
241+ t.Run("a pushed column moved away has no stand-in", func(t *testing.T) {
242+ c, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
243+ w.FocusID(4)
244+ w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
245+ w.FocusID(2)
246+ w.ToggleFullWidth() // {1} {2*} {3/4 5}
247+ w.FocusID(4)
248+ w.ConsumeOrExpel(-1) // {1} {2*} {4 3 5}
249+ w.FocusID(3)
250+ c.applyAction(ActionMoveWindowDown) // {1} {2*} {4 5} {3}
251+ w.FocusID(2)
252+ w.ToggleFullWidth()
253+ wantBands(t, w, []WindowID{1, 2}, []WindowID{4, 5}, []WindowID{3})
254+ })
255+ t.Run("first window of a pushed column closed", func(t *testing.T) {
256+ _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
257+ w.FocusID(4)
258+ w.ConsumeOrExpel(-1) // {1 2 3/4}
259+ w.FocusID(2)
260+ w.ToggleFullWidth() // {1} {2*} {3/4}
261+ w.RemoveWindow(3)
262+ w.FocusID(2)
263+ w.ToggleFullWidth()
264+ wantBands(t, w, []WindowID{1, 2, 4})
265+ })
266+ t.Run("no room keeps the other band as it is", func(t *testing.T) {
267+ c, w := cascadeMovesCore(t, 3, 3, false)
268+ w.FocusID(2)
269+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
270+ w.AddWindow(6) // {1 2 6} {3}
271+ w.FocusID(3)
272+ w.AddWindow(5) // {1 2 6} {3 5}
273+ w.AddWindow(4) // {1 2 6} {3 5 4}
274+ c.applyAction(ActionMoveColumnLeft)
275+ c.applyAction(ActionMoveColumnLeft) // {1 2 6} {4 3 5}
276+ w.FocusID(2)
277+ w.Apply(ActionCycleColumnWidth) // End of the cycle: 3 has no room.
278+ wantBands(t, w, []WindowID{1, 2, 6}, []WindowID{4, 3, 5})
279+ if got := cellsOf(w)[:3]; !slices.Equal(got, []int{0, 0, 0}) {
280+ t.Fatalf("cells %v, want equal", got)
281+ }
282+ })
283+}
284+
285+// Quiet placement (window rules) and slot windows join the focused band
286+// like a new window, keeping the focus.
287+func TestCascadeQuietAndSlotPlacement(t *testing.T) {
288+ _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
289+ w.FocusID(4)
290+ w.addColumnQuiet(Column{Windows: []WindowID{6}})
291+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
292+ w.FocusID(2)
293+ w.addColumnQuiet(Column{Windows: []WindowID{7}})
294+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
295+ if id, _ := w.Focused(); id != 2 {
296+ t.Fatalf("focus %d, want 2", id)
297+ }
298+
299+ _, w = cascadeMovesCore(t, 2, 3, false) // {1 2}
300+ w.AddSlotWindow(9, 1, 0)
301+ wantBands(t, w, []WindowID{9, 1, 2})
302+ if id, _ := w.Focused(); id != 2 {
303+ t.Fatalf("focus %d, want 2", id)
304+ }
305+}
306+
307+// Reapplying the config with a named max-columns above the default keeps
308+// the named workspace's bands.
309+func TestCascadeNamedMaxColumnsKeepsBands(t *testing.T) {
310+ m := newMonitor("", "")
311+ m.SetOutput(900, 600)
312+ m.SetOverflow(OverflowCascade)
313+ m.SetMaxColumns(3)
314+ m.SetNamed([]NamedWorkspace{{Name: "wide", MaxColumns: 5}})
315+ m.ToggleNamed("wide")
316+ w := m.Current()
317+ for id := WindowID(1); id <= 5; id++ {
318+ w.AddWindow(id)
319+ }
320+ wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
321+ m.SetMaxColumns(3)
322+ wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
323+}
324+
325+func bandIDs(w *Workspace) [][]WindowID {
326+ var out [][]WindowID
327+ w.walkBands(func(_, first, last int) bool {
328+ var b []WindowID
329+ for _, c := range w.Columns[first : last+1] {
330+ b = append(b, c.Windows[0])
331+ }
332+ out = append(out, slices.Clone(b))
333+ return true
334+ })
335+ return out
336+}
A internal/core/cascade_edges_test.go +81 -0
@@ -0,0 +1,81 @@
1+package core
2+
3+import "testing"
4+
5+// With no monitor on that side, move-column-left/right at a cascade band
6+// edge does nothing: the column never crosses into the adjacent band.
7+// Bands {1 2 3} {4 5}.
8+func TestCascadeMoveColumnAtBandEdgeDoesNothing(t *testing.T) {
9+ for _, tc := range []struct {
10+ name string
11+ from WindowID
12+ action Action
13+ }{
14+ {"right from band end", 3, ActionMoveColumnRight},
15+ {"left from band start", 4, ActionMoveColumnLeft},
16+ {"left from first column", 1, ActionMoveColumnLeft},
17+ {"right from last column", 5, ActionMoveColumnRight},
18+ } {
19+ t.Run(tc.name, func(t *testing.T) {
20+ c, w := cascadeMovesCore(t, 5, 3, false)
21+ w.FocusID(tc.from)
22+ c.applyAction(tc.action)
23+ wantColumns(t, w, 1, 2, 3, 4, 5)
24+ wantFocus(t, c, "M", tc.from)
25+ wantActive(t, c.cur().mon, 0)
26+ })
27+ }
28+}
29+
30+// Inside a band the column still swaps with its neighbor.
31+func TestCascadeMoveColumnInsideBandSwaps(t *testing.T) {
32+ c, w := cascadeMovesCore(t, 5, 3, false)
33+ w.FocusID(2)
34+ c.applyAction(ActionMoveColumnRight)
35+ wantColumns(t, w, 1, 3, 2, 4, 5)
36+ wantFocus(t, c, "M", 2)
37+ c.applyAction(ActionMoveColumnRight)
38+ wantColumns(t, w, 1, 3, 2, 4, 5)
39+ c.applyAction(ActionMoveColumnLeft)
40+ c.applyAction(ActionMoveColumnLeft)
41+ wantColumns(t, w, 2, 1, 3, 4, 5)
42+ c.applyAction(ActionMoveColumnLeft)
43+ wantColumns(t, w, 2, 1, 3, 4, 5)
44+}
45+
46+// A neighbor monitor on the left screen's right side wins over an occupied
47+// workspace there: focus-column-right at the band edge goes to the screen.
48+func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) {
49+ c := layoutCore(0, tile{"L", 0, 0, 900, 600}, tile{"R", 900, 0, 900, 600})
50+ m := c.cur().mon
51+ m.SetOverflow(OverflowCascade)
52+ m.SetMaxColumns(3)
53+ m.AddWindow(1)
54+ m.AddWindow(2)
55+ m.Focus(1)
56+ m.AddWindow(3) // an occupied workspace to the right on L
57+ m.Focus(0)
58+ m.normalize()
59+ m.Current().FocusID(2)
60+ c.applyAction(ActionFocusColumnRight)
61+ wantFocus(t, c, "R", 0)
62+ wantActive(t, m, 0)
63+}
64+
65+// With no monitor on that side, the occupied neighbor workspace is next.
66+func TestCascadeFocusColumnEdgeSwitchesToOccupiedWorkspace(t *testing.T) {
67+ c := layoutCore(0, tile{"M", 0, 0, 900, 600})
68+ m := c.cur().mon
69+ m.SetOverflow(OverflowCascade)
70+ m.SetMaxColumns(3)
71+ m.AddWindow(1)
72+ m.AddWindow(2)
73+ m.Focus(1)
74+ m.AddWindow(3)
75+ m.Focus(0)
76+ m.normalize()
77+ m.Current().FocusID(2)
78+ c.applyAction(ActionFocusColumnRight)
79+ wantActive(t, m, 1)
80+ wantFocus(t, c, "M", 3)
81+}
@@ -0,0 +1,81 @@
1+package core
2+
3+import "testing"
4+
5+// With no monitor on that side, move-column-left/right at a cascade band
6+// edge does nothing: the column never crosses into the adjacent band.
7+// Bands {1 2 3} {4 5}.
8+func TestCascadeMoveColumnAtBandEdgeDoesNothing(t *testing.T) {
9+ for _, tc := range []struct {
10+ name string
11+ from WindowID
12+ action Action
13+ }{
14+ {"right from band end", 3, ActionMoveColumnRight},
15+ {"left from band start", 4, ActionMoveColumnLeft},
16+ {"left from first column", 1, ActionMoveColumnLeft},
17+ {"right from last column", 5, ActionMoveColumnRight},
18+ } {
19+ t.Run(tc.name, func(t *testing.T) {
20+ c, w := cascadeMovesCore(t, 5, 3, false)
21+ w.FocusID(tc.from)
22+ c.applyAction(tc.action)
23+ wantColumns(t, w, 1, 2, 3, 4, 5)
24+ wantFocus(t, c, "M", tc.from)
25+ wantActive(t, c.cur().mon, 0)
26+ })
27+ }
28+}
29+
30+// Inside a band the column still swaps with its neighbor.
31+func TestCascadeMoveColumnInsideBandSwaps(t *testing.T) {
32+ c, w := cascadeMovesCore(t, 5, 3, false)
33+ w.FocusID(2)
34+ c.applyAction(ActionMoveColumnRight)
35+ wantColumns(t, w, 1, 3, 2, 4, 5)
36+ wantFocus(t, c, "M", 2)
37+ c.applyAction(ActionMoveColumnRight)
38+ wantColumns(t, w, 1, 3, 2, 4, 5)
39+ c.applyAction(ActionMoveColumnLeft)
40+ c.applyAction(ActionMoveColumnLeft)
41+ wantColumns(t, w, 2, 1, 3, 4, 5)
42+ c.applyAction(ActionMoveColumnLeft)
43+ wantColumns(t, w, 2, 1, 3, 4, 5)
44+}
45+
46+// A neighbor monitor on the left screen's right side wins over an occupied
47+// workspace there: focus-column-right at the band edge goes to the screen.
48+func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) {
49+ c := layoutCore(0, tile{"L", 0, 0, 900, 600}, tile{"R", 900, 0, 900, 600})
50+ m := c.cur().mon
51+ m.SetOverflow(OverflowCascade)
52+ m.SetMaxColumns(3)
53+ m.AddWindow(1)
54+ m.AddWindow(2)
55+ m.Focus(1)
56+ m.AddWindow(3) // an occupied workspace to the right on L
57+ m.Focus(0)
58+ m.normalize()
59+ m.Current().FocusID(2)
60+ c.applyAction(ActionFocusColumnRight)
61+ wantFocus(t, c, "R", 0)
62+ wantActive(t, m, 0)
63+}
64+
65+// With no monitor on that side, the occupied neighbor workspace is next.
66+func TestCascadeFocusColumnEdgeSwitchesToOccupiedWorkspace(t *testing.T) {
67+ c := layoutCore(0, tile{"M", 0, 0, 900, 600})
68+ m := c.cur().mon
69+ m.SetOverflow(OverflowCascade)
70+ m.SetMaxColumns(3)
71+ m.AddWindow(1)
72+ m.AddWindow(2)
73+ m.Focus(1)
74+ m.AddWindow(3)
75+ m.Focus(0)
76+ m.normalize()
77+ m.Current().FocusID(2)
78+ c.applyAction(ActionFocusColumnRight)
79+ wantActive(t, m, 1)
80+ wantFocus(t, c, "M", 3)
81+}
M internal/core/cascade_moves_test.go +75 -67
@@ -52,6 +52,20 @@ func wantColumns(t *testing.T, w *Workspace, want ...WindowID) {
5252 }
5353 }
5454
55+// wantBands checks the cascade bands, each by the first window of its
56+// columns.
57+func wantBands(t *testing.T, w *Workspace, want ...[]WindowID) {
58+ t.Helper()
59+ var got [][]WindowID
60+ w.walkBands(func(_, first, last int) bool {
61+ got = append(got, columnOrder(&Workspace{Columns: w.Columns[first : last+1]}))
62+ return true
63+ })
64+ if !slices.EqualFunc(got, want, slices.Equal) {
65+ t.Fatalf("bands %v, want %v", got, want)
66+ }
67+}
68+
5569 func wantFocus(t *testing.T, c *Core, screen string, id WindowID) {
5670 t.Helper()
5771 if got := c.cur().name(); got != screen {
@@ -102,10 +116,10 @@ func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
102116 }
103117
104118 // With no monitor on that side, left/right never cross into the neighbor
105-// band: at the band edge they step the workspace (the first has none before
106-// it; the last is the trailing empty one). up/down cross bands, and
107-// move-column still crosses in column order.
108-func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {
119+// band: at the band edge focus steps the workspace (the first has none
120+// before it; the last is the trailing empty one) and move-column does
121+// nothing. up/down cross bands.
122+func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveStays(t *testing.T) {
109123 c, w := cascadeMovesCore(t, 5, 3, false)
110124 m := c.cur().mon
111125 w.FocusID(4)
@@ -122,12 +136,12 @@ func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {
122136 m.Focus(0)
123137 w.FocusID(4)
124138 c.applyAction(ActionMoveColumnLeft)
125- wantColumns(t, w, 1, 2, 4, 3, 5)
139+ wantColumns(t, w, 1, 2, 3, 4, 5)
126140 wantFocus(t, c, "M", 4)
127- w.FocusID(4)
141+ w.FocusID(3)
128142 c.applyAction(ActionMoveColumnRight)
129143 wantColumns(t, w, 1, 2, 3, 4, 5)
130- wantFocus(t, c, "M", 4)
144+ wantFocus(t, c, "M", 3)
131145 }
132146
133147 // With one column per band, every column is at both edges of its band.
@@ -150,92 +164,85 @@ func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) {
150164 wantFocus(t, c, "M", 2)
151165 }
152166
153-// At the top or bottom of its column, move-window-up/down moves a cascade
154-// column to the adjacent band, where focus-window-up/down goes; bands
155-// {1 2 3} {4 5}.
167+// At the top or bottom of its column, move-window-up/down sends a cascade
168+// column to the adjacent band: it joins it when it has room, at its offset
169+// in the band, else it makes a new band between the two.
156170 func TestCascadeMoveWindowCrossesBands(t *testing.T) {
157- c, w := cascadeMovesCore(t, 5, 3, false)
171+ c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
172+ w.FocusID(2)
173+ c.applyAction(ActionMoveWindowDown)
174+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
175+ wantFocus(t, c, "M", 2)
176+ c.applyAction(ActionMoveWindowUp)
177+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5})
178+ wantFocus(t, c, "M", 2)
179+}
180+
181+// A full band cannot take the column: a new band goes between the two,
182+// and the one it left keeps the others.
183+func TestCascadeMoveWindowIntoFullBandMakesBand(t *testing.T) {
184+ c, w := cascadeMovesCore(t, 6, 3, false) // {1 2 3} {4 5 6}
158185 w.FocusID(5)
159186 c.applyAction(ActionMoveWindowUp)
160- wantColumns(t, w, 1, 5, 3, 4, 2)
187+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5}, []WindowID{4, 6})
161188 wantFocus(t, c, "M", 5)
162- c.applyAction(ActionMoveWindowDown)
163- wantColumns(t, w, 1, 2, 3, 4, 5)
189+ // Alone in its band, it passes the full band above.
190+ c.applyAction(ActionMoveWindowUp)
191+ wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3}, []WindowID{4, 6})
164192 wantFocus(t, c, "M", 5)
165193 }
166194
167-// The last band is partial: the move clamps to its last column, and going
168-// back up keeps the new offset, as focus does.
169-func TestCascadeMoveWindowPartialBand(t *testing.T) {
170- c, w := cascadeMovesCore(t, 5, 3, false)
171- w.FocusID(3)
195+// Past the outer bands a column sharing its band starts a new one; a
196+// column alone there does not move.
197+func TestCascadeMoveWindowOuterBands(t *testing.T) {
198+ c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
199+ w.FocusID(5)
172200 c.applyAction(ActionMoveWindowDown)
173- wantColumns(t, w, 1, 2, 5, 4, 3)
174- c.applyAction(ActionMoveWindowUp)
175- wantColumns(t, w, 1, 3, 5, 4, 2)
176- wantFocus(t, c, "M", 3)
177-}
178-
179-// No band past the first or last: nothing moves.
180-func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) {
181- c, w := cascadeMovesCore(t, 5, 3, false)
201+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5})
202+ c.applyAction(ActionMoveWindowDown)
203+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5})
182204 w.FocusID(1)
183205 c.applyAction(ActionMoveWindowUp)
206+ wantBands(t, w, []WindowID{1}, []WindowID{2, 3}, []WindowID{4}, []WindowID{5})
184207 wantFocus(t, c, "M", 1)
185- w.FocusID(4)
186- c.applyAction(ActionMoveWindowDown)
187- wantColumns(t, w, 1, 2, 3, 4, 5)
188- wantFocus(t, c, "M", 4)
189208 }
190209
191-// A maximized column keeps its band to itself when it moves: it swaps
192-// with the column of the band above and stays maximized.
193-func TestCascadeMoveWindowKeepsMaximization(t *testing.T) {
194- c, w := cascadeMovesCore(t, 5, 3, false)
195- w.FocusID(5)
196- w.ToggleFullWidth() // Bands {1 2 3} {4} {5}.
197- c.applyAction(ActionMoveWindowUp)
198- wantColumns(t, w, 1, 2, 3, 5, 4)
199- wantFocus(t, c, "M", 5)
200- if !w.Columns[3].FullWidth || w.band(3) != 1 || w.band(4) != 2 {
201- t.Fatalf("maximized %t, bands %d %d", w.Columns[3].FullWidth, w.band(3), w.band(4))
202- }
210+// A band left empty goes away.
211+func TestCascadeMoveWindowDropsEmptyBand(t *testing.T) {
212+ c, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
213+ w.FocusID(2)
214+ c.applyAction(ActionMoveWindowDown)
215+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2})
216+ w.FocusID(4)
217+ c.applyAction(ActionMoveWindowUp) // At its offset in the band: first.
218+ wantBands(t, w, []WindowID{4, 1, 3}, []WindowID{2})
203219 }
204220
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) {
221+// A maximized column keeps its band to itself: moving it, or moving a
222+// column toward it, makes a new band; repeating the move passes it.
223+func TestCascadeMoveWindowAndMaximizedBand(t *testing.T) {
210224 for _, tc := range []struct {
211225 name string
226+ n int
212227 max, focus WindowID
213228 action Action
214- want []WindowID
215- band int
229+ want [][]WindowID
216230 }{
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},
231+ // After maximizing 2: {1} {2*} {3 4}.
232+ {"maximized down", 4, 2, 2, ActionMoveWindowDown, [][]WindowID{{1}, {3, 4}, {2}}},
233+ {"maximized up", 4, 2, 2, ActionMoveWindowUp, [][]WindowID{{2}, {1}, {3, 4}}},
234+ // After maximizing 4: {1 2 3} {4*} {5}.
235+ {"into maximized below", 5, 4, 3, ActionMoveWindowDown, [][]WindowID{{1, 2}, {3}, {4}, {5}}},
236+ {"into maximized above", 5, 4, 5, ActionMoveWindowUp, [][]WindowID{{1, 2, 3}, {5}, {4}}},
223237 } {
224238 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)
239+ c, w := cascadeMovesCore(t, tc.n, 3, false)
230240 w.FocusID(tc.max)
231241 w.ToggleFullWidth()
232242 w.FocusID(tc.focus)
233243 c.applyAction(tc.action)
234- wantColumns(t, w, tc.want...)
244+ wantBands(t, w, tc.want...)
235245 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- }
239246 if !w.Columns[w.columnOf(tc.max)].FullWidth {
240247 t.Fatal("maximization lost")
241248 }
@@ -287,7 +294,8 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) {
287294 t.Fatalf("stack %v, want [5 4]", got)
288295 }
289296 c.applyAction(ActionMoveWindowUp)
290- wantColumns(t, w, 5, 2, 3, 1)
297+ // The full band above cannot take it, so it passes it.
298+ wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3})
291299 wantFocus(t, c, "M", 5)
292300 }
293301
@@ -52,6 +52,20 @@ func wantColumns(t *testing.T, w *Workspace, want ...WindowID) {
52 }52 }
53 }53 }
54 54
55+// wantBands checks the cascade bands, each by the first window of its
56+// columns.
57+func wantBands(t *testing.T, w *Workspace, want ...[]WindowID) {
58+ t.Helper()
59+ var got [][]WindowID
60+ w.walkBands(func(_, first, last int) bool {
61+ got = append(got, columnOrder(&Workspace{Columns: w.Columns[first : last+1]}))
62+ return true
63+ })
64+ if !slices.EqualFunc(got, want, slices.Equal) {
65+ t.Fatalf("bands %v, want %v", got, want)
66+ }
67+}
68+
55 func wantFocus(t *testing.T, c *Core, screen string, id WindowID) {69 func wantFocus(t *testing.T, c *Core, screen string, id WindowID) {
56 t.Helper()70 t.Helper()
57 if got := c.cur().name(); got != screen {71 if got := c.cur().name(); got != screen {
@@ -102,10 +116,10 @@ func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
102 }116 }
103 117
104 // With no monitor on that side, left/right never cross into the neighbor118 // With no monitor on that side, left/right never cross into the neighbor
105-// band: at the band edge they step the workspace (the first has none before119+// band: at the band edge focus steps the workspace (the first has none
106-// it; the last is the trailing empty one). up/down cross bands, and120+// before it; the last is the trailing empty one) and move-column does
107-// move-column still crosses in column order.121+// nothing. up/down cross bands.
108-func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {122+func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveStays(t *testing.T) {
109 c, w := cascadeMovesCore(t, 5, 3, false)123 c, w := cascadeMovesCore(t, 5, 3, false)
110 m := c.cur().mon124 m := c.cur().mon
111 w.FocusID(4)125 w.FocusID(4)
@@ -122,12 +136,12 @@ func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {
122 m.Focus(0)136 m.Focus(0)
123 w.FocusID(4)137 w.FocusID(4)
124 c.applyAction(ActionMoveColumnLeft)138 c.applyAction(ActionMoveColumnLeft)
125- wantColumns(t, w, 1, 2, 4, 3, 5)139+ wantColumns(t, w, 1, 2, 3, 4, 5)
126 wantFocus(t, c, "M", 4)140 wantFocus(t, c, "M", 4)
127- w.FocusID(4)141+ w.FocusID(3)
128 c.applyAction(ActionMoveColumnRight)142 c.applyAction(ActionMoveColumnRight)
129 wantColumns(t, w, 1, 2, 3, 4, 5)143 wantColumns(t, w, 1, 2, 3, 4, 5)
130- wantFocus(t, c, "M", 4)144+ wantFocus(t, c, "M", 3)
131 }145 }
132 146
133 // With one column per band, every column is at both edges of its band.147 // With one column per band, every column is at both edges of its band.
@@ -150,92 +164,85 @@ func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) {
150 wantFocus(t, c, "M", 2)164 wantFocus(t, c, "M", 2)
151 }165 }
152 166
153-// At the top or bottom of its column, move-window-up/down moves a cascade167+// At the top or bottom of its column, move-window-up/down sends a cascade
154-// column to the adjacent band, where focus-window-up/down goes; bands168+// column to the adjacent band: it joins it when it has room, at its offset
155-// {1 2 3} {4 5}.169+// in the band, else it makes a new band between the two.
156 func TestCascadeMoveWindowCrossesBands(t *testing.T) {170 func TestCascadeMoveWindowCrossesBands(t *testing.T) {
157- c, w := cascadeMovesCore(t, 5, 3, false)171+ c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
172+ w.FocusID(2)
173+ c.applyAction(ActionMoveWindowDown)
174+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
175+ wantFocus(t, c, "M", 2)
176+ c.applyAction(ActionMoveWindowUp)
177+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5})
178+ wantFocus(t, c, "M", 2)
179+}
180+
181+// A full band cannot take the column: a new band goes between the two,
182+// and the one it left keeps the others.
183+func TestCascadeMoveWindowIntoFullBandMakesBand(t *testing.T) {
184+ c, w := cascadeMovesCore(t, 6, 3, false) // {1 2 3} {4 5 6}
158 w.FocusID(5)185 w.FocusID(5)
159 c.applyAction(ActionMoveWindowUp)186 c.applyAction(ActionMoveWindowUp)
160- wantColumns(t, w, 1, 5, 3, 4, 2)187+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5}, []WindowID{4, 6})
161 wantFocus(t, c, "M", 5)188 wantFocus(t, c, "M", 5)
162- c.applyAction(ActionMoveWindowDown)189+ // Alone in its band, it passes the full band above.
163- wantColumns(t, w, 1, 2, 3, 4, 5)190+ c.applyAction(ActionMoveWindowUp)
191+ wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3}, []WindowID{4, 6})
164 wantFocus(t, c, "M", 5)192 wantFocus(t, c, "M", 5)
165 }193 }
166 194
167-// The last band is partial: the move clamps to its last column, and going195+// Past the outer bands a column sharing its band starts a new one; a
168-// back up keeps the new offset, as focus does.196+// column alone there does not move.
169-func TestCascadeMoveWindowPartialBand(t *testing.T) {197+func TestCascadeMoveWindowOuterBands(t *testing.T) {
170- c, w := cascadeMovesCore(t, 5, 3, false)198+ c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
171- w.FocusID(3)199+ w.FocusID(5)
172 c.applyAction(ActionMoveWindowDown)200 c.applyAction(ActionMoveWindowDown)
173- wantColumns(t, w, 1, 2, 5, 4, 3)201+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5})
174- c.applyAction(ActionMoveWindowUp)202+ c.applyAction(ActionMoveWindowDown)
175- wantColumns(t, w, 1, 3, 5, 4, 2)203+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5})
176- wantFocus(t, c, "M", 3)
177-}
178-
179-// No band past the first or last: nothing moves.
180-func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) {
181- c, w := cascadeMovesCore(t, 5, 3, false)
182 w.FocusID(1)204 w.FocusID(1)
183 c.applyAction(ActionMoveWindowUp)205 c.applyAction(ActionMoveWindowUp)
206+ wantBands(t, w, []WindowID{1}, []WindowID{2, 3}, []WindowID{4}, []WindowID{5})
184 wantFocus(t, c, "M", 1)207 wantFocus(t, c, "M", 1)
185- w.FocusID(4)
186- c.applyAction(ActionMoveWindowDown)
187- wantColumns(t, w, 1, 2, 3, 4, 5)
188- wantFocus(t, c, "M", 4)
189 }208 }
190 209
191-// A maximized column keeps its band to itself when it moves: it swaps210+// A band left empty goes away.
192-// with the column of the band above and stays maximized.211+func TestCascadeMoveWindowDropsEmptyBand(t *testing.T) {
193-func TestCascadeMoveWindowKeepsMaximization(t *testing.T) {212+ c, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
194- c, w := cascadeMovesCore(t, 5, 3, false)213+ w.FocusID(2)
195- w.FocusID(5)214+ c.applyAction(ActionMoveWindowDown)
196- w.ToggleFullWidth() // Bands {1 2 3} {4} {5}.215+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2})
197- c.applyAction(ActionMoveWindowUp)216+ w.FocusID(4)
198- wantColumns(t, w, 1, 2, 3, 5, 4)217+ c.applyAction(ActionMoveWindowUp) // At its offset in the band: first.
199- wantFocus(t, c, "M", 5)218+ wantBands(t, w, []WindowID{4, 1, 3}, []WindowID{2})
200- if !w.Columns[3].FullWidth || w.band(3) != 1 || w.band(4) != 2 {
201- t.Fatalf("maximized %t, bands %d %d", w.Columns[3].FullWidth, w.band(3), w.band(4))
202- }
203 }219 }
204 220
205-// A column moving to or from a maximized column's band passes the whole221+// A maximized column keeps its band to itself: moving it, or moving a
206-// adjacent band. Bands follow column order, so a maximized column leaving222+// column toward it, makes a new band; repeating the move passes it.
207-// lets the bands around it pack together: {1} and {3 4} join.223+func TestCascadeMoveWindowAndMaximizedBand(t *testing.T) {
208-// Repeating the move never leaves the column stuck.
209-func TestCascadeMoveWindowAcrossMaximizedBand(t *testing.T) {
210 for _, tc := range []struct {224 for _, tc := range []struct {
211 name string225 name string
226+ n int
212 max, focus WindowID227 max, focus WindowID
213 action Action228 action Action
214- want []WindowID229+ want [][]WindowID
215- band int
216 }{230 }{
217- // Bands {1} {2*} {3 4}.231+ // After maximizing 2: {1} {2*} {3 4}.
218- {"maximized down", 2, 2, ActionMoveWindowDown, []WindowID{1, 3, 4, 2}, 1},232+ {"maximized down", 4, 2, 2, ActionMoveWindowDown, [][]WindowID{{1}, {3, 4}, {2}}},
219- {"maximized up", 2, 2, ActionMoveWindowUp, []WindowID{2, 1, 3, 4}, 0},233+ {"maximized up", 4, 2, 2, ActionMoveWindowUp, [][]WindowID{{2}, {1}, {3, 4}}},
220- // Bands {1 2 3} {4*} {5}.234+ // After maximizing 4: {1 2 3} {4*} {5}.
221- {"into maximized below", 4, 3, ActionMoveWindowDown, []WindowID{1, 2, 4, 3, 5}, 2},235+ {"into maximized below", 5, 4, 3, ActionMoveWindowDown, [][]WindowID{{1, 2}, {3}, {4}, {5}}},
222- {"into maximized above", 4, 5, ActionMoveWindowUp, []WindowID{1, 2, 3, 5, 4}, 1},236+ {"into maximized above", 5, 4, 5, ActionMoveWindowUp, [][]WindowID{{1, 2, 3}, {5}, {4}}},
223 } {237 } {
224 t.Run(tc.name, func(t *testing.T) {238 t.Run(tc.name, func(t *testing.T) {
225- n := 5239+ c, w := cascadeMovesCore(t, tc.n, 3, false)
226- if tc.max == 2 {
227- n = 4
228- }
229- c, w := cascadeMovesCore(t, n, 3, false)
230 w.FocusID(tc.max)240 w.FocusID(tc.max)
231 w.ToggleFullWidth()241 w.ToggleFullWidth()
232 w.FocusID(tc.focus)242 w.FocusID(tc.focus)
233 c.applyAction(tc.action)243 c.applyAction(tc.action)
234- wantColumns(t, w, tc.want...)244+ wantBands(t, w, tc.want...)
235 wantFocus(t, c, "M", tc.focus)245 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 {246 if !w.Columns[w.columnOf(tc.max)].FullWidth {
240 t.Fatal("maximization lost")247 t.Fatal("maximization lost")
241 }248 }
@@ -287,7 +294,8 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) {
287 t.Fatalf("stack %v, want [5 4]", got)294 t.Fatalf("stack %v, want [5 4]", got)
288 }295 }
289 c.applyAction(ActionMoveWindowUp)296 c.applyAction(ActionMoveWindowUp)
290- wantColumns(t, w, 5, 2, 3, 1)297+ // The full band above cannot take it, so it passes it.
298+ wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3})
291 wantFocus(t, c, "M", 5)299 wantFocus(t, c, "M", 5)
292 }300 }
293 301
M internal/core/cascade_test.go +2 -2
@@ -128,10 +128,10 @@ func TestCascadeCompactionAndNavigation(t *testing.T) {
128128 if w.View != 600 {
129129 t.Fatalf("changed max view=%d", w.View)
130130 }
131- w.FocusID(2)
131+ w.FocusID(7)
132132 w.AddWindow(8)
133133 if w.Columns[len(w.Columns)-1].Windows[0] != 8 {
134- t.Fatal("new column did not append")
134+ t.Fatal("new column did not join the focused last band")
135135 }
136136 w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580})
137137 if w.View != w.band(w.Focus)*580 {
@@ -128,10 +128,10 @@ func TestCascadeCompactionAndNavigation(t *testing.T) {
128 if w.View != 600 {128 if w.View != 600 {
129 t.Fatalf("changed max view=%d", w.View)129 t.Fatalf("changed max view=%d", w.View)
130 }130 }
131- w.FocusID(2)131+ w.FocusID(7)
132 w.AddWindow(8)132 w.AddWindow(8)
133 if w.Columns[len(w.Columns)-1].Windows[0] != 8 {133 if w.Columns[len(w.Columns)-1].Windows[0] != 8 {
134- t.Fatal("new column did not append")134+ t.Fatal("new column did not join the focused last band")
135 }135 }
136 w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580})136 w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580})
137 if w.View != w.band(w.Focus)*580 {137 if w.View != w.band(w.Focus)*580 {
M internal/core/column_cells_test.go +3 -5
@@ -117,8 +117,6 @@ func TestGrowColumnNoop(t *testing.T) {
117117 }{
118118 {"cascade lone", OverflowCascade, 4, 1},
119119 {"fixed lone", OverflowFixed, 4, 1},
120- {"cascade full band", OverflowCascade, 4, 4},
121- {"cascade full bands", OverflowCascade, 3, 6},
122120 {"fixed full row", OverflowFixed, 4, 4},
123121 {"fixed spiral", OverflowFixed, 2, 4},
124122 {"max-columns 1", OverflowCascade, 1, 3},
@@ -287,10 +285,10 @@ func TestGrowColumnEdgeCases(t *testing.T) {
287285 if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 0, 0}) {
288286 t.Fatalf("cells %v", got)
289287 }
290- w.FocusID(1) // a full band
288+ w.FocusID(1) // A full band pushes a neighbor down (cascade_cycle_test).
291289 w.Apply(ActionCycleColumnWidth)
292- if got := cellsOf(w); !reflect.DeepEqual(got, make([]int, 6)) {
293- t.Fatalf("cells %v", got)
290+ if got := cellsOf(w)[4:]; !reflect.DeepEqual(got, []int{0, 0}) {
291+ t.Fatalf("other band changed: cells %v", cellsOf(w))
294292 }
295293 })
296294 }
@@ -117,8 +117,6 @@ func TestGrowColumnNoop(t *testing.T) {
117 }{117 }{
118 {"cascade lone", OverflowCascade, 4, 1},118 {"cascade lone", OverflowCascade, 4, 1},
119 {"fixed lone", OverflowFixed, 4, 1},119 {"fixed lone", OverflowFixed, 4, 1},
120- {"cascade full band", OverflowCascade, 4, 4},
121- {"cascade full bands", OverflowCascade, 3, 6},
122 {"fixed full row", OverflowFixed, 4, 4},120 {"fixed full row", OverflowFixed, 4, 4},
123 {"fixed spiral", OverflowFixed, 2, 4},121 {"fixed spiral", OverflowFixed, 2, 4},
124 {"max-columns 1", OverflowCascade, 1, 3},122 {"max-columns 1", OverflowCascade, 1, 3},
@@ -287,10 +285,10 @@ func TestGrowColumnEdgeCases(t *testing.T) {
287 if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 0, 0}) {285 if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 0, 0}) {
288 t.Fatalf("cells %v", got)286 t.Fatalf("cells %v", got)
289 }287 }
290- w.FocusID(1) // a full band288+ w.FocusID(1) // A full band pushes a neighbor down (cascade_cycle_test).
291 w.Apply(ActionCycleColumnWidth)289 w.Apply(ActionCycleColumnWidth)
292- if got := cellsOf(w); !reflect.DeepEqual(got, make([]int, 6)) {290+ if got := cellsOf(w)[4:]; !reflect.DeepEqual(got, []int{0, 0}) {
293- t.Fatalf("cells %v", got)291+ t.Fatalf("other band changed: cells %v", cellsOf(w))
294 }292 }
295 })293 })
296 }294 }
M internal/core/drag.go +2 -0
@@ -461,6 +461,8 @@ func (c *Core) drop(id WindowID, t dropTarget) {
461461 to := w.columnOf(t.anchor)
462462 // Their fields move with them, as MoveColumn.
463463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
464+ // Band starts are positional: the flag stays at its index.
465+ w.Columns[ci].BandStart, w.Columns[to].BandStart = w.Columns[to].BandStart, w.Columns[ci].BandStart
464466 w.FocusID(id)
465467 } else {
466468 cells := 0
@@ -461,6 +461,8 @@ func (c *Core) drop(id WindowID, t dropTarget) {
461 to := w.columnOf(t.anchor)461 to := w.columnOf(t.anchor)
462 // Their fields move with them, as MoveColumn.462 // Their fields move with them, as MoveColumn.
463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
464+ // Band starts are positional: the flag stays at its index.
465+ w.Columns[ci].BandStart, w.Columns[to].BandStart = w.Columns[to].BandStart, w.Columns[ci].BandStart
464 w.FocusID(id)466 w.FocusID(id)
465 } else {467 } else {
466 cells := 0468 cells := 0
M internal/core/layout.go +82 -9
@@ -30,6 +30,13 @@ type Column struct {
3030 // Windows order. They apply only while there is one per window and
3131 // they sum to 100; otherwise the rows share the height equally.
3232 Shares []int
33+ // BandStart marks the first column of a cascade band. It belongs to the
34+ // position, not the column: swaps leave it in place. See cascade.go.
35+ BandStart bool
36+ // pushedUp and pushedDown are the columns, each by its windows, that
37+ // maximize-column or cycle-column-width sent to the band above or
38+ // below; they come back when the column is restored.
39+ pushedUp, pushedDown [][]WindowID
3340 }
3441 type Placement struct {
3542 ID WindowID
@@ -534,7 +541,12 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
534541 // addColumn places a new column like a new window and focuses it.
535542 func (w *Workspace) addColumn(col Column) {
536543 at := 0
537- if w.policy().equalCells {
544+ col.BandStart = false
545+ if w.policy().wraps {
546+ // After the focused band: it joins it when it has room, else it
547+ // starts a band of its own below it.
548+ at, col.BandStart = w.spawnIndex()
549+ } else if w.policy().equalCells {
538550 // Ordered overflow depends only on window order: new windows go last.
539551 at = len(w.Columns)
540552 } else if len(w.Columns) > 0 {
@@ -564,7 +576,7 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
564576 at = i + 1
565577 }
566578 }
567- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
579+ w.insertAt(at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
568580 if len(w.Columns) > 1 && at <= w.Focus {
569581 w.Focus++
570582 }
@@ -642,7 +654,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
642654 c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
643655 c.Shares = nil
644656 if len(c.Windows) == 0 {
645- w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
657+ w.deleteColumn(i)
646658 if len(w.Columns) == 0 {
647659 w.Focus = 0
648660 w.View = 0
@@ -867,8 +879,13 @@ func (w *Workspace) MoveColumn(dir int) {
867879 return
868880 }
869881 if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
882+ if w.policy().wraps && w.leavesBand(w.Focus+dir) {
883+ return // a cascade column never crosses into the adjacent band
884+ }
870885 i := w.Focus
871886 w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]
887+ // Band starts are positional: the flag stays at its index.
888+ w.Columns[i].BandStart, w.Columns[i+dir].BandStart = w.Columns[i+dir].BandStart, w.Columns[i].BandStart
872889 w.Focus += dir
873890 w.scroll()
874891 }
@@ -990,7 +1007,8 @@ func (w *Workspace) takeColumn() (Column, bool) {
9901007 }
9911008 // Its windows leave this workspace's recent use with it.
9921009 w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) })
993- w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
1010+ col.BandStart = false
1011+ w.deleteColumn(w.Focus)
9941012 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
9951013 if len(w.Columns) == 0 {
9961014 w.View = 0
@@ -1006,11 +1024,21 @@ func (w *Workspace) insertColumn(at int, col Column) {
10061024 if w.policy().maximizeHides() && len(w.Columns) > 0 {
10071025 w.unmaximize()
10081026 }
1009- w.Columns = slices.Insert(w.Columns, at, col)
1027+ w.insertAt(at, col)
10101028 w.Focus = at
10111029 w.scroll()
10121030 }
10131031
1032+// insertAt inserts col at index at; in a cascade it joins the band of the
1033+// column it goes before.
1034+func (w *Workspace) insertAt(at int, col Column) {
1035+ col.BandStart = false
1036+ if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
1037+ col.BandStart, w.Columns[at].BandStart = true, false
1038+ }
1039+ w.Columns = slices.Insert(w.Columns, at, col)
1040+}
1041+
10141042 // GrowColumn widens the focused column by one cell, taking it from another
10151043 // column of its lane, or from the empty space in scroll overflow. A lane
10161044 // that cannot grow further goes back to equal shares when cycle is set
@@ -1021,8 +1049,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
10211049 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
10221050 return
10231051 }
1024- w.unmaximize()
10251052 defer w.scroll()
1053+ if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1054+ w.unmaximizeBand()
1055+ }
1056+ w.unmaximize()
10261057 k := max(w.MaxColumns, 1)
10271058 if w.policy().equalCells {
10281059 if !w.policy().wraps && !w.laneRow() {
@@ -1030,7 +1061,20 @@ func (w *Workspace) GrowColumn(cycle bool) {
10301061 }
10311062 first, last := w.laneBounds(w.laneOf(w.Focus))
10321063 n := last - first + 1
1064+ pushed := false
1065+ if w.policy().wraps && cycle && n > 1 && n >= k && !w.unevenLane(first, last) {
1066+ // A full band makes room: a neighbor goes to the adjacent band.
1067+ w.pushNeighbor()
1068+ first, last = w.laneBounds(w.laneOf(w.Focus))
1069+ n, pushed = last-first+1, true
1070+ }
10331071 if n == 1 || n >= k {
1072+ if w.policy().wraps && cycle && !pushed && w.restoreBand() {
1073+ // End of the cycle: the pushed columns came back.
1074+ first, last = w.laneBounds(w.laneOf(w.Focus))
1075+ w.setLaneCells(first, make([]int, last-first+1))
1076+ return
1077+ }
10341078 // Nothing to grow, but a lane left uneven (a column joined
10351079 // it, or max-columns shrank) still cycles back to equal.
10361080 if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) {
@@ -1048,6 +1092,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
10481092 if d := w.donor(first, last, cells); d >= 0 {
10491093 w.moveCell(first, cells, d, w.Focus)
10501094 } else if cycle {
1095+ if w.policy().wraps && w.restoreBand() {
1096+ // End of the cycle: the pushed columns came back.
1097+ first, last = w.laneBounds(w.laneOf(w.Focus))
1098+ n = last - first + 1
1099+ }
10511100 w.setLaneCells(first, make([]int, n))
10521101 }
10531102 return
@@ -1069,8 +1118,11 @@ func (w *Workspace) ShrinkColumn() {
10691118 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
10701119 return
10711120 }
1072- w.unmaximize()
10731121 defer w.scroll()
1122+ if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1123+ w.unmaximizeBand()
1124+ }
1125+ w.unmaximize()
10741126 k := max(w.MaxColumns, 1)
10751127 if w.policy().equalCells {
10761128 if !w.policy().wraps && !w.laneRow() {
@@ -1138,6 +1190,12 @@ func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
11381190 return cells, false
11391191 }
11401192
1193+// unevenLane reports whether a column of the lane first..last has its
1194+// own width.
1195+func (w *Workspace) unevenLane(first, last int) bool {
1196+ return slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 })
1197+}
1198+
11411199 // setLaneCells stores cells on the lane's columns, from column first.
11421200 func (w *Workspace) setLaneCells(first int, cells []int) {
11431201 for i, c := range cells {
@@ -1224,14 +1282,28 @@ func (w *Workspace) ToggleFullWidth() {
12241282 if len(w.Columns) == 0 || w.onFloat() {
12251283 return
12261284 }
1227- if w.Columns[w.Focus].FullWidth {
1285+ switch {
1286+ case w.policy().wraps && w.Columns[w.Focus].FullWidth:
1287+ w.unmaximizeBand()
1288+ case w.policy().wraps:
1289+ // A cascade column takes a band of its own; the others stay seen.
1290+ w.isolateBand()
1291+ w.maximize(w.Focus)
1292+ case w.Columns[w.Focus].FullWidth:
12281293 w.unmaximize()
1229- } else {
1294+ default:
12301295 w.maximize(w.Focus)
12311296 }
12321297 w.scroll()
12331298 }
12341299
1300+// unmaximizeBand unmaximizes the focused cascade column and brings back
1301+// the columns its maximization sent to the adjacent bands.
1302+func (w *Workspace) unmaximizeBand() {
1303+ w.Columns[w.Focus].FullWidth = false
1304+ w.restoreBand()
1305+}
1306+
12351307 func (w *Workspace) ToggleFullscreen() {
12361308 id, ok := w.Focused()
12371309 if !ok {
@@ -1612,6 +1684,7 @@ func (w *Workspace) scroll() {
16121684 before := w.View
16131685 defer func() { w.retarget(before) }()
16141686 if w.policy().wraps {
1687+ w.markBands()
16151688 w.View = w.band(w.Focus) * w.Usable.H
16161689 return
16171690 }
@@ -30,6 +30,13 @@ type Column struct {
30 // Windows order. They apply only while there is one per window and30 // Windows order. They apply only while there is one per window and
31 // they sum to 100; otherwise the rows share the height equally.31 // they sum to 100; otherwise the rows share the height equally.
32 Shares []int32 Shares []int
33+ // BandStart marks the first column of a cascade band. It belongs to the
34+ // position, not the column: swaps leave it in place. See cascade.go.
35+ BandStart bool
36+ // pushedUp and pushedDown are the columns, each by its windows, that
37+ // maximize-column or cycle-column-width sent to the band above or
38+ // below; they come back when the column is restored.
39+ pushedUp, pushedDown [][]WindowID
33 }40 }
34 type Placement struct {41 type Placement struct {
35 ID WindowID42 ID WindowID
@@ -534,7 +541,12 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
534 // addColumn places a new column like a new window and focuses it.541 // addColumn places a new column like a new window and focuses it.
535 func (w *Workspace) addColumn(col Column) {542 func (w *Workspace) addColumn(col Column) {
536 at := 0543 at := 0
537- if w.policy().equalCells {544+ col.BandStart = false
545+ if w.policy().wraps {
546+ // After the focused band: it joins it when it has room, else it
547+ // starts a band of its own below it.
548+ at, col.BandStart = w.spawnIndex()
549+ } else if w.policy().equalCells {
538 // Ordered overflow depends only on window order: new windows go last.550 // Ordered overflow depends only on window order: new windows go last.
539 at = len(w.Columns)551 at = len(w.Columns)
540 } else if len(w.Columns) > 0 {552 } else if len(w.Columns) > 0 {
@@ -564,7 +576,7 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
564 at = i + 1576 at = i + 1
565 }577 }
566 }578 }
567- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})579+ w.insertAt(at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
568 if len(w.Columns) > 1 && at <= w.Focus {580 if len(w.Columns) > 1 && at <= w.Focus {
569 w.Focus++581 w.Focus++
570 }582 }
@@ -642,7 +654,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
642 c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)654 c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
643 c.Shares = nil655 c.Shares = nil
644 if len(c.Windows) == 0 {656 if len(c.Windows) == 0 {
645- w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)657+ w.deleteColumn(i)
646 if len(w.Columns) == 0 {658 if len(w.Columns) == 0 {
647 w.Focus = 0659 w.Focus = 0
648 w.View = 0660 w.View = 0
@@ -867,8 +879,13 @@ func (w *Workspace) MoveColumn(dir int) {
867 return879 return
868 }880 }
869 if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {881 if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
882+ if w.policy().wraps && w.leavesBand(w.Focus+dir) {
883+ return // a cascade column never crosses into the adjacent band
884+ }
870 i := w.Focus885 i := w.Focus
871 w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]886 w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]
887+ // Band starts are positional: the flag stays at its index.
888+ w.Columns[i].BandStart, w.Columns[i+dir].BandStart = w.Columns[i+dir].BandStart, w.Columns[i].BandStart
872 w.Focus += dir889 w.Focus += dir
873 w.scroll()890 w.scroll()
874 }891 }
@@ -990,7 +1007,8 @@ func (w *Workspace) takeColumn() (Column, bool) {
990 }1007 }
991 // Its windows leave this workspace's recent use with it.1008 // Its windows leave this workspace's recent use with it.
992 w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) })1009 w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) })
993- w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)1010+ col.BandStart = false
1011+ w.deleteColumn(w.Focus)
994 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))1012 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
995 if len(w.Columns) == 0 {1013 if len(w.Columns) == 0 {
996 w.View = 01014 w.View = 0
@@ -1006,11 +1024,21 @@ func (w *Workspace) insertColumn(at int, col Column) {
1006 if w.policy().maximizeHides() && len(w.Columns) > 0 {1024 if w.policy().maximizeHides() && len(w.Columns) > 0 {
1007 w.unmaximize()1025 w.unmaximize()
1008 }1026 }
1009- w.Columns = slices.Insert(w.Columns, at, col)1027+ w.insertAt(at, col)
1010 w.Focus = at1028 w.Focus = at
1011 w.scroll()1029 w.scroll()
1012 }1030 }
1013 1031
1032+// insertAt inserts col at index at; in a cascade it joins the band of the
1033+// column it goes before.
1034+func (w *Workspace) insertAt(at int, col Column) {
1035+ col.BandStart = false
1036+ if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
1037+ col.BandStart, w.Columns[at].BandStart = true, false
1038+ }
1039+ w.Columns = slices.Insert(w.Columns, at, col)
1040+}
1041+
1014 // GrowColumn widens the focused column by one cell, taking it from another1042 // GrowColumn widens the focused column by one cell, taking it from another
1015 // column of its lane, or from the empty space in scroll overflow. A lane1043 // column of its lane, or from the empty space in scroll overflow. A lane
1016 // that cannot grow further goes back to equal shares when cycle is set1044 // that cannot grow further goes back to equal shares when cycle is set
@@ -1021,8 +1049,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
1021 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {1049 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1022 return1050 return
1023 }1051 }
1024- w.unmaximize()
1025 defer w.scroll()1052 defer w.scroll()
1053+ if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1054+ w.unmaximizeBand()
1055+ }
1056+ w.unmaximize()
1026 k := max(w.MaxColumns, 1)1057 k := max(w.MaxColumns, 1)
1027 if w.policy().equalCells {1058 if w.policy().equalCells {
1028 if !w.policy().wraps && !w.laneRow() {1059 if !w.policy().wraps && !w.laneRow() {
@@ -1030,7 +1061,20 @@ func (w *Workspace) GrowColumn(cycle bool) {
1030 }1061 }
1031 first, last := w.laneBounds(w.laneOf(w.Focus))1062 first, last := w.laneBounds(w.laneOf(w.Focus))
1032 n := last - first + 11063 n := last - first + 1
1064+ pushed := false
1065+ if w.policy().wraps && cycle && n > 1 && n >= k && !w.unevenLane(first, last) {
1066+ // A full band makes room: a neighbor goes to the adjacent band.
1067+ w.pushNeighbor()
1068+ first, last = w.laneBounds(w.laneOf(w.Focus))
1069+ n, pushed = last-first+1, true
1070+ }
1033 if n == 1 || n >= k {1071 if n == 1 || n >= k {
1072+ if w.policy().wraps && cycle && !pushed && w.restoreBand() {
1073+ // End of the cycle: the pushed columns came back.
1074+ first, last = w.laneBounds(w.laneOf(w.Focus))
1075+ w.setLaneCells(first, make([]int, last-first+1))
1076+ return
1077+ }
1034 // Nothing to grow, but a lane left uneven (a column joined1078 // Nothing to grow, but a lane left uneven (a column joined
1035 // it, or max-columns shrank) still cycles back to equal.1079 // it, or max-columns shrank) still cycles back to equal.
1036 if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) {1080 if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) {
@@ -1048,6 +1092,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
1048 if d := w.donor(first, last, cells); d >= 0 {1092 if d := w.donor(first, last, cells); d >= 0 {
1049 w.moveCell(first, cells, d, w.Focus)1093 w.moveCell(first, cells, d, w.Focus)
1050 } else if cycle {1094 } else if cycle {
1095+ if w.policy().wraps && w.restoreBand() {
1096+ // End of the cycle: the pushed columns came back.
1097+ first, last = w.laneBounds(w.laneOf(w.Focus))
1098+ n = last - first + 1
1099+ }
1051 w.setLaneCells(first, make([]int, n))1100 w.setLaneCells(first, make([]int, n))
1052 }1101 }
1053 return1102 return
@@ -1069,8 +1118,11 @@ func (w *Workspace) ShrinkColumn() {
1069 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {1118 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1070 return1119 return
1071 }1120 }
1072- w.unmaximize()
1073 defer w.scroll()1121 defer w.scroll()
1122+ if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1123+ w.unmaximizeBand()
1124+ }
1125+ w.unmaximize()
1074 k := max(w.MaxColumns, 1)1126 k := max(w.MaxColumns, 1)
1075 if w.policy().equalCells {1127 if w.policy().equalCells {
1076 if !w.policy().wraps && !w.laneRow() {1128 if !w.policy().wraps && !w.laneRow() {
@@ -1138,6 +1190,12 @@ func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
1138 return cells, false1190 return cells, false
1139 }1191 }
1140 1192
1193+// unevenLane reports whether a column of the lane first..last has its
1194+// own width.
1195+func (w *Workspace) unevenLane(first, last int) bool {
1196+ return slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 })
1197+}
1198+
1141 // setLaneCells stores cells on the lane's columns, from column first.1199 // setLaneCells stores cells on the lane's columns, from column first.
1142 func (w *Workspace) setLaneCells(first int, cells []int) {1200 func (w *Workspace) setLaneCells(first int, cells []int) {
1143 for i, c := range cells {1201 for i, c := range cells {
@@ -1224,14 +1282,28 @@ func (w *Workspace) ToggleFullWidth() {
1224 if len(w.Columns) == 0 || w.onFloat() {1282 if len(w.Columns) == 0 || w.onFloat() {
1225 return1283 return
1226 }1284 }
1227- if w.Columns[w.Focus].FullWidth {1285+ switch {
1286+ case w.policy().wraps && w.Columns[w.Focus].FullWidth:
1287+ w.unmaximizeBand()
1288+ case w.policy().wraps:
1289+ // A cascade column takes a band of its own; the others stay seen.
1290+ w.isolateBand()
1291+ w.maximize(w.Focus)
1292+ case w.Columns[w.Focus].FullWidth:
1228 w.unmaximize()1293 w.unmaximize()
1229- } else {1294+ default:
1230 w.maximize(w.Focus)1295 w.maximize(w.Focus)
1231 }1296 }
1232 w.scroll()1297 w.scroll()
1233 }1298 }
1234 1299
1300+// unmaximizeBand unmaximizes the focused cascade column and brings back
1301+// the columns its maximization sent to the adjacent bands.
1302+func (w *Workspace) unmaximizeBand() {
1303+ w.Columns[w.Focus].FullWidth = false
1304+ w.restoreBand()
1305+}
1306+
1235 func (w *Workspace) ToggleFullscreen() {1307 func (w *Workspace) ToggleFullscreen() {
1236 id, ok := w.Focused()1308 id, ok := w.Focused()
1237 if !ok {1309 if !ok {
@@ -1612,6 +1684,7 @@ func (w *Workspace) scroll() {
1612 before := w.View1684 before := w.View
1613 defer func() { w.retarget(before) }()1685 defer func() { w.retarget(before) }()
1614 if w.policy().wraps {1686 if w.policy().wraps {
1687+ w.markBands()
1615 w.View = w.band(w.Focus) * w.Usable.H1688 w.View = w.band(w.Focus) * w.Usable.H
1616 return1689 return
1617 }1690 }
M internal/core/monitor.go +9 -2
@@ -575,16 +575,23 @@ func (m *Monitor) applyNamed() {
575575 if before != w.Overflow && w.policy().equalCells {
576576 w.clearMaximized()
577577 }
578+ if before != w.Overflow {
579+ // Cascade bands start over from the column order.
580+ w.forgetBands()
581+ }
578582 // The view offset belongs to the old content axis: SetMaxColumns
579583 // scrolls back to the focus along the new one.
580584 if w.policy().content != axis {
581585 w.view.stop()
582586 w.View = 0
583587 }
584- w.SetMaxColumns(m.template.MaxColumns)
588+ // One call with the effective value: a passing smaller one would
589+ // split cascade bands for good.
590+ n := m.template.MaxColumns
585591 if s.MaxColumns > 0 {
586- w.SetMaxColumns(s.MaxColumns)
592+ n = s.MaxColumns
587593 }
594+ w.SetMaxColumns(n)
588595 // Named workspaces take their configured size; any other keeps its own.
589596 if w.Name != "" {
590597 w.SetSize(s.Size[0], s.Size[1])
@@ -575,16 +575,23 @@ func (m *Monitor) applyNamed() {
575 if before != w.Overflow && w.policy().equalCells {575 if before != w.Overflow && w.policy().equalCells {
576 w.clearMaximized()576 w.clearMaximized()
577 }577 }
578+ if before != w.Overflow {
579+ // Cascade bands start over from the column order.
580+ w.forgetBands()
581+ }
578 // The view offset belongs to the old content axis: SetMaxColumns582 // The view offset belongs to the old content axis: SetMaxColumns
579 // scrolls back to the focus along the new one.583 // scrolls back to the focus along the new one.
580 if w.policy().content != axis {584 if w.policy().content != axis {
581 w.view.stop()585 w.view.stop()
582 w.View = 0586 w.View = 0
583 }587 }
584- w.SetMaxColumns(m.template.MaxColumns)588+ // One call with the effective value: a passing smaller one would
589+ // split cascade bands for good.
590+ n := m.template.MaxColumns
585 if s.MaxColumns > 0 {591 if s.MaxColumns > 0 {
586- w.SetMaxColumns(s.MaxColumns)592+ n = s.MaxColumns
587 }593 }
594+ w.SetMaxColumns(n)
588 // Named workspaces take their configured size; any other keeps its own.595 // Named workspaces take their configured size; any other keeps its own.
589 if w.Name != "" {596 if w.Name != "" {
590 w.SetSize(s.Size[0], s.Size[1])597 w.SetSize(s.Size[0], s.Size[1])
M internal/core/overview_cascade_test.go +1 -0
@@ -121,6 +121,7 @@ func TestOverviewCascadeOtherLanes(t *testing.T) {
121121 // Five bands (13 columns): the ones two lanes away are off the area.
122122 m = cascadeOverview()
123123 w := m.Current()
124+ w.FocusID(10) // New windows join the focused band, the last one.
124125 for id := WindowID(11); id <= 16; id++ {
125126 w.AddWindow(id)
126127 }
@@ -121,6 +121,7 @@ func TestOverviewCascadeOtherLanes(t *testing.T) {
121 // Five bands (13 columns): the ones two lanes away are off the area.121 // Five bands (13 columns): the ones two lanes away are off the area.
122 m = cascadeOverview()122 m = cascadeOverview()
123 w := m.Current()123 w := m.Current()
124+ w.FocusID(10) // New windows join the focused band, the last one.
124 for id := WindowID(11); id <= 16; id++ {125 for id := WindowID(11); id <= 16; id++ {
125 w.AddWindow(id)126 w.AddWindow(id)
126 }127 }
M internal/core/rules.go +12 -2
@@ -189,11 +189,21 @@ func (w *Workspace) addColumnQuiet(col Column) {
189189 w.Columns = slices.Insert(w.Columns, at, col)
190190 return
191191 }
192- if w.policy().equalCells {
192+ switch {
193+ case w.policy().wraps:
194+ // As addColumn: after the focused band, in it when it has room.
195+ at, col.BandStart = w.spawnIndex()
196+ w.Columns = slices.Insert(w.Columns, at, col)
197+ if len(w.Columns) > 1 && at <= w.Focus {
198+ w.Focus++
199+ }
200+ w.scroll()
201+ return
202+ case w.policy().equalCells:
193203 if at > 0 && w.policy().maximizeHides() {
194204 w.unmaximize()
195205 }
196- } else if at > 0 {
206+ case at > 0:
197207 at = w.Focus + 1
198208 }
199209 w.Columns = slices.Insert(w.Columns, at, col)
@@ -189,11 +189,21 @@ func (w *Workspace) addColumnQuiet(col Column) {
189 w.Columns = slices.Insert(w.Columns, at, col)189 w.Columns = slices.Insert(w.Columns, at, col)
190 return190 return
191 }191 }
192- if w.policy().equalCells {192+ switch {
193+ case w.policy().wraps:
194+ // As addColumn: after the focused band, in it when it has room.
195+ at, col.BandStart = w.spawnIndex()
196+ w.Columns = slices.Insert(w.Columns, at, col)
197+ if len(w.Columns) > 1 && at <= w.Focus {
198+ w.Focus++
199+ }
200+ w.scroll()
201+ return
202+ case w.policy().equalCells:
193 if at > 0 && w.policy().maximizeHides() {203 if at > 0 && w.policy().maximizeHides() {
194 w.unmaximize()204 w.unmaximize()
195 }205 }
196- } else if at > 0 {206+ case at > 0:
197 at = w.Focus + 1207 at = w.Focus + 1
198 }208 }
199 w.Columns = slices.Insert(w.Columns, at, col)209 w.Columns = slices.Insert(w.Columns, at, col)
M internal/core/workspace_axis_test.go +23 -40
@@ -50,30 +50,23 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
5050 }
5151 }
5252
53-// An occupied workspace wins over a monitor on that side, so the opposite
54-// key brings focus back; the monitor wins over the trailing empty
55-// workspace, so it stays reachable.
56-func TestCascadeFocusColumnStepsWorkspaceBeforeMonitor(t *testing.T) {
53+// A monitor on that side wins over an occupied workspace, so the opposite
54+// key brings focus back; the workspace is reached only with no monitor.
55+func TestCascadeFocusColumnGoesToMonitorBeforeWorkspace(t *testing.T) {
5756 c, m := axisCore(t, true, OverflowCascade, 3)
5857 m.Current().FocusID(23)
5958 c.applyAction(ActionFocusColumnRight)
60- wantActive(t, m, 2)
61- if got := c.cur().name(); got != "M" {
62- t.Fatalf("focused screen %q, want M", got)
63- }
64- // Back with the opposite key, from the first column of workspace 3.
65- m.Current().FocusID(31)
59+ wantFocus(t, c, "R", 0)
60+ wantActive(t, m, 1)
61+ // Back with the opposite key.
6662 c.applyAction(ActionFocusColumnLeft)
63+ wantFocus(t, c, "M", 23)
6764 wantActive(t, m, 1)
68- if got := c.cur().name(); got != "M" {
69- t.Fatalf("focused screen %q, want M", got)
70- }
71- // Past the first workspace: the monitor on the left.
72- m.Focus(0)
73- m.Current().FocusID(11)
65+ // The first column of a band: the monitor on the left.
66+ m.Current().FocusID(24)
7467 c.applyAction(ActionFocusColumnLeft)
7568 wantFocus(t, c, "L", 0)
76- wantActive(t, m, 0)
69+ wantActive(t, m, 1)
7770 // From the last occupied workspace: the monitor on the right, not the
7871 // trailing empty workspace.
7972 c.focusScreen = 1
@@ -99,10 +92,9 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T)
9992 wantActive(t, m, 2)
10093 }
10194
102-// The live regression: one monitor on the left only. Right steps to the
103-// next workspace, and left from its empty band comes back to the previous
104-// workspace instead of leaving for that monitor.
105-func TestCascadeStepRightThenLeftComesBack(t *testing.T) {
95+// One monitor on the left only. Right steps to the next workspace; left
96+// from its empty band leaves for that monitor, which comes first.
97+func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) {
10698 c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})
10799 m := c.cur().mon
108100 m.SetOverflow(OverflowCascade)
@@ -112,8 +104,8 @@ func TestCascadeStepRightThenLeftComesBack(t *testing.T) {
112104 c.applyAction(ActionFocusColumnRight)
113105 wantActive(t, m, 1)
114106 c.applyAction(ActionFocusColumnLeft)
115- wantFocus(t, c, "M", 5)
116- wantActive(t, m, 0)
107+ wantFocus(t, c, "L", 0)
108+ wantActive(t, m, 1)
117109 }
118110
119111 // AC3: up/down cross bands and stop at the last, with no workspace change.
@@ -196,34 +188,25 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
196188 }
197189 }
198190
199-// A covering fullscreen tile follows the same order: the occupied workspace
200-// before the monitor on that side, which comes back only past the last one.
201-func TestCascadeFullscreenStepsWorkspaceBeforeMonitor(t *testing.T) {
191+// A covering fullscreen tile follows the same order: the monitor on that
192+// side before the occupied workspace.
193+func TestCascadeFullscreenGoesToMonitorBeforeWorkspace(t *testing.T) {
202194 c, m := axisCore(t, true, OverflowCascade, 3)
203195 w := m.Current()
204196 w.FocusID(23)
205197 w.ToggleFullscreen()
206198 c.applyAction(ActionFocusColumnRight)
207- wantActive(t, m, 2)
208- if got := c.cur().name(); got != "M" {
209- t.Fatalf("focused screen %q, want M", got)
210- }
199+ wantFocus(t, c, "R", 0)
200+ wantActive(t, m, 1)
211201 if w.fullscreen != 23 {
212202 t.Fatalf("fullscreen %d, want 23", w.fullscreen)
213203 }
214- last := m.Current()
215- last.FocusID(35)
216- last.ToggleFullscreen()
217- c.applyAction(ActionFocusColumnRight)
218- wantFocus(t, c, "R", 0)
219- if last.fullscreen != 35 {
220- t.Fatalf("fullscreen %d, want 35", last.fullscreen)
221- }
222204 }
223205
224-// A workspace holding only a float is occupied: it comes before the monitor.
206+// A workspace holding only a float is occupied: with no monitor on that
207+// side, it comes before the trailing empty one.
225208 func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) {
226- c, m := axisCore(t, true, OverflowCascade, 3)
209+ c, m := axisCore(t, false, OverflowCascade, 3)
227210 m.Focus(3)
228211 m.AddFloating(9, 100, 100)
229212 m.Focus(2)
@@ -50,30 +50,23 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
50 }50 }
51 }51 }
52 52
53-// An occupied workspace wins over a monitor on that side, so the opposite53+// A monitor on that side wins over an occupied workspace, so the opposite
54-// key brings focus back; the monitor wins over the trailing empty54+// key brings focus back; the workspace is reached only with no monitor.
55-// workspace, so it stays reachable.55+func TestCascadeFocusColumnGoesToMonitorBeforeWorkspace(t *testing.T) {
56-func TestCascadeFocusColumnStepsWorkspaceBeforeMonitor(t *testing.T) {
57 c, m := axisCore(t, true, OverflowCascade, 3)56 c, m := axisCore(t, true, OverflowCascade, 3)
58 m.Current().FocusID(23)57 m.Current().FocusID(23)
59 c.applyAction(ActionFocusColumnRight)58 c.applyAction(ActionFocusColumnRight)
60- wantActive(t, m, 2)59+ wantFocus(t, c, "R", 0)
61- if got := c.cur().name(); got != "M" {60+ wantActive(t, m, 1)
62- t.Fatalf("focused screen %q, want M", got)61+ // Back with the opposite key.
63- }
64- // Back with the opposite key, from the first column of workspace 3.
65- m.Current().FocusID(31)
66 c.applyAction(ActionFocusColumnLeft)62 c.applyAction(ActionFocusColumnLeft)
63+ wantFocus(t, c, "M", 23)
67 wantActive(t, m, 1)64 wantActive(t, m, 1)
68- if got := c.cur().name(); got != "M" {65+ // The first column of a band: the monitor on the left.
69- t.Fatalf("focused screen %q, want M", got)66+ m.Current().FocusID(24)
70- }
71- // Past the first workspace: the monitor on the left.
72- m.Focus(0)
73- m.Current().FocusID(11)
74 c.applyAction(ActionFocusColumnLeft)67 c.applyAction(ActionFocusColumnLeft)
75 wantFocus(t, c, "L", 0)68 wantFocus(t, c, "L", 0)
76- wantActive(t, m, 0)69+ wantActive(t, m, 1)
77 // From the last occupied workspace: the monitor on the right, not the70 // From the last occupied workspace: the monitor on the right, not the
78 // trailing empty workspace.71 // trailing empty workspace.
79 c.focusScreen = 172 c.focusScreen = 1
@@ -99,10 +92,9 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T)
99 wantActive(t, m, 2)92 wantActive(t, m, 2)
100 }93 }
101 94
102-// The live regression: one monitor on the left only. Right steps to the95+// One monitor on the left only. Right steps to the next workspace; left
103-// next workspace, and left from its empty band comes back to the previous96+// from its empty band leaves for that monitor, which comes first.
104-// workspace instead of leaving for that monitor.97+func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) {
105-func TestCascadeStepRightThenLeftComesBack(t *testing.T) {
106 c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})98 c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})
107 m := c.cur().mon99 m := c.cur().mon
108 m.SetOverflow(OverflowCascade)100 m.SetOverflow(OverflowCascade)
@@ -112,8 +104,8 @@ func TestCascadeStepRightThenLeftComesBack(t *testing.T) {
112 c.applyAction(ActionFocusColumnRight)104 c.applyAction(ActionFocusColumnRight)
113 wantActive(t, m, 1)105 wantActive(t, m, 1)
114 c.applyAction(ActionFocusColumnLeft)106 c.applyAction(ActionFocusColumnLeft)
115- wantFocus(t, c, "M", 5)107+ wantFocus(t, c, "L", 0)
116- wantActive(t, m, 0)108+ wantActive(t, m, 1)
117 }109 }
118 110
119 // AC3: up/down cross bands and stop at the last, with no workspace change.111 // AC3: up/down cross bands and stop at the last, with no workspace change.
@@ -196,34 +188,25 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
196 }188 }
197 }189 }
198 190
199-// A covering fullscreen tile follows the same order: the occupied workspace191+// A covering fullscreen tile follows the same order: the monitor on that
200-// before the monitor on that side, which comes back only past the last one.192+// side before the occupied workspace.
201-func TestCascadeFullscreenStepsWorkspaceBeforeMonitor(t *testing.T) {193+func TestCascadeFullscreenGoesToMonitorBeforeWorkspace(t *testing.T) {
202 c, m := axisCore(t, true, OverflowCascade, 3)194 c, m := axisCore(t, true, OverflowCascade, 3)
203 w := m.Current()195 w := m.Current()
204 w.FocusID(23)196 w.FocusID(23)
205 w.ToggleFullscreen()197 w.ToggleFullscreen()
206 c.applyAction(ActionFocusColumnRight)198 c.applyAction(ActionFocusColumnRight)
207- wantActive(t, m, 2)199+ wantFocus(t, c, "R", 0)
208- if got := c.cur().name(); got != "M" {200+ wantActive(t, m, 1)
209- t.Fatalf("focused screen %q, want M", got)
210- }
211 if w.fullscreen != 23 {201 if w.fullscreen != 23 {
212 t.Fatalf("fullscreen %d, want 23", w.fullscreen)202 t.Fatalf("fullscreen %d, want 23", w.fullscreen)
213 }203 }
214- last := m.Current()
215- last.FocusID(35)
216- last.ToggleFullscreen()
217- c.applyAction(ActionFocusColumnRight)
218- wantFocus(t, c, "R", 0)
219- if last.fullscreen != 35 {
220- t.Fatalf("fullscreen %d, want 35", last.fullscreen)
221- }
222 }204 }
223 205
224-// A workspace holding only a float is occupied: it comes before the monitor.206+// A workspace holding only a float is occupied: with no monitor on that
207+// side, it comes before the trailing empty one.
225 func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) {208 func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) {
226- c, m := axisCore(t, true, OverflowCascade, 3)209+ c, m := axisCore(t, false, OverflowCascade, 3)
227 m.Focus(3)210 m.Focus(3)
228 m.AddFloating(9, 100, 100)211 m.AddFloating(9, 100, 100)
229 m.Focus(2)212 m.Focus(2)

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 23

Tags 10

↑↓ moveenter openesc close