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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 350 | 350 | return true | |
| 351 | 351 | } | |
| 352 | 352 | ||
| 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)) | |
| 356 | 357 | for i, c := range cols { | |
| 357 | - | ids[i] = c.Windows[0] | |
| 358 | + | ids[i] = slices.Clone(c.Windows) | |
| 358 | 359 | } | |
| 359 | 360 | return ids | |
| 360 | 361 | } | |
| @@ -374,7 +375,10 @@ func (w *Workspace) isolateBand() { | |||
| 374 | 375 | left := slices.Clone(bands[b][:p]) | |
| 375 | 376 | right := slices.Clone(bands[b][p+1:]) | |
| 376 | 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 | 382 | bands[b] = []Column{c} | |
| 379 | 383 | if len(right) > 0 { | |
| 380 | 384 | bands = w.sendBand(bands, b, 1, 0, right) | |
| @@ -396,19 +400,20 @@ func (w *Workspace) pushNeighbor() { | |||
| 396 | 400 | id, _ := w.Focused() | |
| 397 | 401 | c := &w.Columns[w.Focus] | |
| 398 | 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 | 404 | w.shiftBand(w.Focus+1, w.Focus+1, 1, 0) | |
| 401 | 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 | 407 | w.shiftBand(w.Focus-1, w.Focus-1, -1, max(w.MaxColumns, 1)) | |
| 404 | 408 | } | |
| 405 | 409 | w.Focus = w.columnOf(id) | |
| 406 | 410 | } | |
| 407 | 411 | ||
| 408 | 412 | // 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. | |
| 412 | 417 | func (w *Workspace) restoreBand() bool { | |
| 413 | 418 | c := &w.Columns[w.Focus] | |
| 414 | 419 | up, down := c.pushedUp, c.pushedDown | |
| @@ -419,34 +424,42 @@ func (w *Workspace) restoreBand() bool { | |||
| 419 | 424 | id, _ := w.Focused() | |
| 420 | 425 | bands := w.bandList() | |
| 421 | 426 | 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 { | |
| 424 | 432 | if nb < 0 || nb >= len(bands) { | |
| 425 | 433 | return nil | |
| 426 | 434 | } | |
| 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] | |
| 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 | 457 | 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 | 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) | |
| 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:]) | |
| 450 | 463 | w.setBands(bands, id) | |
| 451 | 464 | return true | |
| 452 | 465 | } | |
| @@ -350,11 +350,12 @@ func (w *Workspace) shiftBand(lo, hi, dir, at int) bool { | |||||
| 350 | return true | 350 | 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 ids | 360 | 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 sent | 412 | // restoreBand brings back the columns isolateBand or pushNeighbor sent | ||
| 409 | - | // away, those still in the adjacent band: the ones sent up before the | 413 | + | // 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 the | 414 | + | // 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.pushedDown | 419 | 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 []Column | 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 { | |||
| 424 | if nb < 0 || nb >= len(bands) { | 432 | if nb < 0 || nb >= len(bands) { | ||
| 425 | return nil | 433 | 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 got | 457 | 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 true | 464 | return true | ||
| 452 | } | 465 | } | ||
M internal/core/cascade_bands_test.go +97 -7
| @@ -102,18 +102,26 @@ func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) { | |||
| 102 | 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 | 107 | func TestCascadeMaximizeHidesNothing(t *testing.T) { | |
| 107 | 108 | _, w := cascadeMovesCore(t, 3, 3, false) | |
| 108 | 109 | 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 | + | } | |
| 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 | 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 | 274 | func bandIDs(w *Workspace) [][]WindowID { | |
| 185 | 275 | var out [][]WindowID | |
| 186 | 276 | 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 [][]WindowID | 275 | 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 { | |||
| 33 | 33 | // BandStart marks the first column of a cascade band. It belongs to the | |
| 34 | 34 | // position, not the column: swaps leave it in place. See cascade.go. | |
| 35 | 35 | 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 | |
| 37 | 37 | // maximize-column or cycle-column-width sent to the band above or | |
| 38 | 38 | // below; they come back when the column is restored. | |
| 39 | - | pushedUp, pushedDown []WindowID | |
| 39 | + | pushedUp, pushedDown [][]WindowID | |
| 40 | 40 | } | |
| 41 | 41 | type Placement struct { | |
| 42 | 42 | ID WindowID | |
| @@ -576,7 +576,7 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) { | |||
| 576 | 576 | 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 | 580 | if len(w.Columns) > 1 && at <= w.Focus { | |
| 581 | 581 | w.Focus++ | |
| 582 | 582 | } | |
| @@ -1024,14 +1024,19 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||
| 1024 | 1024 | if w.policy().maximizeHides() && len(w.Columns) > 0 { | |
| 1025 | 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 | 1035 | col.BandStart = false | |
| 1029 | 1036 | if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart { | |
| 1030 | 1037 | col.BandStart, w.Columns[at].BandStart = true, false | |
| 1031 | 1038 | } | |
| 1032 | 1039 | w.Columns = slices.Insert(w.Columns, at, col) | |
| 1033 | - | w.Focus = at | |
| 1034 | - | w.scroll() | |
| 1035 | 1040 | } | |
| 1036 | 1041 | ||
| 1037 | 1042 | // 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 the | 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. | 34 | // position, not the column: swaps leave it in place. See cascade.go. | ||
| 35 | BandStart bool | 35 | 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 |
| 37 | // maximize-column or cycle-column-width sent to the band above or | 37 | // 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 []WindowID | 39 | + | pushedUp, pushedDown [][]WindowID |
| 40 | } | 40 | } | ||
| 41 | type Placement struct { | 41 | type Placement struct { | ||
| 42 | ID WindowID | 42 | ID WindowID | ||
| @@ -576,7 +576,7 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) { | |||||
| 576 | at = i + 1 | 576 | 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 = false | 1035 | 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, false | 1037 | 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 another | 1042 | // 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() { | |||
| 585 | 585 | w.view.stop() | |
| 586 | 586 | 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 | 591 | if s.MaxColumns > 0 { | |
| 590 | - | w.SetMaxColumns(s.MaxColumns) | |
| 592 | + | n = s.MaxColumns | |
| 591 | 593 | } | |
| 594 | + | w.SetMaxColumns(n) | |
| 592 | 595 | // Named workspaces take their configured size; any other keeps its own. | |
| 593 | 596 | if w.Name != "" { | |
| 594 | 597 | 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 = 0 | 586 | 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) { | |||
| 189 | 189 | w.Columns = slices.Insert(w.Columns, at, col) | |
| 190 | 190 | 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 | 203 | if at > 0 && w.policy().maximizeHides() { | |
| 194 | 204 | w.unmaximize() | |
| 195 | 205 | } | |
| 196 | - | } else if at > 0 { | |
| 206 | + | case at > 0: | |
| 197 | 207 | at = w.Focus + 1 | |
| 198 | 208 | } | |
| 199 | 209 | 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 | return | 190 | 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 + 1 | 207 | at = w.Focus + 1 | ||
| 198 | } | 208 | } | ||
| 199 | w.Columns = slices.Insert(w.Columns, at, col) | 209 | w.Columns = slices.Insert(w.Columns, at, col) | ||