detached a5e31f9

Merge branch 'feat/cascade-fullscreen-band' into main (!29)

feat(core): fullscreen cascade band and monitor move
commit
a5e31f94be8069746fb81f764ab532f58bcab09b
author
ermite · · 2026-10-23 04:44 UTC
merge of
f2d166b + 7ef9454 · diff against the first parent
signature
SSH VerifiedSHA256:lY9fILbB/A+hln6Vh/iHoiiog74bbw/4qWrf/lrTU3U · ermite instance key Verified for the committer email
checks
0/1
8 files · +442 -20
M docs/config.md +2 -0
@@ -307,6 +307,8 @@ The default layout. Columns sit side by side in bands of up to `max-columns`, st
307307 - A new window joins the focused band when it has room, else it gets a new band right below.
308308 - `cycle-column-width` in a full band makes room: the rightmost column goes to the band below (from the last column, the leftmost goes to the band above). The end of the cycle brings it back.
309309 - `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back.
310+- A fullscreen window gets a band of its own right below the band it was in (expelled into its own column first when it shares one), so the other columns stay put and focus changes among them leave the fullscreen output alone. Leaving fullscreen keeps the column in that band.
311+- `move-column-left/right` on a fullscreen window (tiled or floating) sends it to the neighbor monitor, where it stays fullscreen and focused; with no neighbor monitor nothing moves.
310312 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
311313
312314 ### Column widths
@@ -307,6 +307,8 @@ The default layout. Columns sit side by side in bands of up to `max-columns`, st
307 - A new window joins the focused band when it has room, else it gets a new band right below.307 - A new window joins the focused band when it has room, else it gets a new band right below.
308 - `cycle-column-width` in a full band makes room: the rightmost column goes to the band below (from the last column, the leftmost goes to the band above). The end of the cycle brings it back.308 - `cycle-column-width` in a full band makes room: the rightmost column goes to the band below (from the last column, the leftmost goes to the band above). The end of the cycle brings it back.
309 - `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back.309 - `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back.
310+- A fullscreen window gets a band of its own right below the band it was in (expelled into its own column first when it shares one), so the other columns stay put and focus changes among them leave the fullscreen output alone. Leaving fullscreen keeps the column in that band.
311+- `move-column-left/right` on a fullscreen window (tiled or floating) sends it to the neighbor monitor, where it stays fullscreen and focused; with no neighbor monitor nothing moves.
310 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.312 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
311 313
312 ### Column widths314 ### Column widths
M internal/core/action.go +47 -1
@@ -388,7 +388,39 @@ func (c *Core) dispatchAction(a Action) Effect {
388388 if a == ActionMoveColumnLeft {
389389 dir, d = -1, dirLeft
390390 }
391- if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
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.
394+ i := c.neighbor(d)
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()
421+ return Effect{}
422+ }
423+ if i >= 0 && w.leavesBand(w.Focus+dir) {
392424 col, ok := w.takeColumn()
393425 if !ok {
394426 return Effect{}
@@ -608,3 +640,17 @@ func (w *Workspace) Apply(a Action) Effect {
608640 }
609641 return Effect{}
610642 }
643+
644+// moveFloat moves float id from workspace cur to to, as MoveToWorkspace
645+// does, focuses it there and keeps it fullscreen.
646+func moveFloat(cur, to *Workspace, id WindowID) {
647+ fl := cur.Floats[cur.floatIndex(id)]
648+ if fl.free && fl.back != nil {
649+ fl = fl.rehome()
650+ }
651+ cur.RemoveWindow(id)
652+ to.AddFloating(id, fl.W, fl.H)
653+ to.Floats[len(to.Floats)-1] = fl
654+ to.Activate(id)
655+ to.SetFullscreen(id, true)
656+}
@@ -388,7 +388,39 @@ 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- if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {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.
394+ i := c.neighbor(d)
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()
421+ return Effect{}
422+ }
423+ if i >= 0 && w.leavesBand(w.Focus+dir) {
392 col, ok := w.takeColumn()424 col, ok := w.takeColumn()
393 if !ok {425 if !ok {
394 return Effect{}426 return Effect{}
@@ -608,3 +640,17 @@ func (w *Workspace) Apply(a Action) Effect {
608 }640 }
609 return Effect{}641 return Effect{}
610 }642 }
643+
644+// moveFloat moves float id from workspace cur to to, as MoveToWorkspace
645+// does, focuses it there and keeps it fullscreen.
646+func moveFloat(cur, to *Workspace, id WindowID) {
647+ fl := cur.Floats[cur.floatIndex(id)]
648+ if fl.free && fl.back != nil {
649+ fl = fl.rehome()
650+ }
651+ cur.RemoveWindow(id)
652+ to.AddFloating(id, fl.W, fl.H)
653+ to.Floats[len(to.Floats)-1] = fl
654+ to.Activate(id)
655+ to.SetFullscreen(id, true)
656+}
M internal/core/cascade.go +80 -14
@@ -371,6 +371,9 @@ func (w *Workspace) shiftBand(lo, hi, dir, at int) bool {
371371 id, _ := w.Focused()
372372 bands := w.bandList()
373373 cols := slices.Clone(bands[b][lo-first : hi-first+1])
374+ for k := range cols {
375+ cols[k].ownBand = false
376+ }
374377 bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)
375378 w.setBands(w.sendBand(bands, b, dir, at, cols), id)
376379 return true
@@ -419,6 +422,53 @@ func (w *Workspace) isolateBand() {
419422 w.setBands(bands, id)
420423 }
421424
425+// fullscreenBand gives tiled window id, about to go fullscreen, a band of
426+// its own right below the band it is in, so the other columns keep their
427+// band and a focus change among them never touches the fullscreen output.
428+// A window stacked in a column is expelled into a column of its own first.
429+// Leaving fullscreen does not move it back: the column keeps its band, so
430+// nothing jumps when the user leaves. Only cascade layouts do this; the
431+// focus stays on the same window.
432+func (w *Workspace) fullscreenBand(id WindowID) {
433+ i := w.columnOf(id)
434+ if !w.policy().wraps || i < 0 {
435+ return
436+ }
437+ b := w.band(i)
438+ first, last := w.bandBounds(b)
439+ if len(w.Columns[i].Windows) == 1 && first == last {
440+ return
441+ }
442+ fc := w.Columns[w.Focus]
443+ focus := fc.Windows[fc.Focus]
444+ bands := w.bandList()
445+ col := bands[b][i-first]
446+ nc := col
447+ if len(col.Windows) == 1 {
448+ // The column leaves its lane, as deleteColumn: manual widths of the
449+ // lane no longer add up. It keeps its slot and its own width.
450+ w.clearSpans(i)
451+ bands[b] = slices.Delete(bands[b], i-first, i-first+1)
452+ } else {
453+ j := slices.Index(col.Windows, id)
454+ // The expelled window gets a plain column: no Span (a manual width
455+ // is the stack's, and the new band is alone), no shares.
456+ nc = Column{Windows: []WindowID{id}, Cells: col.Cells}
457+ col.Windows = slices.Delete(slices.Clone(col.Windows), j, j+1)
458+ col.Shares = nil
459+ if j == 0 {
460+ col.Slot = 0 // the slot window left
461+ }
462+ if col.Focus > j || col.Focus >= len(col.Windows) {
463+ col.Focus = max(min(col.Focus-1, len(col.Windows)-1), 0)
464+ }
465+ bands[b][i-first] = col
466+ }
467+ nc.ownBand = true
468+ bands = slices.Insert(bands, b+1, []Column{nc})
469+ w.setBands(bands, focus)
470+}
471+
422472 // pushNeighbor makes room in the focused column's full cascade band for a
423473 // wider column: the column at the far end of the band leaves it. The
424474 // rightmost goes to the band below; from the last column, the leftmost
@@ -457,13 +507,10 @@ func (w *Workspace) restoreBand() bool {
457507 bands := w.bandList()
458508 b := w.band(w.Focus)
459509 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
460- // take removes from band nb the remembered columns found there, at
461- // most room of them, in the order of ids walked from the focused
510+ // take removes from the bands nbs the remembered columns found there,
511+ // at most room of them, in the order of ids walked from the focused
462512 // column outward (rev: ids end nearest).
463- take := func(nb int, ids [][]WindowID, rev bool) []Column {
464- if nb < 0 || nb >= len(bands) {
465- return nil
466- }
513+ take := func(nbs []int, ids [][]WindowID, rev bool) []Column {
467514 var got []Column
468515 for k := range ids {
469516 if room == 0 {
@@ -481,21 +528,40 @@ func (w *Workspace) restoreBand() bool {
481528 if head < 0 {
482529 continue
483530 }
484- i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
485- if i < 0 {
486- continue
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+ })
537+ if i < 0 {
538+ continue
539+ }
540+ got = append(got, bands[nb][i])
541+ bands[nb] = slices.Delete(bands[nb], i, i+1)
542+ room--
543+ break
487544 }
488- got = append(got, bands[nb][i])
489- bands[nb] = slices.Delete(bands[nb], i, i+1)
490- room--
491545 }
492546 if rev {
493547 slices.Reverse(got)
494548 }
495549 return got
496550 }
497- above := take(b-1, up, true)
498- below := take(b+1, down, false)
551+ // reach lists the bands dir away to look in: the adjacent one, and the
552+ // next, where a column that fullscreenBand split off may lie.
553+ reach := func(dir int) []int {
554+ var nbs []int
555+ for n := b + dir; n >= 0 && n < len(bands); n += dir {
556+ nbs = append(nbs, n)
557+ if len(nbs) == 2 {
558+ break
559+ }
560+ }
561+ return nbs
562+ }
563+ above := take(reach(-1), up, true)
564+ below := take(reach(1), down, false)
499565 bands[b] = slices.Concat(above, bands[b], below)
500566 w.setBands(bands, id)
501567 return true
@@ -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 true379 return true
@@ -419,6 +422,53 @@ func (w *Workspace) isolateBand() {
419 w.setBands(bands, id)422 w.setBands(bands, id)
420 }423 }
421 424
425+// fullscreenBand gives tiled window id, about to go fullscreen, a band of
426+// its own right below the band it is in, so the other columns keep their
427+// band and a focus change among them never touches the fullscreen output.
428+// A window stacked in a column is expelled into a column of its own first.
429+// Leaving fullscreen does not move it back: the column keeps its band, so
430+// nothing jumps when the user leaves. Only cascade layouts do this; the
431+// focus stays on the same window.
432+func (w *Workspace) fullscreenBand(id WindowID) {
433+ i := w.columnOf(id)
434+ if !w.policy().wraps || i < 0 {
435+ return
436+ }
437+ b := w.band(i)
438+ first, last := w.bandBounds(b)
439+ if len(w.Columns[i].Windows) == 1 && first == last {
440+ return
441+ }
442+ fc := w.Columns[w.Focus]
443+ focus := fc.Windows[fc.Focus]
444+ bands := w.bandList()
445+ col := bands[b][i-first]
446+ nc := col
447+ if len(col.Windows) == 1 {
448+ // The column leaves its lane, as deleteColumn: manual widths of the
449+ // lane no longer add up. It keeps its slot and its own width.
450+ w.clearSpans(i)
451+ bands[b] = slices.Delete(bands[b], i-first, i-first+1)
452+ } else {
453+ j := slices.Index(col.Windows, id)
454+ // The expelled window gets a plain column: no Span (a manual width
455+ // is the stack's, and the new band is alone), no shares.
456+ nc = Column{Windows: []WindowID{id}, Cells: col.Cells}
457+ col.Windows = slices.Delete(slices.Clone(col.Windows), j, j+1)
458+ col.Shares = nil
459+ if j == 0 {
460+ col.Slot = 0 // the slot window left
461+ }
462+ if col.Focus > j || col.Focus >= len(col.Windows) {
463+ col.Focus = max(min(col.Focus-1, len(col.Windows)-1), 0)
464+ }
465+ bands[b][i-first] = col
466+ }
467+ nc.ownBand = true
468+ bands = slices.Insert(bands, b+1, []Column{nc})
469+ w.setBands(bands, focus)
470+}
471+
422 // pushNeighbor makes room in the focused column's full cascade band for a472 // pushNeighbor makes room in the focused column's full cascade band for a
423 // wider column: the column at the far end of the band leaves it. The473 // wider column: the column at the far end of the band leaves it. The
424 // rightmost goes to the band below; from the last column, the leftmost474 // rightmost goes to the band below; from the last column, the leftmost
@@ -457,13 +507,10 @@ func (w *Workspace) restoreBand() bool {
457 bands := w.bandList()507 bands := w.bandList()
458 b := w.band(w.Focus)508 b := w.band(w.Focus)
459 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)509 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
460- // take removes from band nb the remembered columns found there, at510+ // take removes from the bands nbs the remembered columns found there,
461- // most room of them, in the order of ids walked from the focused511+ // at most room of them, in the order of ids walked from the focused
462 // column outward (rev: ids end nearest).512 // column outward (rev: ids end nearest).
463- take := func(nb int, ids [][]WindowID, rev bool) []Column {513+ take := func(nbs []int, ids [][]WindowID, rev bool) []Column {
464- if nb < 0 || nb >= len(bands) {
465- return nil
466- }
467 var got []Column514 var got []Column
468 for k := range ids {515 for k := range ids {
469 if room == 0 {516 if room == 0 {
@@ -481,21 +528,40 @@ func (w *Workspace) restoreBand() bool {
481 if head < 0 {528 if head < 0 {
482 continue529 continue
483 }530 }
484- i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })531+ for k, nb := range nbs {
485- if i < 0 {532+ // Past the adjacent band, only a column fullscreenBand put in
486- continue533+ // 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+ })
537+ if i < 0 {
538+ continue
539+ }
540+ got = append(got, bands[nb][i])
541+ bands[nb] = slices.Delete(bands[nb], i, i+1)
542+ room--
543+ break
487 }544 }
488- got = append(got, bands[nb][i])
489- bands[nb] = slices.Delete(bands[nb], i, i+1)
490- room--
491 }545 }
492 if rev {546 if rev {
493 slices.Reverse(got)547 slices.Reverse(got)
494 }548 }
495 return got549 return got
496 }550 }
497- above := take(b-1, up, true)551+ // reach lists the bands dir away to look in: the adjacent one, and the
498- below := take(b+1, down, false)552+ // next, where a column that fullscreenBand split off may lie.
553+ reach := func(dir int) []int {
554+ var nbs []int
555+ for n := b + dir; n >= 0 && n < len(bands); n += dir {
556+ nbs = append(nbs, n)
557+ if len(nbs) == 2 {
558+ break
559+ }
560+ }
561+ return nbs
562+ }
563+ above := take(reach(-1), up, true)
564+ below := take(reach(1), down, false)
499 bands[b] = slices.Concat(above, bands[b], below)565 bands[b] = slices.Concat(above, bands[b], below)
500 w.setBands(bands, id)566 w.setBands(bands, id)
501 return true567 return true
A internal/core/cascade_fullscreen_band_test.go +297 -0
@@ -0,0 +1,297 @@
1+package core
2+
3+import "testing"
4+
5+// Entering fullscreen gives a cascade column a band below its own; the
6+// others stay. Leaving keeps it there.
7+func TestCascadeFullscreenOwnBand(t *testing.T) {
8+ for _, tc := range []struct {
9+ name string
10+ do func(w *Workspace)
11+ }{
12+ {"toggle", func(w *Workspace) { w.ToggleFullscreen() }},
13+ {"client request", func(w *Workspace) { w.SetFullscreen(3, true) }},
14+ } {
15+ t.Run(tc.name, func(t *testing.T) {
16+ c, w := cascadeMovesCore(t, 3, 3, false)
17+ wantBands(t, w, []WindowID{1, 2, 3})
18+ tc.do(w)
19+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
20+ wantFocus(t, c, "M", 3)
21+ if w.fullscreen != 3 {
22+ t.Fatalf("fullscreen %d, want 3", w.fullscreen)
23+ }
24+ w.ToggleFullscreen()
25+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
26+ })
27+ }
28+}
29+
30+func TestCascadeFullscreenMiddleAndAlone(t *testing.T) {
31+ c, w := cascadeMovesCore(t, 3, 3, false)
32+ w.FocusID(2)
33+ w.ToggleFullscreen()
34+ wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
35+ wantFocus(t, c, "M", 2)
36+
37+ _, w = cascadeMovesCore(t, 1, 3, false)
38+ w.ToggleFullscreen()
39+ wantBands(t, w, []WindowID{1})
40+}
41+
42+func TestCascadeFullscreenExpelsFromStack(t *testing.T) {
43+ c, w := cascadeMovesCore(t, 2, 3, false)
44+ w.FocusID(2)
45+ c.applyAction(ActionConsumeOrExpelLeft) // 2 stacks under 1
46+ wantColumns(t, w, 1)
47+ w.ToggleFullscreen()
48+ wantBands(t, w, []WindowID{1}, []WindowID{2})
49+ if len(w.Columns[0].Windows) != 1 || w.fullscreen != 2 {
50+ t.Fatalf("columns %v fullscreen %d", w.Columns, w.fullscreen)
51+ }
52+ wantFocus(t, c, "M", 2)
53+}
54+
55+func TestScrollFullscreenKeepsColumns(t *testing.T) {
56+ _, w := cascadeMovesCore(t, 3, 3, false)
57+ w.Overflow = OverflowScroll
58+ w.forgetBands()
59+ w.ToggleFullscreen()
60+ if len(w.Columns) != 3 {
61+ t.Fatalf("columns %d", len(w.Columns))
62+ }
63+}
64+
65+func TestFullscreenColumnMovesToNeighborMonitor(t *testing.T) {
66+ for _, tc := range []struct {
67+ action Action
68+ screen string
69+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
70+ t.Run(tc.screen, func(t *testing.T) {
71+ c, w := cascadeMovesCore(t, 3, 3, true)
72+ w.ToggleFullscreen()
73+ c.applyAction(tc.action)
74+ wantColumns(t, w, 1, 2)
75+ dst := screenWorkspace(c, tc.screen)
76+ wantColumns(t, dst, 3)
77+ wantFocus(t, c, tc.screen, 3)
78+ if dst.fullscreen != 3 || w.fullscreen != 0 {
79+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
80+ }
81+ })
82+ }
83+}
84+
85+func TestFullscreenColumnNoNeighborStays(t *testing.T) {
86+ c, w := cascadeMovesCore(t, 3, 3, false)
87+ w.ToggleFullscreen()
88+ c.applyAction(ActionMoveColumnLeft)
89+ c.applyAction(ActionMoveColumnRight)
90+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
91+ if w.fullscreen != 3 {
92+ t.Fatalf("fullscreen %d", w.fullscreen)
93+ }
94+}
95+
96+func TestFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
97+ for _, tc := range []struct {
98+ action Action
99+ screen string
100+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
101+ t.Run(tc.screen, func(t *testing.T) {
102+ c, w := cascadeMovesCore(t, 2, 3, true)
103+ w.AddFloating(9, 5120, 2160)
104+ w.SetFullscreen(9, true)
105+ c.applyAction(tc.action)
106+ dst := screenWorkspace(c, tc.screen)
107+ if w.floatIndex(9) >= 0 || dst.floatIndex(9) < 0 || w.fullscreen != 0 || dst.fullscreen != 9 {
108+ t.Fatalf("float on src %v dst %v, fullscreen src %d dst %d", w.Floats, dst.Floats, w.fullscreen, dst.fullscreen)
109+ }
110+ wantFocus(t, c, tc.screen, 9)
111+ wantColumns(t, w, 1, 2)
112+ })
113+ }
114+}
115+
116+func TestFullscreenFloatNoNeighborStays(t *testing.T) {
117+ c, w := cascadeMovesCore(t, 2, 3, false)
118+ w.AddFloating(9, 800, 600)
119+ w.SetFullscreen(9, true)
120+ c.applyAction(ActionMoveColumnLeft)
121+ if w.floatIndex(9) < 0 || w.fullscreen != 9 {
122+ t.Fatalf("float moved: %v fullscreen %d", w.Floats, w.fullscreen)
123+ }
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+ w.FocusID(3)
179+ w.ToggleFullscreen()
180+ w.ToggleFullscreen()
181+ w.FocusID(1)
182+ w.ToggleFullWidth()
183+ wantBands(t, w, []WindowID{1, 2, 3})
184+}
185+
186+func TestFullscreenMoveEdges(t *testing.T) {
187+ t.Run("destination fullscreen elsewhere", func(t *testing.T) {
188+ c, w := cascadeMovesCore(t, 2, 3, true)
189+ dst := screenWorkspace(c, "R")
190+ dst.AddWindow(20)
191+ dst.SetFullscreen(20, true)
192+ w.ToggleFullscreen()
193+ c.applyAction(ActionMoveColumnRight)
194+ if dst.fullscreen != 2 {
195+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
196+ }
197+ wantFocus(t, c, "R", 2)
198+ })
199+ t.Run("scroll layout", func(t *testing.T) {
200+ c, w := cascadeMovesCore(t, 2, 3, true)
201+ w.Overflow = OverflowScroll
202+ w.ToggleFullscreen()
203+ c.applyAction(ActionMoveColumnLeft)
204+ dst := screenWorkspace(c, "L")
205+ wantColumns(t, dst, 2)
206+ if dst.fullscreen != 2 || w.fullscreen != 0 {
207+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
208+ }
209+ })
210+ t.Run("source floats promoted", func(t *testing.T) {
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("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.
246+ c, w := cascadeMovesCore(t, 2, 3, true)
247+ dst := screenWorkspace(c, "R")
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("fullscreen window expelled from a stack then moved", 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)
281+ }
282+ })
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+}
@@ -0,0 +1,297 @@
1+package core
2+
3+import "testing"
4+
5+// Entering fullscreen gives a cascade column a band below its own; the
6+// others stay. Leaving keeps it there.
7+func TestCascadeFullscreenOwnBand(t *testing.T) {
8+ for _, tc := range []struct {
9+ name string
10+ do func(w *Workspace)
11+ }{
12+ {"toggle", func(w *Workspace) { w.ToggleFullscreen() }},
13+ {"client request", func(w *Workspace) { w.SetFullscreen(3, true) }},
14+ } {
15+ t.Run(tc.name, func(t *testing.T) {
16+ c, w := cascadeMovesCore(t, 3, 3, false)
17+ wantBands(t, w, []WindowID{1, 2, 3})
18+ tc.do(w)
19+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
20+ wantFocus(t, c, "M", 3)
21+ if w.fullscreen != 3 {
22+ t.Fatalf("fullscreen %d, want 3", w.fullscreen)
23+ }
24+ w.ToggleFullscreen()
25+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
26+ })
27+ }
28+}
29+
30+func TestCascadeFullscreenMiddleAndAlone(t *testing.T) {
31+ c, w := cascadeMovesCore(t, 3, 3, false)
32+ w.FocusID(2)
33+ w.ToggleFullscreen()
34+ wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
35+ wantFocus(t, c, "M", 2)
36+
37+ _, w = cascadeMovesCore(t, 1, 3, false)
38+ w.ToggleFullscreen()
39+ wantBands(t, w, []WindowID{1})
40+}
41+
42+func TestCascadeFullscreenExpelsFromStack(t *testing.T) {
43+ c, w := cascadeMovesCore(t, 2, 3, false)
44+ w.FocusID(2)
45+ c.applyAction(ActionConsumeOrExpelLeft) // 2 stacks under 1
46+ wantColumns(t, w, 1)
47+ w.ToggleFullscreen()
48+ wantBands(t, w, []WindowID{1}, []WindowID{2})
49+ if len(w.Columns[0].Windows) != 1 || w.fullscreen != 2 {
50+ t.Fatalf("columns %v fullscreen %d", w.Columns, w.fullscreen)
51+ }
52+ wantFocus(t, c, "M", 2)
53+}
54+
55+func TestScrollFullscreenKeepsColumns(t *testing.T) {
56+ _, w := cascadeMovesCore(t, 3, 3, false)
57+ w.Overflow = OverflowScroll
58+ w.forgetBands()
59+ w.ToggleFullscreen()
60+ if len(w.Columns) != 3 {
61+ t.Fatalf("columns %d", len(w.Columns))
62+ }
63+}
64+
65+func TestFullscreenColumnMovesToNeighborMonitor(t *testing.T) {
66+ for _, tc := range []struct {
67+ action Action
68+ screen string
69+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
70+ t.Run(tc.screen, func(t *testing.T) {
71+ c, w := cascadeMovesCore(t, 3, 3, true)
72+ w.ToggleFullscreen()
73+ c.applyAction(tc.action)
74+ wantColumns(t, w, 1, 2)
75+ dst := screenWorkspace(c, tc.screen)
76+ wantColumns(t, dst, 3)
77+ wantFocus(t, c, tc.screen, 3)
78+ if dst.fullscreen != 3 || w.fullscreen != 0 {
79+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
80+ }
81+ })
82+ }
83+}
84+
85+func TestFullscreenColumnNoNeighborStays(t *testing.T) {
86+ c, w := cascadeMovesCore(t, 3, 3, false)
87+ w.ToggleFullscreen()
88+ c.applyAction(ActionMoveColumnLeft)
89+ c.applyAction(ActionMoveColumnRight)
90+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
91+ if w.fullscreen != 3 {
92+ t.Fatalf("fullscreen %d", w.fullscreen)
93+ }
94+}
95+
96+func TestFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
97+ for _, tc := range []struct {
98+ action Action
99+ screen string
100+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
101+ t.Run(tc.screen, func(t *testing.T) {
102+ c, w := cascadeMovesCore(t, 2, 3, true)
103+ w.AddFloating(9, 5120, 2160)
104+ w.SetFullscreen(9, true)
105+ c.applyAction(tc.action)
106+ dst := screenWorkspace(c, tc.screen)
107+ if w.floatIndex(9) >= 0 || dst.floatIndex(9) < 0 || w.fullscreen != 0 || dst.fullscreen != 9 {
108+ t.Fatalf("float on src %v dst %v, fullscreen src %d dst %d", w.Floats, dst.Floats, w.fullscreen, dst.fullscreen)
109+ }
110+ wantFocus(t, c, tc.screen, 9)
111+ wantColumns(t, w, 1, 2)
112+ })
113+ }
114+}
115+
116+func TestFullscreenFloatNoNeighborStays(t *testing.T) {
117+ c, w := cascadeMovesCore(t, 2, 3, false)
118+ w.AddFloating(9, 800, 600)
119+ w.SetFullscreen(9, true)
120+ c.applyAction(ActionMoveColumnLeft)
121+ if w.floatIndex(9) < 0 || w.fullscreen != 9 {
122+ t.Fatalf("float moved: %v fullscreen %d", w.Floats, w.fullscreen)
123+ }
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+ w.FocusID(3)
179+ w.ToggleFullscreen()
180+ w.ToggleFullscreen()
181+ w.FocusID(1)
182+ w.ToggleFullWidth()
183+ wantBands(t, w, []WindowID{1, 2, 3})
184+}
185+
186+func TestFullscreenMoveEdges(t *testing.T) {
187+ t.Run("destination fullscreen elsewhere", func(t *testing.T) {
188+ c, w := cascadeMovesCore(t, 2, 3, true)
189+ dst := screenWorkspace(c, "R")
190+ dst.AddWindow(20)
191+ dst.SetFullscreen(20, true)
192+ w.ToggleFullscreen()
193+ c.applyAction(ActionMoveColumnRight)
194+ if dst.fullscreen != 2 {
195+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
196+ }
197+ wantFocus(t, c, "R", 2)
198+ })
199+ t.Run("scroll layout", func(t *testing.T) {
200+ c, w := cascadeMovesCore(t, 2, 3, true)
201+ w.Overflow = OverflowScroll
202+ w.ToggleFullscreen()
203+ c.applyAction(ActionMoveColumnLeft)
204+ dst := screenWorkspace(c, "L")
205+ wantColumns(t, dst, 2)
206+ if dst.fullscreen != 2 || w.fullscreen != 0 {
207+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
208+ }
209+ })
210+ t.Run("source floats promoted", func(t *testing.T) {
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("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.
246+ c, w := cascadeMovesCore(t, 2, 3, true)
247+ dst := screenWorkspace(c, "R")
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("fullscreen window expelled from a stack then moved", 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)
281+ }
282+ })
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/cascade_moves_test.go +3 -2
@@ -299,13 +299,14 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) {
299299 wantFocus(t, c, "M", 5)
300300 }
301301
302-// A fullscreen window keeps its band.
302+// A fullscreen window keeps its own band (it left {4 5} when it went
303+// fullscreen).
303304 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
304305 c, w := cascadeMovesCore(t, 5, 3, false)
305306 w.FocusID(4)
306307 w.ToggleFullscreen()
307308 c.applyAction(ActionMoveWindowUp)
308- wantColumns(t, w, 1, 2, 3, 4, 5)
309+ wantColumns(t, w, 1, 2, 3, 5, 4)
309310 }
310311
311312 // Scroll overflow has no bands: up/down stop at the column edge.
@@ -299,13 +299,14 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) {
299 wantFocus(t, c, "M", 5)299 wantFocus(t, c, "M", 5)
300 }300 }
301 301
302-// A fullscreen window keeps its band.302+// A fullscreen window keeps its own band (it left {4 5} when it went
303+// fullscreen).
303 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {304 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
304 c, w := cascadeMovesCore(t, 5, 3, false)305 c, w := cascadeMovesCore(t, 5, 3, false)
305 w.FocusID(4)306 w.FocusID(4)
306 w.ToggleFullscreen()307 w.ToggleFullscreen()
307 c.applyAction(ActionMoveWindowUp)308 c.applyAction(ActionMoveWindowUp)
308- wantColumns(t, w, 1, 2, 3, 4, 5)309+ wantColumns(t, w, 1, 2, 3, 5, 4)
309 }310 }
310 311
311 // Scroll overflow has no bands: up/down stop at the column edge.312 // Scroll overflow has no bands: up/down stop at the column edge.
M internal/core/cascade_test.go +1 -1
@@ -195,7 +195,7 @@ func TestCascadeCovers(t *testing.T) {
195195 }
196196 w.ToggleFullscreen()
197197 w.FocusID(4)
198- if w.View != 600 {
198+ if w.View != 1200 {
199199 t.Fatal("fullscreen restore lost band reveal")
200200 }
201201 }
@@ -195,7 +195,7 @@ func TestCascadeCovers(t *testing.T) {
195 }195 }
196 w.ToggleFullscreen()196 w.ToggleFullscreen()
197 w.FocusID(4)197 w.FocusID(4)
198- if w.View != 600 {198+ if w.View != 1200 {
199 t.Fatal("fullscreen restore lost band reveal")199 t.Fatal("fullscreen restore lost band reveal")
200 }200 }
201 }201 }
M internal/core/layout.go +9 -0
@@ -40,6 +40,11 @@ type Column struct {
4040 // maximize-column or cycle-column-width sent to the band above or
4141 // below; they come back when the column is restored.
4242 pushedUp, pushedDown [][]WindowID
43+ // ownBand marks a column fullscreenBand put in a band of its own:
44+ // restoreBand brings it back from past the adjacent band, unlike a
45+ // column the user moved there. shiftBand clears it when the column
46+ // moves to another band on purpose.
47+ ownBand bool
4348 }
4449 type Placement struct {
4550 ID WindowID
@@ -1335,6 +1340,7 @@ func (w *Workspace) ToggleFullscreen() {
13351340 w.endFullscreen()
13361341 return
13371342 }
1343+ w.fullscreenBand(id)
13381344 w.fullscreen = id
13391345 w.scroll()
13401346 }
@@ -1444,6 +1450,9 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
14441450 // Another window covers: the override was for the old cover.
14451451 w.dropStashOver()
14461452 }
1453+ if !w.isFloat(id) && w.fullscreen != id {
1454+ w.fullscreenBand(id)
1455+ }
14471456 w.fullscreen = id
14481457 if !w.isFloat(id) {
14491458 w.scroll()
@@ -40,6 +40,11 @@ type Column struct {
40 // maximize-column or cycle-column-width sent to the band above or40 // 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 [][]WindowID42 pushedUp, pushedDown [][]WindowID
43+ // ownBand marks a column fullscreenBand put in a band of its own:
44+ // restoreBand brings it back from past the adjacent band, unlike a
45+ // column the user moved there. shiftBand clears it when the column
46+ // moves to another band on purpose.
47+ ownBand bool
43 }48 }
44 type Placement struct {49 type Placement struct {
45 ID WindowID50 ID WindowID
@@ -1335,6 +1340,7 @@ func (w *Workspace) ToggleFullscreen() {
1335 w.endFullscreen()1340 w.endFullscreen()
1336 return1341 return
1337 }1342 }
1343+ w.fullscreenBand(id)
1338 w.fullscreen = id1344 w.fullscreen = id
1339 w.scroll()1345 w.scroll()
1340 }1346 }
@@ -1444,6 +1450,9 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
1444 // Another window covers: the override was for the old cover.1450 // Another window covers: the override was for the old cover.
1445 w.dropStashOver()1451 w.dropStashOver()
1446 }1452 }
1453+ if !w.isFloat(id) && w.fullscreen != id {
1454+ w.fullscreenBand(id)
1455+ }
1447 w.fullscreen = id1456 w.fullscreen = id
1448 if !w.isFloat(id) {1457 if !w.isFloat(id) {
1449 w.scroll()1458 w.scroll()
M internal/core/overview_cascade_test.go +3 -2
@@ -728,7 +728,8 @@ func TestOverviewCascadeCoveringFloat(t *testing.T) {
728728 }
729729 }
730730
731-// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
731+// T10b: a pinned fullscreen column keeps its Fullscreen tile, and the
732+// columns of the other bands stay shown.
732733 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
733734 m := cascadeOverview()
734735 w := m.Current()
@@ -744,7 +745,7 @@ func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
744745 t.Fatalf("fullscreen tile: %+v", fs)
745746 }
746747 for _, id := range []WindowID{7, 9} {
747- if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
748+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen {
748749 t.Fatalf("band tile %d: %+v", id, p)
749750 }
750751 }
@@ -728,7 +728,8 @@ func TestOverviewCascadeCoveringFloat(t *testing.T) {
728 }728 }
729 }729 }
730 730
731-// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.731+// T10b: a pinned fullscreen column keeps its Fullscreen tile, and the
732+// columns of the other bands stay shown.
732 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {733 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
733 m := cascadeOverview()734 m := cascadeOverview()
734 w := m.Current()735 w := m.Current()
@@ -744,7 +745,7 @@ func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
744 t.Fatalf("fullscreen tile: %+v", fs)745 t.Fatalf("fullscreen tile: %+v", fs)
745 }746 }
746 for _, id := range []WindowID{7, 9} {747 for _, id := range []WindowID{7, 9} {
747- if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {748+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen {
748 t.Fatalf("band tile %d: %+v", id, p)749 t.Fatalf("band tile %d: %+v", id, p)
749 }750 }
750 }751 }

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 23

Tags 10

↑↓ moveenter openesc close