fix(core): refuse client fullscreen after the user left it
When the user leaves a window's fullscreen for another window (focus move, activation or the overview), the window's client requests are refused until it is focused again, in both overflows. An app that asks again at once (Wine) no longer covers the window just chosen.
- commit
- 2a2f28670e8aeb1a121f30587f590611d344d353
- author
- brice · · 2026-10-02 15:25 UTC
- parent
- 06140d8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +115 -5
A internal/core/fullscreen_left_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // After the user leaves a window's fullscreen for another window, the | |
| 6 | + | // client's next fullscreen request is refused until the window is focused | |
| 7 | + | // again, whichever way the user left: a focus move, an activation or the | |
| 8 | + | // overview. | |
| 9 | + | func TestLeftFullscreenRefusesRequest(t *testing.T) { | |
| 10 | + | leaves := map[string]func(m *Monitor){ | |
| 11 | + | "focus move": func(m *Monitor) { m.Current().FocusColumn(-1) }, | |
| 12 | + | "activate": func(m *Monitor) { m.Current().Activate(1) }, | |
| 13 | + | "overview": func(m *Monitor) { | |
| 14 | + | m.ToggleOverview() | |
| 15 | + | m.OverviewPick(1) | |
| 16 | + | }, | |
| 17 | + | } | |
| 18 | + | for _, tc := range pinnedCases { | |
| 19 | + | for name, leave := range leaves { | |
| 20 | + | t.Run(tc.name+"/"+name, func(t *testing.T) { | |
| 21 | + | m := coverMonitor(tc.overflow, tc.float) | |
| 22 | + | w := m.Current() | |
| 23 | + | leave(m) | |
| 24 | + | if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 || w.left != 2 { | |
| 25 | + | t.Fatalf("leave: focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left) | |
| 26 | + | } | |
| 27 | + | m.SetFullscreen(2, true) | |
| 28 | + | if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 { | |
| 29 | + | t.Fatalf("re-request: focused %d, fullscreen %d", f, w.fullscreen) | |
| 30 | + | } | |
| 31 | + | w.FocusID(2) | |
| 32 | + | m.SetFullscreen(2, true) | |
| 33 | + | if w.fullscreen != 2 || w.left != 0 { | |
| 34 | + | t.Fatalf("focused again: fullscreen %d, left %d", w.fullscreen, w.left) | |
| 35 | + | } | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // The bind leaves a window's own fullscreen without the latch: the app | |
| 42 | + | // may ask again at once. | |
| 43 | + | func TestToggleFullscreenOffNoLatch(t *testing.T) { | |
| 44 | + | m := coverMonitor(OverflowFixed, false) | |
| 45 | + | w := m.Current() | |
| 46 | + | m.ToggleFullscreen() | |
| 47 | + | if w.fullscreen != 0 || w.left != 0 { | |
| 48 | + | t.Fatalf("fullscreen %d, left %d", w.fullscreen, w.left) | |
| 49 | + | } | |
| 50 | + | m.SetFullscreen(2, true) | |
| 51 | + | if w.fullscreen != 2 { | |
| 52 | + | t.Fatalf("request refused: %d", w.fullscreen) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // A latched window that goes away drops the latch. | |
| 57 | + | func TestLeftFullscreenRemoved(t *testing.T) { | |
| 58 | + | m := coverMonitor(OverflowScroll, true) | |
| 59 | + | w := m.Current() | |
| 60 | + | w.Activate(1) | |
| 61 | + | w.RemoveWindow(2) | |
| 62 | + | if w.left != 0 { | |
| 63 | + | t.Fatalf("left %d", w.left) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Activating a hidden stashed window leaves another window's fullscreen | |
| 68 | + | // with the latch too. | |
| 69 | + | func TestActivateHiddenStashLatches(t *testing.T) { | |
| 70 | + | m := newMonitor("", "") | |
| 71 | + | m.SetOutput(300, 200) | |
| 72 | + | m.SetOverflow(OverflowFixed) | |
| 73 | + | w := m.Current() | |
| 74 | + | w.AddWindow(1) | |
| 75 | + | w.AddWindow(5) | |
| 76 | + | w.FocusID(5) | |
| 77 | + | w.ToggleWindowStash() | |
| 78 | + | w.ToggleStashVisible() | |
| 79 | + | w.FocusID(1) | |
| 80 | + | m.ToggleFullscreen() | |
| 81 | + | w.Activate(5) | |
| 82 | + | if f, _ := w.Focused(); f != 5 || w.fullscreen != 0 || w.left != 1 { | |
| 83 | + | t.Fatalf("focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left) | |
| 84 | + | } | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // After the user leaves a window's fullscreen for another window, the | |||
| 6 | + | // client's next fullscreen request is refused until the window is focused | |||
| 7 | + | // again, whichever way the user left: a focus move, an activation or the | |||
| 8 | + | // overview. | |||
| 9 | + | func TestLeftFullscreenRefusesRequest(t *testing.T) { | |||
| 10 | + | leaves := map[string]func(m *Monitor){ | |||
| 11 | + | "focus move": func(m *Monitor) { m.Current().FocusColumn(-1) }, | |||
| 12 | + | "activate": func(m *Monitor) { m.Current().Activate(1) }, | |||
| 13 | + | "overview": func(m *Monitor) { | |||
| 14 | + | m.ToggleOverview() | |||
| 15 | + | m.OverviewPick(1) | |||
| 16 | + | }, | |||
| 17 | + | } | |||
| 18 | + | for _, tc := range pinnedCases { | |||
| 19 | + | for name, leave := range leaves { | |||
| 20 | + | t.Run(tc.name+"/"+name, func(t *testing.T) { | |||
| 21 | + | m := coverMonitor(tc.overflow, tc.float) | |||
| 22 | + | w := m.Current() | |||
| 23 | + | leave(m) | |||
| 24 | + | if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 || w.left != 2 { | |||
| 25 | + | t.Fatalf("leave: focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left) | |||
| 26 | + | } | |||
| 27 | + | m.SetFullscreen(2, true) | |||
| 28 | + | if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 { | |||
| 29 | + | t.Fatalf("re-request: focused %d, fullscreen %d", f, w.fullscreen) | |||
| 30 | + | } | |||
| 31 | + | w.FocusID(2) | |||
| 32 | + | m.SetFullscreen(2, true) | |||
| 33 | + | if w.fullscreen != 2 || w.left != 0 { | |||
| 34 | + | t.Fatalf("focused again: fullscreen %d, left %d", w.fullscreen, w.left) | |||
| 35 | + | } | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // The bind leaves a window's own fullscreen without the latch: the app | |||
| 42 | + | // may ask again at once. | |||
| 43 | + | func TestToggleFullscreenOffNoLatch(t *testing.T) { | |||
| 44 | + | m := coverMonitor(OverflowFixed, false) | |||
| 45 | + | w := m.Current() | |||
| 46 | + | m.ToggleFullscreen() | |||
| 47 | + | if w.fullscreen != 0 || w.left != 0 { | |||
| 48 | + | t.Fatalf("fullscreen %d, left %d", w.fullscreen, w.left) | |||
| 49 | + | } | |||
| 50 | + | m.SetFullscreen(2, true) | |||
| 51 | + | if w.fullscreen != 2 { | |||
| 52 | + | t.Fatalf("request refused: %d", w.fullscreen) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // A latched window that goes away drops the latch. | |||
| 57 | + | func TestLeftFullscreenRemoved(t *testing.T) { | |||
| 58 | + | m := coverMonitor(OverflowScroll, true) | |||
| 59 | + | w := m.Current() | |||
| 60 | + | w.Activate(1) | |||
| 61 | + | w.RemoveWindow(2) | |||
| 62 | + | if w.left != 0 { | |||
| 63 | + | t.Fatalf("left %d", w.left) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Activating a hidden stashed window leaves another window's fullscreen | |||
| 68 | + | // with the latch too. | |||
| 69 | + | func TestActivateHiddenStashLatches(t *testing.T) { | |||
| 70 | + | m := newMonitor("", "") | |||
| 71 | + | m.SetOutput(300, 200) | |||
| 72 | + | m.SetOverflow(OverflowFixed) | |||
| 73 | + | w := m.Current() | |||
| 74 | + | w.AddWindow(1) | |||
| 75 | + | w.AddWindow(5) | |||
| 76 | + | w.FocusID(5) | |||
| 77 | + | w.ToggleWindowStash() | |||
| 78 | + | w.ToggleStashVisible() | |||
| 79 | + | w.FocusID(1) | |||
| 80 | + | m.ToggleFullscreen() | |||
| 81 | + | w.Activate(5) | |||
| 82 | + | if f, _ := w.Focused(); f != 5 || w.fullscreen != 0 || w.left != 1 { | |||
| 83 | + | t.Fatalf("focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left) | |||
| 84 | + | } | |||
| 85 | + | } | |||
M internal/core/layout.go +28 -4
| @@ -171,6 +171,10 @@ type Workspace struct { | |||
| 171 | 171 | border int | |
| 172 | 172 | presets []Width | |
| 173 | 173 | fullscreen WindowID | |
| 174 | + | // left is the window whose fullscreen the user left for another | |
| 175 | + | // window: its client requests are refused until it is focused again, | |
| 176 | + | // so an app that re-asks at once (Wine) does not cover that window. | |
| 177 | + | left WindowID | |
| 174 | 178 | // Floats are native floating windows in user selection order; | |
| 175 | 179 | // below/above and dialog groups determine their layout order. | |
| 176 | 180 | Floats []Float | |
| @@ -450,6 +454,9 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 450 | 454 | if w.hiddenFullscreen == id { | |
| 451 | 455 | w.hiddenFullscreen = 0 | |
| 452 | 456 | } | |
| 457 | + | if w.left == id { | |
| 458 | + | w.left = 0 | |
| 459 | + | } | |
| 453 | 460 | if i := w.floatIndex(id); i >= 0 { | |
| 454 | 461 | w.Floats = slices.Delete(w.Floats, i, i+1) | |
| 455 | 462 | if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below { | |
| @@ -945,6 +952,15 @@ func (w *Workspace) endFullscreen() { | |||
| 945 | 952 | w.scroll() | |
| 946 | 953 | } | |
| 947 | 954 | ||
| 955 | + | // leaveFullscreen ends the fullscreen for another window the user chose, | |
| 956 | + | // and refuses its client's requests until it is focused again (mayCover). | |
| 957 | + | func (w *Workspace) leaveFullscreen() { | |
| 958 | + | if w.fullscreen != 0 { | |
| 959 | + | w.left = w.fullscreen | |
| 960 | + | } | |
| 961 | + | w.endFullscreen() | |
| 962 | + | } | |
| 963 | + | ||
| 948 | 964 | // Activate focuses a window and makes it visible: it leaves another | |
| 949 | 965 | // window's fullscreen, which hides everything else. | |
| 950 | 966 | func (w *Workspace) Activate(id WindowID) { | |
| @@ -952,12 +968,12 @@ func (w *Workspace) Activate(id WindowID) { | |||
| 952 | 968 | // A hidden stashed window must be seen: show the stash with it, | |
| 953 | 969 | // selected. Another window's fullscreen leaves first, so its own | |
| 954 | 970 | // pending fullscreen comes back. | |
| 955 | - | w.endFullscreen() | |
| 971 | + | w.leaveFullscreen() | |
| 956 | 972 | w.stashAt = i | |
| 957 | 973 | w.showStash() | |
| 958 | 974 | } | |
| 959 | 975 | if w.fullscreen != 0 && w.fullscreen != id { | |
| 960 | - | w.endFullscreen() | |
| 976 | + | w.leaveFullscreen() | |
| 961 | 977 | } | |
| 962 | 978 | w.FocusID(id) | |
| 963 | 979 | } | |
| @@ -1068,8 +1084,15 @@ func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |||
| 1068 | 1084 | // overflow it would cover the output at once and take the keyboard from | |
| 1069 | 1085 | // the focused window, so only the focused window may; the request of | |
| 1070 | 1086 | // another is refused and the user can still toggle-fullscreen it. A | |
| 1071 | - | // hidden stash's request waits for the show (showStash). | |
| 1087 | + | // hidden stash's request waits for the show (showStash). A window whose | |
| 1088 | + | // fullscreen the user left may only once focused again, in any overflow. | |
| 1072 | 1089 | func (w *Workspace) mayCover(id WindowID) bool { | |
| 1090 | + | if id == w.left { | |
| 1091 | + | if focused, ok := w.Focused(); !ok || focused != id { | |
| 1092 | + | return false | |
| 1093 | + | } | |
| 1094 | + | w.left = 0 | |
| 1095 | + | } | |
| 1073 | 1096 | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | |
| 1074 | 1097 | return true | |
| 1075 | 1098 | } | |
| @@ -1096,7 +1119,8 @@ func (w *Workspace) leaveCover(move func()) bool { | |||
| 1096 | 1119 | w.fullscreen = 0 | |
| 1097 | 1120 | move() | |
| 1098 | 1121 | if id, ok := w.Focused(); ok && id != full { | |
| 1099 | - | w.endFullscreen() | |
| 1122 | + | w.fullscreen = full | |
| 1123 | + | w.leaveFullscreen() | |
| 1100 | 1124 | return true | |
| 1101 | 1125 | } | |
| 1102 | 1126 | w.fullscreen = full | |
| @@ -171,6 +171,10 @@ type Workspace struct { | |||||
| 171 | border int | 171 | border int | ||
| 172 | presets []Width | 172 | presets []Width | ||
| 173 | fullscreen WindowID | 173 | fullscreen WindowID | ||
| 174 | + | // left is the window whose fullscreen the user left for another | |||
| 175 | + | // window: its client requests are refused until it is focused again, | |||
| 176 | + | // so an app that re-asks at once (Wine) does not cover that window. | |||
| 177 | + | left WindowID | |||
| 174 | // Floats are native floating windows in user selection order; | 178 | // Floats are native floating windows in user selection order; | ||
| 175 | // below/above and dialog groups determine their layout order. | 179 | // below/above and dialog groups determine their layout order. | ||
| 176 | Floats []Float | 180 | Floats []Float | ||
| @@ -450,6 +454,9 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 450 | if w.hiddenFullscreen == id { | 454 | if w.hiddenFullscreen == id { | ||
| 451 | w.hiddenFullscreen = 0 | 455 | w.hiddenFullscreen = 0 | ||
| 452 | } | 456 | } | ||
| 457 | + | if w.left == id { | |||
| 458 | + | w.left = 0 | |||
| 459 | + | } | |||
| 453 | if i := w.floatIndex(id); i >= 0 { | 460 | if i := w.floatIndex(id); i >= 0 { | ||
| 454 | w.Floats = slices.Delete(w.Floats, i, i+1) | 461 | w.Floats = slices.Delete(w.Floats, i, i+1) | ||
| 455 | if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below { | 462 | if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below { | ||
| @@ -945,6 +952,15 @@ func (w *Workspace) endFullscreen() { | |||||
| 945 | w.scroll() | 952 | w.scroll() | ||
| 946 | } | 953 | } | ||
| 947 | 954 | ||||
| 955 | + | // leaveFullscreen ends the fullscreen for another window the user chose, | |||
| 956 | + | // and refuses its client's requests until it is focused again (mayCover). | |||
| 957 | + | func (w *Workspace) leaveFullscreen() { | |||
| 958 | + | if w.fullscreen != 0 { | |||
| 959 | + | w.left = w.fullscreen | |||
| 960 | + | } | |||
| 961 | + | w.endFullscreen() | |||
| 962 | + | } | |||
| 963 | + | ||||
| 948 | // Activate focuses a window and makes it visible: it leaves another | 964 | // Activate focuses a window and makes it visible: it leaves another | ||
| 949 | // window's fullscreen, which hides everything else. | 965 | // window's fullscreen, which hides everything else. | ||
| 950 | func (w *Workspace) Activate(id WindowID) { | 966 | func (w *Workspace) Activate(id WindowID) { | ||
| @@ -952,12 +968,12 @@ func (w *Workspace) Activate(id WindowID) { | |||||
| 952 | // A hidden stashed window must be seen: show the stash with it, | 968 | // A hidden stashed window must be seen: show the stash with it, | ||
| 953 | // selected. Another window's fullscreen leaves first, so its own | 969 | // selected. Another window's fullscreen leaves first, so its own | ||
| 954 | // pending fullscreen comes back. | 970 | // pending fullscreen comes back. | ||
| 955 | - | w.endFullscreen() | 971 | + | w.leaveFullscreen() |
| 956 | w.stashAt = i | 972 | w.stashAt = i | ||
| 957 | w.showStash() | 973 | w.showStash() | ||
| 958 | } | 974 | } | ||
| 959 | if w.fullscreen != 0 && w.fullscreen != id { | 975 | if w.fullscreen != 0 && w.fullscreen != id { | ||
| 960 | - | w.endFullscreen() | 976 | + | w.leaveFullscreen() |
| 961 | } | 977 | } | ||
| 962 | w.FocusID(id) | 978 | w.FocusID(id) | ||
| 963 | } | 979 | } | ||
| @@ -1068,8 +1084,15 @@ func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |||||
| 1068 | // overflow it would cover the output at once and take the keyboard from | 1084 | // overflow it would cover the output at once and take the keyboard from | ||
| 1069 | // the focused window, so only the focused window may; the request of | 1085 | // the focused window, so only the focused window may; the request of | ||
| 1070 | // another is refused and the user can still toggle-fullscreen it. A | 1086 | // another is refused and the user can still toggle-fullscreen it. A | ||
| 1071 | - | // hidden stash's request waits for the show (showStash). | 1087 | + | // hidden stash's request waits for the show (showStash). A window whose |
| 1088 | + | // fullscreen the user left may only once focused again, in any overflow. | |||
| 1072 | func (w *Workspace) mayCover(id WindowID) bool { | 1089 | func (w *Workspace) mayCover(id WindowID) bool { | ||
| 1090 | + | if id == w.left { | |||
| 1091 | + | if focused, ok := w.Focused(); !ok || focused != id { | |||
| 1092 | + | return false | |||
| 1093 | + | } | |||
| 1094 | + | w.left = 0 | |||
| 1095 | + | } | |||
| 1073 | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | 1096 | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | ||
| 1074 | return true | 1097 | return true | ||
| 1075 | } | 1098 | } | ||
| @@ -1096,7 +1119,8 @@ func (w *Workspace) leaveCover(move func()) bool { | |||||
| 1096 | w.fullscreen = 0 | 1119 | w.fullscreen = 0 | ||
| 1097 | move() | 1120 | move() | ||
| 1098 | if id, ok := w.Focused(); ok && id != full { | 1121 | if id, ok := w.Focused(); ok && id != full { | ||
| 1099 | - | w.endFullscreen() | 1122 | + | w.fullscreen = full |
| 1123 | + | w.leaveFullscreen() | |||
| 1100 | return true | 1124 | return true | ||
| 1101 | } | 1125 | } | ||
| 1102 | w.fullscreen = full | 1126 | w.fullscreen = full | ||
M internal/core/overview.go +1 -1
| @@ -225,7 +225,7 @@ func (m *Monitor) closeOverview() { | |||
| 225 | 225 | w := m.Current() | |
| 226 | 226 | front, target := m.stackFront(w), m.overviewTarget() | |
| 227 | 227 | if w.pinned() && target != w.cover() { | |
| 228 | - | w.endFullscreen() | |
| 228 | + | w.leaveFullscreen() | |
| 229 | 229 | } | |
| 230 | 230 | if i := m.cardAt(w); i >= 0 { | |
| 231 | 231 | if w.hiddenFullscreen != w.Stash[i].ID { | |
| @@ -225,7 +225,7 @@ func (m *Monitor) closeOverview() { | |||||
| 225 | w := m.Current() | 225 | w := m.Current() | ||
| 226 | front, target := m.stackFront(w), m.overviewTarget() | 226 | front, target := m.stackFront(w), m.overviewTarget() | ||
| 227 | if w.pinned() && target != w.cover() { | 227 | if w.pinned() && target != w.cover() { | ||
| 228 | - | w.endFullscreen() | 228 | + | w.leaveFullscreen() |
| 229 | } | 229 | } | ||
| 230 | if i := m.cardAt(w); i >= 0 { | 230 | if i := m.cardAt(w); i >= 0 { | ||
| 231 | if w.hiddenFullscreen != w.Stash[i].ID { | 231 | if w.hiddenFullscreen != w.Stash[i].ID { | ||
M internal/core/overview_cover_test.go +1 -0
| @@ -8,6 +8,7 @@ func coverMonitor(o Overflow, float bool) *Monitor { | |||
| 8 | 8 | m := newMonitor("", "") | |
| 9 | 9 | m.SetOutput(300, 200) | |
| 10 | 10 | m.SetOverflow(o) | |
| 11 | + | m.SetMaxColumns(2) | |
| 11 | 12 | m.AddWindow(1) | |
| 12 | 13 | if float { | |
| 13 | 14 | m.AddFloating(2, 300, 200) | |
| @@ -8,6 +8,7 @@ func coverMonitor(o Overflow, float bool) *Monitor { | |||||
| 8 | m := newMonitor("", "") | 8 | m := newMonitor("", "") | ||
| 9 | m.SetOutput(300, 200) | 9 | m.SetOutput(300, 200) | ||
| 10 | m.SetOverflow(o) | 10 | m.SetOverflow(o) | ||
| 11 | + | m.SetMaxColumns(2) | |||
| 11 | m.AddWindow(1) | 12 | m.AddWindow(1) | ||
| 12 | if float { | 13 | if float { | ||
| 13 | m.AddFloating(2, 300, 200) | 14 | m.AddFloating(2, 300, 200) | ||