fix(core): look past only fullscreen bands when restoring columns
fullscreenBand marks the column it puts in its own band; restoreBand looks one band farther only for marked columns, so a column the user moved past a band stays there.
- commit
- f716fc9d3b12837d7a9086ae1c96cbdcecb63320
- author
- brice · · 2026-10-23 00:56 UTC
- parent
- c55955b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +35 -10
M internal/core/cascade.go +12 -5
| @@ -371,6 +371,9 @@ func (w *Workspace) shiftBand(lo, hi, dir, at int) bool { | |||
| 371 | 371 | id, _ := w.Focused() | |
| 372 | 372 | bands := w.bandList() | |
| 373 | 373 | cols := slices.Clone(bands[b][lo-first : hi-first+1]) | |
| 374 | + | for k := range cols { | |
| 375 | + | cols[k].ownBand = false | |
| 376 | + | } | |
| 374 | 377 | bands[b] = slices.Delete(bands[b], lo-first, hi-first+1) | |
| 375 | 378 | w.setBands(w.sendBand(bands, b, dir, at, cols), id) | |
| 376 | 379 | return true | |
| @@ -461,6 +464,7 @@ func (w *Workspace) fullscreenBand(id WindowID) { | |||
| 461 | 464 | } | |
| 462 | 465 | bands[b][i-first] = col | |
| 463 | 466 | } | |
| 467 | + | nc.ownBand = true | |
| 464 | 468 | bands = slices.Insert(bands, b+1, []Column{nc}) | |
| 465 | 469 | w.setBands(bands, focus) | |
| 466 | 470 | } | |
| @@ -524,8 +528,12 @@ func (w *Workspace) restoreBand() bool { | |||
| 524 | 528 | if head < 0 { | |
| 525 | 529 | continue | |
| 526 | 530 | } | |
| 527 | - | for _, nb := range nbs { | |
| 528 | - | i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] }) | |
| 531 | + | for k, nb := range nbs { | |
| 532 | + | // Past the adjacent band, only a column fullscreenBand put in | |
| 533 | + | // a band of its own is taken: one the user moved there stays. | |
| 534 | + | i := slices.IndexFunc(bands[nb], func(c Column) bool { | |
| 535 | + | return c.Windows[0] == e[head] && (k == 0 || c.ownBand) | |
| 536 | + | }) | |
| 529 | 537 | if i < 0 { | |
| 530 | 538 | continue | |
| 531 | 539 | } | |
| @@ -541,13 +549,12 @@ func (w *Workspace) restoreBand() bool { | |||
| 541 | 549 | return got | |
| 542 | 550 | } | |
| 543 | 551 | // reach lists the bands dir away to look in: the adjacent one, and the | |
| 544 | - | // next when the adjacent holds a single column, as a fullscreen column | |
| 545 | - | // split off a band of its own meanwhile leaves. | |
| 552 | + | // next, where a column that fullscreenBand split off may lie. | |
| 546 | 553 | reach := func(dir int) []int { | |
| 547 | 554 | var nbs []int | |
| 548 | 555 | for n := b + dir; n >= 0 && n < len(bands); n += dir { | |
| 549 | 556 | nbs = append(nbs, n) | |
| 550 | - | if len(bands[n]) != 1 || len(nbs) == 2 { | |
| 557 | + | if len(nbs) == 2 { | |
| 551 | 558 | break | |
| 552 | 559 | } | |
| 553 | 560 | } | |
| @@ -371,6 +371,9 @@ func (w *Workspace) shiftBand(lo, hi, dir, at int) bool { | |||||
| 371 | id, _ := w.Focused() | 371 | id, _ := w.Focused() | ||
| 372 | bands := w.bandList() | 372 | bands := w.bandList() | ||
| 373 | cols := slices.Clone(bands[b][lo-first : hi-first+1]) | 373 | cols := slices.Clone(bands[b][lo-first : hi-first+1]) | ||
| 374 | + | for k := range cols { | |||
| 375 | + | cols[k].ownBand = false | |||
| 376 | + | } | |||
| 374 | bands[b] = slices.Delete(bands[b], lo-first, hi-first+1) | 377 | bands[b] = slices.Delete(bands[b], lo-first, hi-first+1) | ||
| 375 | w.setBands(w.sendBand(bands, b, dir, at, cols), id) | 378 | w.setBands(w.sendBand(bands, b, dir, at, cols), id) | ||
| 376 | return true | 379 | return true | ||
| @@ -461,6 +464,7 @@ func (w *Workspace) fullscreenBand(id WindowID) { | |||||
| 461 | } | 464 | } | ||
| 462 | bands[b][i-first] = col | 465 | bands[b][i-first] = col | ||
| 463 | } | 466 | } | ||
| 467 | + | nc.ownBand = true | |||
| 464 | bands = slices.Insert(bands, b+1, []Column{nc}) | 468 | bands = slices.Insert(bands, b+1, []Column{nc}) | ||
| 465 | w.setBands(bands, focus) | 469 | w.setBands(bands, focus) | ||
| 466 | } | 470 | } | ||
| @@ -524,8 +528,12 @@ func (w *Workspace) restoreBand() bool { | |||||
| 524 | if head < 0 { | 528 | if head < 0 { | ||
| 525 | continue | 529 | continue | ||
| 526 | } | 530 | } | ||
| 527 | - | for _, nb := range nbs { | 531 | + | for k, nb := range nbs { |
| 528 | - | i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] }) | 532 | + | // Past the adjacent band, only a column fullscreenBand put in |
| 533 | + | // a band of its own is taken: one the user moved there stays. | |||
| 534 | + | i := slices.IndexFunc(bands[nb], func(c Column) bool { | |||
| 535 | + | return c.Windows[0] == e[head] && (k == 0 || c.ownBand) | |||
| 536 | + | }) | |||
| 529 | if i < 0 { | 537 | if i < 0 { | ||
| 530 | continue | 538 | continue | ||
| 531 | } | 539 | } | ||
| @@ -541,13 +549,12 @@ func (w *Workspace) restoreBand() bool { | |||||
| 541 | return got | 549 | return got | ||
| 542 | } | 550 | } | ||
| 543 | // reach lists the bands dir away to look in: the adjacent one, and the | 551 | // reach lists the bands dir away to look in: the adjacent one, and the | ||
| 544 | - | // next when the adjacent holds a single column, as a fullscreen column | 552 | + | // next, where a column that fullscreenBand split off may lie. |
| 545 | - | // split off a band of its own meanwhile leaves. | |||
| 546 | reach := func(dir int) []int { | 553 | reach := func(dir int) []int { | ||
| 547 | var nbs []int | 554 | var nbs []int | ||
| 548 | for n := b + dir; n >= 0 && n < len(bands); n += dir { | 555 | for n := b + dir; n >= 0 && n < len(bands); n += dir { | ||
| 549 | nbs = append(nbs, n) | 556 | nbs = append(nbs, n) | ||
| 550 | - | if len(bands[n]) != 1 || len(nbs) == 2 { | 557 | + | if len(nbs) == 2 { |
| 551 | break | 558 | break | ||
| 552 | } | 559 | } | ||
| 553 | } | 560 | } | ||
M internal/core/cascade_fullscreen_band_test.go +19 -5
| @@ -239,12 +239,12 @@ func TestCascadeFullscreenRestoresAcrossBand(t *testing.T) { | |||
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | 241 | func TestFullscreenMoveDestinations(t *testing.T) { | |
| 242 | - | t.Run("mayCover refuses", func(t *testing.T) { | |
| 243 | - | // A fixed destination refuses a cover unless the window is focused; | |
| 244 | - | // the arrival is focused, so it ends fullscreen. | |
| 242 | + | t.Run("arrival focused despite left mark", func(t *testing.T) { | |
| 243 | + | // The destination has a pending "left" mark on the window, which | |
| 244 | + | // mayCover would refuse unless it is focused; the arrival is | |
| 245 | + | // focused, so it ends fullscreen. | |
| 245 | 246 | c, w := cascadeMovesCore(t, 2, 3, true) | |
| 246 | 247 | dst := screenWorkspace(c, "R") | |
| 247 | - | dst.Overflow = OverflowFixed | |
| 248 | 248 | dst.left = 2 | |
| 249 | 249 | w.ToggleFullscreen() | |
| 250 | 250 | c.applyAction(ActionMoveColumnRight) | |
| @@ -253,7 +253,7 @@ func TestFullscreenMoveDestinations(t *testing.T) { | |||
| 253 | 253 | t.Fatalf("fullscreen %d, want 2", dst.fullscreen) | |
| 254 | 254 | } | |
| 255 | 255 | }) | |
| 256 | - | t.Run("stacked column in a band", func(t *testing.T) { | |
| 256 | + | t.Run("fullscreen window expelled from a stack then moved", func(t *testing.T) { | |
| 257 | 257 | c, w := cascadeMovesCore(t, 3, 3, true) | |
| 258 | 258 | w.FocusID(3) | |
| 259 | 259 | c.applyAction(ActionConsumeOrExpelLeft) // 3 under 2 | |
| @@ -281,3 +281,17 @@ func TestFullscreenMoveDestinations(t *testing.T) { | |||
| 281 | 281 | } | |
| 282 | 282 | }) | |
| 283 | 283 | } | |
| 284 | + | ||
| 285 | + | // A band the user shaped is not looked past: P, pushed down from F, then | |
| 286 | + | // moved past Y on purpose, stays there. | |
| 287 | + | func TestRestoreBandDoesNotLookPastUserBand(t *testing.T) { | |
| 288 | + | c, w := cascadeMovesCore(t, 3, 3, false) | |
| 289 | + | w.FocusID(1) | |
| 290 | + | w.ToggleFullWidth() // {1} {2 3} | |
| 291 | + | w.FocusID(2) | |
| 292 | + | c.applyAction(ActionMoveWindowDown) // {1} {3} {2} | |
| 293 | + | wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2}) | |
| 294 | + | w.FocusID(1) | |
| 295 | + | w.ToggleFullWidth() | |
| 296 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{2}) | |
| 297 | + | } | |
| @@ -239,12 +239,12 @@ func TestCascadeFullscreenRestoresAcrossBand(t *testing.T) { | |||||
| 239 | } | 239 | } | ||
| 240 | 240 | ||||
| 241 | func TestFullscreenMoveDestinations(t *testing.T) { | 241 | func TestFullscreenMoveDestinations(t *testing.T) { | ||
| 242 | - | t.Run("mayCover refuses", func(t *testing.T) { | 242 | + | t.Run("arrival focused despite left mark", func(t *testing.T) { |
| 243 | - | // A fixed destination refuses a cover unless the window is focused; | 243 | + | // The destination has a pending "left" mark on the window, which |
| 244 | - | // the arrival is focused, so it ends fullscreen. | 244 | + | // mayCover would refuse unless it is focused; the arrival is |
| 245 | + | // focused, so it ends fullscreen. | |||
| 245 | c, w := cascadeMovesCore(t, 2, 3, true) | 246 | c, w := cascadeMovesCore(t, 2, 3, true) | ||
| 246 | dst := screenWorkspace(c, "R") | 247 | dst := screenWorkspace(c, "R") | ||
| 247 | - | dst.Overflow = OverflowFixed | |||
| 248 | dst.left = 2 | 248 | dst.left = 2 | ||
| 249 | w.ToggleFullscreen() | 249 | w.ToggleFullscreen() | ||
| 250 | c.applyAction(ActionMoveColumnRight) | 250 | c.applyAction(ActionMoveColumnRight) | ||
| @@ -253,7 +253,7 @@ func TestFullscreenMoveDestinations(t *testing.T) { | |||||
| 253 | t.Fatalf("fullscreen %d, want 2", dst.fullscreen) | 253 | t.Fatalf("fullscreen %d, want 2", dst.fullscreen) | ||
| 254 | } | 254 | } | ||
| 255 | }) | 255 | }) | ||
| 256 | - | t.Run("stacked column in a band", func(t *testing.T) { | 256 | + | t.Run("fullscreen window expelled from a stack then moved", func(t *testing.T) { |
| 257 | c, w := cascadeMovesCore(t, 3, 3, true) | 257 | c, w := cascadeMovesCore(t, 3, 3, true) | ||
| 258 | w.FocusID(3) | 258 | w.FocusID(3) | ||
| 259 | c.applyAction(ActionConsumeOrExpelLeft) // 3 under 2 | 259 | c.applyAction(ActionConsumeOrExpelLeft) // 3 under 2 | ||
| @@ -281,3 +281,17 @@ func TestFullscreenMoveDestinations(t *testing.T) { | |||||
| 281 | } | 281 | } | ||
| 282 | }) | 282 | }) | ||
| 283 | } | 283 | } | ||
| 284 | + | ||||
| 285 | + | // A band the user shaped is not looked past: P, pushed down from F, then | |||
| 286 | + | // moved past Y on purpose, stays there. | |||
| 287 | + | func TestRestoreBandDoesNotLookPastUserBand(t *testing.T) { | |||
| 288 | + | c, w := cascadeMovesCore(t, 3, 3, false) | |||
| 289 | + | w.FocusID(1) | |||
| 290 | + | w.ToggleFullWidth() // {1} {2 3} | |||
| 291 | + | w.FocusID(2) | |||
| 292 | + | c.applyAction(ActionMoveWindowDown) // {1} {3} {2} | |||
| 293 | + | wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2}) | |||
| 294 | + | w.FocusID(1) | |||
| 295 | + | w.ToggleFullWidth() | |||
| 296 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{2}) | |||
| 297 | + | } | |||
M internal/core/layout.go +4 -0
| @@ -40,6 +40,10 @@ type Column struct { | |||
| 40 | 40 | // maximize-column or cycle-column-width sent to the band above or | |
| 41 | 41 | // below; they come back when the column is restored. | |
| 42 | 42 | pushedUp, pushedDown [][]WindowID | |
| 43 | + | // ownBand marks a column fullscreenBand put in a band of its own: | |
| 44 | + | // restoreBand looks past such a band. shiftBand clears it when the | |
| 45 | + | // column moves to another band on purpose. | |
| 46 | + | ownBand bool | |
| 43 | 47 | } | |
| 44 | 48 | type Placement struct { | |
| 45 | 49 | ID WindowID | |
| @@ -40,6 +40,10 @@ type Column struct { | |||||
| 40 | // maximize-column or cycle-column-width sent to the band above or | 40 | // maximize-column or cycle-column-width sent to the band above or | ||
| 41 | // below; they come back when the column is restored. | 41 | // below; they come back when the column is restored. | ||
| 42 | pushedUp, pushedDown [][]WindowID | 42 | pushedUp, pushedDown [][]WindowID | ||
| 43 | + | // ownBand marks a column fullscreenBand put in a band of its own: | |||
| 44 | + | // restoreBand looks past such a band. shiftBand clears it when the | |||
| 45 | + | // column moves to another band on purpose. | |||
| 46 | + | ownBand bool | |||
| 43 | } | 47 | } | ||
| 44 | type Placement struct { | 48 | type Placement struct { | ||
| 45 | ID WindowID | 49 | ID WindowID | ||