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 Unverified
B5690EEEBB952194The 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) { | |||
| 116 | 116 | if err := req.Dst.File.Close(); err != nil { | |
| 117 | 117 | t.Fatal(err) | |
| 118 | 118 | } | |
| 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()}) | |
| 123 | 120 | case <-time.After(2 * time.Second): | |
| 124 | 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 | 360 | // captureDetails records protocol payloads, not just the event opcodes. | |
| 343 | 361 | type captureDetails struct { | |
| 344 | 362 | wlturbo.BaseProxy | |
| @@ -459,10 +477,7 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||
| 459 | 477 | if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil { | |
| 460 | 478 | t.Fatal(err) | |
| 461 | 479 | } | |
| 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)}) | |
| 466 | 481 | if err := c.Roundtrip(); err != nil { | |
| 467 | 482 | t.Fatal(err) | |
| 468 | 483 | } | |
| @@ -515,10 +530,7 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||
| 515 | 530 | if err := c.Roundtrip(); err != nil { | |
| 516 | 531 | t.Fatal(err) | |
| 517 | 532 | } | |
| 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)}) | |
| 522 | 534 | if err := c.Roundtrip(); err != nil { | |
| 523 | 535 | t.Fatal(err) | |
| 524 | 536 | } | |
| @@ -681,10 +693,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) { | |||
| 681 | 693 | if _, err := req.Dst.File.Stat(); err == nil { | |
| 682 | 694 | t.Fatal("output descriptor open") | |
| 683 | 695 | } | |
| 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}) | |
| 688 | 697 | if err := c.Roundtrip(); err != nil { | |
| 689 | 698 | t.Fatal(err) | |
| 690 | 699 | } | |
| @@ -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.BaseProxy | 362 | 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 { | |||
| 147 | 147 | // sendConfigure sends w.last as xdg_toplevel + xdg_surface configure. | |
| 148 | 148 | func (w *window) sendConfigure() { | |
| 149 | 149 | 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 | 159 | var states []byte | |
| 160 | + | add := func(st xdgshell.ToplevelState) { | |
| 161 | + | states = binary.LittleEndian.AppendUint32(states, uint32(st)) | |
| 162 | + | } | |
| 151 | 163 | if c.Fullscreen { | |
| 152 | - | states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen)) | |
| 164 | + | add(xdgshell.ToplevelStateFullscreen) | |
| 153 | 165 | } | |
| 154 | 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 { | |
| 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 { | |
| 159 | 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) | |
| 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 | |
| 168 | 182 | } | |
| 169 | 183 | ||
| 170 | 184 | 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.last | 149 | 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 []byte | 159 | 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 | + | } | |||