detached bc208ba

fix(core): keep auto-fullscreen within the focus rules

commit
bc208baf6bbbcf50383df44041e1f92c1f500980
author
brice · · 2026-10-22 20:27 UTC
parent
fb82a5b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
1/1
refs
feat/auto-fullscreen
4 files · +121 -12
M docs/config.md +2 -1
@@ -204,7 +204,6 @@ A swipe moves one step at most. Other gestures (pinch, hold, two-finger scroll)
204204 - HDR-aware apps (games, browsers, video players) can show HDR content. Some need HDR turned on in their own settings. X11 apps stay SDR.
205205 - The desktop stays SDR, shown at `sdr-brightness` nits.
206206 - Fullscreen HDR windows go straight to the display.
207-- A window that maps with the exact logical size of its output, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized.
208207 - Screenshots and captures are SDR (tone-mapped).
209208 - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline.
210209
@@ -261,6 +260,8 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
261260 | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config |
262261 | `quit` | Exit NeferWL |
263262
263+A parentless window whose first committed size is exactly the logical size of the output it is on, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized, when it is the focused window of the shown workspace. Later resizes do not.
264+
264265 ### Default binds
265266
266267 | Keys | Action |
@@ -204,7 +204,6 @@ A swipe moves one step at most. Other gestures (pinch, hold, two-finger scroll)
204 - HDR-aware apps (games, browsers, video players) can show HDR content. Some need HDR turned on in their own settings. X11 apps stay SDR.204 - HDR-aware apps (games, browsers, video players) can show HDR content. Some need HDR turned on in their own settings. X11 apps stay SDR.
205 - The desktop stays SDR, shown at `sdr-brightness` nits.205 - The desktop stays SDR, shown at `sdr-brightness` nits.
206 - Fullscreen HDR windows go straight to the display.206 - Fullscreen HDR windows go straight to the display.
207-- A window that maps with the exact logical size of its output, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized.
208 - Screenshots and captures are SDR (tone-mapped).207 - Screenshots and captures are SDR (tone-mapped).
209 - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline.208 - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline.
210 209
@@ -261,6 +260,8 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
261 | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config |260 | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config |
262 | `quit` | Exit NeferWL |261 | `quit` | Exit NeferWL |
263 262
263+A parentless window whose first committed size is exactly the logical size of the output it is on, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized, when it is the focused window of the shown workspace. Later resizes do not.
264+
264 ### Default binds265 ### Default binds
265 266
266 | Keys | Action |267 | Keys | Action |
M internal/adapters/wayland/xdg.go +2 -0
@@ -418,6 +418,8 @@ func (w *window) afterCommit() {
418418 return
419419 }
420420 if !w.floating {
421+ // Core is authoritative: this prefilter matches any output, and
422+ // core compares with the output the window is on.
421423 // Core reads a tile's first size once, to spot a window that maps
422424 // at its output's size without asking for fullscreen.
423425 if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {
@@ -418,6 +418,8 @@ func (w *window) afterCommit() {
418 return418 return
419 }419 }
420 if !w.floating {420 if !w.floating {
421+ // Core is authoritative: this prefilter matches any output, and
422+ // core compares with the output the window is on.
421 // Core reads a tile's first size once, to spot a window that maps423 // Core reads a tile's first size once, to spot a window that maps
422 // at its output's size without asking for fullscreen.424 // at its output's size without asking for fullscreen.
423 if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {425 if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {
M internal/core/legacy_fullscreen_internal_test.go +94 -0
@@ -73,3 +73,97 @@ func TestLegacyFullscreenNoFalsePositive(t *testing.T) {
7373 }
7474 })
7575 }
76+
77+// With animations on, the entrance never restarts for the window that went
78+// fullscreen: no motion, and the scene shows it at the full output.
79+func TestLegacyFullscreenDropsEntrance(t *testing.T) {
80+ c, _, _ := fadeCore(t)
81+ c.cfg.Animations.On = true
82+ sc := c.cur()
83+ c.mapWindow(ports.WindowMapped{ID: 1})
84+ c.stopAnimations()
85+ c.mapWindow(ports.WindowMapped{ID: 2})
86+ o := sc.rect()
87+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
88+ s := indicatorScene(t, c)
89+ if _, ok := sc.rects[2]; ok {
90+ t.Fatalf("entrance motion left for the fullscreen window: %+v", sc.rects)
91+ }
92+ if w := sceneWindow(t, s, 2); w.Rect != (Rect{W: o.W, H: o.H}) || w.Fade != 0 {
93+ t.Fatalf("scene window %+v, want the full output %+v", w, o)
94+ }
95+}
96+
97+// Focus rules (ADR 011): never takes the focus from a fullscreen cover, nor
98+// acts on a workspace that is not shown.
99+func TestLegacyFullscreenRespectsFocus(t *testing.T) {
100+ t.Run("under a fullscreen window", func(t *testing.T) {
101+ c, _, _ := fadeCore(t)
102+ sc := c.cur()
103+ c.mapWindow(ports.WindowMapped{ID: 1})
104+ sc.mon.SetFullscreen(1, true)
105+ c.mapWindow(ports.WindowMapped{ID: 2})
106+ o := sc.rect()
107+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
108+ w := sc.mon.Current()
109+ if w.fullscreen != 1 {
110+ t.Fatalf("fullscreen = %d, want the cover 1", w.fullscreen)
111+ }
112+ if id, _ := w.Focused(); id != 1 {
113+ t.Fatalf("focus moved to %d", id)
114+ }
115+ })
116+ t.Run("other workspace", func(t *testing.T) {
117+ c, _, _ := fadeCore(t)
118+ sc := c.cur()
119+ c.mapWindow(ports.WindowMapped{ID: 1})
120+ ws := sc.mon.Current()
121+ sc.mon.Focus(1)
122+ if sc.mon.Current() == ws {
123+ t.Skip("no second workspace")
124+ }
125+ o := sc.rect()
126+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
127+ if ws.fullscreen != 0 {
128+ t.Fatal("a window on a hidden workspace went fullscreen")
129+ }
130+ })
131+}
132+
133+func TestLegacyFullscreenRemapAndOutputs(t *testing.T) {
134+ t.Run("remap", func(t *testing.T) {
135+ c, _, _ := fadeCore(t)
136+ sc := c.cur()
137+ c.mapWindow(ports.WindowMapped{ID: 1, Remap: true})
138+ o := sc.rect()
139+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
140+ if sc.mon.Current().fullscreen != 0 {
141+ t.Fatal("a remap went fullscreen")
142+ }
143+ })
144+ second := func(t *testing.T) (*Core, *screen) {
145+ c, _, _ := fadeCore(t)
146+ c.addScreen(ports.OutputInfo{Name: "B", Width: 500, Height: 300})
147+ c.focusScreen = c.screenIndex("B")
148+ return c, c.cur()
149+ }
150+ t.Run("sized to its own output", func(t *testing.T) {
151+ c, b := second(t)
152+ c.mapWindow(ports.WindowMapped{ID: 1})
153+ if s, _ := c.screenOf(1); s != b {
154+ t.Fatal("window not on B")
155+ }
156+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 500, Height: 300})
157+ if b.mon.Current().fullscreen != 1 {
158+ t.Fatal("window sized to its output B did not go fullscreen")
159+ }
160+ })
161+ t.Run("sized to another output", func(t *testing.T) {
162+ c, b := second(t)
163+ c.mapWindow(ports.WindowMapped{ID: 1})
164+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 300, Height: 200})
165+ if b.mon.Current().fullscreen != 0 {
166+ t.Fatal("window sized to output A went fullscreen on B")
167+ }
168+ })
169+}
@@ -73,3 +73,97 @@ func TestLegacyFullscreenNoFalsePositive(t *testing.T) {
73 }73 }
74 })74 })
75 }75 }
76+
77+// With animations on, the entrance never restarts for the window that went
78+// fullscreen: no motion, and the scene shows it at the full output.
79+func TestLegacyFullscreenDropsEntrance(t *testing.T) {
80+ c, _, _ := fadeCore(t)
81+ c.cfg.Animations.On = true
82+ sc := c.cur()
83+ c.mapWindow(ports.WindowMapped{ID: 1})
84+ c.stopAnimations()
85+ c.mapWindow(ports.WindowMapped{ID: 2})
86+ o := sc.rect()
87+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
88+ s := indicatorScene(t, c)
89+ if _, ok := sc.rects[2]; ok {
90+ t.Fatalf("entrance motion left for the fullscreen window: %+v", sc.rects)
91+ }
92+ if w := sceneWindow(t, s, 2); w.Rect != (Rect{W: o.W, H: o.H}) || w.Fade != 0 {
93+ t.Fatalf("scene window %+v, want the full output %+v", w, o)
94+ }
95+}
96+
97+// Focus rules (ADR 011): never takes the focus from a fullscreen cover, nor
98+// acts on a workspace that is not shown.
99+func TestLegacyFullscreenRespectsFocus(t *testing.T) {
100+ t.Run("under a fullscreen window", func(t *testing.T) {
101+ c, _, _ := fadeCore(t)
102+ sc := c.cur()
103+ c.mapWindow(ports.WindowMapped{ID: 1})
104+ sc.mon.SetFullscreen(1, true)
105+ c.mapWindow(ports.WindowMapped{ID: 2})
106+ o := sc.rect()
107+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
108+ w := sc.mon.Current()
109+ if w.fullscreen != 1 {
110+ t.Fatalf("fullscreen = %d, want the cover 1", w.fullscreen)
111+ }
112+ if id, _ := w.Focused(); id != 1 {
113+ t.Fatalf("focus moved to %d", id)
114+ }
115+ })
116+ t.Run("other workspace", func(t *testing.T) {
117+ c, _, _ := fadeCore(t)
118+ sc := c.cur()
119+ c.mapWindow(ports.WindowMapped{ID: 1})
120+ ws := sc.mon.Current()
121+ sc.mon.Focus(1)
122+ if sc.mon.Current() == ws {
123+ t.Skip("no second workspace")
124+ }
125+ o := sc.rect()
126+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
127+ if ws.fullscreen != 0 {
128+ t.Fatal("a window on a hidden workspace went fullscreen")
129+ }
130+ })
131+}
132+
133+func TestLegacyFullscreenRemapAndOutputs(t *testing.T) {
134+ t.Run("remap", func(t *testing.T) {
135+ c, _, _ := fadeCore(t)
136+ sc := c.cur()
137+ c.mapWindow(ports.WindowMapped{ID: 1, Remap: true})
138+ o := sc.rect()
139+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
140+ if sc.mon.Current().fullscreen != 0 {
141+ t.Fatal("a remap went fullscreen")
142+ }
143+ })
144+ second := func(t *testing.T) (*Core, *screen) {
145+ c, _, _ := fadeCore(t)
146+ c.addScreen(ports.OutputInfo{Name: "B", Width: 500, Height: 300})
147+ c.focusScreen = c.screenIndex("B")
148+ return c, c.cur()
149+ }
150+ t.Run("sized to its own output", func(t *testing.T) {
151+ c, b := second(t)
152+ c.mapWindow(ports.WindowMapped{ID: 1})
153+ if s, _ := c.screenOf(1); s != b {
154+ t.Fatal("window not on B")
155+ }
156+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 500, Height: 300})
157+ if b.mon.Current().fullscreen != 1 {
158+ t.Fatal("window sized to its output B did not go fullscreen")
159+ }
160+ })
161+ t.Run("sized to another output", func(t *testing.T) {
162+ c, b := second(t)
163+ c.mapWindow(ports.WindowMapped{ID: 1})
164+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 300, Height: 200})
165+ if b.mon.Current().fullscreen != 0 {
166+ t.Fatal("window sized to output A went fullscreen on B")
167+ }
168+ })
169+}
M internal/core/transition.go +23 -11
@@ -518,7 +518,9 @@ func (c *Core) resizeFloating(v ports.WindowResized) {
518518 old = settledRect(sc.mon, v.ID)
519519 }
520520 w.ResizeFloating(v.ID, v.Width, v.Height)
521- c.legacyFullscreen(sc, v)
521+ if c.legacyFullscreen(sc, v) {
522+ return
523+ }
522524 if !running {
523525 return
524526 }
@@ -984,23 +986,33 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot {
984986 }
985987
986988 // legacyFullscreen puts a window in the fullscreen state when the first size
987-// it commits after mapping is exactly its output's logical size: a game in
988-// borderless-windowed mode (X11 through xwayland-satellite) never asks for
989-// fullscreen. It is the state toggle-fullscreen sets, and the window is
990-// not resized. Dialogs (a parent), remaps and later resizes are left alone.
991-func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) {
989+// it commits after mapping is exactly the logical size of the output it is
990+// on: a game in borderless-windowed mode (X11 through xwayland-satellite)
991+// never asks for fullscreen. It is the state toggle-fullscreen sets, and the
992+// window is not resized. It follows the focus rules (ADR 011): only the
993+// window that already has the focus on the shown workspace goes fullscreen,
994+// so a fullscreen cover or another workspace is never disturbed. The parent
995+// is the one read at map (a later set_parent is not excluded); remaps and
996+// later resizes are left alone. It reports whether the window went
997+// fullscreen.
998+func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) bool {
992999 if !c.windows.firstSize(v.ID) {
993- return
1000+ return false
9941001 }
9951002 if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {
996- return
1003+ return false
9971004 }
9981005 if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {
999- return
1006+ return false
10001007 }
1001- if w, _ := sc.mon.find(v.ID); w != nil {
1002- w.FocusID(v.ID)
1008+ cur := sc.mon.Current()
1009+ if id, ok := cur.Focused(); !ok || id != v.ID || !cur.has(v.ID) {
1010+ return false
10031011 }
10041012 sc.mon.SetFullscreen(v.ID, true)
1013+ if cur.fullscreen != v.ID {
1014+ return false
1015+ }
10051016 delete(sc.rects, v.ID)
1017+ return true
10061018 }
@@ -518,7 +518,9 @@ func (c *Core) resizeFloating(v ports.WindowResized) {
518 old = settledRect(sc.mon, v.ID)518 old = settledRect(sc.mon, v.ID)
519 }519 }
520 w.ResizeFloating(v.ID, v.Width, v.Height)520 w.ResizeFloating(v.ID, v.Width, v.Height)
521- c.legacyFullscreen(sc, v)521+ if c.legacyFullscreen(sc, v) {
522+ return
523+ }
522 if !running {524 if !running {
523 return525 return
524 }526 }
@@ -984,23 +986,33 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot {
984 }986 }
985 987
986 // legacyFullscreen puts a window in the fullscreen state when the first size988 // legacyFullscreen puts a window in the fullscreen state when the first size
987-// it commits after mapping is exactly its output's logical size: a game in989+// it commits after mapping is exactly the logical size of the output it is
988-// borderless-windowed mode (X11 through xwayland-satellite) never asks for990+// on: a game in borderless-windowed mode (X11 through xwayland-satellite)
989-// fullscreen. It is the state toggle-fullscreen sets, and the window is991+// never asks for fullscreen. It is the state toggle-fullscreen sets, and the
990-// not resized. Dialogs (a parent), remaps and later resizes are left alone.992+// window is not resized. It follows the focus rules (ADR 011): only the
991-func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) {993+// window that already has the focus on the shown workspace goes fullscreen,
994+// so a fullscreen cover or another workspace is never disturbed. The parent
995+// is the one read at map (a later set_parent is not excluded); remaps and
996+// later resizes are left alone. It reports whether the window went
997+// fullscreen.
998+func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) bool {
992 if !c.windows.firstSize(v.ID) {999 if !c.windows.firstSize(v.ID) {
993- return1000+ return false
994 }1001 }
995 if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {1002 if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {
996- return1003+ return false
997 }1004 }
998 if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {1005 if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {
999- return1006+ return false
1000 }1007 }
1001- if w, _ := sc.mon.find(v.ID); w != nil {1008+ cur := sc.mon.Current()
1002- w.FocusID(v.ID)1009+ if id, ok := cur.Focused(); !ok || id != v.ID || !cur.has(v.ID) {
1010+ return false
1003 }1011 }
1004 sc.mon.SetFullscreen(v.ID, true)1012 sc.mon.SetFullscreen(v.ID, true)
1013+ if cur.fullscreen != v.ID {
1014+ return false
1015+ }
1005 delete(sc.rects, v.ID)1016 delete(sc.rects, v.ID)
1017+ return true
1006 }1018 }

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