detached 0e73719

fix(core): keep cascade band placement and push memory consistent

Rule and slot windows join the focused band like new windows. applyNamed
sets max-columns once, so a named override no longer splits bands on reload.
Pushed columns are remembered by all their windows, survive a maximize after
a width cycle, and the ones without room stay where they are.
commit
0e73719b95d8cdff0d7b6a2c7d0e87f185b7de9e
author
brice · · 2026-10-16 16:54 UTC
parent
a978937
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +168 -47
M internal/core/cascade.go +43 -30
@@ -350,11 +350,12 @@ func (w *Workspace) shiftBand(lo, hi, dir, at int) bool {
350350 return true
351351 }
352352
353-// firstWindows lists the first window of each column.
354-func firstWindows(cols []Column) []WindowID {
355- ids := make([]WindowID, len(cols))
353+// columnIDs lists the windows of each column, its identity for
354+// restoreBand: any of them still together finds the column.
355+func columnIDs(cols []Column) [][]WindowID {
356+ ids := make([][]WindowID, len(cols))
356357 for i, c := range cols {
357- ids[i] = c.Windows[0]
358+ ids[i] = slices.Clone(c.Windows)
358359 }
359360 return ids
360361 }
@@ -374,7 +375,10 @@ func (w *Workspace) isolateBand() {
374375 left := slices.Clone(bands[b][:p])
375376 right := slices.Clone(bands[b][p+1:])
376377 c := bands[b][p]
377- c.pushedUp, c.pushedDown = firstWindows(left), firstWindows(right)
378+ // Earlier pushes (cycle-column-width) stay remembered, farther out:
379+ // pushedUp ends nearest, pushedDown starts nearest.
380+ c.pushedUp = append(c.pushedUp, columnIDs(left)...)
381+ c.pushedDown = append(columnIDs(right), c.pushedDown...)
378382 bands[b] = []Column{c}
379383 if len(right) > 0 {
380384 bands = w.sendBand(bands, b, 1, 0, right)
@@ -396,19 +400,20 @@ func (w *Workspace) pushNeighbor() {
396400 id, _ := w.Focused()
397401 c := &w.Columns[w.Focus]
398402 if w.Focus < last {
399- c.pushedDown = append(c.pushedDown, w.Columns[w.Focus+1].Windows[0])
403+ c.pushedDown = append(columnIDs(w.Columns[w.Focus+1:w.Focus+2]), c.pushedDown...)
400404 w.shiftBand(w.Focus+1, w.Focus+1, 1, 0)
401405 } else {
402- c.pushedUp = append(c.pushedUp, w.Columns[w.Focus-1].Windows[0])
406+ c.pushedUp = append(c.pushedUp, columnIDs(w.Columns[w.Focus-1:w.Focus])...)
403407 w.shiftBand(w.Focus-1, w.Focus-1, -1, max(w.MaxColumns, 1))
404408 }
405409 w.Focus = w.columnOf(id)
406410 }
407411
408412 // 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.
413+// away, those still in the adjacent band, as many as the band has room
414+// for, nearest to the focused column first: the ones sent up before it,
415+// the ones sent down after it. The others stay where they are. It reports
416+// whether the column remembered any.
412417 func (w *Workspace) restoreBand() bool {
413418 c := &w.Columns[w.Focus]
414419 up, down := c.pushedUp, c.pushedDown
@@ -419,34 +424,42 @@ func (w *Workspace) restoreBand() bool {
419424 id, _ := w.Focused()
420425 bands := w.bandList()
421426 b := w.band(w.Focus)
422- take := func(nb int, ids []WindowID) []Column {
423- var got []Column
427+ room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
428+ // take removes from band nb the remembered columns found there, at
429+ // most room of them, in the order of ids walked from the focused
430+ // column outward (rev: ids end nearest).
431+ take := func(nb int, ids [][]WindowID, rev bool) []Column {
424432 if nb < 0 || nb >= len(bands) {
425433 return nil
426434 }
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)
435+ var got []Column
436+ for k := range ids {
437+ if room == 0 {
438+ break
439+ }
440+ e := ids[k]
441+ if rev {
442+ e = ids[len(ids)-1-k]
431443 }
444+ i := slices.IndexFunc(bands[nb], func(c Column) bool {
445+ return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(e, v) })
446+ })
447+ if i < 0 {
448+ continue
449+ }
450+ got = append(got, bands[nb][i])
451+ bands[nb] = slices.Delete(bands[nb], i, i+1)
452+ room--
453+ }
454+ if rev {
455+ slices.Reverse(got)
432456 }
433457 return got
434458 }
435- above, below := take(b-1, up), take(b+1, down)
459+ above := take(b-1, up, true)
460+ below := take(b+1, down, false)
436461 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
462+ bands[b] = slices.Concat(bands[b][:p], above, bands[b][p:p+1], below, bands[b][p+1:])
450463 w.setBands(bands, id)
451464 return true
452465 }
@@ -350,11 +350,12 @@ func (w *Workspace) shiftBand(lo, hi, dir, at int) bool {
350 return true350 return true
351 }351 }
352 352
353-// firstWindows lists the first window of each column.353+// columnIDs lists the windows of each column, its identity for
354-func firstWindows(cols []Column) []WindowID {354+// restoreBand: any of them still together finds the column.
355- ids := make([]WindowID, len(cols))355+func columnIDs(cols []Column) [][]WindowID {
356+ ids := make([][]WindowID, len(cols))
356 for i, c := range cols {357 for i, c := range cols {
357- ids[i] = c.Windows[0]358+ ids[i] = slices.Clone(c.Windows)
358 }359 }
359 return ids360 return ids
360 }361 }
@@ -374,7 +375,10 @@ func (w *Workspace) isolateBand() {
374 left := slices.Clone(bands[b][:p])375 left := slices.Clone(bands[b][:p])
375 right := slices.Clone(bands[b][p+1:])376 right := slices.Clone(bands[b][p+1:])
376 c := bands[b][p]377 c := bands[b][p]
377- c.pushedUp, c.pushedDown = firstWindows(left), firstWindows(right)378+ // Earlier pushes (cycle-column-width) stay remembered, farther out:
379+ // pushedUp ends nearest, pushedDown starts nearest.
380+ c.pushedUp = append(c.pushedUp, columnIDs(left)...)
381+ c.pushedDown = append(columnIDs(right), c.pushedDown...)
378 bands[b] = []Column{c}382 bands[b] = []Column{c}
379 if len(right) > 0 {383 if len(right) > 0 {
380 bands = w.sendBand(bands, b, 1, 0, right)384 bands = w.sendBand(bands, b, 1, 0, right)
@@ -396,19 +400,20 @@ func (w *Workspace) pushNeighbor() {
396 id, _ := w.Focused()400 id, _ := w.Focused()
397 c := &w.Columns[w.Focus]401 c := &w.Columns[w.Focus]
398 if w.Focus < last {402 if w.Focus < last {
399- c.pushedDown = append(c.pushedDown, w.Columns[w.Focus+1].Windows[0])403+ c.pushedDown = append(columnIDs(w.Columns[w.Focus+1:w.Focus+2]), c.pushedDown...)
400 w.shiftBand(w.Focus+1, w.Focus+1, 1, 0)404 w.shiftBand(w.Focus+1, w.Focus+1, 1, 0)
401 } else {405 } else {
402- c.pushedUp = append(c.pushedUp, w.Columns[w.Focus-1].Windows[0])406+ c.pushedUp = append(c.pushedUp, columnIDs(w.Columns[w.Focus-1:w.Focus])...)
403 w.shiftBand(w.Focus-1, w.Focus-1, -1, max(w.MaxColumns, 1))407 w.shiftBand(w.Focus-1, w.Focus-1, -1, max(w.MaxColumns, 1))
404 }408 }
405 w.Focus = w.columnOf(id)409 w.Focus = w.columnOf(id)
406 }410 }
407 411
408 // restoreBand brings back the columns isolateBand or pushNeighbor sent412 // restoreBand brings back the columns isolateBand or pushNeighbor sent
409-// away, those still in the adjacent band: the ones sent up before the413+// away, those still in the adjacent band, as many as the band has room
410-// focused column, the ones sent down after it. It reports whether the414+// for, nearest to the focused column first: the ones sent up before it,
411-// column remembered any.415+// the ones sent down after it. The others stay where they are. It reports
416+// whether the column remembered any.
412 func (w *Workspace) restoreBand() bool {417 func (w *Workspace) restoreBand() bool {
413 c := &w.Columns[w.Focus]418 c := &w.Columns[w.Focus]
414 up, down := c.pushedUp, c.pushedDown419 up, down := c.pushedUp, c.pushedDown
@@ -419,34 +424,42 @@ func (w *Workspace) restoreBand() bool {
419 id, _ := w.Focused()424 id, _ := w.Focused()
420 bands := w.bandList()425 bands := w.bandList()
421 b := w.band(w.Focus)426 b := w.band(w.Focus)
422- take := func(nb int, ids []WindowID) []Column {427+ room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
423- var got []Column428+ // take removes from band nb the remembered columns found there, at
429+ // most room of them, in the order of ids walked from the focused
430+ // column outward (rev: ids end nearest).
431+ take := func(nb int, ids [][]WindowID, rev bool) []Column {
424 if nb < 0 || nb >= len(bands) {432 if nb < 0 || nb >= len(bands) {
425 return nil433 return nil
426 }434 }
427- for _, v := range ids {435+ var got []Column
428- if i := slices.IndexFunc(bands[nb], func(c Column) bool { return len(c.Windows) > 0 && c.Windows[0] == v }); i >= 0 {436+ for k := range ids {
429- got = append(got, bands[nb][i])437+ if room == 0 {
430- bands[nb] = slices.Delete(bands[nb], i, i+1)438+ break
439+ }
440+ e := ids[k]
441+ if rev {
442+ e = ids[len(ids)-1-k]
431 }443 }
444+ i := slices.IndexFunc(bands[nb], func(c Column) bool {
445+ return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(e, v) })
446+ })
447+ if i < 0 {
448+ continue
449+ }
450+ got = append(got, bands[nb][i])
451+ bands[nb] = slices.Delete(bands[nb], i, i+1)
452+ room--
453+ }
454+ if rev {
455+ slices.Reverse(got)
432 }456 }
433 return got457 return got
434 }458 }
435- above, below := take(b-1, up), take(b+1, down)459+ above := take(b-1, up, true)
460+ below := take(b+1, down, false)
436 p := slices.IndexFunc(bands[b], func(c Column) bool { return slices.Contains(c.Windows, id) })461 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)462+ bands[b] = slices.Concat(bands[b][:p], above, bands[b][p:p+1], below, bands[b][p+1:])
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)463 w.setBands(bands, id)
451 return true464 return true
452 }465 }
M internal/core/cascade_bands_test.go +97 -7
@@ -102,18 +102,26 @@ func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) {
102102 wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
103103 }
104104
105-// No column is hidden by a maximized cascade column.
105+// No column is hidden by a maximized cascade column: focusing each band
106+// shows its windows.
106107 func TestCascadeMaximizeHidesNothing(t *testing.T) {
107108 _, w := cascadeMovesCore(t, 3, 3, false)
108109 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)
110+ w.ToggleFullWidth() // {1} {2*} {3}
111+ for id := WindowID(1); id <= 3; id++ {
112+ w.FocusID(id)
113+ var found bool
114+ for _, p := range w.Layout() {
115+ if p.ID == id {
116+ found = true
117+ if p.Hidden || p.Rect.W <= 0 || p.Rect.H <= 0 {
118+ t.Fatalf("window %d not shown: %+v", id, p)
119+ }
115120 }
116121 }
122+ if !found {
123+ t.Fatalf("no placement for window %d", id)
124+ }
117125 }
118126 }
119127
@@ -181,6 +189,88 @@ func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) {
181189 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4})
182190 }
183191
192+// Review regressions: push memory survives a maximize in between, finds a
193+// column whose first window closed, and leaves columns without room in
194+// place.
195+func TestCascadeRestoreBandMemory(t *testing.T) {
196+ t.Run("cycle then maximize", func(t *testing.T) {
197+ _, w := cascadeMovesCore(t, 3, 3, false)
198+ w.FocusID(2)
199+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
200+ w.ToggleFullWidth() // {1} {2*} {3}
201+ w.ToggleFullWidth() // {1 2 3}
202+ wantBands(t, w, []WindowID{1, 2, 3})
203+ })
204+ t.Run("first window of a pushed column closed", func(t *testing.T) {
205+ _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
206+ w.FocusID(4)
207+ w.ConsumeOrExpel(-1) // {1 2 3/4}
208+ w.FocusID(2)
209+ w.ToggleFullWidth() // {1} {2*} {3/4}
210+ w.RemoveWindow(3)
211+ w.FocusID(2)
212+ w.ToggleFullWidth()
213+ wantBands(t, w, []WindowID{1, 2, 4})
214+ })
215+ t.Run("no room keeps the other band as it is", func(t *testing.T) {
216+ c, w := cascadeMovesCore(t, 3, 3, false)
217+ w.FocusID(2)
218+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
219+ w.AddWindow(6) // {1 2 6} {3}
220+ w.FocusID(3)
221+ w.AddWindow(5) // {1 2 6} {3 5}
222+ w.AddWindow(4) // {1 2 6} {3 5 4}
223+ c.applyAction(ActionMoveColumnLeft)
224+ c.applyAction(ActionMoveColumnLeft) // {1 2 6} {4 3 5}
225+ w.FocusID(2)
226+ w.Apply(ActionCycleColumnWidth) // End of the cycle: 3 has no room.
227+ wantBands(t, w, []WindowID{1, 2, 6}, []WindowID{4, 3, 5})
228+ if got := cellsOf(w)[:3]; !slices.Equal(got, []int{0, 0, 0}) {
229+ t.Fatalf("cells %v, want equal", got)
230+ }
231+ })
232+}
233+
234+// Quiet placement (window rules) and slot windows join the focused band
235+// like a new window, keeping the focus.
236+func TestCascadeQuietAndSlotPlacement(t *testing.T) {
237+ _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
238+ w.FocusID(4)
239+ w.addColumnQuiet(Column{Windows: []WindowID{6}})
240+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
241+ w.FocusID(2)
242+ w.addColumnQuiet(Column{Windows: []WindowID{7}})
243+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
244+ if id, _ := w.Focused(); id != 2 {
245+ t.Fatalf("focus %d, want 2", id)
246+ }
247+
248+ _, w = cascadeMovesCore(t, 2, 3, false) // {1 2}
249+ w.AddSlotWindow(9, 1, 0)
250+ wantBands(t, w, []WindowID{9, 1, 2})
251+ if id, _ := w.Focused(); id != 2 {
252+ t.Fatalf("focus %d, want 2", id)
253+ }
254+}
255+
256+// Reapplying the config with a named max-columns above the default keeps
257+// the named workspace's bands.
258+func TestCascadeNamedMaxColumnsKeepsBands(t *testing.T) {
259+ m := newMonitor("", "")
260+ m.SetOutput(900, 600)
261+ m.SetOverflow(OverflowCascade)
262+ m.SetMaxColumns(3)
263+ m.SetNamed([]NamedWorkspace{{Name: "wide", MaxColumns: 5}})
264+ m.ToggleNamed("wide")
265+ w := m.Current()
266+ for id := WindowID(1); id <= 5; id++ {
267+ w.AddWindow(id)
268+ }
269+ wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
270+ m.SetMaxColumns(3)
271+ wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
272+}
273+
184274 func bandIDs(w *Workspace) [][]WindowID {
185275 var out [][]WindowID
186276 w.walkBands(func(_, first, last int) bool {
@@ -102,18 +102,26 @@ func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) {
102 wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})102 wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
103 }103 }
104 104
105-// No column is hidden by a maximized cascade column.105+// No column is hidden by a maximized cascade column: focusing each band
106+// shows its windows.
106 func TestCascadeMaximizeHidesNothing(t *testing.T) {107 func TestCascadeMaximizeHidesNothing(t *testing.T) {
107 _, w := cascadeMovesCore(t, 3, 3, false)108 _, w := cascadeMovesCore(t, 3, 3, false)
108 w.FocusID(2)109 w.FocusID(2)
109- w.ToggleFullWidth()110+ w.ToggleFullWidth() // {1} {2*} {3}
110- for _, p := range w.Layout() {111+ for id := WindowID(1); id <= 3; id++ {
111- if p.Hidden && p.ID != 0 {112+ w.FocusID(id)
112- // Off-screen bands are laid out, not hidden by maximize.113+ var found bool
113- if w.hiddenByMaximized(w.columnOf(p.ID)) {114+ for _, p := range w.Layout() {
114- t.Fatalf("window %d hidden by maximize", p.ID)115+ if p.ID == id {
116+ found = true
117+ if p.Hidden || p.Rect.W <= 0 || p.Rect.H <= 0 {
118+ t.Fatalf("window %d not shown: %+v", id, p)
119+ }
115 }120 }
116 }121 }
122+ if !found {
123+ t.Fatalf("no placement for window %d", id)
124+ }
117 }125 }
118 }126 }
119 127
@@ -181,6 +189,88 @@ func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) {
181 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4})189 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4})
182 }190 }
183 191
192+// Review regressions: push memory survives a maximize in between, finds a
193+// column whose first window closed, and leaves columns without room in
194+// place.
195+func TestCascadeRestoreBandMemory(t *testing.T) {
196+ t.Run("cycle then maximize", func(t *testing.T) {
197+ _, w := cascadeMovesCore(t, 3, 3, false)
198+ w.FocusID(2)
199+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
200+ w.ToggleFullWidth() // {1} {2*} {3}
201+ w.ToggleFullWidth() // {1 2 3}
202+ wantBands(t, w, []WindowID{1, 2, 3})
203+ })
204+ t.Run("first window of a pushed column closed", func(t *testing.T) {
205+ _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
206+ w.FocusID(4)
207+ w.ConsumeOrExpel(-1) // {1 2 3/4}
208+ w.FocusID(2)
209+ w.ToggleFullWidth() // {1} {2*} {3/4}
210+ w.RemoveWindow(3)
211+ w.FocusID(2)
212+ w.ToggleFullWidth()
213+ wantBands(t, w, []WindowID{1, 2, 4})
214+ })
215+ t.Run("no room keeps the other band as it is", func(t *testing.T) {
216+ c, w := cascadeMovesCore(t, 3, 3, false)
217+ w.FocusID(2)
218+ w.Apply(ActionCycleColumnWidth) // {1 2} {3}
219+ w.AddWindow(6) // {1 2 6} {3}
220+ w.FocusID(3)
221+ w.AddWindow(5) // {1 2 6} {3 5}
222+ w.AddWindow(4) // {1 2 6} {3 5 4}
223+ c.applyAction(ActionMoveColumnLeft)
224+ c.applyAction(ActionMoveColumnLeft) // {1 2 6} {4 3 5}
225+ w.FocusID(2)
226+ w.Apply(ActionCycleColumnWidth) // End of the cycle: 3 has no room.
227+ wantBands(t, w, []WindowID{1, 2, 6}, []WindowID{4, 3, 5})
228+ if got := cellsOf(w)[:3]; !slices.Equal(got, []int{0, 0, 0}) {
229+ t.Fatalf("cells %v, want equal", got)
230+ }
231+ })
232+}
233+
234+// Quiet placement (window rules) and slot windows join the focused band
235+// like a new window, keeping the focus.
236+func TestCascadeQuietAndSlotPlacement(t *testing.T) {
237+ _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
238+ w.FocusID(4)
239+ w.addColumnQuiet(Column{Windows: []WindowID{6}})
240+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
241+ w.FocusID(2)
242+ w.addColumnQuiet(Column{Windows: []WindowID{7}})
243+ wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
244+ if id, _ := w.Focused(); id != 2 {
245+ t.Fatalf("focus %d, want 2", id)
246+ }
247+
248+ _, w = cascadeMovesCore(t, 2, 3, false) // {1 2}
249+ w.AddSlotWindow(9, 1, 0)
250+ wantBands(t, w, []WindowID{9, 1, 2})
251+ if id, _ := w.Focused(); id != 2 {
252+ t.Fatalf("focus %d, want 2", id)
253+ }
254+}
255+
256+// Reapplying the config with a named max-columns above the default keeps
257+// the named workspace's bands.
258+func TestCascadeNamedMaxColumnsKeepsBands(t *testing.T) {
259+ m := newMonitor("", "")
260+ m.SetOutput(900, 600)
261+ m.SetOverflow(OverflowCascade)
262+ m.SetMaxColumns(3)
263+ m.SetNamed([]NamedWorkspace{{Name: "wide", MaxColumns: 5}})
264+ m.ToggleNamed("wide")
265+ w := m.Current()
266+ for id := WindowID(1); id <= 5; id++ {
267+ w.AddWindow(id)
268+ }
269+ wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
270+ m.SetMaxColumns(3)
271+ wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
272+}
273+
184 func bandIDs(w *Workspace) [][]WindowID {274 func bandIDs(w *Workspace) [][]WindowID {
185 var out [][]WindowID275 var out [][]WindowID
186 w.walkBands(func(_, first, last int) bool {276 w.walkBands(func(_, first, last int) bool {
M internal/core/layout.go +11 -6
@@ -33,10 +33,10 @@ type Column struct {
3333 // BandStart marks the first column of a cascade band. It belongs to the
3434 // position, not the column: swaps leave it in place. See cascade.go.
3535 BandStart bool
36- // pushedUp and pushedDown are the columns (by first window) that
36+ // pushedUp and pushedDown are the columns, each by its windows, that
3737 // maximize-column or cycle-column-width sent to the band above or
3838 // below; they come back when the column is restored.
39- pushedUp, pushedDown []WindowID
39+ pushedUp, pushedDown [][]WindowID
4040 }
4141 type Placement struct {
4242 ID WindowID
@@ -576,7 +576,7 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
576576 at = i + 1
577577 }
578578 }
579- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
579+ w.insertAt(at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
580580 if len(w.Columns) > 1 && at <= w.Focus {
581581 w.Focus++
582582 }
@@ -1024,14 +1024,19 @@ func (w *Workspace) insertColumn(at int, col Column) {
10241024 if w.policy().maximizeHides() && len(w.Columns) > 0 {
10251025 w.unmaximize()
10261026 }
1027- // In a cascade the column goes before column at, in its band.
1027+ w.insertAt(at, col)
1028+ w.Focus = at
1029+ w.scroll()
1030+}
1031+
1032+// insertAt inserts col at index at; in a cascade it joins the band of the
1033+// column it goes before.
1034+func (w *Workspace) insertAt(at int, col Column) {
10281035 col.BandStart = false
10291036 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
10301037 col.BandStart, w.Columns[at].BandStart = true, false
10311038 }
10321039 w.Columns = slices.Insert(w.Columns, at, col)
1033- w.Focus = at
1034- w.scroll()
10351040 }
10361041
10371042 // GrowColumn widens the focused column by one cell, taking it from another
@@ -33,10 +33,10 @@ type Column struct {
33 // BandStart marks the first column of a cascade band. It belongs to the33 // 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.34 // position, not the column: swaps leave it in place. See cascade.go.
35 BandStart bool35 BandStart bool
36- // pushedUp and pushedDown are the columns (by first window) that36+ // pushedUp and pushedDown are the columns, each by its windows, that
37 // maximize-column or cycle-column-width sent to the band above or37 // maximize-column or cycle-column-width sent to the band above or
38 // below; they come back when the column is restored.38 // below; they come back when the column is restored.
39- pushedUp, pushedDown []WindowID39+ pushedUp, pushedDown [][]WindowID
40 }40 }
41 type Placement struct {41 type Placement struct {
42 ID WindowID42 ID WindowID
@@ -576,7 +576,7 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
576 at = i + 1576 at = i + 1
577 }577 }
578 }578 }
579- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})579+ w.insertAt(at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
580 if len(w.Columns) > 1 && at <= w.Focus {580 if len(w.Columns) > 1 && at <= w.Focus {
581 w.Focus++581 w.Focus++
582 }582 }
@@ -1024,14 +1024,19 @@ func (w *Workspace) insertColumn(at int, col Column) {
1024 if w.policy().maximizeHides() && len(w.Columns) > 0 {1024 if w.policy().maximizeHides() && len(w.Columns) > 0 {
1025 w.unmaximize()1025 w.unmaximize()
1026 }1026 }
1027- // In a cascade the column goes before column at, in its band.1027+ w.insertAt(at, col)
1028+ w.Focus = at
1029+ w.scroll()
1030+}
1031+
1032+// insertAt inserts col at index at; in a cascade it joins the band of the
1033+// column it goes before.
1034+func (w *Workspace) insertAt(at int, col Column) {
1028 col.BandStart = false1035 col.BandStart = false
1029 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {1036 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
1030 col.BandStart, w.Columns[at].BandStart = true, false1037 col.BandStart, w.Columns[at].BandStart = true, false
1031 }1038 }
1032 w.Columns = slices.Insert(w.Columns, at, col)1039 w.Columns = slices.Insert(w.Columns, at, col)
1033- w.Focus = at
1034- w.scroll()
1035 }1040 }
1036 1041
1037 // GrowColumn widens the focused column by one cell, taking it from another1042 // GrowColumn widens the focused column by one cell, taking it from another
M internal/core/monitor.go +5 -2
@@ -585,10 +585,13 @@ func (m *Monitor) applyNamed() {
585585 w.view.stop()
586586 w.View = 0
587587 }
588- w.SetMaxColumns(m.template.MaxColumns)
588+ // One call with the effective value: a passing smaller one would
589+ // split cascade bands for good.
590+ n := m.template.MaxColumns
589591 if s.MaxColumns > 0 {
590- w.SetMaxColumns(s.MaxColumns)
592+ n = s.MaxColumns
591593 }
594+ w.SetMaxColumns(n)
592595 // Named workspaces take their configured size; any other keeps its own.
593596 if w.Name != "" {
594597 w.SetSize(s.Size[0], s.Size[1])
@@ -585,10 +585,13 @@ func (m *Monitor) applyNamed() {
585 w.view.stop()585 w.view.stop()
586 w.View = 0586 w.View = 0
587 }587 }
588- w.SetMaxColumns(m.template.MaxColumns)588+ // One call with the effective value: a passing smaller one would
589+ // split cascade bands for good.
590+ n := m.template.MaxColumns
589 if s.MaxColumns > 0 {591 if s.MaxColumns > 0 {
590- w.SetMaxColumns(s.MaxColumns)592+ n = s.MaxColumns
591 }593 }
594+ w.SetMaxColumns(n)
592 // Named workspaces take their configured size; any other keeps its own.595 // Named workspaces take their configured size; any other keeps its own.
593 if w.Name != "" {596 if w.Name != "" {
594 w.SetSize(s.Size[0], s.Size[1])597 w.SetSize(s.Size[0], s.Size[1])
M internal/core/rules.go +12 -2
@@ -189,11 +189,21 @@ func (w *Workspace) addColumnQuiet(col Column) {
189189 w.Columns = slices.Insert(w.Columns, at, col)
190190 return
191191 }
192- if w.policy().equalCells {
192+ switch {
193+ case w.policy().wraps:
194+ // As addColumn: after the focused band, in it when it has room.
195+ at, col.BandStart = w.spawnIndex()
196+ w.Columns = slices.Insert(w.Columns, at, col)
197+ if len(w.Columns) > 1 && at <= w.Focus {
198+ w.Focus++
199+ }
200+ w.scroll()
201+ return
202+ case w.policy().equalCells:
193203 if at > 0 && w.policy().maximizeHides() {
194204 w.unmaximize()
195205 }
196- } else if at > 0 {
206+ case at > 0:
197207 at = w.Focus + 1
198208 }
199209 w.Columns = slices.Insert(w.Columns, at, col)
@@ -189,11 +189,21 @@ func (w *Workspace) addColumnQuiet(col Column) {
189 w.Columns = slices.Insert(w.Columns, at, col)189 w.Columns = slices.Insert(w.Columns, at, col)
190 return190 return
191 }191 }
192- if w.policy().equalCells {192+ switch {
193+ case w.policy().wraps:
194+ // As addColumn: after the focused band, in it when it has room.
195+ at, col.BandStart = w.spawnIndex()
196+ w.Columns = slices.Insert(w.Columns, at, col)
197+ if len(w.Columns) > 1 && at <= w.Focus {
198+ w.Focus++
199+ }
200+ w.scroll()
201+ return
202+ case w.policy().equalCells:
193 if at > 0 && w.policy().maximizeHides() {203 if at > 0 && w.policy().maximizeHides() {
194 w.unmaximize()204 w.unmaximize()
195 }205 }
196- } else if at > 0 {206+ case at > 0:
197 at = w.Focus + 1207 at = w.Focus + 1
198 }208 }
199 w.Columns = slices.Insert(w.Columns, at, col)209 w.Columns = slices.Insert(w.Columns, at, col)

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