detached 089984d

fix(wayland): send maximized state to tiled toplevels (#25)

* fix(wayland): send maximized state to tiled toplevels

Wine ignores configures that only carry tiled states on a window it considers monitor-sized, so its content kept the old width after a column resize.

* test(wayland): wait for capture replies before reading frame events

The capture tests sent a reply and synced with an empty display.Do, which can run before forwardCaptured hands the reply to the display goroutine. They now wait until the reply leaves captureReplies.
commit
089984d67acee4969e4ad22e267c7f2123e80f1a
author
bnema · · 2026-09-27 16:17 UTC
committer
GitHub · · 2026-09-27 16:17 UTC
parent
318f46a
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +86 -26
M internal/adapters/wayland/screencopy_test.go +25 -16
@@ -116,10 +116,7 @@ func TestScreencopyProtocol(t *testing.T) {
116116 if err := req.Dst.File.Close(); err != nil {
117117 t.Fatal(err)
118118 }
119- replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Time: time.Now()}
120- if !s.display.Do(func() {}) {
121- t.Fatal("display stopped")
122- }
119+ deliverCapture(t, s, replies, ports.CaptureDone{ID: req.ID, Output: req.Output, Time: time.Now()})
123120 case <-time.After(2 * time.Second):
124121 t.Fatal("no request")
125122 }
@@ -339,6 +336,27 @@ func TestCaptureProtocolErrors(t *testing.T) {
339336 }
340337 }
341338
339+// deliverCapture sends a capture reply and waits until the display
340+// goroutine has run it, so a following Roundtrip sees its events.
341+func deliverCapture(t *testing.T, s *Server, replies chan<- ports.CaptureDone, done ports.CaptureDone) {
342+ t.Helper()
343+ replies <- done
344+ deadline := time.Now().Add(2 * time.Second)
345+ for {
346+ pending := false
347+ if !s.display.Do(func() { _, pending = s.captureReplies[done.ID] }) {
348+ t.Fatal("display stopped")
349+ }
350+ if !pending {
351+ return
352+ }
353+ if time.Now().After(deadline) {
354+ t.Fatal("capture reply not delivered")
355+ }
356+ time.Sleep(time.Millisecond)
357+ }
358+}
359+
342360 // captureDetails records protocol payloads, not just the event opcodes.
343361 type captureDetails struct {
344362 wlturbo.BaseProxy
@@ -459,10 +477,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
459477 if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
460478 t.Fatal(err)
461479 }
462- replies <- ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)}
463- if !s.display.Do(func() {}) {
464- t.Fatal("display stopped")
465- }
480+ deliverCapture(t, s, replies, ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)})
466481 if err := c.Roundtrip(); err != nil {
467482 t.Fatal(err)
468483 }
@@ -515,10 +530,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
515530 if err := c.Roundtrip(); err != nil {
516531 t.Fatal(err)
517532 }
518- replies <- ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)}
519- if !s.display.Do(func() {}) {
520- t.Fatal("display stopped")
521- }
533+ deliverCapture(t, s, replies, ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)})
522534 if err := c.Roundtrip(); err != nil {
523535 t.Fatal(err)
524536 }
@@ -681,10 +693,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
681693 if _, err := req.Dst.File.Stat(); err == nil {
682694 t.Fatal("output descriptor open")
683695 }
684- replies <- ports.CaptureDone{ID: req.ID}
685- if !s.display.Do(func() {}) {
686- t.Fatal("display stopped")
687- }
696+ deliverCapture(t, s, replies, ports.CaptureDone{ID: req.ID})
688697 if err := c.Roundtrip(); err != nil {
689698 t.Fatal(err)
690699 }
@@ -116,10 +116,7 @@ func TestScreencopyProtocol(t *testing.T) {
116 if err := req.Dst.File.Close(); err != nil {116 if err := req.Dst.File.Close(); err != nil {
117 t.Fatal(err)117 t.Fatal(err)
118 }118 }
119- replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Time: time.Now()}119+ deliverCapture(t, s, replies, ports.CaptureDone{ID: req.ID, Output: req.Output, Time: time.Now()})
120- if !s.display.Do(func() {}) {
121- t.Fatal("display stopped")
122- }
123 case <-time.After(2 * time.Second):120 case <-time.After(2 * time.Second):
124 t.Fatal("no request")121 t.Fatal("no request")
125 }122 }
@@ -339,6 +336,27 @@ func TestCaptureProtocolErrors(t *testing.T) {
339 }336 }
340 }337 }
341 338
339+// deliverCapture sends a capture reply and waits until the display
340+// goroutine has run it, so a following Roundtrip sees its events.
341+func deliverCapture(t *testing.T, s *Server, replies chan<- ports.CaptureDone, done ports.CaptureDone) {
342+ t.Helper()
343+ replies <- done
344+ deadline := time.Now().Add(2 * time.Second)
345+ for {
346+ pending := false
347+ if !s.display.Do(func() { _, pending = s.captureReplies[done.ID] }) {
348+ t.Fatal("display stopped")
349+ }
350+ if !pending {
351+ return
352+ }
353+ if time.Now().After(deadline) {
354+ t.Fatal("capture reply not delivered")
355+ }
356+ time.Sleep(time.Millisecond)
357+ }
358+}
359+
342 // captureDetails records protocol payloads, not just the event opcodes.360 // captureDetails records protocol payloads, not just the event opcodes.
343 type captureDetails struct {361 type captureDetails struct {
344 wlturbo.BaseProxy362 wlturbo.BaseProxy
@@ -459,10 +477,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
459 if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {477 if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
460 t.Fatal(err)478 t.Fatal(err)
461 }479 }
462- replies <- ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)}480+ deliverCapture(t, s, replies, ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)})
463- if !s.display.Do(func() {}) {
464- t.Fatal("display stopped")
465- }
466 if err := c.Roundtrip(); err != nil {481 if err := c.Roundtrip(); err != nil {
467 t.Fatal(err)482 t.Fatal(err)
468 }483 }
@@ -515,10 +530,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
515 if err := c.Roundtrip(); err != nil {530 if err := c.Roundtrip(); err != nil {
516 t.Fatal(err)531 t.Fatal(err)
517 }532 }
518- replies <- ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)}533+ deliverCapture(t, s, replies, ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)})
519- if !s.display.Do(func() {}) {
520- t.Fatal("display stopped")
521- }
522 if err := c.Roundtrip(); err != nil {534 if err := c.Roundtrip(); err != nil {
523 t.Fatal(err)535 t.Fatal(err)
524 }536 }
@@ -681,10 +693,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
681 if _, err := req.Dst.File.Stat(); err == nil {693 if _, err := req.Dst.File.Stat(); err == nil {
682 t.Fatal("output descriptor open")694 t.Fatal("output descriptor open")
683 }695 }
684- replies <- ports.CaptureDone{ID: req.ID}696+ deliverCapture(t, s, replies, ports.CaptureDone{ID: req.ID})
685- if !s.display.Do(func() {}) {
686- t.Fatal("display stopped")
687- }
688 if err := c.Roundtrip(); err != nil {697 if err := c.Roundtrip(); err != nil {
689 t.Fatal(err)698 t.Fatal(err)
690 }699 }
M internal/adapters/wayland/xdg.go +24 -10
@@ -147,24 +147,38 @@ type window struct {
147147 // sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.
148148 func (w *window) sendConfigure() {
149149 c := w.last
150+ w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
151+ s := w.xdg.server
152+ s.serial++
153+ w.xdg.resource.SendConfigure(s.serial)
154+ w.xdg.serials = append(w.xdg.serials, s.serial)
155+}
156+
157+// toplevelStates encodes the xdg_toplevel states of configure c.
158+func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
150159 var states []byte
160+ add := func(st xdgshell.ToplevelState) {
161+ states = binary.LittleEndian.AppendUint32(states, uint32(st))
162+ }
151163 if c.Fullscreen {
152- states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen))
164+ add(xdgshell.ToplevelStateFullscreen)
153165 }
154166 if c.Activated {
155- states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
167+ add(xdgshell.ToplevelStateActivated)
168+ }
169+ if c.Fullscreen || c.Floating {
170+ return states
156171 }
157- if !c.Fullscreen && !c.Floating && w.toplevel.Version() >= 2 {
158- // Tiled: the client must use exactly this size (no CSD shadows or rounding).
172+ // Tiled windows must use exactly this size. Maximized is the strict
173+ // size state every client honours: Wine ignores a size change that only
174+ // carries tiled states on a window it considers monitor-sized.
175+ add(xdgshell.ToplevelStateMaximized)
176+ if version >= 2 {
159177 for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} {
160- states = binary.LittleEndian.AppendUint32(states, uint32(st))
178+ add(st)
161179 }
162180 }
163- w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
164- s := w.xdg.server
165- s.serial++
166- w.xdg.resource.SendConfigure(s.serial)
167- w.xdg.serials = append(w.xdg.serials, s.serial)
181+ return states
168182 }
169183
170184 func (w *window) unmap() {
@@ -147,24 +147,38 @@ type window struct {
147 // sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.147 // sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.
148 func (w *window) sendConfigure() {148 func (w *window) sendConfigure() {
149 c := w.last149 c := w.last
150+ w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
151+ s := w.xdg.server
152+ s.serial++
153+ w.xdg.resource.SendConfigure(s.serial)
154+ w.xdg.serials = append(w.xdg.serials, s.serial)
155+}
156+
157+// toplevelStates encodes the xdg_toplevel states of configure c.
158+func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
150 var states []byte159 var states []byte
160+ add := func(st xdgshell.ToplevelState) {
161+ states = binary.LittleEndian.AppendUint32(states, uint32(st))
162+ }
151 if c.Fullscreen {163 if c.Fullscreen {
152- states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen))164+ add(xdgshell.ToplevelStateFullscreen)
153 }165 }
154 if c.Activated {166 if c.Activated {
155- states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))167+ add(xdgshell.ToplevelStateActivated)
168+ }
169+ if c.Fullscreen || c.Floating {
170+ return states
156 }171 }
157- if !c.Fullscreen && !c.Floating && w.toplevel.Version() >= 2 {172+ // Tiled windows must use exactly this size. Maximized is the strict
158- // Tiled: the client must use exactly this size (no CSD shadows or rounding).173+ // size state every client honours: Wine ignores a size change that only
174+ // carries tiled states on a window it considers monitor-sized.
175+ add(xdgshell.ToplevelStateMaximized)
176+ if version >= 2 {
159 for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} {177 for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} {
160- states = binary.LittleEndian.AppendUint32(states, uint32(st))178+ add(st)
161 }179 }
162 }180 }
163- w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)181+ return states
164- s := w.xdg.server
165- s.serial++
166- w.xdg.resource.SendConfigure(s.serial)
167- w.xdg.serials = append(w.xdg.serials, s.serial)
168 }182 }
169 183
170 func (w *window) unmap() {184 func (w *window) unmap() {
A internal/adapters/wayland/xdg_states_test.go +37 -0
@@ -0,0 +1,37 @@
1+package wayland
2+
3+import (
4+ "encoding/binary"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
10+)
11+
12+func TestToplevelStates(t *testing.T) {
13+ tiled := []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom}
14+ tests := []struct {
15+ name string
16+ c ports.ConfigureWindow
17+ version int32
18+ want []xdgshell.ToplevelState
19+ }{
20+ {"tiled", ports.ConfigureWindow{Activated: true}, 2, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateActivated, xdgshell.ToplevelStateMaximized}, tiled...)},
21+ {"tiled v1", ports.ConfigureWindow{}, 1, []xdgshell.ToplevelState{xdgshell.ToplevelStateMaximized}},
22+ {"floating", ports.ConfigureWindow{Floating: true, Activated: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateActivated}},
23+ {"fullscreen", ports.ConfigureWindow{Fullscreen: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen}},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ raw := toplevelStates(tt.c, tt.version)
28+ var got []xdgshell.ToplevelState
29+ for i := 0; i+4 <= len(raw); i += 4 {
30+ got = append(got, xdgshell.ToplevelState(binary.LittleEndian.Uint32(raw[i:])))
31+ }
32+ if !slices.Equal(got, tt.want) {
33+ t.Fatalf("states = %v, want %v", got, tt.want)
34+ }
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package wayland
2+
3+import (
4+ "encoding/binary"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
10+)
11+
12+func TestToplevelStates(t *testing.T) {
13+ tiled := []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom}
14+ tests := []struct {
15+ name string
16+ c ports.ConfigureWindow
17+ version int32
18+ want []xdgshell.ToplevelState
19+ }{
20+ {"tiled", ports.ConfigureWindow{Activated: true}, 2, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateActivated, xdgshell.ToplevelStateMaximized}, tiled...)},
21+ {"tiled v1", ports.ConfigureWindow{}, 1, []xdgshell.ToplevelState{xdgshell.ToplevelStateMaximized}},
22+ {"floating", ports.ConfigureWindow{Floating: true, Activated: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateActivated}},
23+ {"fullscreen", ports.ConfigureWindow{Fullscreen: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen}},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ raw := toplevelStates(tt.c, tt.version)
28+ var got []xdgshell.ToplevelState
29+ for i := 0; i+4 <= len(raw); i += 4 {
30+ got = append(got, xdgshell.ToplevelState(binary.LittleEndian.Uint32(raw[i:])))
31+ }
32+ if !slices.Equal(got, tt.want) {
33+ t.Fatalf("states = %v, want %v", got, tt.want)
34+ }
35+ })
36+ }
37+}

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