detached aa552bf

fix(core): harden the fullscreen cascade band

Re-requests no longer re-split bands, the expelled column drops stale
shares and slot only when the slot window left, the source workspace
promotes its demoted floats after a fullscreen move, and restoreBand
looks past an emptied band so maximize round-trips still restore.
commit
aa552bf9a14032dcd310ff2b88d92502b9213b2b
author
brice · · 2026-10-22 19:51 UTC
parent
c73000c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +137 -9
M internal/core/action.go +3 -1
@@ -394,6 +394,7 @@ func (c *Core) dispatchAction(a Action) Effect {
394394 from := c.cur()
395395 c.focusScreen = i
396396 moveFloat(w, c.cur().mon.Current(), fid)
397+ w.reconcileFloats()
397398 from.mon.normalize()
398399 c.cur().mon.normalize()
399400 }
@@ -412,6 +413,7 @@ func (c *Core) dispatchAction(a Action) Effect {
412413 if !ok {
413414 return Effect{}
414415 }
416+ w.reconcileFloats() // floats the fullscreen kept demoted show again
415417 from := c.cur()
416418 c.focusScreen = i
417419 to := c.cur().mon.Current()
@@ -421,7 +423,7 @@ func (c *Core) dispatchAction(a Action) Effect {
421423 }
422424 to.receive(col, at)
423425 if full {
424- to.ToggleFullscreen()
426+ to.SetFullscreen(fid, true)
425427 }
426428 from.mon.normalize()
427429 c.cur().mon.normalize()
@@ -394,6 +394,7 @@ func (c *Core) dispatchAction(a Action) Effect {
394 from := c.cur()394 from := c.cur()
395 c.focusScreen = i395 c.focusScreen = i
396 moveFloat(w, c.cur().mon.Current(), fid)396 moveFloat(w, c.cur().mon.Current(), fid)
397+ w.reconcileFloats()
397 from.mon.normalize()398 from.mon.normalize()
398 c.cur().mon.normalize()399 c.cur().mon.normalize()
399 }400 }
@@ -412,6 +413,7 @@ func (c *Core) dispatchAction(a Action) Effect {
412 if !ok {413 if !ok {
413 return Effect{}414 return Effect{}
414 }415 }
416+ w.reconcileFloats() // floats the fullscreen kept demoted show again
415 from := c.cur()417 from := c.cur()
416 c.focusScreen = i418 c.focusScreen = i
417 to := c.cur().mon.Current()419 to := c.cur().mon.Current()
@@ -421,7 +423,7 @@ func (c *Core) dispatchAction(a Action) Effect {
421 }423 }
422 to.receive(col, at)424 to.receive(col, at)
423 if full {425 if full {
424- to.ToggleFullscreen()426+ to.SetFullscreen(fid, true)
425 }427 }
426 from.mon.normalize()428 from.mon.normalize()
427 c.cur().mon.normalize()429 c.cur().mon.normalize()
M internal/core/cascade.go +19 -3
@@ -438,18 +438,25 @@ func (w *Workspace) fullscreenBand(id WindowID) {
438438 }
439439 fc := w.Columns[w.Focus]
440440 focus := fc.Windows[fc.Focus]
441- w.dropSlotOf(id)
442441 bands := w.bandList()
443442 col := bands[b][i-first]
444443 nc := col
445444 if len(col.Windows) == 1 {
445+ // The column leaves its lane, as deleteColumn: manual widths of the
446+ // lane no longer add up. It keeps its slot and its own width.
447+ w.clearSpans(i)
446448 bands[b] = slices.Delete(bands[b], i-first, i-first+1)
447449 } else {
448450 j := slices.Index(col.Windows, id)
451+ // The expelled window gets a plain column: no Span (a manual width
452+ // is the stack's, and the new band is alone), no shares.
449453 nc = Column{Windows: []WindowID{id}, Cells: col.Cells}
450454 col.Windows = slices.Delete(slices.Clone(col.Windows), j, j+1)
451- switch {
452- case col.Focus > j, col.Focus >= len(col.Windows):
455+ col.Shares = nil
456+ if j == 0 {
457+ col.Slot = 0 // the slot window left
458+ }
459+ if col.Focus > j || col.Focus >= len(col.Windows) {
453460 col.Focus = max(min(col.Focus-1, len(col.Windows)-1), 0)
454461 }
455462 bands[b][i-first] = col
@@ -533,8 +540,17 @@ func (w *Workspace) restoreBand() bool {
533540 }
534541 return got
535542 }
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.
536546 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+ }
537550 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))
553+ }
538554 bands[b] = slices.Concat(above, bands[b], below)
539555 w.setBands(bands, id)
540556 return true
@@ -438,18 +438,25 @@ func (w *Workspace) fullscreenBand(id WindowID) {
438 }438 }
439 fc := w.Columns[w.Focus]439 fc := w.Columns[w.Focus]
440 focus := fc.Windows[fc.Focus]440 focus := fc.Windows[fc.Focus]
441- w.dropSlotOf(id)
442 bands := w.bandList()441 bands := w.bandList()
443 col := bands[b][i-first]442 col := bands[b][i-first]
444 nc := col443 nc := col
445 if len(col.Windows) == 1 {444 if len(col.Windows) == 1 {
445+ // The column leaves its lane, as deleteColumn: manual widths of the
446+ // lane no longer add up. It keeps its slot and its own width.
447+ w.clearSpans(i)
446 bands[b] = slices.Delete(bands[b], i-first, i-first+1)448 bands[b] = slices.Delete(bands[b], i-first, i-first+1)
447 } else {449 } else {
448 j := slices.Index(col.Windows, id)450 j := slices.Index(col.Windows, id)
451+ // The expelled window gets a plain column: no Span (a manual width
452+ // is the stack's, and the new band is alone), no shares.
449 nc = Column{Windows: []WindowID{id}, Cells: col.Cells}453 nc = Column{Windows: []WindowID{id}, Cells: col.Cells}
450 col.Windows = slices.Delete(slices.Clone(col.Windows), j, j+1)454 col.Windows = slices.Delete(slices.Clone(col.Windows), j, j+1)
451- switch {455+ col.Shares = nil
452- case col.Focus > j, col.Focus >= len(col.Windows):456+ if j == 0 {
457+ col.Slot = 0 // the slot window left
458+ }
459+ if col.Focus > j || col.Focus >= len(col.Windows) {
453 col.Focus = max(min(col.Focus-1, len(col.Windows)-1), 0)460 col.Focus = max(min(col.Focus-1, len(col.Windows)-1), 0)
454 }461 }
455 bands[b][i-first] = col462 bands[b][i-first] = col
@@ -533,8 +540,17 @@ func (w *Workspace) restoreBand() bool {
533 }540 }
534 return got541 return got
535 }542 }
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.
536 above := take(b-1, up, true)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+ }
537 below := take(b+1, down, false)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))
553+ }
538 bands[b] = slices.Concat(above, bands[b], below)554 bands[b] = slices.Concat(above, bands[b], below)
539 w.setBands(bands, id)555 w.setBands(bands, id)
540 return true556 return true
M internal/core/cascade_fullscreen_band_test.go +114 -4
@@ -7,15 +7,15 @@ import "testing"
77 func TestCascadeFullscreenOwnBand(t *testing.T) {
88 for _, tc := range []struct {
99 name string
10- do func(c *Core, w *Workspace)
10+ do func(w *Workspace)
1111 }{
12- {"toggle", func(_ *Core, w *Workspace) { w.ToggleFullscreen() }},
13- {"client request", func(_ *Core, w *Workspace) { w.SetFullscreen(3, true) }},
12+ {"toggle", func(w *Workspace) { w.ToggleFullscreen() }},
13+ {"client request", func(w *Workspace) { w.SetFullscreen(3, true) }},
1414 } {
1515 t.Run(tc.name, func(t *testing.T) {
1616 c, w := cascadeMovesCore(t, 3, 3, false)
1717 wantBands(t, w, []WindowID{1, 2, 3})
18- tc.do(c, w)
18+ tc.do(w)
1919 wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
2020 wantFocus(t, c, "M", 3)
2121 if w.fullscreen != 3 {
@@ -122,3 +122,113 @@ func TestFullscreenFloatNoNeighborStays(t *testing.T) {
122122 t.Fatalf("float moved: %v fullscreen %d", w.Floats, w.fullscreen)
123123 }
124124 }
125+
126+func TestCascadeFullscreenRepeatedRequestKeepsBands(t *testing.T) {
127+ _, w := cascadeMovesCore(t, 3, 3, false)
128+ w.SetFullscreen(3, true)
129+ w.SetFullscreen(3, true)
130+ w.SetFullscreen(3, true)
131+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
132+}
133+
134+func TestCascadeFullscreenStackedFixups(t *testing.T) {
135+ for _, tc := range []struct {
136+ name string
137+ focus WindowID
138+ rest WindowID
139+ slotKept bool
140+ }{{"last row", 3, 2, true}, {"first row", 2, 3, false}} {
141+ t.Run(tc.name, func(t *testing.T) {
142+ c, w := cascadeMovesCore(t, 3, 3, false)
143+ w.FocusID(3)
144+ c.applyAction(ActionConsumeOrExpelLeft) // 3 stacks under 2
145+ w.FocusID(tc.focus)
146+ w.Columns[1].Shares = []int{5000, 5000}
147+ w.Columns[1].Span = 3000
148+ w.Columns[1].Slot = 4
149+ w.SetFullscreen(tc.focus, true)
150+ wantBands(t, w, []WindowID{1, tc.rest}, []WindowID{tc.focus})
151+ col := w.Columns[1]
152+ if len(col.Windows) != 1 || col.Windows[0] != tc.rest || col.Shares != nil || col.Focus != 0 || (col.Slot == 4) != tc.slotKept {
153+ t.Fatalf("remaining column %+v", col)
154+ }
155+ if got, _ := w.Focused(); got != tc.focus {
156+ t.Fatalf("focus %d, want %d", got, tc.focus)
157+ }
158+ if w.Columns[2].Span != 0 || w.Columns[2].Slot != 0 {
159+ t.Fatalf("expelled column %+v", w.Columns[2])
160+ }
161+ })
162+ }
163+}
164+
165+func TestCascadeFullscreenKeepsSlotOfIntactColumn(t *testing.T) {
166+ _, w := cascadeMovesCore(t, 3, 3, false)
167+ w.Columns[2].Slot = 4
168+ w.ToggleFullscreen()
169+ if i := w.columnOf(3); w.Columns[i].Slot != 4 {
170+ t.Fatalf("slot %d, want 4", w.Columns[i].Slot)
171+ }
172+}
173+
174+func TestCascadeFullscreenAfterMaximizeRestores(t *testing.T) {
175+ _, w := cascadeMovesCore(t, 3, 3, false)
176+ w.FocusID(1)
177+ w.ToggleFullWidth()
178+ before := columnOrder(w)
179+ w.FocusID(3)
180+ w.ToggleFullscreen()
181+ w.ToggleFullscreen()
182+ w.FocusID(1)
183+ w.ToggleFullWidth()
184+ t.Logf("before %v bands after %v", before, columnOrder(w))
185+ wantBands(t, w, []WindowID{1, 2, 3})
186+}
187+
188+func TestFullscreenMoveEdges(t *testing.T) {
189+ t.Run("destination fullscreen elsewhere", func(t *testing.T) {
190+ c, w := cascadeMovesCore(t, 2, 3, true)
191+ dst := screenWorkspace(c, "R")
192+ dst.AddWindow(20)
193+ dst.SetFullscreen(20, true)
194+ w.ToggleFullscreen()
195+ c.applyAction(ActionMoveColumnRight)
196+ if dst.fullscreen != 2 {
197+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
198+ }
199+ wantFocus(t, c, "R", 2)
200+ })
201+ t.Run("scroll layout", func(t *testing.T) {
202+ c, w := cascadeMovesCore(t, 2, 3, true)
203+ w.Overflow = OverflowScroll
204+ w.ToggleFullscreen()
205+ c.applyAction(ActionMoveColumnLeft)
206+ dst := screenWorkspace(c, "L")
207+ wantColumns(t, dst, 2)
208+ if dst.fullscreen != 2 || w.fullscreen != 0 {
209+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
210+ }
211+ })
212+ 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+ }
232+ }
233+ })
234+}
@@ -7,15 +7,15 @@ import "testing"
7 func TestCascadeFullscreenOwnBand(t *testing.T) {7 func TestCascadeFullscreenOwnBand(t *testing.T) {
8 for _, tc := range []struct {8 for _, tc := range []struct {
9 name string9 name string
10- do func(c *Core, w *Workspace)10+ do func(w *Workspace)
11 }{11 }{
12- {"toggle", func(_ *Core, w *Workspace) { w.ToggleFullscreen() }},12+ {"toggle", func(w *Workspace) { w.ToggleFullscreen() }},
13- {"client request", func(_ *Core, w *Workspace) { w.SetFullscreen(3, true) }},13+ {"client request", func(w *Workspace) { w.SetFullscreen(3, true) }},
14 } {14 } {
15 t.Run(tc.name, func(t *testing.T) {15 t.Run(tc.name, func(t *testing.T) {
16 c, w := cascadeMovesCore(t, 3, 3, false)16 c, w := cascadeMovesCore(t, 3, 3, false)
17 wantBands(t, w, []WindowID{1, 2, 3})17 wantBands(t, w, []WindowID{1, 2, 3})
18- tc.do(c, w)18+ tc.do(w)
19 wantBands(t, w, []WindowID{1, 2}, []WindowID{3})19 wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
20 wantFocus(t, c, "M", 3)20 wantFocus(t, c, "M", 3)
21 if w.fullscreen != 3 {21 if w.fullscreen != 3 {
@@ -122,3 +122,113 @@ func TestFullscreenFloatNoNeighborStays(t *testing.T) {
122 t.Fatalf("float moved: %v fullscreen %d", w.Floats, w.fullscreen)122 t.Fatalf("float moved: %v fullscreen %d", w.Floats, w.fullscreen)
123 }123 }
124 }124 }
125+
126+func TestCascadeFullscreenRepeatedRequestKeepsBands(t *testing.T) {
127+ _, w := cascadeMovesCore(t, 3, 3, false)
128+ w.SetFullscreen(3, true)
129+ w.SetFullscreen(3, true)
130+ w.SetFullscreen(3, true)
131+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
132+}
133+
134+func TestCascadeFullscreenStackedFixups(t *testing.T) {
135+ for _, tc := range []struct {
136+ name string
137+ focus WindowID
138+ rest WindowID
139+ slotKept bool
140+ }{{"last row", 3, 2, true}, {"first row", 2, 3, false}} {
141+ t.Run(tc.name, func(t *testing.T) {
142+ c, w := cascadeMovesCore(t, 3, 3, false)
143+ w.FocusID(3)
144+ c.applyAction(ActionConsumeOrExpelLeft) // 3 stacks under 2
145+ w.FocusID(tc.focus)
146+ w.Columns[1].Shares = []int{5000, 5000}
147+ w.Columns[1].Span = 3000
148+ w.Columns[1].Slot = 4
149+ w.SetFullscreen(tc.focus, true)
150+ wantBands(t, w, []WindowID{1, tc.rest}, []WindowID{tc.focus})
151+ col := w.Columns[1]
152+ if len(col.Windows) != 1 || col.Windows[0] != tc.rest || col.Shares != nil || col.Focus != 0 || (col.Slot == 4) != tc.slotKept {
153+ t.Fatalf("remaining column %+v", col)
154+ }
155+ if got, _ := w.Focused(); got != tc.focus {
156+ t.Fatalf("focus %d, want %d", got, tc.focus)
157+ }
158+ if w.Columns[2].Span != 0 || w.Columns[2].Slot != 0 {
159+ t.Fatalf("expelled column %+v", w.Columns[2])
160+ }
161+ })
162+ }
163+}
164+
165+func TestCascadeFullscreenKeepsSlotOfIntactColumn(t *testing.T) {
166+ _, w := cascadeMovesCore(t, 3, 3, false)
167+ w.Columns[2].Slot = 4
168+ w.ToggleFullscreen()
169+ if i := w.columnOf(3); w.Columns[i].Slot != 4 {
170+ t.Fatalf("slot %d, want 4", w.Columns[i].Slot)
171+ }
172+}
173+
174+func TestCascadeFullscreenAfterMaximizeRestores(t *testing.T) {
175+ _, w := cascadeMovesCore(t, 3, 3, false)
176+ w.FocusID(1)
177+ w.ToggleFullWidth()
178+ before := columnOrder(w)
179+ w.FocusID(3)
180+ w.ToggleFullscreen()
181+ w.ToggleFullscreen()
182+ w.FocusID(1)
183+ w.ToggleFullWidth()
184+ t.Logf("before %v bands after %v", before, columnOrder(w))
185+ wantBands(t, w, []WindowID{1, 2, 3})
186+}
187+
188+func TestFullscreenMoveEdges(t *testing.T) {
189+ t.Run("destination fullscreen elsewhere", func(t *testing.T) {
190+ c, w := cascadeMovesCore(t, 2, 3, true)
191+ dst := screenWorkspace(c, "R")
192+ dst.AddWindow(20)
193+ dst.SetFullscreen(20, true)
194+ w.ToggleFullscreen()
195+ c.applyAction(ActionMoveColumnRight)
196+ if dst.fullscreen != 2 {
197+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
198+ }
199+ wantFocus(t, c, "R", 2)
200+ })
201+ t.Run("scroll layout", func(t *testing.T) {
202+ c, w := cascadeMovesCore(t, 2, 3, true)
203+ w.Overflow = OverflowScroll
204+ w.ToggleFullscreen()
205+ c.applyAction(ActionMoveColumnLeft)
206+ dst := screenWorkspace(c, "L")
207+ wantColumns(t, dst, 2)
208+ if dst.fullscreen != 2 || w.fullscreen != 0 {
209+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
210+ }
211+ })
212+ 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+ }
232+ }
233+ })
234+}
M internal/core/layout.go +1 -1
@@ -1445,7 +1445,7 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
14451445 // Another window covers: the override was for the old cover.
14461446 w.dropStashOver()
14471447 }
1448- if !w.isFloat(id) {
1448+ if !w.isFloat(id) && w.fullscreen != id {
14491449 w.fullscreenBand(id)
14501450 }
14511451 w.fullscreen = id
@@ -1445,7 +1445,7 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
1445 // Another window covers: the override was for the old cover.1445 // Another window covers: the override was for the old cover.
1446 w.dropStashOver()1446 w.dropStashOver()
1447 }1447 }
1448- if !w.isFloat(id) {1448+ if !w.isFloat(id) && w.fullscreen != id {
1449 w.fullscreenBand(id)1449 w.fullscreenBand(id)
1450 }1450 }
1451 w.fullscreen = id1451 w.fullscreen = id

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 26

Tags 10

↑↓ moveenter openesc close