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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 146 | 146 | // column, the column's width and slot, or its floating size. | |
| 147 | 147 | type origPlace struct { | |
| 148 | 148 | 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 | |
| 152 | 154 | } | |
| 153 | 155 | ||
| 154 | 156 | // 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 int | 148 | col, row, slot int | ||
| 149 | - | stacked bool | 149 | + | // stacked holds a window left in the column: the window returns to its |
| 150 | - | width Width | 150 | + | // row only while that window still marks the column. |
| 151 | - | float *Float | 151 | + | 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) { | |||
| 270 | 270 | focused, _ := w.Focused() | |
| 271 | 271 | m.enterFullscreen(w, id, w == m.Current() && focused == id) | |
| 272 | 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 | 275 | default: | |
| 275 | 276 | w.SetFullscreen(id, on) | |
| 276 | 277 | } | |
| @@ -318,7 +319,10 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||
| 318 | 319 | } else { | |
| 319 | 320 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 320 | 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 | 326 | w.RemoveWindow(id) | |
| 323 | 327 | fs.AddWindow(id) | |
| 324 | 328 | fs.Columns[0].Width = c.Width | |
| @@ -354,7 +358,8 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 354 | 358 | case back.float != nil: | |
| 355 | 359 | // At the bottom: the top float, maybe focused, stays on top. | |
| 356 | 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 | 363 | c := &origin.Columns[back.col] | |
| 359 | 364 | row := min(back.row, len(c.Windows)) | |
| 360 | 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 | 402 | // fullscreenHome keeps each fullscreen workspace linked to its origin only | |
| 387 | 403 | // while it holds just its fullscreen window and the origin is still here. | |
| 388 | 404 | // 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.Width | 328 | 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 only | 402 | // 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 activated | 404 | // 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) { | |||
| 534 | 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 | 558 | t.Run("floating window", func(t *testing.T) { | |
| 538 | 559 | m := fixed() | |
| 539 | 560 | 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() { | |||
| 141 | 141 | if st.window == 0 { | |
| 142 | 142 | continue | |
| 143 | 143 | } | |
| 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) { | |
| 146 | 147 | st.window = 0 | |
| 147 | 148 | } | |
| 148 | 149 | } | |
| @@ -141,8 +141,9 @@ func (c *Core) releaseSlots() { | |||||
| 141 | if st.window == 0 { | 141 | if st.window == 0 { | ||
| 142 | continue | 142 | 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 = 0 | 147 | st.window = 0 | ||
| 147 | } | 148 | } | ||
| 148 | } | 149 | } | ||
M internal/core/slots_test.go +19 -1
| @@ -23,7 +23,8 @@ type slotRig struct { | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | // 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 | 28 | func startSlots(t *testing.T, numbered ...bool) *slotRig { | |
| 28 | 29 | t.Helper() | |
| 29 | 30 | cfg := config.Defaults() | |
| @@ -34,6 +35,9 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||
| 34 | 35 | {Index: 2, Width: "30%", Argv: []string{"foot"}}, | |
| 35 | 36 | }}} | |
| 36 | 37 | cfg.Binds["Alt+d"] = "workspace dev" | |
| 38 | + | if len(numbered) > 1 { | |
| 39 | + | cfg.Layout.Overflow = "fixed" | |
| 40 | + | } | |
| 37 | 41 | r := &slotRig{ | |
| 38 | 42 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | |
| 39 | 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 | + | } | |
| @@ -23,7 +23,8 @@ type slotRig struct { | |||||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | // startSlots runs core with a hidden "dev" workspace of two slots, toggled | 25 | // 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 | + | } | |||