detached 3a820d6

Merge branch 'fix/fullscreen-float-band' into main (!34)

fix(core): give fullscreen cascade floats their own band
commit
3a820d691572e637d50bc48b479ee25d44b127b0
author
ermite · · 2026-10-23 05:54 UTC
merge of
be3fb23 + 34ae144 · diff against the first parent
signature
SSH VerifiedSHA256:lY9fILbB/A+hln6Vh/iHoiiog74bbw/4qWrf/lrTU3U · ermite instance key Verified for the committer email
checks
1/1
7 files · +254 -11
M docs/config.md +2 -2
@@ -302,14 +302,14 @@ With `keyboard.cmd = alt` or `ctrl`, the defaults that would combine `cmd` with
302302
303303 The default layout. Columns sit side by side in bands of up to `max-columns`, stacked one below the other. Each band fills the screen height and keeps its columns until you move them.
304304
305-- Left/right focus moves within a band, then to the neighbor monitor, then to the neighbor workspace.
305+- Left/right focus moves within a band, then to an occupied neighbor workspace, then to the neighbor monitor, then to the trailing empty workspace. From a fullscreen float it crosses that edge without revealing the tiles underneath; only up/down reaches another band.
306306 - `move-column-left/right` stays in the band; at its edge the column goes to the neighbor monitor, if any.
307307 - Up/down moves through the windows of a column, then to the band above or below.
308308 - `move-window-up/down` past the column's top or bottom sends the whole column to the band above or below. It joins that band when it has room, else it gets a new band between the two.
309309 - A new window joins the focused band when it has room, else it gets a new band right below.
310310 - `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.
311311 - `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.
312-- 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.
312+- 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. A parentless, non-stashed floating window becomes a tiled column in its own band below the selected column's band on fullscreen entry, including `toggle-fullscreen`; dialogs with a parent and stash entries stay floating.
313313 - `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.
314314 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
315315
@@ -302,14 +302,14 @@ With `keyboard.cmd = alt` or `ctrl`, the defaults that would combine `cmd` with
302 302
303 The default layout. Columns sit side by side in bands of up to `max-columns`, stacked one below the other. Each band fills the screen height and keeps its columns until you move them.303 The default layout. Columns sit side by side in bands of up to `max-columns`, stacked one below the other. Each band fills the screen height and keeps its columns until you move them.
304 304
305-- Left/right focus moves within a band, then to the neighbor monitor, then to the neighbor workspace.305+- Left/right focus moves within a band, then to an occupied neighbor workspace, then to the neighbor monitor, then to the trailing empty workspace. From a fullscreen float it crosses that edge without revealing the tiles underneath; only up/down reaches another band.
306 - `move-column-left/right` stays in the band; at its edge the column goes to the neighbor monitor, if any.306 - `move-column-left/right` stays in the band; at its edge the column goes to the neighbor monitor, if any.
307 - Up/down moves through the windows of a column, then to the band above or below.307 - Up/down moves through the windows of a column, then to the band above or below.
308 - `move-window-up/down` past the column's top or bottom sends the whole column to the band above or below. It joins that band when it has room, else it gets a new band between the two.308 - `move-window-up/down` past the column's top or bottom sends the whole column to the band above or below. It joins that band when it has room, else it gets a new band between the two.
309 - A new window joins the focused band when it has room, else it gets a new band right below.309 - A new window joins the focused band when it has room, else it gets a new band right below.
310 - `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.310 - `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.
311 - `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.311 - `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.
312-- 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.312+- 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. A parentless, non-stashed floating window becomes a tiled column in its own band below the selected column's band on fullscreen entry, including `toggle-fullscreen`; dialogs with a parent and stash entries stay floating.
313 - `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.313 - `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.
314 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.314 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
315 315
M internal/adapters/wayland/xdg.go +2 -0
@@ -400,11 +400,13 @@ func (t top) SetMinSize(r *xdgshell.Toplevel, w, h int32) {
400400 func (top) SetMaximized(*xdgshell.Toplevel) {}
401401 func (top) UnsetMaximized(*xdgshell.Toplevel) {}
402402 func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) {
403+ t.w.xdg.server.log.Info().Uint64("id", uint64(t.w.id)).Bool("on", true).Msg("client fullscreen request")
403404 if t.w.mapped {
404405 t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true})
405406 }
406407 }
407408 func (t top) UnsetFullscreen(*xdgshell.Toplevel) {
409+ t.w.xdg.server.log.Info().Uint64("id", uint64(t.w.id)).Bool("on", false).Msg("client fullscreen request")
408410 if t.w.mapped {
409411 t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id})
410412 }
@@ -400,11 +400,13 @@ func (t top) SetMinSize(r *xdgshell.Toplevel, w, h int32) {
400 func (top) SetMaximized(*xdgshell.Toplevel) {}400 func (top) SetMaximized(*xdgshell.Toplevel) {}
401 func (top) UnsetMaximized(*xdgshell.Toplevel) {}401 func (top) UnsetMaximized(*xdgshell.Toplevel) {}
402 func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) {402 func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) {
403+ t.w.xdg.server.log.Info().Uint64("id", uint64(t.w.id)).Bool("on", true).Msg("client fullscreen request")
403 if t.w.mapped {404 if t.w.mapped {
404 t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true})405 t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true})
405 }406 }
406 }407 }
407 func (t top) UnsetFullscreen(*xdgshell.Toplevel) {408 func (t top) UnsetFullscreen(*xdgshell.Toplevel) {
409+ t.w.xdg.server.log.Info().Uint64("id", uint64(t.w.id)).Bool("on", false).Msg("client fullscreen request")
408 if t.w.mapped {410 if t.w.mapped {
409 t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id})411 t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id})
410 }412 }
M internal/core/action.go +7 -0
@@ -323,6 +323,13 @@ func (c *Core) dispatchAction(a Action) Effect {
323323 if a == ActionFocusColumnLeft {
324324 dir, d = -1, dirLeft
325325 }
326+ // A fullscreen float has no column in its band. Horizontal moves
327+ // use the existing workspace/monitor edge order; only up/down may
328+ // reveal the tiles below.
329+ if w.policy().wraps && w.cover() != 0 && w.isFloat(w.cover()) && !w.stashOverCover() && !w.coverDialogFocused() {
330+ c.edgeFocus(horizontalAxis, dir, d)
331+ return Effect{}
332+ }
326333 // A covering fullscreen window is left for the window on that side;
327334 // with none (the workspace, band or stash edge), the move goes past
328335 // the edge (see edgeFocus) and fullscreen stays.
@@ -323,6 +323,13 @@ func (c *Core) dispatchAction(a Action) Effect {
323 if a == ActionFocusColumnLeft {323 if a == ActionFocusColumnLeft {
324 dir, d = -1, dirLeft324 dir, d = -1, dirLeft
325 }325 }
326+ // A fullscreen float has no column in its band. Horizontal moves
327+ // use the existing workspace/monitor edge order; only up/down may
328+ // reveal the tiles below.
329+ if w.policy().wraps && w.cover() != 0 && w.isFloat(w.cover()) && !w.stashOverCover() && !w.coverDialogFocused() {
330+ c.edgeFocus(horizontalAxis, dir, d)
331+ return Effect{}
332+ }
326 // A covering fullscreen window is left for the window on that side;333 // A covering fullscreen window is left for the window on that side;
327 // with none (the workspace, band or stash edge), the move goes past334 // with none (the workspace, band or stash edge), the move goes past
328 // the edge (see edgeFocus) and fullscreen stays.335 // the edge (see edgeFocus) and fullscreen stays.
M internal/core/cascade.go +37 -7
@@ -491,7 +491,8 @@ func (w *Workspace) pushNeighbor() {
491491 }
492492
493493 // restoreBand brings back the columns isolateBand or pushNeighbor sent
494-// away, those still in the adjacent band, as many as the band has room
494+// away, those still in the adjacent ordinary band (across automatic
495+// own-band isolates), as many as the band has room
495496 // for, nearest to the focused column first: the ones sent up at the start
496497 // of the band, the ones sent down at its end, in their former order. The
497498 // others stay where they are. It reports whether the column remembered
@@ -507,6 +508,13 @@ func (w *Workspace) restoreBand() bool {
507508 bands := w.bandList()
508509 b := w.band(w.Focus)
509510 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
511+ // Automatically isolated fullscreen bands do not separate a column
512+ // from its remembered neighbors. User-shaped bands still do: ordinary
513+ // ones and one whose column the user maximized.
514+ ownOnly := make([]bool, len(bands))
515+ for i, band := range bands {
516+ ownOnly[i] = len(band) > 0 && !slices.ContainsFunc(band, func(c Column) bool { return !c.ownBand || c.FullWidth })
517+ }
510518 // take removes from the bands nbs the remembered columns found there,
511519 // at most room of them, in the order of ids walked from the focused
512520 // column outward (rev: ids end nearest).
@@ -529,10 +537,10 @@ func (w *Workspace) restoreBand() bool {
529537 continue
530538 }
531539 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.
540+ adjacent := !slices.ContainsFunc(nbs[:k], func(n int) bool { return !ownOnly[n] })
534541 i := slices.IndexFunc(bands[nb], func(c Column) bool {
535- return c.Windows[0] == e[head] && (k == 0 || c.ownBand)
542+ // A column the user maximized keeps its band.
543+ return c.Windows[0] == e[head] && !c.FullWidth && (adjacent || c.ownBand)
536544 })
537545 if i < 0 {
538546 continue
@@ -548,13 +556,13 @@ func (w *Workspace) restoreBand() bool {
548556 }
549557 return got
550558 }
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.
559+ // reach crosses automatic own-band isolates, but looks only one band
560+ // beyond the first ordinary band, as before: user moves remain boundaries.
553561 reach := func(dir int) []int {
554562 var nbs []int
555563 for n := b + dir; n >= 0 && n < len(bands); n += dir {
556564 nbs = append(nbs, n)
557- if len(nbs) == 2 {
565+ if len(nbs) > 1 && !ownOnly[n-dir] {
558566 break
559567 }
560568 }
@@ -598,3 +606,25 @@ func (w *Workspace) forgetBands() {
598606 c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil
599607 }
600608 }
609+
610+// fullscreenFloatBand promotes a parentless float into a permanent cascade
611+// column below the selected column's band. Dialogs and stash entries keep
612+// their floating placement. Like fullscreenBand, this never changes focus.
613+func (w *Workspace) fullscreenFloatBand(id WindowID) {
614+ i := w.floatIndex(id)
615+ if !w.policy().wraps || i < 0 || w.Floats[i].over != 0 || w.stashIndex(id) >= 0 {
616+ return
617+ }
618+ focus, _ := w.Focused()
619+ bands := w.bandList()
620+ at := 0
621+ if len(w.Columns) > 0 {
622+ at = w.band(w.Focus) + 1
623+ }
624+ w.Floats = slices.Delete(w.Floats, i, i+1)
625+ bands = slices.Insert(bands, at, []Column{{Windows: []WindowID{id}, ownBand: true}})
626+ w.setBands(bands, focus)
627+ if focus == id {
628+ w.floatFocus, w.stashFocus = false, false
629+ }
630+}
@@ -491,7 +491,8 @@ func (w *Workspace) pushNeighbor() {
491 }491 }
492 492
493 // restoreBand brings back the columns isolateBand or pushNeighbor sent493 // restoreBand brings back the columns isolateBand or pushNeighbor sent
494-// away, those still in the adjacent band, as many as the band has room494+// away, those still in the adjacent ordinary band (across automatic
495+// own-band isolates), as many as the band has room
495 // for, nearest to the focused column first: the ones sent up at the start496 // for, nearest to the focused column first: the ones sent up at the start
496 // of the band, the ones sent down at its end, in their former order. The497 // of the band, the ones sent down at its end, in their former order. The
497 // others stay where they are. It reports whether the column remembered498 // others stay where they are. It reports whether the column remembered
@@ -507,6 +508,13 @@ func (w *Workspace) restoreBand() bool {
507 bands := w.bandList()508 bands := w.bandList()
508 b := w.band(w.Focus)509 b := w.band(w.Focus)
509 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)510 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
511+ // Automatically isolated fullscreen bands do not separate a column
512+ // from its remembered neighbors. User-shaped bands still do: ordinary
513+ // ones and one whose column the user maximized.
514+ ownOnly := make([]bool, len(bands))
515+ for i, band := range bands {
516+ ownOnly[i] = len(band) > 0 && !slices.ContainsFunc(band, func(c Column) bool { return !c.ownBand || c.FullWidth })
517+ }
510 // take removes from the bands nbs the remembered columns found there,518 // 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 focused519 // at most room of them, in the order of ids walked from the focused
512 // column outward (rev: ids end nearest).520 // column outward (rev: ids end nearest).
@@ -529,10 +537,10 @@ func (w *Workspace) restoreBand() bool {
529 continue537 continue
530 }538 }
531 for k, nb := range nbs {539 for k, nb := range nbs {
532- // Past the adjacent band, only a column fullscreenBand put in540+ adjacent := !slices.ContainsFunc(nbs[:k], func(n int) bool { return !ownOnly[n] })
533- // a band of its own is taken: one the user moved there stays.
534 i := slices.IndexFunc(bands[nb], func(c Column) bool {541 i := slices.IndexFunc(bands[nb], func(c Column) bool {
535- return c.Windows[0] == e[head] && (k == 0 || c.ownBand)542+ // A column the user maximized keeps its band.
543+ return c.Windows[0] == e[head] && !c.FullWidth && (adjacent || c.ownBand)
536 })544 })
537 if i < 0 {545 if i < 0 {
538 continue546 continue
@@ -548,13 +556,13 @@ func (w *Workspace) restoreBand() bool {
548 }556 }
549 return got557 return got
550 }558 }
551- // reach lists the bands dir away to look in: the adjacent one, and the559+ // reach crosses automatic own-band isolates, but looks only one band
552- // next, where a column that fullscreenBand split off may lie.560+ // beyond the first ordinary band, as before: user moves remain boundaries.
553 reach := func(dir int) []int {561 reach := func(dir int) []int {
554 var nbs []int562 var nbs []int
555 for n := b + dir; n >= 0 && n < len(bands); n += dir {563 for n := b + dir; n >= 0 && n < len(bands); n += dir {
556 nbs = append(nbs, n)564 nbs = append(nbs, n)
557- if len(nbs) == 2 {565+ if len(nbs) > 1 && !ownOnly[n-dir] {
558 break566 break
559 }567 }
560 }568 }
@@ -598,3 +606,25 @@ func (w *Workspace) forgetBands() {
598 c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil606 c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil
599 }607 }
600 }608 }
609+
610+// fullscreenFloatBand promotes a parentless float into a permanent cascade
611+// column below the selected column's band. Dialogs and stash entries keep
612+// their floating placement. Like fullscreenBand, this never changes focus.
613+func (w *Workspace) fullscreenFloatBand(id WindowID) {
614+ i := w.floatIndex(id)
615+ if !w.policy().wraps || i < 0 || w.Floats[i].over != 0 || w.stashIndex(id) >= 0 {
616+ return
617+ }
618+ focus, _ := w.Focused()
619+ bands := w.bandList()
620+ at := 0
621+ if len(w.Columns) > 0 {
622+ at = w.band(w.Focus) + 1
623+ }
624+ w.Floats = slices.Delete(w.Floats, i, i+1)
625+ bands = slices.Insert(bands, at, []Column{{Windows: []WindowID{id}, ownBand: true}})
626+ w.setBands(bands, focus)
627+ if focus == id {
628+ w.floatFocus, w.stashFocus = false, false
629+ }
630+}
M internal/core/cascade_fullscreen_band_test.go +148 -2
@@ -100,7 +100,7 @@ func TestFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
100100 }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
101101 t.Run(tc.screen, func(t *testing.T) {
102102 c, w := cascadeMovesCore(t, 2, 3, true)
103- w.AddFloating(9, 5120, 2160)
103+ w.AddDialog(9, 2, 5120, 2160)
104104 w.SetFullscreen(9, true)
105105 c.applyAction(tc.action)
106106 dst := screenWorkspace(c, tc.screen)
@@ -115,7 +115,7 @@ func TestFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
115115
116116 func TestFullscreenFloatNoNeighborStays(t *testing.T) {
117117 c, w := cascadeMovesCore(t, 2, 3, false)
118- w.AddFloating(9, 800, 600)
118+ w.AddDialog(9, 2, 800, 600)
119119 w.SetFullscreen(9, true)
120120 c.applyAction(ActionMoveColumnLeft)
121121 if w.floatIndex(9) < 0 || w.fullscreen != 9 {
@@ -295,3 +295,149 @@ func TestRestoreBandDoesNotLookPastUserBand(t *testing.T) {
295295 w.ToggleFullWidth()
296296 wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
297297 }
298+
299+func TestCascadeFullscreenHorizontalKeepsCover(t *testing.T) {
300+ for _, floating := range []bool{false, true} {
301+ t.Run(map[bool]string{false: "tile", true: "dialog"}[floating], func(t *testing.T) {
302+ c, w := cascadeMovesCore(t, 2, 3, true)
303+ id := WindowID(2)
304+ if floating {
305+ id = 9
306+ w.AddDialog(id, 2, 800, 600)
307+ }
308+ w.SetFullscreen(id, true)
309+ c.applyAction(ActionFocusColumnLeft)
310+ if c.cur().name() != "L" || w.fullscreen != id {
311+ t.Fatalf("screen %+v fullscreen %d", c.cur(), w.fullscreen)
312+ }
313+ })
314+ }
315+}
316+
317+func TestCascadeFullscreenUpReachesPreviousBand(t *testing.T) {
318+ c, w := cascadeMovesCore(t, 2, 3, false)
319+ w.ToggleFullscreen()
320+ c.applyAction(ActionFocusWindowUp)
321+ if w.fullscreen != 0 {
322+ t.Fatalf("fullscreen %d", w.fullscreen)
323+ }
324+ wantFocus(t, c, "M", 1)
325+}
326+
327+func TestCascadeFullscreenFloatEdgePrefersWorkspace(t *testing.T) {
328+ c, _ := cascadeMovesCore(t, 1, 3, true)
329+ m := c.cur().mon
330+ m.Focus(1)
331+ m.AddWindow(2)
332+ w := m.Current()
333+ w.Overflow = OverflowCascade
334+ w.AddDialog(9, 2, 800, 600)
335+ w.SetFullscreen(9, true)
336+ c.applyAction(ActionFocusColumnLeft)
337+ wantFocus(t, c, "M", 1)
338+ if w.fullscreen != 9 {
339+ t.Fatalf("fullscreen %d", w.fullscreen)
340+ }
341+}
342+
343+func TestCascadeFullscreenFloatChildHorizontalReturnsToCover(t *testing.T) {
344+ for _, sides := range []bool{false, true} {
345+ for _, a := range []Action{ActionFocusColumnLeft, ActionFocusColumnRight} {
346+ t.Run(string(a)+map[bool]string{false: "/no-edge", true: "/edge"}[sides], func(t *testing.T) {
347+ c, w := cascadeMovesCore(t, 2, 3, sides)
348+ w.AddDialog(9, 2, 800, 600)
349+ w.SetFullscreen(9, true)
350+ w.AddDialog(10, 9, 100, 100)
351+ c.applyAction(a)
352+ wantFocus(t, c, "M", 9)
353+ if w.fullscreen != 9 {
354+ t.Fatalf("fullscreen %d", w.fullscreen)
355+ }
356+ })
357+ }
358+ }
359+}
360+
361+func TestRestoreBandAcrossPromotedFloatBand(t *testing.T) {
362+ for _, tc := range []struct {
363+ name string
364+ maximize bool // the user maximizes the promoted column before 1 comes back
365+ want [][]WindowID
366+ }{
367+ {"an automatic band is crossed", false, [][]WindowID{{1, 2, 3}, {9}}},
368+ {"a maximized band stops the restore", true, [][]WindowID{{1}, {9}, {2, 3}}},
369+ } {
370+ t.Run(tc.name, func(t *testing.T) {
371+ _, w := cascadeMovesCore(t, 3, 3, false)
372+ w.FocusID(1)
373+ w.ToggleFullWidth()
374+ w.AddFloating(9, 800, 600)
375+ w.SetFullscreen(9, true)
376+ wantBands(t, w, []WindowID{1}, []WindowID{9}, []WindowID{2, 3})
377+ w.SetFullscreen(9, false)
378+ if tc.maximize {
379+ w.FocusID(9)
380+ w.ToggleFullWidth()
381+ }
382+ w.FocusID(1)
383+ w.ToggleFullWidth()
384+ wantBands(t, w, tc.want...)
385+ })
386+ }
387+}
388+
389+// A remembered neighbor the user maximized since keeps its band; the
390+// ordinary neighbors still come back.
391+func TestRestoreBandLeavesMaximizedNeighbor(t *testing.T) {
392+ _, w := cascadeMovesCore(t, 3, 3, false)
393+ w.FocusID(1)
394+ w.ToggleFullWidth()
395+ wantBands(t, w, []WindowID{1}, []WindowID{2, 3})
396+ w.FocusID(2)
397+ w.ToggleFullscreen()
398+ w.ToggleFullscreen()
399+ w.ToggleFullWidth()
400+ wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2})
401+ w.FocusID(1)
402+ w.ToggleFullWidth()
403+ wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
404+ if c := w.columnOf(2); c < 0 || !w.Columns[c].FullWidth {
405+ t.Fatal("column 2 lost its maximization")
406+ }
407+}
408+
409+func TestPromotedFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
410+ for _, tc := range []struct {
411+ a Action
412+ screen string
413+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
414+ t.Run(tc.screen, func(t *testing.T) {
415+ c, w := cascadeMovesCore(t, 2, 3, true)
416+ w.AddFloating(9, 800, 600)
417+ w.SetFullscreen(9, true)
418+ c.applyAction(tc.a)
419+ dst := screenWorkspace(c, tc.screen)
420+ wantFocus(t, c, tc.screen, 9)
421+ wantColumns(t, w, 1, 2)
422+ if dst.isFloat(9) || dst.columnOf(9) < 0 || dst.fullscreen != 9 || w.fullscreen != 0 {
423+ t.Fatalf("destination %+v", dst)
424+ }
425+ })
426+ }
427+}
428+
429+func TestUserFloatedToggleFullscreenPromotes(t *testing.T) {
430+ _, w := cascadeMovesCore(t, 2, 3, false)
431+ w.ToggleFloating()
432+ if !w.isFloat(2) {
433+ t.Fatal("setup not floating")
434+ }
435+ w.ToggleFullscreen()
436+ if w.isFloat(2) || w.fullscreen != 2 {
437+ t.Fatal("user float not promoted")
438+ }
439+ wantBands(t, w, []WindowID{1}, []WindowID{2})
440+ w.SetFullscreen(2, true)
441+ w.SetFullscreen(2, true)
442+ wantBands(t, w, []WindowID{1}, []WindowID{2})
443+}
@@ -100,7 +100,7 @@ func TestFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
100 }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {100 }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
101 t.Run(tc.screen, func(t *testing.T) {101 t.Run(tc.screen, func(t *testing.T) {
102 c, w := cascadeMovesCore(t, 2, 3, true)102 c, w := cascadeMovesCore(t, 2, 3, true)
103- w.AddFloating(9, 5120, 2160)103+ w.AddDialog(9, 2, 5120, 2160)
104 w.SetFullscreen(9, true)104 w.SetFullscreen(9, true)
105 c.applyAction(tc.action)105 c.applyAction(tc.action)
106 dst := screenWorkspace(c, tc.screen)106 dst := screenWorkspace(c, tc.screen)
@@ -115,7 +115,7 @@ func TestFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
115 115
116 func TestFullscreenFloatNoNeighborStays(t *testing.T) {116 func TestFullscreenFloatNoNeighborStays(t *testing.T) {
117 c, w := cascadeMovesCore(t, 2, 3, false)117 c, w := cascadeMovesCore(t, 2, 3, false)
118- w.AddFloating(9, 800, 600)118+ w.AddDialog(9, 2, 800, 600)
119 w.SetFullscreen(9, true)119 w.SetFullscreen(9, true)
120 c.applyAction(ActionMoveColumnLeft)120 c.applyAction(ActionMoveColumnLeft)
121 if w.floatIndex(9) < 0 || w.fullscreen != 9 {121 if w.floatIndex(9) < 0 || w.fullscreen != 9 {
@@ -295,3 +295,149 @@ func TestRestoreBandDoesNotLookPastUserBand(t *testing.T) {
295 w.ToggleFullWidth()295 w.ToggleFullWidth()
296 wantBands(t, w, []WindowID{1, 3}, []WindowID{2})296 wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
297 }297 }
298+
299+func TestCascadeFullscreenHorizontalKeepsCover(t *testing.T) {
300+ for _, floating := range []bool{false, true} {
301+ t.Run(map[bool]string{false: "tile", true: "dialog"}[floating], func(t *testing.T) {
302+ c, w := cascadeMovesCore(t, 2, 3, true)
303+ id := WindowID(2)
304+ if floating {
305+ id = 9
306+ w.AddDialog(id, 2, 800, 600)
307+ }
308+ w.SetFullscreen(id, true)
309+ c.applyAction(ActionFocusColumnLeft)
310+ if c.cur().name() != "L" || w.fullscreen != id {
311+ t.Fatalf("screen %+v fullscreen %d", c.cur(), w.fullscreen)
312+ }
313+ })
314+ }
315+}
316+
317+func TestCascadeFullscreenUpReachesPreviousBand(t *testing.T) {
318+ c, w := cascadeMovesCore(t, 2, 3, false)
319+ w.ToggleFullscreen()
320+ c.applyAction(ActionFocusWindowUp)
321+ if w.fullscreen != 0 {
322+ t.Fatalf("fullscreen %d", w.fullscreen)
323+ }
324+ wantFocus(t, c, "M", 1)
325+}
326+
327+func TestCascadeFullscreenFloatEdgePrefersWorkspace(t *testing.T) {
328+ c, _ := cascadeMovesCore(t, 1, 3, true)
329+ m := c.cur().mon
330+ m.Focus(1)
331+ m.AddWindow(2)
332+ w := m.Current()
333+ w.Overflow = OverflowCascade
334+ w.AddDialog(9, 2, 800, 600)
335+ w.SetFullscreen(9, true)
336+ c.applyAction(ActionFocusColumnLeft)
337+ wantFocus(t, c, "M", 1)
338+ if w.fullscreen != 9 {
339+ t.Fatalf("fullscreen %d", w.fullscreen)
340+ }
341+}
342+
343+func TestCascadeFullscreenFloatChildHorizontalReturnsToCover(t *testing.T) {
344+ for _, sides := range []bool{false, true} {
345+ for _, a := range []Action{ActionFocusColumnLeft, ActionFocusColumnRight} {
346+ t.Run(string(a)+map[bool]string{false: "/no-edge", true: "/edge"}[sides], func(t *testing.T) {
347+ c, w := cascadeMovesCore(t, 2, 3, sides)
348+ w.AddDialog(9, 2, 800, 600)
349+ w.SetFullscreen(9, true)
350+ w.AddDialog(10, 9, 100, 100)
351+ c.applyAction(a)
352+ wantFocus(t, c, "M", 9)
353+ if w.fullscreen != 9 {
354+ t.Fatalf("fullscreen %d", w.fullscreen)
355+ }
356+ })
357+ }
358+ }
359+}
360+
361+func TestRestoreBandAcrossPromotedFloatBand(t *testing.T) {
362+ for _, tc := range []struct {
363+ name string
364+ maximize bool // the user maximizes the promoted column before 1 comes back
365+ want [][]WindowID
366+ }{
367+ {"an automatic band is crossed", false, [][]WindowID{{1, 2, 3}, {9}}},
368+ {"a maximized band stops the restore", true, [][]WindowID{{1}, {9}, {2, 3}}},
369+ } {
370+ t.Run(tc.name, func(t *testing.T) {
371+ _, w := cascadeMovesCore(t, 3, 3, false)
372+ w.FocusID(1)
373+ w.ToggleFullWidth()
374+ w.AddFloating(9, 800, 600)
375+ w.SetFullscreen(9, true)
376+ wantBands(t, w, []WindowID{1}, []WindowID{9}, []WindowID{2, 3})
377+ w.SetFullscreen(9, false)
378+ if tc.maximize {
379+ w.FocusID(9)
380+ w.ToggleFullWidth()
381+ }
382+ w.FocusID(1)
383+ w.ToggleFullWidth()
384+ wantBands(t, w, tc.want...)
385+ })
386+ }
387+}
388+
389+// A remembered neighbor the user maximized since keeps its band; the
390+// ordinary neighbors still come back.
391+func TestRestoreBandLeavesMaximizedNeighbor(t *testing.T) {
392+ _, w := cascadeMovesCore(t, 3, 3, false)
393+ w.FocusID(1)
394+ w.ToggleFullWidth()
395+ wantBands(t, w, []WindowID{1}, []WindowID{2, 3})
396+ w.FocusID(2)
397+ w.ToggleFullscreen()
398+ w.ToggleFullscreen()
399+ w.ToggleFullWidth()
400+ wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2})
401+ w.FocusID(1)
402+ w.ToggleFullWidth()
403+ wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
404+ if c := w.columnOf(2); c < 0 || !w.Columns[c].FullWidth {
405+ t.Fatal("column 2 lost its maximization")
406+ }
407+}
408+
409+func TestPromotedFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
410+ for _, tc := range []struct {
411+ a Action
412+ screen string
413+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
414+ t.Run(tc.screen, func(t *testing.T) {
415+ c, w := cascadeMovesCore(t, 2, 3, true)
416+ w.AddFloating(9, 800, 600)
417+ w.SetFullscreen(9, true)
418+ c.applyAction(tc.a)
419+ dst := screenWorkspace(c, tc.screen)
420+ wantFocus(t, c, tc.screen, 9)
421+ wantColumns(t, w, 1, 2)
422+ if dst.isFloat(9) || dst.columnOf(9) < 0 || dst.fullscreen != 9 || w.fullscreen != 0 {
423+ t.Fatalf("destination %+v", dst)
424+ }
425+ })
426+ }
427+}
428+
429+func TestUserFloatedToggleFullscreenPromotes(t *testing.T) {
430+ _, w := cascadeMovesCore(t, 2, 3, false)
431+ w.ToggleFloating()
432+ if !w.isFloat(2) {
433+ t.Fatal("setup not floating")
434+ }
435+ w.ToggleFullscreen()
436+ if w.isFloat(2) || w.fullscreen != 2 {
437+ t.Fatal("user float not promoted")
438+ }
439+ wantBands(t, w, []WindowID{1}, []WindowID{2})
440+ w.SetFullscreen(2, true)
441+ w.SetFullscreen(2, true)
442+ wantBands(t, w, []WindowID{1}, []WindowID{2})
443+}
M internal/core/layout.go +2 -0
@@ -1340,6 +1340,7 @@ func (w *Workspace) ToggleFullscreen() {
13401340 w.endFullscreen()
13411341 return
13421342 }
1343+ w.fullscreenFloatBand(id)
13431344 w.fullscreenBand(id)
13441345 w.fullscreen = id
13451346 w.scroll()
@@ -1450,6 +1451,7 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
14501451 // Another window covers: the override was for the old cover.
14511452 w.dropStashOver()
14521453 }
1454+ w.fullscreenFloatBand(id)
14531455 if !w.isFloat(id) && w.fullscreen != id {
14541456 w.fullscreenBand(id)
14551457 }
@@ -1340,6 +1340,7 @@ func (w *Workspace) ToggleFullscreen() {
1340 w.endFullscreen()1340 w.endFullscreen()
1341 return1341 return
1342 }1342 }
1343+ w.fullscreenFloatBand(id)
1343 w.fullscreenBand(id)1344 w.fullscreenBand(id)
1344 w.fullscreen = id1345 w.fullscreen = id
1345 w.scroll()1346 w.scroll()
@@ -1450,6 +1451,7 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
1450 // Another window covers: the override was for the old cover.1451 // Another window covers: the override was for the old cover.
1451 w.dropStashOver()1452 w.dropStashOver()
1452 }1453 }
1454+ w.fullscreenFloatBand(id)
1453 if !w.isFloat(id) && w.fullscreen != id {1455 if !w.isFloat(id) && w.fullscreen != id {
1454 w.fullscreenBand(id)1456 w.fullscreenBand(id)
1455 }1457 }
M internal/core/legacy_fullscreen_internal_test.go +56 -0
@@ -244,3 +244,59 @@ func TestLegacyFullscreenUnderBoundCover(t *testing.T) {
244244 }
245245 }
246246 }
247+
248+func TestLegacyFullscreenCascadeFloatBand(t *testing.T) {
249+ for _, parent := range []WindowID{0, 1} {
250+ t.Run(map[bool]string{true: "dialog", false: "parentless"}[parent != 0], func(t *testing.T) {
251+ c, clock, _ := fadeCore(t)
252+ sc := c.cur()
253+ w := sc.mon.Current()
254+ w.Overflow, w.MaxColumns = OverflowCascade, 3
255+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 1})
256+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 2, Floating: true, Parent: parent})
257+ o := sc.rect()
258+ c.clientEvent(context.Background(), ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
259+ if parent != 0 {
260+ if !w.isFloat(2) || w.fullscreen != 0 {
261+ t.Fatal("dialog was promoted or auto-fullscreened")
262+ }
263+ clock.now = clock.now.Add(fullscreenGrace)
264+ c.clientEvent(context.Background(), ports.WindowFullscreenRequest{ID: 2, Fullscreen: true})
265+ if w.fullscreen != 2 || !w.isFloat(2) {
266+ t.Fatal("explicit fullscreen promoted a dialog")
267+ }
268+ return
269+ }
270+ if w.isFloat(2) || w.fullscreen != 2 || len(w.Columns) != 2 || w.band(w.columnOf(2)) != w.band(w.columnOf(1))+1 || !w.Columns[w.columnOf(2)].ownBand {
271+ t.Fatalf("fullscreen float placement: %+v, floats %+v fullscreen %d", w.Columns, w.Floats, w.fullscreen)
272+ }
273+ c.clientEvent(context.Background(), ports.WindowFullscreenRequest{ID: 2})
274+ if id, _ := w.Focused(); id != 2 || w.fullscreen != 0 || w.isFloat(2) || w.band(w.columnOf(2)) != 1 {
275+ t.Fatalf("after unset: focus %d fullscreen %d columns %+v floats %+v", id, w.fullscreen, w.Columns, w.Floats)
276+ }
277+ if r, ok := w.placed(2); !ok || r.W <= 0 || r.H <= 0 {
278+ t.Fatalf("game not visible: %+v %v", r, ok)
279+ }
280+ })
281+ }
282+}
283+
284+func TestCascadeClientFullscreenPromotesFloatAfterGrace(t *testing.T) {
285+ for _, requests := range []int{1, 3} {
286+ t.Run(map[int]string{1: "once", 3: "repeated"}[requests], func(t *testing.T) {
287+ c, clock, _ := fadeCore(t)
288+ w := c.cur().mon.Current()
289+ w.Overflow, w.MaxColumns = OverflowCascade, 3
290+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 1})
291+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 2, Floating: true})
292+ clock.now = clock.now.Add(fullscreenGrace)
293+ for range requests {
294+ c.clientEvent(context.Background(), ports.WindowFullscreenRequest{ID: 2, Fullscreen: true})
295+ }
296+ if w.fullscreen != 2 || w.isFloat(2) {
297+ t.Fatalf("fullscreen %d floats %+v", w.fullscreen, w.Floats)
298+ }
299+ wantBands(t, w, []WindowID{1}, []WindowID{2})
300+ })
301+ }
302+}
@@ -244,3 +244,59 @@ func TestLegacyFullscreenUnderBoundCover(t *testing.T) {
244 }244 }
245 }245 }
246 }246 }
247+
248+func TestLegacyFullscreenCascadeFloatBand(t *testing.T) {
249+ for _, parent := range []WindowID{0, 1} {
250+ t.Run(map[bool]string{true: "dialog", false: "parentless"}[parent != 0], func(t *testing.T) {
251+ c, clock, _ := fadeCore(t)
252+ sc := c.cur()
253+ w := sc.mon.Current()
254+ w.Overflow, w.MaxColumns = OverflowCascade, 3
255+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 1})
256+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 2, Floating: true, Parent: parent})
257+ o := sc.rect()
258+ c.clientEvent(context.Background(), ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
259+ if parent != 0 {
260+ if !w.isFloat(2) || w.fullscreen != 0 {
261+ t.Fatal("dialog was promoted or auto-fullscreened")
262+ }
263+ clock.now = clock.now.Add(fullscreenGrace)
264+ c.clientEvent(context.Background(), ports.WindowFullscreenRequest{ID: 2, Fullscreen: true})
265+ if w.fullscreen != 2 || !w.isFloat(2) {
266+ t.Fatal("explicit fullscreen promoted a dialog")
267+ }
268+ return
269+ }
270+ if w.isFloat(2) || w.fullscreen != 2 || len(w.Columns) != 2 || w.band(w.columnOf(2)) != w.band(w.columnOf(1))+1 || !w.Columns[w.columnOf(2)].ownBand {
271+ t.Fatalf("fullscreen float placement: %+v, floats %+v fullscreen %d", w.Columns, w.Floats, w.fullscreen)
272+ }
273+ c.clientEvent(context.Background(), ports.WindowFullscreenRequest{ID: 2})
274+ if id, _ := w.Focused(); id != 2 || w.fullscreen != 0 || w.isFloat(2) || w.band(w.columnOf(2)) != 1 {
275+ t.Fatalf("after unset: focus %d fullscreen %d columns %+v floats %+v", id, w.fullscreen, w.Columns, w.Floats)
276+ }
277+ if r, ok := w.placed(2); !ok || r.W <= 0 || r.H <= 0 {
278+ t.Fatalf("game not visible: %+v %v", r, ok)
279+ }
280+ })
281+ }
282+}
283+
284+func TestCascadeClientFullscreenPromotesFloatAfterGrace(t *testing.T) {
285+ for _, requests := range []int{1, 3} {
286+ t.Run(map[int]string{1: "once", 3: "repeated"}[requests], func(t *testing.T) {
287+ c, clock, _ := fadeCore(t)
288+ w := c.cur().mon.Current()
289+ w.Overflow, w.MaxColumns = OverflowCascade, 3
290+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 1})
291+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 2, Floating: true})
292+ clock.now = clock.now.Add(fullscreenGrace)
293+ for range requests {
294+ c.clientEvent(context.Background(), ports.WindowFullscreenRequest{ID: 2, Fullscreen: true})
295+ }
296+ if w.fullscreen != 2 || w.isFloat(2) {
297+ t.Fatalf("fullscreen %d floats %+v", w.fullscreen, w.Floats)
298+ }
299+ wantBands(t, w, []WindowID{1}, []WindowID{2})
300+ })
301+ }
302+}

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