fix(core): tile windows opened over a fixed fullscreen on the home workspace (#62)
* fix(core): tile windows opened over a fixed fullscreen on the home workspace Under fixed overflow a new tiled window (normal, targeted terminal or slot) opening while a fullscreen window holds its workspace was hidden forever. It now tiles on the home workspace; a fullscreen window still in place moves to its own workspace first. The view and keyboard stay on the fullscreen window; an xdg-activation shows the new window. * test(core): cover slot, named workspace and float arrivals over a fixed fullscreen
- commit
- 3adce452eb0e37e13ba01b17633e4bdbe18fa0c8
- author
- bnema · · 2026-09-29 10:22 UTC
- committer
- GitHub · · 2026-09-29 10:22 UTC
- parent
- 6648be1
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
5 files · +140 -18
M docs/config.md +1 -1
| @@ -106,7 +106,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 106 | 106 | | `spawn <command>` | Run a program | | |
| 107 | 107 | | `spawn-terminal` | Run `terminal` | | |
| 108 | 108 | | `close-window` | Close the focused window | | |
| 109 | - | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | |
| 109 | + | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen. In fixed overflow, the window gets its own workspace below its home one; a window opening while it is on screen tiles on the home workspace and the view stays on the fullscreen window; activating it shows the home workspace | | |
| 110 | 110 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | |
| 111 | 111 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | |
| 112 | 112 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | |
| @@ -106,7 +106,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 106 | | `spawn <command>` | Run a program | | 106 | | `spawn <command>` | Run a program | | ||
| 107 | | `spawn-terminal` | Run `terminal` | | 107 | | `spawn-terminal` | Run `terminal` | | ||
| 108 | | `close-window` | Close the focused window | | 108 | | `close-window` | Close the focused window | | ||
| 109 | - | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | 109 | + | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen. In fixed overflow, the window gets its own workspace below its home one; a window opening while it is on screen tiles on the home workspace and the view stays on the fullscreen window; activating it shows the home workspace | |
| 110 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | 110 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | ||
| 111 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | 111 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | ||
| 112 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | 112 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | ||
M internal/core/core_test.go +32 -0
| @@ -3,6 +3,7 @@ package core_test | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "reflect" | |
| 6 | 7 | "slices" | |
| 7 | 8 | "sync/atomic" | |
| 8 | 9 | "testing" | |
| @@ -845,6 +846,37 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 845 | 846 | } | |
| 846 | 847 | } | |
| 847 | 848 | ||
| 849 | + | // Under fixed overflow, a window opening over a fullscreen game tiles on | |
| 850 | + | // its home workspace: the game keeps the screen and the keyboard, and an | |
| 851 | + | // activation (a user action in the new client) shows the window there. | |
| 852 | + | func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) { | |
| 853 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 854 | + | r.mapWindow(t, 1) | |
| 855 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 856 | + | set := r.mapWindow(t, 2) | |
| 857 | + | for len(r.commands) > 0 { | |
| 858 | + | <-r.commands | |
| 859 | + | } | |
| 860 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |
| 861 | + | t.Fatal("game disturbed:", got, focused) | |
| 862 | + | } | |
| 863 | + | r.client <- ports.WindowActivate{ID: 2} | |
| 864 | + | set = receive(t, r.scenes) | |
| 865 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |
| 866 | + | t.Fatal("after activate:", got, focused) | |
| 867 | + | } | |
| 868 | + | for { | |
| 869 | + | if f, ok := command(t, r.commands).(ports.FocusWindow); ok && f.ID == 2 { | |
| 870 | + | break | |
| 871 | + | } | |
| 872 | + | } | |
| 873 | + | // The game still waits fullscreen one workspace down. | |
| 874 | + | set = r.key(t, "Next", ports.ModAlt) | |
| 875 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |
| 876 | + | t.Fatal("game workspace:", got, focused) | |
| 877 | + | } | |
| 878 | + | } | |
| 879 | + | ||
| 848 | 880 | // A taskbar's fullscreen request is the user's: it applies at once. | |
| 849 | 881 | func TestExternalFullscreenAtMapApplies(t *testing.T) { | |
| 850 | 882 | cfg := config.Defaults() | |
| @@ -3,6 +3,7 @@ package core_test | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "reflect" | |||
| 6 | "slices" | 7 | "slices" | ||
| 7 | "sync/atomic" | 8 | "sync/atomic" | ||
| 8 | "testing" | 9 | "testing" | ||
| @@ -845,6 +846,37 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||||
| 845 | } | 846 | } | ||
| 846 | } | 847 | } | ||
| 847 | 848 | ||||
| 849 | + | // Under fixed overflow, a window opening over a fullscreen game tiles on | |||
| 850 | + | // its home workspace: the game keeps the screen and the keyboard, and an | |||
| 851 | + | // activation (a user action in the new client) shows the window there. | |||
| 852 | + | func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) { | |||
| 853 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |||
| 854 | + | r.mapWindow(t, 1) | |||
| 855 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |||
| 856 | + | set := r.mapWindow(t, 2) | |||
| 857 | + | for len(r.commands) > 0 { | |||
| 858 | + | <-r.commands | |||
| 859 | + | } | |||
| 860 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |||
| 861 | + | t.Fatal("game disturbed:", got, focused) | |||
| 862 | + | } | |||
| 863 | + | r.client <- ports.WindowActivate{ID: 2} | |||
| 864 | + | set = receive(t, r.scenes) | |||
| 865 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |||
| 866 | + | t.Fatal("after activate:", got, focused) | |||
| 867 | + | } | |||
| 868 | + | for { | |||
| 869 | + | if f, ok := command(t, r.commands).(ports.FocusWindow); ok && f.ID == 2 { | |||
| 870 | + | break | |||
| 871 | + | } | |||
| 872 | + | } | |||
| 873 | + | // The game still waits fullscreen one workspace down. | |||
| 874 | + | set = r.key(t, "Next", ports.ModAlt) | |||
| 875 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |||
| 876 | + | t.Fatal("game workspace:", got, focused) | |||
| 877 | + | } | |||
| 878 | + | } | |||
| 879 | + | ||||
| 848 | // A taskbar's fullscreen request is the user's: it applies at once. | 880 | // A taskbar's fullscreen request is the user's: it applies at once. | ||
| 849 | func TestExternalFullscreenAtMapApplies(t *testing.T) { | 881 | func TestExternalFullscreenAtMapApplies(t *testing.T) { | ||
| 850 | cfg := config.Defaults() | 882 | cfg := config.Defaults() | ||
M internal/core/monitor.go +20 -8
| @@ -260,19 +260,31 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||
| 260 | 260 | return nil, -1 | |
| 261 | 261 | } | |
| 262 | 262 | ||
| 263 | - | // AddWindow places a new window on the workspace on screen. On a | |
| 264 | - | // fullscreen workspace it floats, hidden until the fullscreen window | |
| 265 | - | // leaves, then tiles after it at home. | |
| 263 | + | // AddWindow places a new window on the workspace on screen (see arrive). | |
| 266 | 264 | func (m *Monitor) AddWindow(id WindowID) { | |
| 267 | 265 | if w, _ := m.find(id); w != nil { | |
| 268 | 266 | return | |
| 269 | 267 | } | |
| 270 | - | if cur := m.Current(); cur.origin != nil { | |
| 271 | - | cur.joinFullscreen(id) | |
| 272 | - | m.normalize() | |
| 273 | - | return | |
| 268 | + | m.arrive(m.Current(), func(w *Workspace) { w.AddWindow(id) }) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // arrive adds a new tiled window to w with add. Under fixed overflow a | |
| 272 | + | // fullscreen window hides every other one of its workspace and keeps the | |
| 273 | + | // view (ADR 011), so the new window goes to its home workspace instead: | |
| 274 | + | // the origin of a fullscreen workspace, or w itself once a fullscreen | |
| 275 | + | // window still in place there has moved to its own workspace. | |
| 276 | + | func (m *Monitor) arrive(w *Workspace, add func(*Workspace)) { | |
| 277 | + | switch { | |
| 278 | + | case w.origin != nil: | |
| 279 | + | add(w.origin) | |
| 280 | + | case w.Overflow == OverflowFixed && w.fullscreen != 0 && m.has(w): | |
| 281 | + | // Added first, hidden and unfocused: w is never left empty, | |
| 282 | + | // which normalize would drop. | |
| 283 | + | add(w) | |
| 284 | + | m.enterFullscreen(w, w.fullscreen, m.Current() == w) | |
| 285 | + | default: | |
| 286 | + | add(w) | |
| 274 | 287 | } | |
| 275 | - | m.Current().AddWindow(id) | |
| 276 | 288 | m.normalize() | |
| 277 | 289 | } | |
| 278 | 290 | ||
| @@ -260,19 +260,31 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||||
| 260 | return nil, -1 | 260 | return nil, -1 | ||
| 261 | } | 261 | } | ||
| 262 | 262 | ||||
| 263 | - | // AddWindow places a new window on the workspace on screen. On a | 263 | + | // AddWindow places a new window on the workspace on screen (see arrive). |
| 264 | - | // fullscreen workspace it floats, hidden until the fullscreen window | |||
| 265 | - | // leaves, then tiles after it at home. | |||
| 266 | func (m *Monitor) AddWindow(id WindowID) { | 264 | func (m *Monitor) AddWindow(id WindowID) { | ||
| 267 | if w, _ := m.find(id); w != nil { | 265 | if w, _ := m.find(id); w != nil { | ||
| 268 | return | 266 | return | ||
| 269 | } | 267 | } | ||
| 270 | - | if cur := m.Current(); cur.origin != nil { | 268 | + | m.arrive(m.Current(), func(w *Workspace) { w.AddWindow(id) }) |
| 271 | - | cur.joinFullscreen(id) | 269 | + | } |
| 272 | - | m.normalize() | 270 | + | |
| 273 | - | return | 271 | + | // arrive adds a new tiled window to w with add. Under fixed overflow a |
| 272 | + | // fullscreen window hides every other one of its workspace and keeps the | |||
| 273 | + | // view (ADR 011), so the new window goes to its home workspace instead: | |||
| 274 | + | // the origin of a fullscreen workspace, or w itself once a fullscreen | |||
| 275 | + | // window still in place there has moved to its own workspace. | |||
| 276 | + | func (m *Monitor) arrive(w *Workspace, add func(*Workspace)) { | |||
| 277 | + | switch { | |||
| 278 | + | case w.origin != nil: | |||
| 279 | + | add(w.origin) | |||
| 280 | + | case w.Overflow == OverflowFixed && w.fullscreen != 0 && m.has(w): | |||
| 281 | + | // Added first, hidden and unfocused: w is never left empty, | |||
| 282 | + | // which normalize would drop. | |||
| 283 | + | add(w) | |||
| 284 | + | m.enterFullscreen(w, w.fullscreen, m.Current() == w) | |||
| 285 | + | default: | |||
| 286 | + | add(w) | |||
| 274 | } | 287 | } | ||
| 275 | - | m.Current().AddWindow(id) | |||
| 276 | m.normalize() | 288 | m.normalize() | ||
| 277 | } | 289 | } | ||
| 278 | 290 | ||||
M internal/core/monitor_test.go +85 -5
| @@ -523,26 +523,106 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||
| 523 | 523 | } | |
| 524 | 524 | return m | |
| 525 | 525 | } | |
| 526 | - | t.Run("a window opened there floats, then tiles after it at home", func(t *testing.T) { | |
| 526 | + | t.Run("a window opened there tiles at home, the view stays", func(t *testing.T) { | |
| 527 | 527 | m := fixed() | |
| 528 | 528 | m.Current().FocusID(2) | |
| 529 | 529 | m.ToggleFullscreen() | |
| 530 | 530 | m.AddWindow(4) | |
| 531 | - | if fs := m.Current(); fs.floatIndex(4) != 0 || fs.fullscreen != 2 { | |
| 532 | - | t.Fatal(fs.Floats, fs.fullscreen) | |
| 531 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {2}, {}}) || m.Active != 1 { | |
| 532 | + | t.Fatal(got, m.Active) | |
| 533 | + | } | |
| 534 | + | if id, _ := m.Focused(); id != 2 { | |
| 535 | + | t.Fatal("focus", id) | |
| 533 | 536 | } | |
| 534 | 537 | m.SetFullscreen(2, false) | |
| 535 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 4, 3}, {}}) || m.Active != 0 { | |
| 538 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Active != 0 { | |
| 536 | 539 | t.Fatal(got, m.Active) | |
| 537 | 540 | } | |
| 538 | 541 | }) | |
| 542 | + | t.Run("a window opened over a lone fullscreen moves it to its own workspace", func(t *testing.T) { | |
| 543 | + | for _, client := range []bool{false, true} { | |
| 544 | + | m := monitor() | |
| 545 | + | m.SetOverflow(OverflowFixed) | |
| 546 | + | m.AddWindow(1) | |
| 547 | + | if client { | |
| 548 | + | m.SetFullscreen(1, true) | |
| 549 | + | } else { | |
| 550 | + | m.ToggleFullscreen() | |
| 551 | + | } | |
| 552 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {}}) || m.Current().fullscreen != 1 { | |
| 553 | + | t.Fatal("in place", got) | |
| 554 | + | } | |
| 555 | + | m.AddWindow(2) | |
| 556 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}, {}}) || m.Active != 1 { | |
| 557 | + | t.Fatal(client, got, m.Active) | |
| 558 | + | } | |
| 559 | + | if id, _ := m.Focused(); id != 1 || m.Current().cover() != 1 { | |
| 560 | + | t.Fatal("focus", id) | |
| 561 | + | } | |
| 562 | + | m.SetFullscreen(1, false) | |
| 563 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 { | |
| 564 | + | t.Fatal(client, got, m.Active) | |
| 565 | + | } | |
| 566 | + | if id, _ := m.Focused(); id != 1 { | |
| 567 | + | t.Fatal("focus after", id) | |
| 568 | + | } | |
| 569 | + | } | |
| 570 | + | }) | |
| 571 | + | t.Run("a window opened over a lone fullscreen off screen stays there", func(t *testing.T) { | |
| 572 | + | m := monitor() | |
| 573 | + | m.SetOverflow(OverflowFixed) | |
| 574 | + | m.AddWindow(1) | |
| 575 | + | m.ToggleFullscreen() | |
| 576 | + | m.Focus(1) | |
| 577 | + | m.AddWindow(2) | |
| 578 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2}, {}}) || m.Active != 1 { | |
| 579 | + | t.Fatal(got, m.Active) | |
| 580 | + | } | |
| 581 | + | }) | |
| 582 | + | t.Run("arrivals over a lone fullscreen: slot, named workspace, float", func(t *testing.T) { | |
| 583 | + | for _, tc := range []struct { | |
| 584 | + | name string | |
| 585 | + | setup func(m *Monitor) *Workspace | |
| 586 | + | add func(w *Workspace) | |
| 587 | + | }{ | |
| 588 | + | {"slot", func(m *Monitor) *Workspace { m.AddWindow(1); return m.Current() }, | |
| 589 | + | func(w *Workspace) { w.AddSlotWindow(2, 1, Width{}) }}, | |
| 590 | + | {"hidden named", func(m *Monitor) *Workspace { | |
| 591 | + | m.SetNamed([]NamedWorkspace{{Name: "dev", Overflow: OverflowFixed}}) | |
| 592 | + | m.Apply("workspace dev") | |
| 593 | + | m.AddWindow(1) | |
| 594 | + | return m.Current() | |
| 595 | + | }, func(w *Workspace) { w.AddWindow(2) }}, | |
| 596 | + | {"float", func(m *Monitor) *Workspace { m.AddFloating(1, 10, 10); return m.Current() }, | |
| 597 | + | func(w *Workspace) { w.AddWindow(2) }}, | |
| 598 | + | } { | |
| 599 | + | m := monitor() | |
| 600 | + | m.SetOverflow(OverflowFixed) | |
| 601 | + | home := tc.setup(m) | |
| 602 | + | m.SetFullscreen(1, true) | |
| 603 | + | m.arrive(home, tc.add) | |
| 604 | + | if fs := m.Current(); fs == home || fs.cover() != 1 || fs.origin != home { | |
| 605 | + | t.Fatal(tc.name, "fullscreen not on its own workspace", windows(m)) | |
| 606 | + | } | |
| 607 | + | if id, _ := m.Focused(); id != 1 || !home.has(2) || home.has(1) { | |
| 608 | + | t.Fatal(tc.name, "focus", id, home.windows()) | |
| 609 | + | } | |
| 610 | + | m.SetFullscreen(1, false) | |
| 611 | + | if m.Current() != home || !home.has(1) || !home.has(2) { | |
| 612 | + | t.Fatal(tc.name, "not home", windows(m), home.windows()) | |
| 613 | + | } | |
| 614 | + | if id, _ := m.Focused(); id != 1 { | |
| 615 | + | t.Fatal(tc.name, "focus after", id) | |
| 616 | + | } | |
| 617 | + | } | |
| 618 | + | }) | |
| 539 | 619 | t.Run("a joined window lands after its stack when columns shifted", func(t *testing.T) { | |
| 540 | 620 | m := fixed() | |
| 541 | 621 | w := m.Current() | |
| 542 | 622 | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{5}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{6}}} | |
| 543 | 623 | w.FocusID(3) | |
| 544 | 624 | m.ToggleFullscreen() | |
| 545 | - | m.AddWindow(7) | |
| 625 | + | m.Current().joinFullscreen(7) | |
| 546 | 626 | m.RemoveWindow(1) | |
| 547 | 627 | m.ToggleFullscreen() | |
| 548 | 628 | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{5, 2, 3, 7, 6}, {}}) { | |
| @@ -523,26 +523,106 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||||
| 523 | } | 523 | } | ||
| 524 | return m | 524 | return m | ||
| 525 | } | 525 | } | ||
| 526 | - | t.Run("a window opened there floats, then tiles after it at home", func(t *testing.T) { | 526 | + | t.Run("a window opened there tiles at home, the view stays", func(t *testing.T) { |
| 527 | m := fixed() | 527 | m := fixed() | ||
| 528 | m.Current().FocusID(2) | 528 | m.Current().FocusID(2) | ||
| 529 | m.ToggleFullscreen() | 529 | m.ToggleFullscreen() | ||
| 530 | m.AddWindow(4) | 530 | m.AddWindow(4) | ||
| 531 | - | if fs := m.Current(); fs.floatIndex(4) != 0 || fs.fullscreen != 2 { | 531 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {2}, {}}) || m.Active != 1 { |
| 532 | - | t.Fatal(fs.Floats, fs.fullscreen) | 532 | + | t.Fatal(got, m.Active) |
| 533 | + | } | |||
| 534 | + | if id, _ := m.Focused(); id != 2 { | |||
| 535 | + | t.Fatal("focus", id) | |||
| 533 | } | 536 | } | ||
| 534 | m.SetFullscreen(2, false) | 537 | m.SetFullscreen(2, false) | ||
| 535 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 4, 3}, {}}) || m.Active != 0 { | 538 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Active != 0 { |
| 536 | t.Fatal(got, m.Active) | 539 | t.Fatal(got, m.Active) | ||
| 537 | } | 540 | } | ||
| 538 | }) | 541 | }) | ||
| 542 | + | t.Run("a window opened over a lone fullscreen moves it to its own workspace", func(t *testing.T) { | |||
| 543 | + | for _, client := range []bool{false, true} { | |||
| 544 | + | m := monitor() | |||
| 545 | + | m.SetOverflow(OverflowFixed) | |||
| 546 | + | m.AddWindow(1) | |||
| 547 | + | if client { | |||
| 548 | + | m.SetFullscreen(1, true) | |||
| 549 | + | } else { | |||
| 550 | + | m.ToggleFullscreen() | |||
| 551 | + | } | |||
| 552 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {}}) || m.Current().fullscreen != 1 { | |||
| 553 | + | t.Fatal("in place", got) | |||
| 554 | + | } | |||
| 555 | + | m.AddWindow(2) | |||
| 556 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}, {}}) || m.Active != 1 { | |||
| 557 | + | t.Fatal(client, got, m.Active) | |||
| 558 | + | } | |||
| 559 | + | if id, _ := m.Focused(); id != 1 || m.Current().cover() != 1 { | |||
| 560 | + | t.Fatal("focus", id) | |||
| 561 | + | } | |||
| 562 | + | m.SetFullscreen(1, false) | |||
| 563 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 { | |||
| 564 | + | t.Fatal(client, got, m.Active) | |||
| 565 | + | } | |||
| 566 | + | if id, _ := m.Focused(); id != 1 { | |||
| 567 | + | t.Fatal("focus after", id) | |||
| 568 | + | } | |||
| 569 | + | } | |||
| 570 | + | }) | |||
| 571 | + | t.Run("a window opened over a lone fullscreen off screen stays there", func(t *testing.T) { | |||
| 572 | + | m := monitor() | |||
| 573 | + | m.SetOverflow(OverflowFixed) | |||
| 574 | + | m.AddWindow(1) | |||
| 575 | + | m.ToggleFullscreen() | |||
| 576 | + | m.Focus(1) | |||
| 577 | + | m.AddWindow(2) | |||
| 578 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2}, {}}) || m.Active != 1 { | |||
| 579 | + | t.Fatal(got, m.Active) | |||
| 580 | + | } | |||
| 581 | + | }) | |||
| 582 | + | t.Run("arrivals over a lone fullscreen: slot, named workspace, float", func(t *testing.T) { | |||
| 583 | + | for _, tc := range []struct { | |||
| 584 | + | name string | |||
| 585 | + | setup func(m *Monitor) *Workspace | |||
| 586 | + | add func(w *Workspace) | |||
| 587 | + | }{ | |||
| 588 | + | {"slot", func(m *Monitor) *Workspace { m.AddWindow(1); return m.Current() }, | |||
| 589 | + | func(w *Workspace) { w.AddSlotWindow(2, 1, Width{}) }}, | |||
| 590 | + | {"hidden named", func(m *Monitor) *Workspace { | |||
| 591 | + | m.SetNamed([]NamedWorkspace{{Name: "dev", Overflow: OverflowFixed}}) | |||
| 592 | + | m.Apply("workspace dev") | |||
| 593 | + | m.AddWindow(1) | |||
| 594 | + | return m.Current() | |||
| 595 | + | }, func(w *Workspace) { w.AddWindow(2) }}, | |||
| 596 | + | {"float", func(m *Monitor) *Workspace { m.AddFloating(1, 10, 10); return m.Current() }, | |||
| 597 | + | func(w *Workspace) { w.AddWindow(2) }}, | |||
| 598 | + | } { | |||
| 599 | + | m := monitor() | |||
| 600 | + | m.SetOverflow(OverflowFixed) | |||
| 601 | + | home := tc.setup(m) | |||
| 602 | + | m.SetFullscreen(1, true) | |||
| 603 | + | m.arrive(home, tc.add) | |||
| 604 | + | if fs := m.Current(); fs == home || fs.cover() != 1 || fs.origin != home { | |||
| 605 | + | t.Fatal(tc.name, "fullscreen not on its own workspace", windows(m)) | |||
| 606 | + | } | |||
| 607 | + | if id, _ := m.Focused(); id != 1 || !home.has(2) || home.has(1) { | |||
| 608 | + | t.Fatal(tc.name, "focus", id, home.windows()) | |||
| 609 | + | } | |||
| 610 | + | m.SetFullscreen(1, false) | |||
| 611 | + | if m.Current() != home || !home.has(1) || !home.has(2) { | |||
| 612 | + | t.Fatal(tc.name, "not home", windows(m), home.windows()) | |||
| 613 | + | } | |||
| 614 | + | if id, _ := m.Focused(); id != 1 { | |||
| 615 | + | t.Fatal(tc.name, "focus after", id) | |||
| 616 | + | } | |||
| 617 | + | } | |||
| 618 | + | }) | |||
| 539 | t.Run("a joined window lands after its stack when columns shifted", func(t *testing.T) { | 619 | t.Run("a joined window lands after its stack when columns shifted", func(t *testing.T) { | ||
| 540 | m := fixed() | 620 | m := fixed() | ||
| 541 | w := m.Current() | 621 | w := m.Current() | ||
| 542 | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{5}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{6}}} | 622 | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{5}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{6}}} | ||
| 543 | w.FocusID(3) | 623 | w.FocusID(3) | ||
| 544 | m.ToggleFullscreen() | 624 | m.ToggleFullscreen() | ||
| 545 | - | m.AddWindow(7) | 625 | + | m.Current().joinFullscreen(7) |
| 546 | m.RemoveWindow(1) | 626 | m.RemoveWindow(1) | ||
| 547 | m.ToggleFullscreen() | 627 | m.ToggleFullscreen() | ||
| 548 | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{5, 2, 3, 7, 6}, {}}) { | 628 | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{5, 2, 3, 7, 6}, {}}) { | ||
M internal/core/spawn_placement.go +2 -4
| @@ -84,8 +84,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 84 | 84 | delete(p.pending, v.Slot) | |
| 85 | 85 | for _, s := range c.screens { | |
| 86 | 86 | if slices.Contains(s.mon.all(), target.terminal) { | |
| 87 | - | target.terminal.AddWindow(v.ID) | |
| 88 | - | s.mon.normalize() | |
| 87 | + | s.mon.arrive(target.terminal, func(w *Workspace) { w.AddWindow(v.ID) }) | |
| 89 | 88 | return | |
| 90 | 89 | } | |
| 91 | 90 | } | |
| @@ -93,8 +92,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 93 | 92 | if sc, w := c.byName(target.slot.workspace); w != nil { | |
| 94 | 93 | p.dropSlot(target.slot) | |
| 95 | 94 | st.window = v.ID | |
| 96 | - | w.AddSlotWindow(v.ID, target.slot.index, st.width) | |
| 97 | - | sc.mon.normalize() | |
| 95 | + | sc.mon.arrive(w, func(w *Workspace) { w.AddSlotWindow(v.ID, target.slot.index, st.width) }) | |
| 98 | 96 | return | |
| 99 | 97 | } | |
| 100 | 98 | } | |
| @@ -84,8 +84,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 84 | delete(p.pending, v.Slot) | 84 | delete(p.pending, v.Slot) | ||
| 85 | for _, s := range c.screens { | 85 | for _, s := range c.screens { | ||
| 86 | if slices.Contains(s.mon.all(), target.terminal) { | 86 | if slices.Contains(s.mon.all(), target.terminal) { | ||
| 87 | - | target.terminal.AddWindow(v.ID) | 87 | + | s.mon.arrive(target.terminal, func(w *Workspace) { w.AddWindow(v.ID) }) |
| 88 | - | s.mon.normalize() | |||
| 89 | return | 88 | return | ||
| 90 | } | 89 | } | ||
| 91 | } | 90 | } | ||
| @@ -93,8 +92,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 93 | if sc, w := c.byName(target.slot.workspace); w != nil { | 92 | if sc, w := c.byName(target.slot.workspace); w != nil { | ||
| 94 | p.dropSlot(target.slot) | 93 | p.dropSlot(target.slot) | ||
| 95 | st.window = v.ID | 94 | st.window = v.ID | ||
| 96 | - | w.AddSlotWindow(v.ID, target.slot.index, st.width) | 95 | + | sc.mon.arrive(w, func(w *Workspace) { w.AddSlotWindow(v.ID, target.slot.index, st.width) }) |
| 97 | - | sc.mon.normalize() | |||
| 98 | return | 96 | return | ||
| 99 | } | 97 | } | ||
| 100 | } | 98 | } | ||