detached 2fd2ce0

Merge bc208baf6bbbcf50383df44041e1f92c1f500980 into f2d166b7fd4fa830ec06f487fcdcc64f1eacb4c9

commit
2fd2ce08f3cf9928ed78c03cb7db60a0b5a6a1aa
author
ermite · · 2026-10-22 20:26 UTC
merge of
f2d166b + bc208ba · diff against the first parent
signature
unsigned
6 files · +307 -3
M docs/config.md +2 -0
@@ -260,6 +260,8 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
260260 | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config |
261261 | `quit` | Exit NeferWL |
262262
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+
263265 ### Default binds
264266
265267 | Keys | Action |
@@ -260,6 +260,8 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
260 | `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 |
261 | `quit` | Exit NeferWL |261 | `quit` | Exit NeferWL |
262 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+
263 ### Default binds265 ### Default binds
264 266
265 | Keys | Action |267 | Keys | Action |
A internal/adapters/wayland/legacy_fullscreen_test.go +59 -0
@@ -0,0 +1,59 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/adapters/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// outputSizedServer runs a server whose only output is w×h logical.
13+func outputSizedServer(t *testing.T, w, h int) (*Server, chan ports.ClientEvent, string) {
14+ t.Helper()
15+ dir := t.TempDir()
16+ events := make(chan ports.ClientEvent, 16)
17+ out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: w, Height: h}, Width: w, Height: h, Scale: 1}}
18+ s, err := New(Options{RuntimeDir: dir, Outputs: out}, Channels{Events: events, Commands: make(chan ports.ClientCommand, 16)}, logging.For(context.Background(), "wayland"))
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ ctx, cancel := context.WithCancel(context.Background())
23+ done := make(chan error, 1)
24+ go func() { done <- s.Run(ctx) }()
25+ t.Cleanup(func() {
26+ cancel()
27+ <-done
28+ })
29+ return s, events, dir
30+}
31+
32+// A tiled toplevel whose first committed size is its output's size reports
33+// it once (core then fullscreens it); a smaller one reports nothing.
34+func TestTiledFirstSizeAtOutputSizeEmitsResized(t *testing.T) {
35+ s, events, dir := outputSizedServer(t, 1, 1)
36+ c := protocolClient(t, s, dir)
37+ w := toplevelMapper(t, c, events)()
38+ if w.Floating {
39+ t.Fatal("expected a tiled window")
40+ }
41+ ev := waitEvent(t, events, 2*time.Second)
42+ if r, ok := ev.(ports.WindowResized); !ok || r.ID != w.ID || r.Width != 1 || r.Height != 1 {
43+ t.Fatalf("got %#v, want WindowResized 1x1 for %d", ev, w.ID)
44+ }
45+}
46+
47+func TestTiledSmallerThanOutputEmitsNoResized(t *testing.T) {
48+ s, events, dir := outputSizedServer(t, 2, 2)
49+ c := protocolClient(t, s, dir)
50+ toplevelMapper(t, c, events)()
51+ if err := c.Roundtrip(); err != nil {
52+ t.Fatal(err)
53+ }
54+ select {
55+ case ev := <-events:
56+ t.Fatalf("unexpected event %#v", ev)
57+ case <-time.After(200 * time.Millisecond):
58+ }
59+}
@@ -0,0 +1,59 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/adapters/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// outputSizedServer runs a server whose only output is w×h logical.
13+func outputSizedServer(t *testing.T, w, h int) (*Server, chan ports.ClientEvent, string) {
14+ t.Helper()
15+ dir := t.TempDir()
16+ events := make(chan ports.ClientEvent, 16)
17+ out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: w, Height: h}, Width: w, Height: h, Scale: 1}}
18+ s, err := New(Options{RuntimeDir: dir, Outputs: out}, Channels{Events: events, Commands: make(chan ports.ClientCommand, 16)}, logging.For(context.Background(), "wayland"))
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ ctx, cancel := context.WithCancel(context.Background())
23+ done := make(chan error, 1)
24+ go func() { done <- s.Run(ctx) }()
25+ t.Cleanup(func() {
26+ cancel()
27+ <-done
28+ })
29+ return s, events, dir
30+}
31+
32+// A tiled toplevel whose first committed size is its output's size reports
33+// it once (core then fullscreens it); a smaller one reports nothing.
34+func TestTiledFirstSizeAtOutputSizeEmitsResized(t *testing.T) {
35+ s, events, dir := outputSizedServer(t, 1, 1)
36+ c := protocolClient(t, s, dir)
37+ w := toplevelMapper(t, c, events)()
38+ if w.Floating {
39+ t.Fatal("expected a tiled window")
40+ }
41+ ev := waitEvent(t, events, 2*time.Second)
42+ if r, ok := ev.(ports.WindowResized); !ok || r.ID != w.ID || r.Width != 1 || r.Height != 1 {
43+ t.Fatalf("got %#v, want WindowResized 1x1 for %d", ev, w.ID)
44+ }
45+}
46+
47+func TestTiledSmallerThanOutputEmitsNoResized(t *testing.T) {
48+ s, events, dir := outputSizedServer(t, 2, 2)
49+ c := protocolClient(t, s, dir)
50+ toplevelMapper(t, c, events)()
51+ if err := c.Roundtrip(); err != nil {
52+ t.Fatal(err)
53+ }
54+ select {
55+ case ev := <-events:
56+ t.Fatalf("unexpected event %#v", ev)
57+ case <-time.After(200 * time.Millisecond):
58+ }
59+}
M internal/adapters/wayland/xdg.go +28 -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,21 @@ 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 is authoritative: this prefilter matches any output, and
422+ // core compares with the output the window is on.
423+ // Core reads a tile's first size once, to spot a window that maps
424+ // at its output's size without asking for fullscreen.
425+ if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {
426+ w.sized = true
427+ if !w.xdg.server.outputSized(fw, fh) {
428+ return
429+ }
430+ w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
431+ }
416432 return
417433 }
418434 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
@@ -453,3 +469,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface {
453469 }
454470 return nil
455471 }
472+
473+// outputSized reports whether an output has the logical size w×h.
474+func (s *Server) outputSized(w, h int) bool {
475+ for _, o := range s.outputs {
476+ if o.place.Width == w && o.place.Height == h {
477+ return true
478+ }
479+ }
480+ return false
481+}
@@ -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,21 @@ 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 is authoritative: this prefilter matches any output, and
422+ // core compares with the output the window is on.
423+ // Core reads a tile's first size once, to spot a window that maps
424+ // at its output's size without asking for fullscreen.
425+ if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {
426+ w.sized = true
427+ if !w.xdg.server.outputSized(fw, fh) {
428+ return
429+ }
430+ w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
431+ }
416 return432 return
417 }433 }
418 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {434 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
@@ -453,3 +469,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface {
453 }469 }
454 return nil470 return nil
455 }471 }
472+
473+// outputSized reports whether an output has the logical size w×h.
474+func (s *Server) outputSized(w, h int) bool {
475+ for _, o := range s.outputs {
476+ if o.place.Width == w && o.place.Height == h {
477+ return true
478+ }
479+ }
480+ return false
481+}
A internal/core/legacy_fullscreen_internal_test.go +169 -0
@@ -0,0 +1,169 @@
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+}
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+}
@@ -0,0 +1,169 @@
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+}
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 +35 -0
@@ -518,6 +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+ if c.legacyFullscreen(sc, v) {
522+ return
523+ }
521524 if !running {
522525 return
523526 }
@@ -981,3 +984,35 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot {
981984 }
982985 return nil
983986 }
987+
988+// legacyFullscreen puts a window in the fullscreen state when the first size
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 {
999+ if !c.windows.firstSize(v.ID) {
1000+ return false
1001+ }
1002+ if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {
1003+ return false
1004+ }
1005+ if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {
1006+ return false
1007+ }
1008+ cur := sc.mon.Current()
1009+ if id, ok := cur.Focused(); !ok || id != v.ID || !cur.has(v.ID) {
1010+ return false
1011+ }
1012+ sc.mon.SetFullscreen(v.ID, true)
1013+ if cur.fullscreen != v.ID {
1014+ return false
1015+ }
1016+ delete(sc.rects, v.ID)
1017+ return true
1018+}
@@ -518,6 +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+ if c.legacyFullscreen(sc, v) {
522+ return
523+ }
521 if !running {524 if !running {
522 return525 return
523 }526 }
@@ -981,3 +984,35 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot {
981 }984 }
982 return nil985 return nil
983 }986 }
987+
988+// legacyFullscreen puts a window in the fullscreen state when the first size
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 {
999+ if !c.windows.firstSize(v.ID) {
1000+ return false
1001+ }
1002+ if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {
1003+ return false
1004+ }
1005+ if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {
1006+ return false
1007+ }
1008+ cur := sc.mon.Current()
1009+ if id, ok := cur.Focused(); !ok || id != v.ID || !cur.has(v.ID) {
1010+ return false
1011+ }
1012+ sc.mon.SetFullscreen(v.ID, true)
1013+ if cur.fullscreen != v.ID {
1014+ return false
1015+ }
1016+ delete(sc.rects, v.ID)
1017+ return true
1018+}
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