detached 2a2f286

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
171171 border int
172172 presets []Width
173173 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
174178 // Floats are native floating windows in user selection order;
175179 // below/above and dialog groups determine their layout order.
176180 Floats []Float
@@ -450,6 +454,9 @@ func (w *Workspace) RemoveWindow(id WindowID) {
450454 if w.hiddenFullscreen == id {
451455 w.hiddenFullscreen = 0
452456 }
457+ if w.left == id {
458+ w.left = 0
459+ }
453460 if i := w.floatIndex(id); i >= 0 {
454461 w.Floats = slices.Delete(w.Floats, i, i+1)
455462 if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
@@ -945,6 +952,15 @@ func (w *Workspace) endFullscreen() {
945952 w.scroll()
946953 }
947954
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+
948964 // Activate focuses a window and makes it visible: it leaves another
949965 // window's fullscreen, which hides everything else.
950966 func (w *Workspace) Activate(id WindowID) {
@@ -952,12 +968,12 @@ func (w *Workspace) Activate(id WindowID) {
952968 // A hidden stashed window must be seen: show the stash with it,
953969 // selected. Another window's fullscreen leaves first, so its own
954970 // pending fullscreen comes back.
955- w.endFullscreen()
971+ w.leaveFullscreen()
956972 w.stashAt = i
957973 w.showStash()
958974 }
959975 if w.fullscreen != 0 && w.fullscreen != id {
960- w.endFullscreen()
976+ w.leaveFullscreen()
961977 }
962978 w.FocusID(id)
963979 }
@@ -1068,8 +1084,15 @@ func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
10681084 // overflow it would cover the output at once and take the keyboard from
10691085 // the focused window, so only the focused window may; the request of
10701086 // 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.
10721089 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+ }
10731096 if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 {
10741097 return true
10751098 }
@@ -1096,7 +1119,8 @@ func (w *Workspace) leaveCover(move func()) bool {
10961119 w.fullscreen = 0
10971120 move()
10981121 if id, ok := w.Focused(); ok && id != full {
1099- w.endFullscreen()
1122+ w.fullscreen = full
1123+ w.leaveFullscreen()
11001124 return true
11011125 }
11021126 w.fullscreen = full
@@ -171,6 +171,10 @@ type Workspace struct {
171 border int171 border int
172 presets []Width172 presets []Width
173 fullscreen WindowID173 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 []Float180 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 = 0455 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 another964 // 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 own969 // 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 = i972 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 from1084 // 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 of1085 // the focused window, so only the focused window may; the request of
1070 // another is refused and the user can still toggle-fullscreen it. A1086 // 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 true1097 return true
1075 }1098 }
@@ -1096,7 +1119,8 @@ func (w *Workspace) leaveCover(move func()) bool {
1096 w.fullscreen = 01119 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 true1124 return true
1101 }1125 }
1102 w.fullscreen = full1126 w.fullscreen = full
M internal/core/overview.go +1 -1
@@ -225,7 +225,7 @@ func (m *Monitor) closeOverview() {
225225 w := m.Current()
226226 front, target := m.stackFront(w), m.overviewTarget()
227227 if w.pinned() && target != w.cover() {
228- w.endFullscreen()
228+ w.leaveFullscreen()
229229 }
230230 if i := m.cardAt(w); i >= 0 {
231231 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 {
88 m := newMonitor("", "")
99 m.SetOutput(300, 200)
1010 m.SetOverflow(o)
11+ m.SetMaxColumns(2)
1112 m.AddWindow(1)
1213 if float {
1314 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)

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 25

Tags 10

↑↓ moveenter openesc close