detached b49bcb3

fix(layout): keep slots and focus across fullscreen trips, restack only into the same column

commit
b49bcb338b2b881dcd740da7661077bfd28446b4
author
brice · · 2026-09-25 20:16 UTC
parent
3fea6c2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +67 -9
M internal/core/layout.go +5 -3
@@ -146,9 +146,11 @@ type Workspace struct {
146146 // column, the column's width and slot, or its floating size.
147147 type origPlace struct {
148148 col, row, slot int
149- stacked bool
150- width Width
151- float *Float
149+ // stacked holds a window left in the column: the window returns to its
150+ // row only while that window still marks the column.
151+ stacked WindowID
152+ width Width
153+ float *Float
152154 }
153155
154156 // Float is a floating window and its client size, logical.
@@ -146,9 +146,11 @@ type Workspace struct {
146 // column, the column's width and slot, or its floating size.146 // column, the column's width and slot, or its floating size.
147 type origPlace struct {147 type origPlace struct {
148 col, row, slot int148 col, row, slot int
149- stacked bool149+ // stacked holds a window left in the column: the window returns to its
150- width Width150+ // row only while that window still marks the column.
151- float *Float151+ stacked WindowID
152+ width Width
153+ float *Float
152 }154 }
153 155
154 // Float is a floating window and its client size, logical.156 // Float is a floating window and its client size, logical.
M internal/core/monitor.go +19 -3
@@ -270,7 +270,8 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
270270 focused, _ := w.Focused()
271271 m.enterFullscreen(w, id, w == m.Current() && focused == id)
272272 case !on && w.origin != nil && w.fullscreen == id:
273- m.leaveFullscreen(w, false)
273+ // The user was on that window: they stay on it, back home.
274+ m.leaveFullscreen(w, m.Current() == w)
274275 default:
275276 w.SetFullscreen(id, on)
276277 }
@@ -318,7 +319,10 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
318319 } else {
319320 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
320321 c := w.Columns[col]
321- fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, stacked: len(c.Windows) > 1, width: c.Width}
322+ fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}
323+ if i := slices.IndexFunc(c.Windows, func(v WindowID) bool { return v != id }); i >= 0 {
324+ fs.back.stacked = c.Windows[i]
325+ }
322326 w.RemoveWindow(id)
323327 fs.AddWindow(id)
324328 fs.Columns[0].Width = c.Width
@@ -354,7 +358,8 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
354358 case back.float != nil:
355359 // At the bottom: the top float, maybe focused, stays on top.
356360 origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})
357- case back.stacked && back.col < len(origin.Columns):
361+ case back.stacked != 0 && slices.ContainsFunc(origin.Columns, func(c Column) bool { return slices.Contains(c.Windows, back.stacked) }):
362+ back.col = slices.IndexFunc(origin.Columns, func(c Column) bool { return slices.Contains(c.Windows, back.stacked) })
358363 c := &origin.Columns[back.col]
359364 row := min(back.row, len(c.Windows))
360365 c.Windows = slices.Insert(c.Windows, row, id)
@@ -383,6 +388,17 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
383388 }
384389 }
385390
391+// awayInSlot reports whether id is away from slot n of w, fullscreen in a
392+// workspace that returns it there.
393+func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool {
394+ for _, fs := range m.all() {
395+ if fs.origin == w && fs.fullscreen == id && fs.back.slot == n {
396+ return true
397+ }
398+ }
399+ return false
400+}
401+
386402 // fullscreenHome keeps each fullscreen workspace linked to its origin only
387403 // while it holds just its fullscreen window and the origin is still here.
388404 // Anything else (another window opened or moved in, the window activated
@@ -270,7 +270,8 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
270 focused, _ := w.Focused()270 focused, _ := w.Focused()
271 m.enterFullscreen(w, id, w == m.Current() && focused == id)271 m.enterFullscreen(w, id, w == m.Current() && focused == id)
272 case !on && w.origin != nil && w.fullscreen == id:272 case !on && w.origin != nil && w.fullscreen == id:
273- m.leaveFullscreen(w, false)273+ // The user was on that window: they stay on it, back home.
274+ m.leaveFullscreen(w, m.Current() == w)
274 default:275 default:
275 w.SetFullscreen(id, on)276 w.SetFullscreen(id, on)
276 }277 }
@@ -318,7 +319,10 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
318 } else {319 } else {
319 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })320 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
320 c := w.Columns[col]321 c := w.Columns[col]
321- fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, stacked: len(c.Windows) > 1, width: c.Width}322+ fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}
323+ if i := slices.IndexFunc(c.Windows, func(v WindowID) bool { return v != id }); i >= 0 {
324+ fs.back.stacked = c.Windows[i]
325+ }
322 w.RemoveWindow(id)326 w.RemoveWindow(id)
323 fs.AddWindow(id)327 fs.AddWindow(id)
324 fs.Columns[0].Width = c.Width328 fs.Columns[0].Width = c.Width
@@ -354,7 +358,8 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
354 case back.float != nil:358 case back.float != nil:
355 // At the bottom: the top float, maybe focused, stays on top.359 // At the bottom: the top float, maybe focused, stays on top.
356 origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})360 origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})
357- case back.stacked && back.col < len(origin.Columns):361+ case back.stacked != 0 && slices.ContainsFunc(origin.Columns, func(c Column) bool { return slices.Contains(c.Windows, back.stacked) }):
362+ back.col = slices.IndexFunc(origin.Columns, func(c Column) bool { return slices.Contains(c.Windows, back.stacked) })
358 c := &origin.Columns[back.col]363 c := &origin.Columns[back.col]
359 row := min(back.row, len(c.Windows))364 row := min(back.row, len(c.Windows))
360 c.Windows = slices.Insert(c.Windows, row, id)365 c.Windows = slices.Insert(c.Windows, row, id)
@@ -383,6 +388,17 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
383 }388 }
384 }389 }
385 390
391+// awayInSlot reports whether id is away from slot n of w, fullscreen in a
392+// workspace that returns it there.
393+func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool {
394+ for _, fs := range m.all() {
395+ if fs.origin == w && fs.fullscreen == id && fs.back.slot == n {
396+ return true
397+ }
398+ }
399+ return false
400+}
401+
386 // fullscreenHome keeps each fullscreen workspace linked to its origin only402 // fullscreenHome keeps each fullscreen workspace linked to its origin only
387 // while it holds just its fullscreen window and the origin is still here.403 // while it holds just its fullscreen window and the origin is still here.
388 // Anything else (another window opened or moved in, the window activated404 // Anything else (another window opened or moved in, the window activated
M internal/core/monitor_test.go +21 -0
@@ -534,6 +534,27 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
534534 t.Fatal("view jumped to", m.Active)
535535 }
536536 })
537+ t.Run("client exit on screen keeps the window focused", func(t *testing.T) {
538+ m := fixed()
539+ m.Current().FocusID(1)
540+ m.ToggleFullscreen()
541+ m.SetFullscreen(1, false)
542+ if id, _ := m.Focused(); id != 1 || m.Active != 0 {
543+ t.Fatal("focus", id, m.Active)
544+ }
545+ })
546+ t.Run("stack gone: back as a column", func(t *testing.T) {
547+ m := fixed()
548+ w := m.Current()
549+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{4}}}
550+ w.FocusID(3)
551+ m.ToggleFullscreen()
552+ m.RemoveWindow(2)
553+ m.ToggleFullscreen()
554+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {}}) || len(m.Current().Columns) != 3 {
555+ t.Fatal(got, m.Current().Columns)
556+ }
557+ })
537558 t.Run("floating window", func(t *testing.T) {
538559 m := fixed()
539560 m.AddFloating(4, 10, 10)
@@ -534,6 +534,27 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
534 t.Fatal("view jumped to", m.Active)534 t.Fatal("view jumped to", m.Active)
535 }535 }
536 })536 })
537+ t.Run("client exit on screen keeps the window focused", func(t *testing.T) {
538+ m := fixed()
539+ m.Current().FocusID(1)
540+ m.ToggleFullscreen()
541+ m.SetFullscreen(1, false)
542+ if id, _ := m.Focused(); id != 1 || m.Active != 0 {
543+ t.Fatal("focus", id, m.Active)
544+ }
545+ })
546+ t.Run("stack gone: back as a column", func(t *testing.T) {
547+ m := fixed()
548+ w := m.Current()
549+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{4}}}
550+ w.FocusID(3)
551+ m.ToggleFullscreen()
552+ m.RemoveWindow(2)
553+ m.ToggleFullscreen()
554+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {}}) || len(m.Current().Columns) != 3 {
555+ t.Fatal(got, m.Current().Columns)
556+ }
557+ })
537 t.Run("floating window", func(t *testing.T) {558 t.Run("floating window", func(t *testing.T) {
538 m := fixed()559 m := fixed()
539 m.AddFloating(4, 10, 10)560 m.AddFloating(4, 10, 10)
M internal/core/slots.go +3 -2
@@ -141,8 +141,9 @@ func (c *Core) releaseSlots() {
141141 if st.window == 0 {
142142 continue
143143 }
144- _, w := c.byName(key.workspace)
145- if w == nil || !w.inSlot(st.window, key.index) {
144+ sc, w := c.byName(key.workspace)
145+ // A slot window away in its own fullscreen workspace keeps its slot.
146+ if w == nil || !w.inSlot(st.window, key.index) && !sc.mon.awayInSlot(w, st.window, key.index) {
146147 st.window = 0
147148 }
148149 }
@@ -141,8 +141,9 @@ func (c *Core) releaseSlots() {
141 if st.window == 0 {141 if st.window == 0 {
142 continue142 continue
143 }143 }
144- _, w := c.byName(key.workspace)144+ sc, w := c.byName(key.workspace)
145- if w == nil || !w.inSlot(st.window, key.index) {145+ // A slot window away in its own fullscreen workspace keeps its slot.
146+ if w == nil || !w.inSlot(st.window, key.index) && !sc.mon.awayInSlot(w, st.window, key.index) {
146 st.window = 0147 st.window = 0
147 }148 }
148 }149 }
M internal/core/slots_test.go +19 -1
@@ -23,7 +23,8 @@ type slotRig struct {
2323 }
2424
2525 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
26-// by Alt+d, on a 100x80 output.
26+// by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
27+// fixed overflow.
2728 func startSlots(t *testing.T, numbered ...bool) *slotRig {
2829 t.Helper()
2930 cfg := config.Defaults()
@@ -34,6 +35,9 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
3435 {Index: 2, Width: "30%", Argv: []string{"foot"}},
3536 }}}
3637 cfg.Binds["Alt+d"] = "workspace dev"
38+ if len(numbered) > 1 {
39+ cfg.Layout.Overflow = "fixed"
40+ }
3741 r := &slotRig{
3842 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
3943 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
@@ -367,3 +371,17 @@ func TestStartupCommands(t *testing.T) {
367371 }
368372 }
369373 }
374+
375+// A slot window in its own fullscreen workspace (fixed overflow) keeps its
376+// slot: coming back to dev spawns no duplicate.
377+func TestSlotKeptWhileFullscreenAway(t *testing.T) {
378+ r := startSlots(t, true, true)
379+ r.fill(t)
380+ r.press(t, "d") // on dev, focus on code (slot 1)
381+ r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
382+ r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift}
383+ scene(t, r.scenes)
384+ r.press(t, "d") // leave and show dev while code is away
385+ r.press(t, "d")
386+ noSpawn(t, r.spawn)
387+}
@@ -23,7 +23,8 @@ type slotRig struct {
23 }23 }
24 24
25 // startSlots runs core with a hidden "dev" workspace of two slots, toggled25 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
26-// by Alt+d, on a 100x80 output.26+// by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
27+// fixed overflow.
27 func startSlots(t *testing.T, numbered ...bool) *slotRig {28 func startSlots(t *testing.T, numbered ...bool) *slotRig {
28 t.Helper()29 t.Helper()
29 cfg := config.Defaults()30 cfg := config.Defaults()
@@ -34,6 +35,9 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
34 {Index: 2, Width: "30%", Argv: []string{"foot"}},35 {Index: 2, Width: "30%", Argv: []string{"foot"}},
35 }}}36 }}}
36 cfg.Binds["Alt+d"] = "workspace dev"37 cfg.Binds["Alt+d"] = "workspace dev"
38+ if len(numbered) > 1 {
39+ cfg.Layout.Overflow = "fixed"
40+ }
37 r := &slotRig{41 r := &slotRig{
38 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),42 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
39 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),43 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
@@ -367,3 +371,17 @@ func TestStartupCommands(t *testing.T) {
367 }371 }
368 }372 }
369 }373 }
374+
375+// A slot window in its own fullscreen workspace (fixed overflow) keeps its
376+// slot: coming back to dev spawns no duplicate.
377+func TestSlotKeptWhileFullscreenAway(t *testing.T) {
378+ r := startSlots(t, true, true)
379+ r.fill(t)
380+ r.press(t, "d") // on dev, focus on code (slot 1)
381+ r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
382+ r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift}
383+ scene(t, r.scenes)
384+ r.press(t, "d") // leave and show dev while code is away
385+ r.press(t, "d")
386+ noSpawn(t, r.spawn)
387+}

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