detached 43dcf6e

feat(core): fullscreen windows that map at the output size

commit
43dcf6e780863b56b8d9c40cdf554bfd0b52d844
author
brice · · 2026-10-22 19:43 UTC
parent
fdd661a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +139 -3
M docs/config.md +1 -0
@@ -204,6 +204,7 @@ 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.
207208 - Screenshots and captures are SDR (tone-mapped).
208209 - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline.
209210
@@ -204,6 +204,7 @@ 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.
207 - Screenshots and captures are SDR (tone-mapped).208 - Screenshots and captures are SDR (tone-mapped).
208 - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline.209 - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline.
209 210
M internal/adapters/wayland/xdg.go +26 -2
@@ -145,7 +145,8 @@ type window struct {
145145 minW, minH int32
146146 maxW, maxH int32
147147 floating bool
148- floatW, floatH int // size last sent to core
148+ floatW, floatH int // size last sent to core
149+ sized bool // the first committed size after a map was sent
149150 // last is the most recent core configure, resent when decoration mode changes.
150151 last ports.ConfigureWindow
151152 hasLast bool
@@ -238,6 +239,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
238239 x.serials = append(x.serials, x.server.serial)
239240 } else if buffer && !w.mapped && x.acked {
240241 w.mapped = true
242+ w.sized = false
241243 w.gen++
242244 // Placement claims are for a new toplevel, not later remaps.
243245 // Ordinary commits, popups and remaps need no /proc lookup.
@@ -412,7 +414,19 @@ func (top) SetMinimized(*xdgshell.Toplevel) {}
412414 // afterCommit runs once the commit applied content and geometry: a
413415 // floating window keeps the size it draws.
414416 func (w *window) afterCommit() {
415- if !w.mapped || !w.floating {
417+ if !w.mapped {
418+ return
419+ }
420+ if !w.floating {
421+ // Core reads a tile's first size once, to spot a window that maps
422+ // at its output's size without asking for fullscreen.
423+ if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {
424+ w.sized = true
425+ if !w.xdg.server.outputSized(fw, fh) {
426+ return
427+ }
428+ w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
429+ }
416430 return
417431 }
418432 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
@@ -453,3 +467,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface {
453467 }
454468 return nil
455469 }
470+
471+// outputSized reports whether an output has the logical size w×h.
472+func (s *Server) outputSized(w, h int) bool {
473+ for _, o := range s.outputs {
474+ if o.place.Width == w && o.place.Height == h {
475+ return true
476+ }
477+ }
478+ return false
479+}
@@ -145,7 +145,8 @@ type window struct {
145 minW, minH int32145 minW, minH int32
146 maxW, maxH int32146 maxW, maxH int32
147 floating bool147 floating bool
148- floatW, floatH int // size last sent to core148+ floatW, floatH int // size last sent to core
149+ sized bool // the first committed size after a map was sent
149 // last is the most recent core configure, resent when decoration mode changes.150 // last is the most recent core configure, resent when decoration mode changes.
150 last ports.ConfigureWindow151 last ports.ConfigureWindow
151 hasLast bool152 hasLast bool
@@ -238,6 +239,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
238 x.serials = append(x.serials, x.server.serial)239 x.serials = append(x.serials, x.server.serial)
239 } else if buffer && !w.mapped && x.acked {240 } else if buffer && !w.mapped && x.acked {
240 w.mapped = true241 w.mapped = true
242+ w.sized = false
241 w.gen++243 w.gen++
242 // Placement claims are for a new toplevel, not later remaps.244 // Placement claims are for a new toplevel, not later remaps.
243 // Ordinary commits, popups and remaps need no /proc lookup.245 // Ordinary commits, popups and remaps need no /proc lookup.
@@ -412,7 +414,19 @@ func (top) SetMinimized(*xdgshell.Toplevel) {}
412 // afterCommit runs once the commit applied content and geometry: a414 // afterCommit runs once the commit applied content and geometry: a
413 // floating window keeps the size it draws.415 // floating window keeps the size it draws.
414 func (w *window) afterCommit() {416 func (w *window) afterCommit() {
415- if !w.mapped || !w.floating {417+ if !w.mapped {
418+ return
419+ }
420+ if !w.floating {
421+ // Core reads a tile's first size once, to spot a window that maps
422+ // at its output's size without asking for fullscreen.
423+ if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {
424+ w.sized = true
425+ if !w.xdg.server.outputSized(fw, fh) {
426+ return
427+ }
428+ w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
429+ }
416 return430 return
417 }431 }
418 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {432 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
@@ -453,3 +467,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface {
453 }467 }
454 return nil468 return nil
455 }469 }
470+
471+// outputSized reports whether an output has the logical size w×h.
472+func (s *Server) outputSized(w, h int) bool {
473+ for _, o := range s.outputs {
474+ if o.place.Width == w && o.place.Height == h {
475+ return true
476+ }
477+ }
478+ return false
479+}
A internal/core/legacy_fullscreen_internal_test.go +75 -0
@@ -0,0 +1,75 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// A window whose first committed size is its output's size goes fullscreen,
10+// focused, exactly as toggle-fullscreen does, and toggling leaves it.
11+func TestLegacyFullscreenAtOutputSize(t *testing.T) {
12+ c, _, _ := fadeCore(t)
13+ sc := c.cur()
14+ c.mapWindow(ports.WindowMapped{ID: 1})
15+ c.mapWindow(ports.WindowMapped{ID: 2})
16+ o := sc.rect()
17+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
18+ w := sc.mon.Current()
19+ if w.fullscreen != 2 {
20+ t.Fatalf("fullscreen = %d, want 2", w.fullscreen)
21+ }
22+ if id, _ := w.Focused(); id != 2 {
23+ t.Fatalf("focused %d, want 2", id)
24+ }
25+ sc.mon.ToggleFullscreen()
26+ if w.fullscreen != 0 {
27+ t.Fatalf("toggle left fullscreen = %d", w.fullscreen)
28+ }
29+}
30+
31+func TestLegacyFullscreenFloatingFixedSize(t *testing.T) {
32+ c, _, _ := fadeCore(t)
33+ sc := c.cur()
34+ c.mapWindow(ports.WindowMapped{ID: 1, Floating: true})
35+ o := sc.rect()
36+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
37+ if sc.mon.Current().fullscreen != 1 {
38+ t.Fatal("a screen-sized float did not go fullscreen")
39+ }
40+}
41+
42+func TestLegacyFullscreenNoFalsePositive(t *testing.T) {
43+ t.Run("smaller", func(t *testing.T) {
44+ c, _, _ := fadeCore(t)
45+ sc := c.cur()
46+ c.mapWindow(ports.WindowMapped{ID: 1})
47+ o := sc.rect()
48+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W - 1, Height: o.H})
49+ if sc.mon.Current().fullscreen != 0 {
50+ t.Fatal("smaller window went fullscreen")
51+ }
52+ })
53+ t.Run("parent", func(t *testing.T) {
54+ c, _, _ := fadeCore(t)
55+ sc := c.cur()
56+ c.mapWindow(ports.WindowMapped{ID: 1})
57+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Parent: 1})
58+ o := sc.rect()
59+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
60+ if sc.mon.Current().fullscreen != 0 {
61+ t.Fatal("dialog went fullscreen")
62+ }
63+ })
64+ t.Run("later resize", func(t *testing.T) {
65+ c, _, _ := fadeCore(t)
66+ sc := c.cur()
67+ c.mapWindow(ports.WindowMapped{ID: 1})
68+ o := sc.rect()
69+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 50, Height: 50})
70+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
71+ if sc.mon.Current().fullscreen != 0 {
72+ t.Fatal("a later resize went fullscreen")
73+ }
74+ })
75+}
@@ -0,0 +1,75 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// A window whose first committed size is its output's size goes fullscreen,
10+// focused, exactly as toggle-fullscreen does, and toggling leaves it.
11+func TestLegacyFullscreenAtOutputSize(t *testing.T) {
12+ c, _, _ := fadeCore(t)
13+ sc := c.cur()
14+ c.mapWindow(ports.WindowMapped{ID: 1})
15+ c.mapWindow(ports.WindowMapped{ID: 2})
16+ o := sc.rect()
17+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
18+ w := sc.mon.Current()
19+ if w.fullscreen != 2 {
20+ t.Fatalf("fullscreen = %d, want 2", w.fullscreen)
21+ }
22+ if id, _ := w.Focused(); id != 2 {
23+ t.Fatalf("focused %d, want 2", id)
24+ }
25+ sc.mon.ToggleFullscreen()
26+ if w.fullscreen != 0 {
27+ t.Fatalf("toggle left fullscreen = %d", w.fullscreen)
28+ }
29+}
30+
31+func TestLegacyFullscreenFloatingFixedSize(t *testing.T) {
32+ c, _, _ := fadeCore(t)
33+ sc := c.cur()
34+ c.mapWindow(ports.WindowMapped{ID: 1, Floating: true})
35+ o := sc.rect()
36+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
37+ if sc.mon.Current().fullscreen != 1 {
38+ t.Fatal("a screen-sized float did not go fullscreen")
39+ }
40+}
41+
42+func TestLegacyFullscreenNoFalsePositive(t *testing.T) {
43+ t.Run("smaller", func(t *testing.T) {
44+ c, _, _ := fadeCore(t)
45+ sc := c.cur()
46+ c.mapWindow(ports.WindowMapped{ID: 1})
47+ o := sc.rect()
48+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W - 1, Height: o.H})
49+ if sc.mon.Current().fullscreen != 0 {
50+ t.Fatal("smaller window went fullscreen")
51+ }
52+ })
53+ t.Run("parent", func(t *testing.T) {
54+ c, _, _ := fadeCore(t)
55+ sc := c.cur()
56+ c.mapWindow(ports.WindowMapped{ID: 1})
57+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Parent: 1})
58+ o := sc.rect()
59+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
60+ if sc.mon.Current().fullscreen != 0 {
61+ t.Fatal("dialog went fullscreen")
62+ }
63+ })
64+ t.Run("later resize", func(t *testing.T) {
65+ c, _, _ := fadeCore(t)
66+ sc := c.cur()
67+ c.mapWindow(ports.WindowMapped{ID: 1})
68+ o := sc.rect()
69+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 50, Height: 50})
70+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
71+ if sc.mon.Current().fullscreen != 0 {
72+ t.Fatal("a later resize went fullscreen")
73+ }
74+ })
75+}
M internal/core/transition.go +23 -0
@@ -518,6 +518,7 @@ 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)
521522 if !running {
522523 return
523524 }
@@ -981,3 +982,25 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot {
981982 }
982983 return nil
983984 }
985+
986+// 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) {
992+ if !c.windows.firstSize(v.ID) {
993+ return
994+ }
995+ if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {
996+ return
997+ }
998+ if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {
999+ return
1000+ }
1001+ if w, _ := sc.mon.find(v.ID); w != nil {
1002+ w.FocusID(v.ID)
1003+ }
1004+ sc.mon.SetFullscreen(v.ID, true)
1005+ delete(sc.rects, v.ID)
1006+}
@@ -518,6 +518,7 @@ 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 !running {522 if !running {
522 return523 return
523 }524 }
@@ -981,3 +982,25 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot {
981 }982 }
982 return nil983 return nil
983 }984 }
985+
986+// 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) {
992+ if !c.windows.firstSize(v.ID) {
993+ return
994+ }
995+ if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {
996+ return
997+ }
998+ if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {
999+ return
1000+ }
1001+ if w, _ := sc.mon.find(v.ID); w != nil {
1002+ w.FocusID(v.ID)
1003+ }
1004+ sc.mon.SetFullscreen(v.ID, true)
1005+ delete(sc.rects, v.ID)
1006+}
M internal/core/windows.go +14 -1
@@ -12,6 +12,8 @@ type windowRecord struct {
1212 // client holds the app ID and PID of a mapped window, for State.
1313 client ports.WindowMapped
1414 mappedAt time.Time
15+ // sized is set once the window's first committed size was seen.
16+ sized bool
1517 // region is the input region; nil accepts input everywhere.
1618 region *ports.InputRegionChanged
1719 // inhibitShortcuts asks to keep compositor binds while focused;
@@ -50,7 +52,18 @@ func (r *windowRegistry) lookup(id WindowID) windowRecord {
5052
5153 func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) {
5254 rec := r.at(v.ID)
53- rec.client, rec.mappedAt = v, now
55+ rec.client, rec.mappedAt, rec.sized = v, now, false
56+}
57+
58+// firstSize reports whether this is the first committed size of the window
59+// since it mapped, and records that it was seen.
60+func (r *windowRegistry) firstSize(id WindowID) bool {
61+ rec := r.records[id]
62+ if rec == nil || rec.sized || rec.client.ID == 0 {
63+ return false
64+ }
65+ rec.sized = true
66+ return true
5467 }
5568
5669 // setAppID updates the app ID of a mapped window only.
@@ -12,6 +12,8 @@ type windowRecord struct {
12 // client holds the app ID and PID of a mapped window, for State.12 // client holds the app ID and PID of a mapped window, for State.
13 client ports.WindowMapped13 client ports.WindowMapped
14 mappedAt time.Time14 mappedAt time.Time
15+ // sized is set once the window's first committed size was seen.
16+ sized bool
15 // region is the input region; nil accepts input everywhere.17 // region is the input region; nil accepts input everywhere.
16 region *ports.InputRegionChanged18 region *ports.InputRegionChanged
17 // inhibitShortcuts asks to keep compositor binds while focused;19 // inhibitShortcuts asks to keep compositor binds while focused;
@@ -50,7 +52,18 @@ func (r *windowRegistry) lookup(id WindowID) windowRecord {
50 52
51 func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) {53 func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) {
52 rec := r.at(v.ID)54 rec := r.at(v.ID)
53- rec.client, rec.mappedAt = v, now55+ rec.client, rec.mappedAt, rec.sized = v, now, false
56+}
57+
58+// firstSize reports whether this is the first committed size of the window
59+// since it mapped, and records that it was seen.
60+func (r *windowRegistry) firstSize(id WindowID) bool {
61+ rec := r.records[id]
62+ if rec == nil || rec.sized || rec.client.ID == 0 {
63+ return false
64+ }
65+ rec.sized = true
66+ return true
54 }67 }
55 68
56 // setAppID updates the app ID of a mapped window only.69 // setAppID updates the app ID of a mapped window only.

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