detached c55955b

fix(core): restore every remembered column across a fullscreen band

restoreBand looks past a single-column adjacent band, move-column
dispatches fullscreen windows through one branch, and the fullscreen
tests cover stacked, populated-destination and demoted-float cases.
commit
c55955b353be20c11e800406ae8f1b964b88a0cf
author
brice · · 2026-10-22 20:28 UTC
parent
aa552bf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
1/1
refs
feat/cascade-fullscreen-band
3 files · +126 -65
M internal/core/action.go +30 -22
@@ -388,32 +388,43 @@ func (c *Core) dispatchAction(a Action) Effect {
388388 if a == ActionMoveColumnLeft {
389389 dir, d = -1, dirLeft
390390 }
391- // A fullscreen float goes to the neighbor screen, fullscreen there.
392- if fid, ok := w.Focused(); ok && w.fullscreen == fid && w.floatIndex(fid) >= 0 {
393- if i := c.neighbor(d); i >= 0 {
394- from := c.cur()
395- c.focusScreen = i
396- moveFloat(w, c.cur().mon.Current(), fid)
397- w.reconcileFloats()
398- from.mon.normalize()
399- c.cur().mon.normalize()
400- }
401- return Effect{}
402- }
403- // A fullscreen window leaves for the neighbor screen at once, and
404- // stays fullscreen there; with no screen, nothing moves.
405- fid, _ := w.Focused()
406- full := fid != 0 && !w.onFloat() && w.fullscreen == fid
391+ // A fullscreen window (tiled or floating) leaves for the neighbor
392+ // screen at once and stays fullscreen there; with no screen,
393+ // nothing moves.
407394 i := c.neighbor(d)
408- if full && i < 0 {
395+ if fid, ok := w.Focused(); ok && w.fullscreen == fid && (w.floatIndex(fid) >= 0 || !w.onFloat()) {
396+ if i < 0 {
397+ return Effect{}
398+ }
399+ var col Column
400+ if w.floatIndex(fid) < 0 {
401+ if col, ok = w.takeColumn(); !ok {
402+ return Effect{}
403+ }
404+ }
405+ w.reconcileFloats() // floats the fullscreen kept demoted show again
406+ from := c.cur()
407+ c.focusScreen = i
408+ to := c.cur().mon.Current()
409+ if len(col.Windows) == 0 {
410+ moveFloat(w, to, fid)
411+ } else {
412+ at := 0
413+ if d == dirLeft {
414+ at = len(to.Columns)
415+ }
416+ to.receive(col, at)
417+ to.SetFullscreen(fid, true)
418+ }
419+ from.mon.normalize()
420+ c.cur().mon.normalize()
409421 return Effect{}
410422 }
411- if i >= 0 && (full || w.leavesBand(w.Focus+dir)) {
423+ if i >= 0 && w.leavesBand(w.Focus+dir) {
412424 col, ok := w.takeColumn()
413425 if !ok {
414426 return Effect{}
415427 }
416- w.reconcileFloats() // floats the fullscreen kept demoted show again
417428 from := c.cur()
418429 c.focusScreen = i
419430 to := c.cur().mon.Current()
@@ -422,9 +433,6 @@ func (c *Core) dispatchAction(a Action) Effect {
422433 at = len(to.Columns)
423434 }
424435 to.receive(col, at)
425- if full {
426- to.SetFullscreen(fid, true)
427- }
428436 from.mon.normalize()
429437 c.cur().mon.normalize()
430438 return Effect{}
@@ -388,32 +388,43 @@ func (c *Core) dispatchAction(a Action) Effect {
388 if a == ActionMoveColumnLeft {388 if a == ActionMoveColumnLeft {
389 dir, d = -1, dirLeft389 dir, d = -1, dirLeft
390 }390 }
391- // A fullscreen float goes to the neighbor screen, fullscreen there.391+ // A fullscreen window (tiled or floating) leaves for the neighbor
392- if fid, ok := w.Focused(); ok && w.fullscreen == fid && w.floatIndex(fid) >= 0 {392+ // screen at once and stays fullscreen there; with no screen,
393- if i := c.neighbor(d); i >= 0 {393+ // nothing moves.
394- from := c.cur()
395- c.focusScreen = i
396- moveFloat(w, c.cur().mon.Current(), fid)
397- w.reconcileFloats()
398- from.mon.normalize()
399- c.cur().mon.normalize()
400- }
401- return Effect{}
402- }
403- // A fullscreen window leaves for the neighbor screen at once, and
404- // stays fullscreen there; with no screen, nothing moves.
405- fid, _ := w.Focused()
406- full := fid != 0 && !w.onFloat() && w.fullscreen == fid
407 i := c.neighbor(d)394 i := c.neighbor(d)
408- if full && i < 0 {395+ if fid, ok := w.Focused(); ok && w.fullscreen == fid && (w.floatIndex(fid) >= 0 || !w.onFloat()) {
396+ if i < 0 {
397+ return Effect{}
398+ }
399+ var col Column
400+ if w.floatIndex(fid) < 0 {
401+ if col, ok = w.takeColumn(); !ok {
402+ return Effect{}
403+ }
404+ }
405+ w.reconcileFloats() // floats the fullscreen kept demoted show again
406+ from := c.cur()
407+ c.focusScreen = i
408+ to := c.cur().mon.Current()
409+ if len(col.Windows) == 0 {
410+ moveFloat(w, to, fid)
411+ } else {
412+ at := 0
413+ if d == dirLeft {
414+ at = len(to.Columns)
415+ }
416+ to.receive(col, at)
417+ to.SetFullscreen(fid, true)
418+ }
419+ from.mon.normalize()
420+ c.cur().mon.normalize()
409 return Effect{}421 return Effect{}
410 }422 }
411- if i >= 0 && (full || w.leavesBand(w.Focus+dir)) {423+ if i >= 0 && w.leavesBand(w.Focus+dir) {
412 col, ok := w.takeColumn()424 col, ok := w.takeColumn()
413 if !ok {425 if !ok {
414 return Effect{}426 return Effect{}
415 }427 }
416- w.reconcileFloats() // floats the fullscreen kept demoted show again
417 from := c.cur()428 from := c.cur()
418 c.focusScreen = i429 c.focusScreen = i
419 to := c.cur().mon.Current()430 to := c.cur().mon.Current()
@@ -422,9 +433,6 @@ func (c *Core) dispatchAction(a Action) Effect {
422 at = len(to.Columns)433 at = len(to.Columns)
423 }434 }
424 to.receive(col, at)435 to.receive(col, at)
425- if full {
426- to.SetFullscreen(fid, true)
427- }
428 from.mon.normalize()436 from.mon.normalize()
429 c.cur().mon.normalize()437 c.cur().mon.normalize()
430 return Effect{}438 return Effect{}
M internal/core/cascade.go +26 -22
@@ -503,13 +503,10 @@ func (w *Workspace) restoreBand() bool {
503503 bands := w.bandList()
504504 b := w.band(w.Focus)
505505 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
506- // take removes from band nb the remembered columns found there, at
507- // most room of them, in the order of ids walked from the focused
506+ // take removes from the bands nbs the remembered columns found there,
507+ // at most room of them, in the order of ids walked from the focused
508508 // column outward (rev: ids end nearest).
509- take := func(nb int, ids [][]WindowID, rev bool) []Column {
510- if nb < 0 || nb >= len(bands) {
511- return nil
512- }
509+ take := func(nbs []int, ids [][]WindowID, rev bool) []Column {
513510 var got []Column
514511 for k := range ids {
515512 if room == 0 {
@@ -527,30 +524,37 @@ func (w *Workspace) restoreBand() bool {
527524 if head < 0 {
528525 continue
529526 }
530- i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
531- if i < 0 {
532- continue
527+ for _, nb := range nbs {
528+ i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
529+ if i < 0 {
530+ continue
531+ }
532+ got = append(got, bands[nb][i])
533+ bands[nb] = slices.Delete(bands[nb], i, i+1)
534+ room--
535+ break
533536 }
534- got = append(got, bands[nb][i])
535- bands[nb] = slices.Delete(bands[nb], i, i+1)
536- room--
537537 }
538538 if rev {
539539 slices.Reverse(got)
540540 }
541541 return got
542542 }
543- // A band the take left empty (a fullscreen column that split off its
544- // own band meanwhile) is looked past: the remembered columns may lie
545- // one band farther.
546- above := take(b-1, up, true)
547- if b >= 2 && len(bands[b-1]) == 0 {
548- above = slices.Concat(take(b-2, up, true), above)
549- }
550- below := take(b+1, down, false)
551- if b+2 < len(bands) && len(bands[b+1]) == 0 {
552- below = slices.Concat(below, take(b+2, down, false))
543+ // 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.
546+ reach := func(dir int) []int {
547+ var nbs []int
548+ for n := b + dir; n >= 0 && n < len(bands); n += dir {
549+ nbs = append(nbs, n)
550+ if len(bands[n]) != 1 || len(nbs) == 2 {
551+ break
552+ }
553+ }
554+ return nbs
553555 }
556+ above := take(reach(-1), up, true)
557+ below := take(reach(1), down, false)
554558 bands[b] = slices.Concat(above, bands[b], below)
555559 w.setBands(bands, id)
556560 return true
@@ -503,13 +503,10 @@ func (w *Workspace) restoreBand() bool {
503 bands := w.bandList()503 bands := w.bandList()
504 b := w.band(w.Focus)504 b := w.band(w.Focus)
505 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)505 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
506- // take removes from band nb the remembered columns found there, at506+ // take removes from the bands nbs the remembered columns found there,
507- // most room of them, in the order of ids walked from the focused507+ // at most room of them, in the order of ids walked from the focused
508 // column outward (rev: ids end nearest).508 // column outward (rev: ids end nearest).
509- take := func(nb int, ids [][]WindowID, rev bool) []Column {509+ take := func(nbs []int, ids [][]WindowID, rev bool) []Column {
510- if nb < 0 || nb >= len(bands) {
511- return nil
512- }
513 var got []Column510 var got []Column
514 for k := range ids {511 for k := range ids {
515 if room == 0 {512 if room == 0 {
@@ -527,30 +524,37 @@ func (w *Workspace) restoreBand() bool {
527 if head < 0 {524 if head < 0 {
528 continue525 continue
529 }526 }
530- i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })527+ for _, nb := range nbs {
531- if i < 0 {528+ i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
532- continue529+ if i < 0 {
530+ continue
531+ }
532+ got = append(got, bands[nb][i])
533+ bands[nb] = slices.Delete(bands[nb], i, i+1)
534+ room--
535+ break
533 }536 }
534- got = append(got, bands[nb][i])
535- bands[nb] = slices.Delete(bands[nb], i, i+1)
536- room--
537 }537 }
538 if rev {538 if rev {
539 slices.Reverse(got)539 slices.Reverse(got)
540 }540 }
541 return got541 return got
542 }542 }
543- // A band the take left empty (a fullscreen column that split off its543+ // reach lists the bands dir away to look in: the adjacent one, and the
544- // own band meanwhile) is looked past: the remembered columns may lie544+ // next when the adjacent holds a single column, as a fullscreen column
545- // one band farther.545+ // split off a band of its own meanwhile leaves.
546- above := take(b-1, up, true)546+ reach := func(dir int) []int {
547- if b >= 2 && len(bands[b-1]) == 0 {547+ var nbs []int
548- above = slices.Concat(take(b-2, up, true), above)548+ for n := b + dir; n >= 0 && n < len(bands); n += dir {
549- }549+ nbs = append(nbs, n)
550- below := take(b+1, down, false)550+ if len(bands[n]) != 1 || len(nbs) == 2 {
551- if b+2 < len(bands) && len(bands[b+1]) == 0 {551+ break
552- below = slices.Concat(below, take(b+2, down, false))552+ }
553+ }
554+ return nbs
553 }555 }
556+ above := take(reach(-1), up, true)
557+ below := take(reach(1), down, false)
554 bands[b] = slices.Concat(above, bands[b], below)558 bands[b] = slices.Concat(above, bands[b], below)
555 w.setBands(bands, id)559 w.setBands(bands, id)
556 return true560 return true
M internal/core/cascade_fullscreen_band_test.go +70 -21
@@ -175,13 +175,11 @@ func TestCascadeFullscreenAfterMaximizeRestores(t *testing.T) {
175175 _, w := cascadeMovesCore(t, 3, 3, false)
176176 w.FocusID(1)
177177 w.ToggleFullWidth()
178- before := columnOrder(w)
179178 w.FocusID(3)
180179 w.ToggleFullscreen()
181180 w.ToggleFullscreen()
182181 w.FocusID(1)
183182 w.ToggleFullWidth()
184- t.Logf("before %v bands after %v", before, columnOrder(w))
185183 wantBands(t, w, []WindowID{1, 2, 3})
186184 }
187185
@@ -210,25 +208,76 @@ func TestFullscreenMoveEdges(t *testing.T) {
210208 }
211209 })
212210 t.Run("source floats promoted", func(t *testing.T) {
213- for _, float := range []bool{false, true} {
214- c, w := cascadeMovesCore(t, 2, 3, true)
215- w.AddFloating(9, 100, 100)
216- w.SetFullscreen(9, true)
217- if !float {
218- w.FocusID(2)
219- w.ToggleFullscreen()
220- }
221- demoted := w.Floats[0].below
222- c.applyAction(ActionMoveColumnRight)
223- if float {
224- demoted = false
225- }
226- _ = demoted
227- for _, f := range w.Floats {
228- if f.below {
229- t.Fatalf("float=%v: float %d still demoted", float, f.ID)
230- }
231- }
211+ c, w := cascadeMovesCore(t, 2, 3, true)
212+ w.AddFloating(9, 100, 100)
213+ w.FocusID(2)
214+ w.ToggleFullscreen()
215+ // A fullscreen keeps a float demoted without it covering the output.
216+ w.Floats[0].below = true
217+ if len(w.Floats) != 1 || !w.Floats[0].below || w.fullscreen != 2 {
218+ t.Fatalf("setup: float not demoted: %+v", w.Floats)
219+ }
220+ c.applyAction(ActionMoveColumnRight)
221+ if len(w.Floats) != 1 || w.Floats[0].below {
222+ t.Fatalf("float still demoted: %+v", w.Floats)
223+ }
224+ })
225+}
226+
227+func TestCascadeFullscreenRestoresAcrossBand(t *testing.T) {
228+ _, w := cascadeMovesCore(t, 3, 3, false)
229+ w.FocusID(1)
230+ w.ToggleFullWidth() // X=1 alone; 2 and 3 pushed below
231+ wantBands(t, w, []WindowID{1}, []WindowID{2, 3})
232+ w.FocusID(2)
233+ w.ToggleFullscreen()
234+ wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2})
235+ w.ToggleFullscreen()
236+ w.FocusID(1)
237+ w.ToggleFullWidth()
238+ wantBands(t, w, []WindowID{1, 2, 3})
239+}
240+
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.
245+ c, w := cascadeMovesCore(t, 2, 3, true)
246+ dst := screenWorkspace(c, "R")
247+ dst.Overflow = OverflowFixed
248+ dst.left = 2
249+ w.ToggleFullscreen()
250+ c.applyAction(ActionMoveColumnRight)
251+ wantFocus(t, c, "R", 2)
252+ if dst.fullscreen != 2 {
253+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
254+ }
255+ })
256+ t.Run("stacked column in a band", func(t *testing.T) {
257+ c, w := cascadeMovesCore(t, 3, 3, true)
258+ w.FocusID(3)
259+ c.applyAction(ActionConsumeOrExpelLeft) // 3 under 2
260+ w.ToggleFullscreen() // 3 expelled to its own band
261+ c.applyAction(ActionMoveColumnLeft)
262+ wantColumns(t, w, 1, 2)
263+ dst := screenWorkspace(c, "L")
264+ wantColumns(t, dst, 3)
265+ if dst.fullscreen != 3 || len(w.Columns[1].Windows) != 1 {
266+ t.Fatalf("fullscreen %d, source column %v", dst.fullscreen, w.Columns[1].Windows)
267+ }
268+ })
269+ t.Run("destination with columns gets its own band", func(t *testing.T) {
270+ c, w := cascadeMovesCore(t, 1, 3, true)
271+ dst := screenWorkspace(c, "R")
272+ dst.Overflow = OverflowCascade
273+ dst.SetMaxColumns(3)
274+ dst.AddWindow(20)
275+ dst.AddWindow(21)
276+ w.ToggleFullscreen()
277+ c.applyAction(ActionMoveColumnRight)
278+ wantBands(t, dst, []WindowID{20, 21}, []WindowID{1})
279+ if dst.fullscreen != 1 {
280+ t.Fatalf("fullscreen %d", dst.fullscreen)
232281 }
233282 })
234283 }
@@ -175,13 +175,11 @@ func TestCascadeFullscreenAfterMaximizeRestores(t *testing.T) {
175 _, w := cascadeMovesCore(t, 3, 3, false)175 _, w := cascadeMovesCore(t, 3, 3, false)
176 w.FocusID(1)176 w.FocusID(1)
177 w.ToggleFullWidth()177 w.ToggleFullWidth()
178- before := columnOrder(w)
179 w.FocusID(3)178 w.FocusID(3)
180 w.ToggleFullscreen()179 w.ToggleFullscreen()
181 w.ToggleFullscreen()180 w.ToggleFullscreen()
182 w.FocusID(1)181 w.FocusID(1)
183 w.ToggleFullWidth()182 w.ToggleFullWidth()
184- t.Logf("before %v bands after %v", before, columnOrder(w))
185 wantBands(t, w, []WindowID{1, 2, 3})183 wantBands(t, w, []WindowID{1, 2, 3})
186 }184 }
187 185
@@ -210,25 +208,76 @@ func TestFullscreenMoveEdges(t *testing.T) {
210 }208 }
211 })209 })
212 t.Run("source floats promoted", func(t *testing.T) {210 t.Run("source floats promoted", func(t *testing.T) {
213- for _, float := range []bool{false, true} {211+ c, w := cascadeMovesCore(t, 2, 3, true)
214- c, w := cascadeMovesCore(t, 2, 3, true)212+ w.AddFloating(9, 100, 100)
215- w.AddFloating(9, 100, 100)213+ w.FocusID(2)
216- w.SetFullscreen(9, true)214+ w.ToggleFullscreen()
217- if !float {215+ // A fullscreen keeps a float demoted without it covering the output.
218- w.FocusID(2)216+ w.Floats[0].below = true
219- w.ToggleFullscreen()217+ if len(w.Floats) != 1 || !w.Floats[0].below || w.fullscreen != 2 {
220- }218+ t.Fatalf("setup: float not demoted: %+v", w.Floats)
221- demoted := w.Floats[0].below219+ }
222- c.applyAction(ActionMoveColumnRight)220+ c.applyAction(ActionMoveColumnRight)
223- if float {221+ if len(w.Floats) != 1 || w.Floats[0].below {
224- demoted = false222+ t.Fatalf("float still demoted: %+v", w.Floats)
225- }223+ }
226- _ = demoted224+ })
227- for _, f := range w.Floats {225+}
228- if f.below {226+
229- t.Fatalf("float=%v: float %d still demoted", float, f.ID)227+func TestCascadeFullscreenRestoresAcrossBand(t *testing.T) {
230- }228+ _, w := cascadeMovesCore(t, 3, 3, false)
231- }229+ w.FocusID(1)
230+ w.ToggleFullWidth() // X=1 alone; 2 and 3 pushed below
231+ wantBands(t, w, []WindowID{1}, []WindowID{2, 3})
232+ w.FocusID(2)
233+ w.ToggleFullscreen()
234+ wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2})
235+ w.ToggleFullscreen()
236+ w.FocusID(1)
237+ w.ToggleFullWidth()
238+ wantBands(t, w, []WindowID{1, 2, 3})
239+}
240+
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.
245+ c, w := cascadeMovesCore(t, 2, 3, true)
246+ dst := screenWorkspace(c, "R")
247+ dst.Overflow = OverflowFixed
248+ dst.left = 2
249+ w.ToggleFullscreen()
250+ c.applyAction(ActionMoveColumnRight)
251+ wantFocus(t, c, "R", 2)
252+ if dst.fullscreen != 2 {
253+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
254+ }
255+ })
256+ t.Run("stacked column in a band", func(t *testing.T) {
257+ c, w := cascadeMovesCore(t, 3, 3, true)
258+ w.FocusID(3)
259+ c.applyAction(ActionConsumeOrExpelLeft) // 3 under 2
260+ w.ToggleFullscreen() // 3 expelled to its own band
261+ c.applyAction(ActionMoveColumnLeft)
262+ wantColumns(t, w, 1, 2)
263+ dst := screenWorkspace(c, "L")
264+ wantColumns(t, dst, 3)
265+ if dst.fullscreen != 3 || len(w.Columns[1].Windows) != 1 {
266+ t.Fatalf("fullscreen %d, source column %v", dst.fullscreen, w.Columns[1].Windows)
267+ }
268+ })
269+ t.Run("destination with columns gets its own band", func(t *testing.T) {
270+ c, w := cascadeMovesCore(t, 1, 3, true)
271+ dst := screenWorkspace(c, "R")
272+ dst.Overflow = OverflowCascade
273+ dst.SetMaxColumns(3)
274+ dst.AddWindow(20)
275+ dst.AddWindow(21)
276+ w.ToggleFullscreen()
277+ c.applyAction(ActionMoveColumnRight)
278+ wantBands(t, dst, []WindowID{20, 21}, []WindowID{1})
279+ if dst.fullscreen != 1 {
280+ t.Fatalf("fullscreen %d", dst.fullscreen)
232 }281 }
233 })282 })
234 }283 }

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