detached a978937

feat(core): keep cascade bands explicit

Columns carry a band start, so bands keep their columns. move-window-up/down
past a column sends it to the adjacent band, joining it or making a new one;
cycle-column-width in a full band pushes a neighbor out and brings it back at
the end of the cycle; maximize-column splits the band and restores it. New
windows join the focused band, and a smaller max-columns splits bands.
move-column-left/right no longer crosses bands, and focus at a band edge
prefers the neighbor monitor over the neighbor workspace.
commit
a978937fa6fbdaf70b083844135ab01b9d3ee306
author
brice · · 2026-10-16 16:44 UTC
parent
ef6bf5d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +729 -168
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 right neighbor goes to the band below (the left one to the band above from the last column). 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 right neighbor goes to the band below (the left one to the band above from the last column). 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 +251 -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
244255 }
245- first, last := w.bandBounds(w.band(i))
246- col := w.Columns[w.Focus]
247- w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
248- at := first // dir < 0: before the band above.
249- if dir > 0 {
250- at = last // After the band below, one index lower once removed.
251- }
252- w.Columns = slices.Insert(w.Columns, at, col)
253- w.Focus = at
254- w.scroll()
255256 }
256257
257258 // laneNeighbor is the closest column to the focus in the lane d lanes away,
@@ -265,3 +266,217 @@ 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+// firstWindows lists the first window of each column.
354+func firstWindows(cols []Column) []WindowID {
355+ ids := make([]WindowID, len(cols))
356+ for i, c := range cols {
357+ ids[i] = c.Windows[0]
358+ }
359+ return ids
360+}
361+
362+// isolateBand gives the focused cascade column a band of its own: the
363+// columns on its left go to the band above, the ones on its right to the
364+// band below (sendBand), and the column remembers them for restoreBand.
365+func (w *Workspace) isolateBand() {
366+ b := w.band(w.Focus)
367+ first, last := w.bandBounds(b)
368+ if first == last {
369+ return
370+ }
371+ id, _ := w.Focused()
372+ bands := w.bandList()
373+ p := w.Focus - first
374+ left := slices.Clone(bands[b][:p])
375+ right := slices.Clone(bands[b][p+1:])
376+ c := bands[b][p]
377+ c.pushedUp, c.pushedDown = firstWindows(left), firstWindows(right)
378+ bands[b] = []Column{c}
379+ if len(right) > 0 {
380+ bands = w.sendBand(bands, b, 1, 0, right)
381+ }
382+ if len(left) > 0 {
383+ bands = w.sendBand(bands, b, -1, max(w.MaxColumns, 1), left)
384+ }
385+ w.setBands(bands, id)
386+}
387+
388+// pushNeighbor makes room in the focused column's full cascade band for a
389+// wider column: its right neighbor goes to the band below, or its left
390+// one to the band above at the band's right end. The column remembers it.
391+func (w *Workspace) pushNeighbor() {
392+ first, last := w.bandBounds(w.band(w.Focus))
393+ if first == last {
394+ return
395+ }
396+ id, _ := w.Focused()
397+ c := &w.Columns[w.Focus]
398+ if w.Focus < last {
399+ c.pushedDown = append(c.pushedDown, w.Columns[w.Focus+1].Windows[0])
400+ w.shiftBand(w.Focus+1, w.Focus+1, 1, 0)
401+ } else {
402+ c.pushedUp = append(c.pushedUp, w.Columns[w.Focus-1].Windows[0])
403+ w.shiftBand(w.Focus-1, w.Focus-1, -1, max(w.MaxColumns, 1))
404+ }
405+ w.Focus = w.columnOf(id)
406+}
407+
408+// restoreBand brings back the columns isolateBand or pushNeighbor sent
409+// away, those still in the adjacent band: the ones sent up before the
410+// focused column, the ones sent down after it. It reports whether the
411+// column remembered any.
412+func (w *Workspace) restoreBand() bool {
413+ c := &w.Columns[w.Focus]
414+ up, down := c.pushedUp, c.pushedDown
415+ c.pushedUp, c.pushedDown = nil, nil
416+ if len(up) == 0 && len(down) == 0 {
417+ return false
418+ }
419+ id, _ := w.Focused()
420+ bands := w.bandList()
421+ b := w.band(w.Focus)
422+ take := func(nb int, ids []WindowID) []Column {
423+ var got []Column
424+ if nb < 0 || nb >= len(bands) {
425+ return nil
426+ }
427+ for _, v := range ids {
428+ if i := slices.IndexFunc(bands[nb], func(c Column) bool { return len(c.Windows) > 0 && c.Windows[0] == v }); i >= 0 {
429+ got = append(got, bands[nb][i])
430+ bands[nb] = slices.Delete(bands[nb], i, i+1)
431+ }
432+ }
433+ return got
434+ }
435+ above, below := take(b-1, up), take(b+1, down)
436+ p := slices.IndexFunc(bands[b], func(c Column) bool { return slices.Contains(c.Windows, id) })
437+ room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
438+ // Columns that no longer fit stay where they are.
439+ if len(above) > room {
440+ bands[b-1] = append(bands[b-1], above[:len(above)-room]...)
441+ above = above[len(above)-room:]
442+ }
443+ room -= len(above)
444+ if len(below) > room {
445+ bands[b+1] = slices.Insert(bands[b+1], 0, below[room:]...)
446+ below = below[:room]
447+ }
448+ row := slices.Concat(bands[b][:p], above, bands[b][p:p+1], below, bands[b][p+1:])
449+ bands[b] = row
450+ w.setBands(bands, id)
451+ return true
452+}
453+
454+// spawnIndex is where a new cascade column goes: after the focused band,
455+// which it joins when it has room; otherwise it starts a band of its own
456+// between it and the next (newBand).
457+func (w *Workspace) spawnIndex() (at int, newBand bool) {
458+ if len(w.Columns) == 0 {
459+ return 0, true
460+ }
461+ b := w.band(w.Focus)
462+ first, last := w.bandBounds(b)
463+ bands := [][]Column{w.Columns[first : last+1]}
464+ return last + 1, !w.fits(bands, 0, []Column{{}})
465+}
466+
467+// deleteColumn removes column i; a band it started keeps its start.
468+func (w *Workspace) deleteColumn(i int) {
469+ if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
470+ w.Columns[i+1].BandStart = true
471+ }
472+ w.Columns = slices.Delete(w.Columns, i, i+1)
473+}
474+
475+// forgetBands drops the cascade band starts and pushed columns: the bands
476+// pack again in column order.
477+func (w *Workspace) forgetBands() {
478+ for i := range w.Columns {
479+ c := &w.Columns[i]
480+ c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil
481+ }
482+}
@@ -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 }255 }
245- first, last := w.bandBounds(w.band(i))
246- col := w.Columns[w.Focus]
247- w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
248- at := first // dir < 0: before the band above.
249- if dir > 0 {
250- at = last // After the band below, one index lower once removed.
251- }
252- w.Columns = slices.Insert(w.Columns, at, col)
253- w.Focus = at
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,217 @@ 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+// firstWindows lists the first window of each column.
354+func firstWindows(cols []Column) []WindowID {
355+ ids := make([]WindowID, len(cols))
356+ for i, c := range cols {
357+ ids[i] = c.Windows[0]
358+ }
359+ return ids
360+}
361+
362+// isolateBand gives the focused cascade column a band of its own: the
363+// columns on its left go to the band above, the ones on its right to the
364+// band below (sendBand), and the column remembers them for restoreBand.
365+func (w *Workspace) isolateBand() {
366+ b := w.band(w.Focus)
367+ first, last := w.bandBounds(b)
368+ if first == last {
369+ return
370+ }
371+ id, _ := w.Focused()
372+ bands := w.bandList()
373+ p := w.Focus - first
374+ left := slices.Clone(bands[b][:p])
375+ right := slices.Clone(bands[b][p+1:])
376+ c := bands[b][p]
377+ c.pushedUp, c.pushedDown = firstWindows(left), firstWindows(right)
378+ bands[b] = []Column{c}
379+ if len(right) > 0 {
380+ bands = w.sendBand(bands, b, 1, 0, right)
381+ }
382+ if len(left) > 0 {
383+ bands = w.sendBand(bands, b, -1, max(w.MaxColumns, 1), left)
384+ }
385+ w.setBands(bands, id)
386+}
387+
388+// pushNeighbor makes room in the focused column's full cascade band for a
389+// wider column: its right neighbor goes to the band below, or its left
390+// one to the band above at the band's right end. The column remembers it.
391+func (w *Workspace) pushNeighbor() {
392+ first, last := w.bandBounds(w.band(w.Focus))
393+ if first == last {
394+ return
395+ }
396+ id, _ := w.Focused()
397+ c := &w.Columns[w.Focus]
398+ if w.Focus < last {
399+ c.pushedDown = append(c.pushedDown, w.Columns[w.Focus+1].Windows[0])
400+ w.shiftBand(w.Focus+1, w.Focus+1, 1, 0)
401+ } else {
402+ c.pushedUp = append(c.pushedUp, w.Columns[w.Focus-1].Windows[0])
403+ w.shiftBand(w.Focus-1, w.Focus-1, -1, max(w.MaxColumns, 1))
404+ }
405+ w.Focus = w.columnOf(id)
406+}
407+
408+// restoreBand brings back the columns isolateBand or pushNeighbor sent
409+// away, those still in the adjacent band: the ones sent up before the
410+// focused column, the ones sent down after it. It reports whether the
411+// column remembered any.
412+func (w *Workspace) restoreBand() bool {
413+ c := &w.Columns[w.Focus]
414+ up, down := c.pushedUp, c.pushedDown
415+ c.pushedUp, c.pushedDown = nil, nil
416+ if len(up) == 0 && len(down) == 0 {
417+ return false
418+ }
419+ id, _ := w.Focused()
420+ bands := w.bandList()
421+ b := w.band(w.Focus)
422+ take := func(nb int, ids []WindowID) []Column {
423+ var got []Column
424+ if nb < 0 || nb >= len(bands) {
425+ return nil
426+ }
427+ for _, v := range ids {
428+ if i := slices.IndexFunc(bands[nb], func(c Column) bool { return len(c.Windows) > 0 && c.Windows[0] == v }); i >= 0 {
429+ got = append(got, bands[nb][i])
430+ bands[nb] = slices.Delete(bands[nb], i, i+1)
431+ }
432+ }
433+ return got
434+ }
435+ above, below := take(b-1, up), take(b+1, down)
436+ p := slices.IndexFunc(bands[b], func(c Column) bool { return slices.Contains(c.Windows, id) })
437+ room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
438+ // Columns that no longer fit stay where they are.
439+ if len(above) > room {
440+ bands[b-1] = append(bands[b-1], above[:len(above)-room]...)
441+ above = above[len(above)-room:]
442+ }
443+ room -= len(above)
444+ if len(below) > room {
445+ bands[b+1] = slices.Insert(bands[b+1], 0, below[room:]...)
446+ below = below[:room]
447+ }
448+ row := slices.Concat(bands[b][:p], above, bands[b][p:p+1], below, bands[b][p+1:])
449+ bands[b] = row
450+ w.setBands(bands, id)
451+ return true
452+}
453+
454+// spawnIndex is where a new cascade column goes: after the focused band,
455+// which it joins when it has room; otherwise it starts a band of its own
456+// between it and the next (newBand).
457+func (w *Workspace) spawnIndex() (at int, newBand bool) {
458+ if len(w.Columns) == 0 {
459+ return 0, true
460+ }
461+ b := w.band(w.Focus)
462+ first, last := w.bandBounds(b)
463+ bands := [][]Column{w.Columns[first : last+1]}
464+ return last + 1, !w.fits(bands, 0, []Column{{}})
465+}
466+
467+// deleteColumn removes column i; a band it started keeps its start.
468+func (w *Workspace) deleteColumn(i int) {
469+ if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
470+ w.Columns[i+1].BandStart = true
471+ }
472+ w.Columns = slices.Delete(w.Columns, i, i+1)
473+}
474+
475+// forgetBands drops the cascade band starts and pushed columns: the bands
476+// pack again in column order.
477+func (w *Workspace) forgetBands() {
478+ for i := range w.Columns {
479+ c := &w.Columns[i]
480+ c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil
481+ }
482+}
A internal/core/cascade_bands_test.go +195 -0
@@ -0,0 +1,195 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// cycle-column-width in a full band pushes the right neighbor to the band
9+// below, or the left one up from the last column; the end of the cycle
10+// brings it back.
11+func TestCascadeCyclePushesNeighbor(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ n int
15+ focus WindowID
16+ pushed [][]WindowID
17+ }{
18+ {"middle pushes right down", 3, 2, [][]WindowID{{1, 2}, {3}}},
19+ {"left pushes right down", 3, 1, [][]WindowID{{1, 3}, {2}}},
20+ {"last pushes left up", 3, 3, [][]WindowID{{2}, {1, 3}}},
21+ {"joins the band below", 5, 2, [][]WindowID{{1, 2}, {3, 4, 5}}},
22+ } {
23+ t.Run(tc.name, func(t *testing.T) {
24+ _, w := cascadeMovesCore(t, tc.n, 3, false)
25+ w.FocusID(tc.focus)
26+ before := bandIDs(w)
27+ w.Apply(ActionCycleColumnWidth)
28+ wantBands(t, w, tc.pushed...)
29+ if id, _ := w.Focused(); id != tc.focus {
30+ t.Fatalf("focus %d, want %d", id, tc.focus)
31+ }
32+ if got := w.Columns[w.Focus].Cells; got != 2 {
33+ t.Fatalf("focused cells %d, want 2", got)
34+ }
35+ w.Apply(ActionCycleColumnWidth)
36+ wantBands(t, w, before...)
37+ for _, c := range w.Columns {
38+ if c.Cells != 0 {
39+ t.Fatalf("cells not back to equal: %+v", w.Columns)
40+ }
41+ }
42+ })
43+ }
44+}
45+
46+// A pushed column that moved meanwhile stays where it is.
47+func TestCascadeCycleRestoreSkipsMovedColumn(t *testing.T) {
48+ c, w := cascadeMovesCore(t, 3, 3, false)
49+ w.FocusID(2)
50+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
51+ w.FocusID(3)
52+ c.applyAction(ActionMoveWindowUp) // {3 1 2}: 3 is back by hand.
53+ w.FocusID(2)
54+ w.Apply(ActionCycleColumnWidth)
55+ wantBands(t, w, []WindowID{3, 1, 2})
56+ if got := cellsOf(w); !slices.Equal(got, []int{0, 0, 0}) {
57+ t.Fatalf("cells %v, want equal", got)
58+ }
59+}
60+
61+// maximize-column isolates the column: the left ones go up, the right ones
62+// down, together; maximizing again forms the band back.
63+func TestCascadeMaximizeSplitsAndRestoresBand(t *testing.T) {
64+ for _, tc := range []struct {
65+ name string
66+ n, k int
67+ focus WindowID
68+ split [][]WindowID
69+ }{
70+ {"middle", 3, 3, 2, [][]WindowID{{1}, {2}, {3}}},
71+ {"first", 3, 3, 1, [][]WindowID{{1}, {2, 3}}},
72+ {"left side moves together", 4, 4, 3, [][]WindowID{{1, 2}, {3}, {4}}},
73+ {"left joins the band above", 5, 3, 5, [][]WindowID{{1, 2, 3}, {4}, {5}}},
74+ } {
75+ t.Run(tc.name, func(t *testing.T) {
76+ _, w := cascadeMovesCore(t, tc.n, tc.k, false)
77+ w.FocusID(tc.focus)
78+ before := bandIDs(w)
79+ w.ToggleFullWidth()
80+ wantBands(t, w, tc.split...)
81+ if !w.Columns[w.Focus].FullWidth {
82+ t.Fatal("not maximized")
83+ }
84+ w.ToggleFullWidth()
85+ wantBands(t, w, before...)
86+ if w.Columns[w.Focus].FullWidth {
87+ t.Fatal("still maximized")
88+ }
89+ })
90+ }
91+}
92+
93+// Columns on the left that fit the band above join it at its end.
94+func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) {
95+ c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
96+ w.FocusID(2)
97+ c.applyAction(ActionMoveWindowDown) // {1 3} {4 2 5}
98+ w.FocusID(2)
99+ w.ToggleFullWidth()
100+ wantBands(t, w, []WindowID{1, 3, 4}, []WindowID{2}, []WindowID{5})
101+ w.ToggleFullWidth()
102+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
103+}
104+
105+// No column is hidden by a maximized cascade column.
106+func TestCascadeMaximizeHidesNothing(t *testing.T) {
107+ _, w := cascadeMovesCore(t, 3, 3, false)
108+ w.FocusID(2)
109+ w.ToggleFullWidth()
110+ for _, p := range w.Layout() {
111+ if p.Hidden && p.ID != 0 {
112+ // Off-screen bands are laid out, not hidden by maximize.
113+ if w.hiddenByMaximized(w.columnOf(p.ID)) {
114+ t.Fatalf("window %d hidden by maximize", p.ID)
115+ }
116+ }
117+ }
118+}
119+
120+// A new window joins the focused band when it has room, else it starts a
121+// band of its own right below it; other bands do not move.
122+func TestCascadeSpawnInFocusedBand(t *testing.T) {
123+ _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
124+ w.FocusID(4)
125+ w.AddWindow(6)
126+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
127+ w.FocusID(2)
128+ w.AddWindow(7)
129+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
130+ if id, _ := w.Focused(); id != 7 {
131+ t.Fatalf("focus %d, want 7", id)
132+ }
133+ w.AddWindow(8)
134+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7, 8}, []WindowID{4, 5, 6})
135+}
136+
137+// A band keeps its columns: closing one leaves the others in place, and a
138+// band left empty goes away.
139+func TestCascadeCloseKeepsBands(t *testing.T) {
140+ c, w := cascadeMovesCore(t, 5, 3, false)
141+ w.FocusID(5)
142+ c.applyAction(ActionMoveWindowDown) // {1 2 3} {4} {5}
143+ w.RemoveWindow(2)
144+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4}, []WindowID{5})
145+ w.RemoveWindow(4)
146+ wantBands(t, w, []WindowID{1, 3}, []WindowID{5})
147+}
148+
149+// A smaller max-columns sends the extra columns of a band to a new band
150+// right below it; a larger one keeps the bands.
151+func TestCascadeMaxColumnsShrinkSplitsBand(t *testing.T) {
152+ _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
153+ w.SetMaxColumns(2)
154+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
155+ w.SetMaxColumns(3)
156+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
157+}
158+
159+// Switching to cascade from another overflow packs the columns in order.
160+func TestCascadeOverflowSwitchRepacks(t *testing.T) {
161+ m := newMonitor("", "")
162+ m.SetOutput(900, 600)
163+ m.SetMaxColumns(3)
164+ m.SetOverflow(OverflowCascade)
165+ for id := WindowID(1); id <= 4; id++ {
166+ m.AddWindow(id)
167+ }
168+ w := m.Current()
169+ w.FocusID(2)
170+ w.ToggleFullWidth() // {1} {2*} {3 4}
171+ m.SetOverflow(OverflowScroll)
172+ m.SetOverflow(OverflowCascade)
173+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4})
174+}
175+
176+// Moves inside a band keep the band starts in place.
177+func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) {
178+ c, w := cascadeMovesCore(t, 5, 3, false)
179+ w.FocusID(4)
180+ c.applyAction(ActionMoveColumnRight)
181+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4})
182+}
183+
184+func bandIDs(w *Workspace) [][]WindowID {
185+ var out [][]WindowID
186+ w.walkBands(func(_, first, last int) bool {
187+ var b []WindowID
188+ for _, c := range w.Columns[first : last+1] {
189+ b = append(b, c.Windows[0])
190+ }
191+ out = append(out, slices.Clone(b))
192+ return true
193+ })
194+ return out
195+}
@@ -0,0 +1,195 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// cycle-column-width in a full band pushes the right neighbor to the band
9+// below, or the left one up from the last column; the end of the cycle
10+// brings it back.
11+func TestCascadeCyclePushesNeighbor(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ n int
15+ focus WindowID
16+ pushed [][]WindowID
17+ }{
18+ {"middle pushes right down", 3, 2, [][]WindowID{{1, 2}, {3}}},
19+ {"left pushes right down", 3, 1, [][]WindowID{{1, 3}, {2}}},
20+ {"last pushes left up", 3, 3, [][]WindowID{{2}, {1, 3}}},
21+ {"joins the band below", 5, 2, [][]WindowID{{1, 2}, {3, 4, 5}}},
22+ } {
23+ t.Run(tc.name, func(t *testing.T) {
24+ _, w := cascadeMovesCore(t, tc.n, 3, false)
25+ w.FocusID(tc.focus)
26+ before := bandIDs(w)
27+ w.Apply(ActionCycleColumnWidth)
28+ wantBands(t, w, tc.pushed...)
29+ if id, _ := w.Focused(); id != tc.focus {
30+ t.Fatalf("focus %d, want %d", id, tc.focus)
31+ }
32+ if got := w.Columns[w.Focus].Cells; got != 2 {
33+ t.Fatalf("focused cells %d, want 2", got)
34+ }
35+ w.Apply(ActionCycleColumnWidth)
36+ wantBands(t, w, before...)
37+ for _, c := range w.Columns {
38+ if c.Cells != 0 {
39+ t.Fatalf("cells not back to equal: %+v", w.Columns)
40+ }
41+ }
42+ })
43+ }
44+}
45+
46+// A pushed column that moved meanwhile stays where it is.
47+func TestCascadeCycleRestoreSkipsMovedColumn(t *testing.T) {
48+ c, w := cascadeMovesCore(t, 3, 3, false)
49+ w.FocusID(2)
50+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
51+ w.FocusID(3)
52+ c.applyAction(ActionMoveWindowUp) // {3 1 2}: 3 is back by hand.
53+ w.FocusID(2)
54+ w.Apply(ActionCycleColumnWidth)
55+ wantBands(t, w, []WindowID{3, 1, 2})
56+ if got := cellsOf(w); !slices.Equal(got, []int{0, 0, 0}) {
57+ t.Fatalf("cells %v, want equal", got)
58+ }
59+}
60+
61+// maximize-column isolates the column: the left ones go up, the right ones
62+// down, together; maximizing again forms the band back.
63+func TestCascadeMaximizeSplitsAndRestoresBand(t *testing.T) {
64+ for _, tc := range []struct {
65+ name string
66+ n, k int
67+ focus WindowID
68+ split [][]WindowID
69+ }{
70+ {"middle", 3, 3, 2, [][]WindowID{{1}, {2}, {3}}},
71+ {"first", 3, 3, 1, [][]WindowID{{1}, {2, 3}}},
72+ {"left side moves together", 4, 4, 3, [][]WindowID{{1, 2}, {3}, {4}}},
73+ {"left joins the band above", 5, 3, 5, [][]WindowID{{1, 2, 3}, {4}, {5}}},
74+ } {
75+ t.Run(tc.name, func(t *testing.T) {
76+ _, w := cascadeMovesCore(t, tc.n, tc.k, false)
77+ w.FocusID(tc.focus)
78+ before := bandIDs(w)
79+ w.ToggleFullWidth()
80+ wantBands(t, w, tc.split...)
81+ if !w.Columns[w.Focus].FullWidth {
82+ t.Fatal("not maximized")
83+ }
84+ w.ToggleFullWidth()
85+ wantBands(t, w, before...)
86+ if w.Columns[w.Focus].FullWidth {
87+ t.Fatal("still maximized")
88+ }
89+ })
90+ }
91+}
92+
93+// Columns on the left that fit the band above join it at its end.
94+func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) {
95+ c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
96+ w.FocusID(2)
97+ c.applyAction(ActionMoveWindowDown) // {1 3} {4 2 5}
98+ w.FocusID(2)
99+ w.ToggleFullWidth()
100+ wantBands(t, w, []WindowID{1, 3, 4}, []WindowID{2}, []WindowID{5})
101+ w.ToggleFullWidth()
102+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
103+}
104+
105+// No column is hidden by a maximized cascade column.
106+func TestCascadeMaximizeHidesNothing(t *testing.T) {
107+ _, w := cascadeMovesCore(t, 3, 3, false)
108+ w.FocusID(2)
109+ w.ToggleFullWidth()
110+ for _, p := range w.Layout() {
111+ if p.Hidden && p.ID != 0 {
112+ // Off-screen bands are laid out, not hidden by maximize.
113+ if w.hiddenByMaximized(w.columnOf(p.ID)) {
114+ t.Fatalf("window %d hidden by maximize", p.ID)
115+ }
116+ }
117+ }
118+}
119+
120+// A new window joins the focused band when it has room, else it starts a
121+// band of its own right below it; other bands do not move.
122+func TestCascadeSpawnInFocusedBand(t *testing.T) {
123+ _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
124+ w.FocusID(4)
125+ w.AddWindow(6)
126+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
127+ w.FocusID(2)
128+ w.AddWindow(7)
129+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
130+ if id, _ := w.Focused(); id != 7 {
131+ t.Fatalf("focus %d, want 7", id)
132+ }
133+ w.AddWindow(8)
134+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7, 8}, []WindowID{4, 5, 6})
135+}
136+
137+// A band keeps its columns: closing one leaves the others in place, and a
138+// band left empty goes away.
139+func TestCascadeCloseKeepsBands(t *testing.T) {
140+ c, w := cascadeMovesCore(t, 5, 3, false)
141+ w.FocusID(5)
142+ c.applyAction(ActionMoveWindowDown) // {1 2 3} {4} {5}
143+ w.RemoveWindow(2)
144+ wantBands(t, w, []WindowID{1, 3}, []WindowID{4}, []WindowID{5})
145+ w.RemoveWindow(4)
146+ wantBands(t, w, []WindowID{1, 3}, []WindowID{5})
147+}
148+
149+// A smaller max-columns sends the extra columns of a band to a new band
150+// right below it; a larger one keeps the bands.
151+func TestCascadeMaxColumnsShrinkSplitsBand(t *testing.T) {
152+ _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
153+ w.SetMaxColumns(2)
154+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
155+ w.SetMaxColumns(3)
156+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
157+}
158+
159+// Switching to cascade from another overflow packs the columns in order.
160+func TestCascadeOverflowSwitchRepacks(t *testing.T) {
161+ m := newMonitor("", "")
162+ m.SetOutput(900, 600)
163+ m.SetMaxColumns(3)
164+ m.SetOverflow(OverflowCascade)
165+ for id := WindowID(1); id <= 4; id++ {
166+ m.AddWindow(id)
167+ }
168+ w := m.Current()
169+ w.FocusID(2)
170+ w.ToggleFullWidth() // {1} {2*} {3 4}
171+ m.SetOverflow(OverflowScroll)
172+ m.SetOverflow(OverflowCascade)
173+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4})
174+}
175+
176+// Moves inside a band keep the band starts in place.
177+func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) {
178+ c, w := cascadeMovesCore(t, 5, 3, false)
179+ w.FocusID(4)
180+ c.applyAction(ActionMoveColumnRight)
181+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4})
182+}
183+
184+func bandIDs(w *Workspace) [][]WindowID {
185+ var out [][]WindowID
186+ w.walkBands(func(_, first, last int) bool {
187+ var b []WindowID
188+ for _, c := range w.Columns[first : last+1] {
189+ b = append(b, c.Windows[0])
190+ }
191+ out = append(out, slices.Clone(b))
192+ return true
193+ })
194+ return out
195+}
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 +75 -7
@@ -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 (by first window) 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 {
@@ -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,6 +1024,11 @@ func (w *Workspace) insertColumn(at int, col Column) {
10061024 if w.policy().maximizeHides() && len(w.Columns) > 0 {
10071025 w.unmaximize()
10081026 }
1027+ // In a cascade the column goes before column at, in its band.
1028+ col.BandStart = false
1029+ if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
1030+ col.BandStart, w.Columns[at].BandStart = true, false
1031+ }
10091032 w.Columns = slices.Insert(w.Columns, at, col)
10101033 w.Focus = at
10111034 w.scroll()
@@ -1021,8 +1044,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
10211044 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
10221045 return
10231046 }
1024- w.unmaximize()
10251047 defer w.scroll()
1048+ if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1049+ w.unmaximizeBand()
1050+ }
1051+ w.unmaximize()
10261052 k := max(w.MaxColumns, 1)
10271053 if w.policy().equalCells {
10281054 if !w.policy().wraps && !w.laneRow() {
@@ -1030,7 +1056,20 @@ func (w *Workspace) GrowColumn(cycle bool) {
10301056 }
10311057 first, last := w.laneBounds(w.laneOf(w.Focus))
10321058 n := last - first + 1
1059+ pushed := false
1060+ if w.policy().wraps && cycle && n > 1 && n >= k && !w.unevenLane(first, last) {
1061+ // A full band makes room: a neighbor goes to the adjacent band.
1062+ w.pushNeighbor()
1063+ first, last = w.laneBounds(w.laneOf(w.Focus))
1064+ n, pushed = last-first+1, true
1065+ }
10331066 if n == 1 || n >= k {
1067+ if w.policy().wraps && cycle && !pushed && w.restoreBand() {
1068+ // End of the cycle: the pushed columns came back.
1069+ first, last = w.laneBounds(w.laneOf(w.Focus))
1070+ w.setLaneCells(first, make([]int, last-first+1))
1071+ return
1072+ }
10341073 // Nothing to grow, but a lane left uneven (a column joined
10351074 // it, or max-columns shrank) still cycles back to equal.
10361075 if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) {
@@ -1048,6 +1087,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
10481087 if d := w.donor(first, last, cells); d >= 0 {
10491088 w.moveCell(first, cells, d, w.Focus)
10501089 } else if cycle {
1090+ if w.policy().wraps && w.restoreBand() {
1091+ // End of the cycle: the pushed columns came back.
1092+ first, last = w.laneBounds(w.laneOf(w.Focus))
1093+ n = last - first + 1
1094+ }
10511095 w.setLaneCells(first, make([]int, n))
10521096 }
10531097 return
@@ -1069,8 +1113,11 @@ func (w *Workspace) ShrinkColumn() {
10691113 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
10701114 return
10711115 }
1072- w.unmaximize()
10731116 defer w.scroll()
1117+ if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1118+ w.unmaximizeBand()
1119+ }
1120+ w.unmaximize()
10741121 k := max(w.MaxColumns, 1)
10751122 if w.policy().equalCells {
10761123 if !w.policy().wraps && !w.laneRow() {
@@ -1138,6 +1185,12 @@ func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
11381185 return cells, false
11391186 }
11401187
1188+// unevenLane reports whether a column of the lane first..last has its
1189+// own width.
1190+func (w *Workspace) unevenLane(first, last int) bool {
1191+ return slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 })
1192+}
1193+
11411194 // setLaneCells stores cells on the lane's columns, from column first.
11421195 func (w *Workspace) setLaneCells(first int, cells []int) {
11431196 for i, c := range cells {
@@ -1224,14 +1277,28 @@ func (w *Workspace) ToggleFullWidth() {
12241277 if len(w.Columns) == 0 || w.onFloat() {
12251278 return
12261279 }
1227- if w.Columns[w.Focus].FullWidth {
1280+ switch {
1281+ case w.policy().wraps && w.Columns[w.Focus].FullWidth:
1282+ w.unmaximizeBand()
1283+ case w.policy().wraps:
1284+ // A cascade column takes a band of its own; the others stay seen.
1285+ w.isolateBand()
1286+ w.maximize(w.Focus)
1287+ case w.Columns[w.Focus].FullWidth:
12281288 w.unmaximize()
1229- } else {
1289+ default:
12301290 w.maximize(w.Focus)
12311291 }
12321292 w.scroll()
12331293 }
12341294
1295+// unmaximizeBand unmaximizes the focused cascade column and brings back
1296+// the columns its maximization sent to the adjacent bands.
1297+func (w *Workspace) unmaximizeBand() {
1298+ w.Columns[w.Focus].FullWidth = false
1299+ w.restoreBand()
1300+}
1301+
12351302 func (w *Workspace) ToggleFullscreen() {
12361303 id, ok := w.Focused()
12371304 if !ok {
@@ -1612,6 +1679,7 @@ func (w *Workspace) scroll() {
16121679 before := w.View
16131680 defer func() { w.retarget(before) }()
16141681 if w.policy().wraps {
1682+ w.markBands()
16151683 w.View = w.band(w.Focus) * w.Usable.H
16161684 return
16171685 }
@@ -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 (by first window) 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 {
@@ -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,6 +1024,11 @@ 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 }
1027+ // In a cascade the column goes before column at, in its band.
1028+ col.BandStart = false
1029+ if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
1030+ col.BandStart, w.Columns[at].BandStart = true, false
1031+ }
1009 w.Columns = slices.Insert(w.Columns, at, col)1032 w.Columns = slices.Insert(w.Columns, at, col)
1010 w.Focus = at1033 w.Focus = at
1011 w.scroll()1034 w.scroll()
@@ -1021,8 +1044,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
1021 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {1044 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1022 return1045 return
1023 }1046 }
1024- w.unmaximize()
1025 defer w.scroll()1047 defer w.scroll()
1048+ if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1049+ w.unmaximizeBand()
1050+ }
1051+ w.unmaximize()
1026 k := max(w.MaxColumns, 1)1052 k := max(w.MaxColumns, 1)
1027 if w.policy().equalCells {1053 if w.policy().equalCells {
1028 if !w.policy().wraps && !w.laneRow() {1054 if !w.policy().wraps && !w.laneRow() {
@@ -1030,7 +1056,20 @@ func (w *Workspace) GrowColumn(cycle bool) {
1030 }1056 }
1031 first, last := w.laneBounds(w.laneOf(w.Focus))1057 first, last := w.laneBounds(w.laneOf(w.Focus))
1032 n := last - first + 11058 n := last - first + 1
1059+ pushed := false
1060+ if w.policy().wraps && cycle && n > 1 && n >= k && !w.unevenLane(first, last) {
1061+ // A full band makes room: a neighbor goes to the adjacent band.
1062+ w.pushNeighbor()
1063+ first, last = w.laneBounds(w.laneOf(w.Focus))
1064+ n, pushed = last-first+1, true
1065+ }
1033 if n == 1 || n >= k {1066 if n == 1 || n >= k {
1067+ if w.policy().wraps && cycle && !pushed && w.restoreBand() {
1068+ // End of the cycle: the pushed columns came back.
1069+ first, last = w.laneBounds(w.laneOf(w.Focus))
1070+ w.setLaneCells(first, make([]int, last-first+1))
1071+ return
1072+ }
1034 // Nothing to grow, but a lane left uneven (a column joined1073 // Nothing to grow, but a lane left uneven (a column joined
1035 // it, or max-columns shrank) still cycles back to equal.1074 // 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 }) {1075 if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) {
@@ -1048,6 +1087,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
1048 if d := w.donor(first, last, cells); d >= 0 {1087 if d := w.donor(first, last, cells); d >= 0 {
1049 w.moveCell(first, cells, d, w.Focus)1088 w.moveCell(first, cells, d, w.Focus)
1050 } else if cycle {1089 } else if cycle {
1090+ if w.policy().wraps && w.restoreBand() {
1091+ // End of the cycle: the pushed columns came back.
1092+ first, last = w.laneBounds(w.laneOf(w.Focus))
1093+ n = last - first + 1
1094+ }
1051 w.setLaneCells(first, make([]int, n))1095 w.setLaneCells(first, make([]int, n))
1052 }1096 }
1053 return1097 return
@@ -1069,8 +1113,11 @@ func (w *Workspace) ShrinkColumn() {
1069 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {1113 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1070 return1114 return
1071 }1115 }
1072- w.unmaximize()
1073 defer w.scroll()1116 defer w.scroll()
1117+ if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1118+ w.unmaximizeBand()
1119+ }
1120+ w.unmaximize()
1074 k := max(w.MaxColumns, 1)1121 k := max(w.MaxColumns, 1)
1075 if w.policy().equalCells {1122 if w.policy().equalCells {
1076 if !w.policy().wraps && !w.laneRow() {1123 if !w.policy().wraps && !w.laneRow() {
@@ -1138,6 +1185,12 @@ func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
1138 return cells, false1185 return cells, false
1139 }1186 }
1140 1187
1188+// unevenLane reports whether a column of the lane first..last has its
1189+// own width.
1190+func (w *Workspace) unevenLane(first, last int) bool {
1191+ return slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 })
1192+}
1193+
1141 // setLaneCells stores cells on the lane's columns, from column first.1194 // setLaneCells stores cells on the lane's columns, from column first.
1142 func (w *Workspace) setLaneCells(first int, cells []int) {1195 func (w *Workspace) setLaneCells(first int, cells []int) {
1143 for i, c := range cells {1196 for i, c := range cells {
@@ -1224,14 +1277,28 @@ func (w *Workspace) ToggleFullWidth() {
1224 if len(w.Columns) == 0 || w.onFloat() {1277 if len(w.Columns) == 0 || w.onFloat() {
1225 return1278 return
1226 }1279 }
1227- if w.Columns[w.Focus].FullWidth {1280+ switch {
1281+ case w.policy().wraps && w.Columns[w.Focus].FullWidth:
1282+ w.unmaximizeBand()
1283+ case w.policy().wraps:
1284+ // A cascade column takes a band of its own; the others stay seen.
1285+ w.isolateBand()
1286+ w.maximize(w.Focus)
1287+ case w.Columns[w.Focus].FullWidth:
1228 w.unmaximize()1288 w.unmaximize()
1229- } else {1289+ default:
1230 w.maximize(w.Focus)1290 w.maximize(w.Focus)
1231 }1291 }
1232 w.scroll()1292 w.scroll()
1233 }1293 }
1234 1294
1295+// unmaximizeBand unmaximizes the focused cascade column and brings back
1296+// the columns its maximization sent to the adjacent bands.
1297+func (w *Workspace) unmaximizeBand() {
1298+ w.Columns[w.Focus].FullWidth = false
1299+ w.restoreBand()
1300+}
1301+
1235 func (w *Workspace) ToggleFullscreen() {1302 func (w *Workspace) ToggleFullscreen() {
1236 id, ok := w.Focused()1303 id, ok := w.Focused()
1237 if !ok {1304 if !ok {
@@ -1612,6 +1679,7 @@ func (w *Workspace) scroll() {
1612 before := w.View1679 before := w.View
1613 defer func() { w.retarget(before) }()1680 defer func() { w.retarget(before) }()
1614 if w.policy().wraps {1681 if w.policy().wraps {
1682+ w.markBands()
1615 w.View = w.band(w.Focus) * w.Usable.H1683 w.View = w.band(w.Focus) * w.Usable.H
1616 return1684 return
1617 }1685 }
M internal/core/monitor.go +4 -0
@@ -575,6 +575,10 @@ 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 {
@@ -575,6 +575,10 @@ 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 {
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/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 25

Tags 10

↑↓ moveenter openesc close