1package wayland
2
3import (
4 "context"
5 "slices"
6 "testing"
7 "time"
8
9 "github.com/bnema/go-wayland-bindings/server/fractionalscale"
10 "github.com/bnema/go-wayland-bindings/server/viewporter"
11 "github.com/bnema/go-wayland-bindings/server/wayland"
12 "github.com/bnema/go-wayland-bindings/server/xdgshell"
13 "github.com/bnema/neferwl/internal/adapters/logging"
14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/bnema/wlturbo"
16 "golang.org/x/sys/unix"
17)
18
19type scaleProxy struct {
20 wlturbo.BaseProxy
21 values chan uint32
22}
23
24func (p *scaleProxy) Dispatch(e *wlturbo.Event) {
25 if e.Opcode == 0 {
26 p.values <- e.Uint32()
27 }
28}
29
30// surfaceScaleProxy records wl_surface.preferred_buffer_scale (opcode 2).
31type surfaceScaleProxy struct {
32 wlturbo.BaseProxy
33 scales chan int32
34}
35
36func (p *surfaceScaleProxy) Dispatch(e *wlturbo.Event) {
37 if e.Opcode == 2 {
38 p.scales <- e.Int32()
39 }
40}
41
42// surfaceTransformProxy records wl_surface.preferred_buffer_transform (opcode 3).
43type surfaceTransformProxy struct {
44 wlturbo.BaseProxy
45 transforms chan uint32
46}
47
48func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) {
49 if e.Opcode == 3 {
50 p.transforms <- e.Uint32()
51 }
52}
53
54func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
55 t.Helper()
56 dir := t.TempDir()
57 events := make(chan ports.ClientEvent, 16)
58 commands := make(chan ports.ClientCommand, 16)
59 contents := make(chan ports.SurfaceContent, 16)
60 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland"))
61 if err != nil {
62 t.Fatal(err)
63 }
64 ctx, cancel := context.WithCancel(context.Background())
65 done := make(chan error, 1)
66 go func() { done <- s.Run(ctx) }()
67 t.Cleanup(func() { cancel(); <-done })
68 return s, events, commands, contents, dir
69}
70
71// A v6 surface learns the transform of its output when it differs from
72// normal (the protocol default), and again only when it changes. A cursor
73// surface never does: cursor buffers must stay untransformed.
74func TestPreferredBufferTransform(t *testing.T) {
75 s, _, _, _, dir := contentServer(t)
76 info := testOutputs[0].Info
77 setOutputs := func(tr ports.BufferTransform, scale float64) {
78 if !s.display.Do(func() {
79 s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: info, Width: info.Height, Height: info.Width, Scale: scale, Transform: tr}}})
80 }) {
81 t.Fatal("display stopped")
82 }
83 }
84 newSurface := func(c *wlturbo.Display, comp uint32) (uint32, func() []uint32) {
85 surf := c.AllocateID()
86 p := &surfaceTransformProxy{transforms: make(chan uint32, 8)}
87 p.SetID(surf)
88 registerWireProxy(c, p)
89 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
90 return surf, func() []uint32 {
91 if err := c.Roundtrip(); err != nil {
92 t.Fatal(err)
93 }
94 var got []uint32
95 for len(p.transforms) > 0 {
96 got = append(got, <-p.transforms)
97 }
98 return got
99 }
100 }
101 setOutputs(0, 1)
102 c := protocolClient(t, s, dir)
103 g, _ := c.Registry().FindGlobal("wl_compositor")
104 comp, err := bindWireID(c, g.Name, g.Interface, 6)
105 if err != nil {
106 t.Fatal(err)
107 }
108 seat := bindProtocol(t, c, "wl_seat")
109 registerProtocol(t, c, seat)
110 pointer := c.AllocateID()
111 registerProtocol(t, c, pointer)
112 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
113
114 _, normal := newSurface(c, comp)
115 cursor, cursorSeen := newSurface(c, comp)
116 if got := normal(); len(got) != 0 {
117 t.Fatalf("normal output sent transforms %v", got)
118 }
119 cursorSeen()
120 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), cursor, int32(0), int32(0))
121 // Roundtrip so the server has made it a cursor before the output changes.
122 if got := cursorSeen(); len(got) != 0 {
123 t.Fatalf("cursor surface got transforms %v before rotation", got)
124 }
125
126 setOutputs(1, 1)
127 if got := normal(); !slices.Equal(got, []uint32{1}) {
128 t.Fatalf("rotated transforms %v, want [1]", got)
129 }
130 if got := cursorSeen(); len(got) != 0 {
131 t.Fatalf("cursor surface got transforms %v", got)
132 }
133 // A scale change alone does not repeat the transform.
134 setOutputs(1, 2)
135 if got := normal(); len(got) != 0 {
136 t.Fatalf("unchanged transform resent: %v", got)
137 }
138 setOutputs(3, 2)
139 if got := normal(); !slices.Equal(got, []uint32{3}) {
140 t.Fatalf("changed transforms %v, want [3]", got)
141 }
142 setOutputs(0, 2)
143 if got := normal(); !slices.Equal(got, []uint32{0}) {
144 t.Fatalf("back to normal: %v, want [0]", got)
145 }
146 if got := cursorSeen(); len(got) != 0 {
147 t.Fatalf("cursor surface got transforms %v", got)
148 }
149}
150
151func TestFractionalScaleAndViewport(t *testing.T) {
152 s, events, commands, contents, dir := contentServer(t)
153 c := protocolClient(t, s, dir)
154 // wl_surface.preferred_buffer_scale needs wl_compositor v6.
155 g, _ := c.Registry().FindGlobal("wl_compositor")
156 comp, err := bindWireID(c, g.Name, g.Interface, 6)
157 if err != nil {
158 t.Fatal(err)
159 }
160 wm := bindProtocol(t, c, "xdg_wm_base")
161 shm := bindProtocol(t, c, "wl_shm")
162 fm := bindProtocol(t, c, "wp_fractional_scale_manager_v1")
163 vpm := bindProtocol(t, c, "wp_viewporter")
164 registerProtocol(t, c, shm) // wl_shm sends its formats right away
165 // New IDs must be used in allocation order.
166 surf := c.AllocateID()
167 bufferScales := make(chan int32, 4)
168 sp := &surfaceScaleProxy{scales: bufferScales}
169 sp.SetID(surf)
170 registerWireProxy(c, sp)
171 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
172 frac := c.AllocateID()
173 values := make(chan uint32, 4)
174 proxy := &scaleProxy{values: values}
175 proxy.SetID(frac)
176 registerWireProxy(c, proxy)
177 requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf)
178 vp := c.AllocateID()
179 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
180 registerProtocol(t, c, vp)
181 if err := c.Roundtrip(); err != nil {
182 t.Fatal(err)
183 }
184 if v := <-values; v != 120 {
185 t.Fatalf("initial scale %d", v)
186 }
187 // Core switches to 1.5: the surface gets 180/120.
188 commands <- ports.SetOutputs{Outputs: ports.Layout{{Info: testOutputs[0].Info, Width: 1280, Height: 720, Scale: 1.5}}}
189 // Events are read by Roundtrip; the command is applied asynchronously.
190 var got uint32
191 for deadline := time.Now().Add(2 * time.Second); got == 0 && time.Now().Before(deadline); {
192 if err := c.Roundtrip(); err != nil {
193 t.Fatal(err)
194 }
195 select {
196 case got = <-values:
197 case <-time.After(10 * time.Millisecond):
198 }
199 }
200 if got != 180 {
201 t.Fatalf("scale %d", got)
202 }
203 // Clients without fractional scale get the integer scale rounded up.
204 var last int32
205 for len(bufferScales) > 0 {
206 last = <-bufferScales
207 }
208 if last != 2 {
209 t.Fatalf("preferred_buffer_scale %d", last)
210 }
211 // A 30x15 buffer shown at 20x10 logical through the viewport.
212 xdg := c.AllocateID()
213 requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
214 serials := make(chan uint32, 4)
215 cp := &configureProxy{serial: serials}
216 cp.SetID(xdg)
217 registerWireProxy(c, cp)
218 top := c.AllocateID()
219 registerProtocol(t, c, top)
220 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
221 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
222 if err := c.Roundtrip(); err != nil {
223 t.Fatal(err)
224 }
225 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials)
226 fd, err := unix.MemfdCreate("scaled", 0)
227 if err != nil {
228 t.Fatal(err)
229 }
230 defer unix.Close(fd)
231 size := int32(30 * 15 * 4)
232 if err := unix.Ftruncate(fd, int64(size)); err != nil {
233 t.Fatal(err)
234 }
235 pool, buffer := c.AllocateID(), c.AllocateID()
236 registerProtocol(t, c, pool)
237 registerProtocol(t, c, buffer)
238 if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
239 t.Fatal(err)
240 }
241 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(30), int32(15), int32(120), uint32(0))
242 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(20), int32(10))
243 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
244 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
245 if err := c.Roundtrip(); err != nil {
246 t.Fatal(err)
247 }
248 mapped(t, events, 2*time.Second)
249 deadline := time.After(2 * time.Second)
250 for {
251 select {
252 case got := <-contents:
253 if got.SHM == nil {
254 continue
255 }
256 if got.Width != 30 || got.Height != 15 || got.LogicalW != 20 || got.LogicalH != 10 {
257 t.Fatalf("%+v", got)
258 }
259 goto viewportOnly
260 case <-deadline:
261 t.Fatal("no content")
262 }
263 }
264viewportOnly:
265 // Changing only the destination must update content with the current buffer.
266 for _, size := range [][2]int32{{1, 1}, {7, 3}} {
267 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, size[0], size[1])
268 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
269 if err := c.Roundtrip(); err != nil {
270 t.Fatal(err)
271 }
272 select {
273 case got := <-contents:
274 if got.LogicalW != int(size[0]) || got.LogicalH != int(size[1]) || got.Width != 30 || got.Height != 15 {
275 t.Fatalf("viewport-only content: %+v", got)
276 }
277 case <-time.After(2 * time.Second):
278 t.Fatal("no viewport-only content")
279 }
280 }
281 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetSource, int32(256), int32(256), int32(5*256), int32(4*256))
282 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(-1), int32(-1))
283 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
284 if err := c.Roundtrip(); err != nil {
285 t.Fatal(err)
286 }
287 select {
288 case got := <-contents:
289 if got.LogicalW != 5 || got.LogicalH != 4 || got.Source != [4]float32{1, 1, 5, 4} {
290 t.Fatalf("source-only crop: %+v", got)
291 }
292 case <-time.After(2 * time.Second):
293 t.Fatal("no crop content")
294 }
295 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(2), int32(3))
296 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
297 if err := c.Roundtrip(); err != nil {
298 t.Fatal(err)
299 }
300 select {
301 case got := <-contents:
302 if got.LogicalW != 2 || got.LogicalH != 3 || got.Source != [4]float32{1, 1, 5, 4} {
303 t.Fatalf("source and destination: %+v", got)
304 }
305 case <-time.After(2 * time.Second):
306 t.Fatal("no source and destination content")
307 }
308}
309
310func TestBufferScaleLogicalSize(t *testing.T) {
311 st := &surface{bufferScale: 2}
312 if w, h := st.logicalSize(200, 100); w != 100 || h != 50 {
313 t.Fatal(w, h)
314 }
315 st.committedViewport = viewportState{destW: 7, destH: 3, dest: true}
316 if w, h := st.logicalSize(200, 100); w != 7 || h != 3 {
317 t.Fatal(w, h)
318 }
319}
320
321func TestViewportBadSource(t *testing.T) {
322 s, _, _, _, dir := contentServer(t)
323 c := protocolClient(t, s, dir)
324 comp := bindProtocol(t, c, "wl_compositor")
325 vpm := bindProtocol(t, c, "wp_viewporter")
326 surf, vp := c.AllocateID(), c.AllocateID()
327 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
328 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
329 registerProtocol(t, c, vp)
330 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetSource, int32(-256), int32(0), int32(256), int32(256))
331 expectProtocolError(t, c, vp, uint32(viewporter.WpViewportErrorBadValue))
332}
333
334func TestViewportApplyErrors(t *testing.T) {
335 for _, tc := range []struct {
336 name string
337 width int32
338 destination bool
339 code viewporter.WpViewportError
340 }{
341 {"bad_size", 128, false, viewporter.WpViewportErrorBadSize},
342 {"out_of_buffer", 512, true, viewporter.WpViewportErrorOutOfBuffer},
343 } {
344 t.Run(tc.name, func(t *testing.T) {
345 s, _, _, _, dir := contentServer(t)
346 c := protocolClient(t, s, dir)
347 comp := bindProtocol(t, c, "wl_compositor")
348 vpm := bindProtocol(t, c, "wp_viewporter")
349 shm := bindProtocol(t, c, "wl_shm")
350 registerProtocol(t, c, shm)
351 surf, vp := c.AllocateID(), c.AllocateID()
352 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
353 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
354 registerProtocol(t, c, vp)
355 fd, err := unix.MemfdCreate("viewport", 0)
356 if err != nil {
357 t.Fatal(err)
358 }
359 defer unix.Close(fd)
360 if err := unix.Ftruncate(fd, 4); err != nil {
361 t.Fatal(err)
362 }
363 pool, buf := c.AllocateID(), c.AllocateID()
364 registerProtocol(t, c, pool)
365 registerProtocol(t, c, buf)
366 if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
367 t.Fatal(err)
368 }
369 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))
370 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetSource, int32(0), int32(0), tc.width, int32(256))
371 if tc.destination {
372 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(1), int32(1))
373 }
374 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
375 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
376 expectProtocolError(t, c, vp, uint32(tc.code))
377 })
378 }
379}
380
381// A client rounds the source position and size to 1/256 independently, so
382// their sum can pass the buffer edge by a unit: that is accepted. The
383// values are a real rejected source (y 213.86, height 1946.14 on 2160 rows).
384// One pixel past the edge is still out_of_buffer.
385func TestViewportSourceEdgeRounding(t *testing.T) {
386 for _, tc := range []struct {
387 name string
388 y, h int32
389 error bool
390 }{
391 {"rounded edge", 54749, 498212, false},
392 {"one pixel past", 54749, 498212 + 256, true},
393 } {
394 t.Run(tc.name, func(t *testing.T) {
395 s, events, _, contents, dir := contentServer(t)
396 c := protocolClient(t, s, dir)
397 win, surf, _ := surfaceMapper(t, c, events)()
398 drainContents(contents)
399 vpm := bindProtocol(t, c, "wp_viewporter")
400 shm := bindProtocol(t, c, "wl_shm")
401 registerProtocol(t, c, shm)
402 vp := c.AllocateID()
403 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
404 registerProtocol(t, c, vp)
405 const w, h = 4, 2160
406 fd, err := unix.MemfdCreate("viewport-edge", 0)
407 if err != nil {
408 t.Fatal(err)
409 }
410 defer unix.Close(fd)
411 if err := unix.Ftruncate(fd, w*h*4); err != nil {
412 t.Fatal(err)
413 }
414 pool, buf := c.AllocateID(), c.AllocateID()
415 registerProtocol(t, c, pool)
416 registerProtocol(t, c, buf)
417 if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(w*h*4)); err != nil {
418 t.Fatal(err)
419 }
420 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(w), int32(h), int32(w*4), uint32(0))
421 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(w), int32(100))
422 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetSource, int32(0), tc.y, int32(w*256), tc.h)
423 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
424 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
425 if tc.error {
426 expectProtocolError(t, c, vp, uint32(viewporter.WpViewportErrorOutOfBuffer))
427 return
428 }
429 roundtrip(t, c)
430 select {
431 case got := <-contents:
432 if got.ID != win.ID || got.Source[1]+got.Source[3] > h || got.Source[1] == 0 {
433 t.Fatalf("published source outside buffer: %+v", got)
434 }
435 case <-time.After(2 * time.Second):
436 t.Fatal("missing viewport publication")
437 }
438 })
439 }
440}
441
442// A NULL attach unmaps the surface: viewporter.xml exempts it from
443// out_of_buffer, so a source larger than the previous buffer is not an error.
444// A later commit that keeps the retained buffer is still validated.
445func TestViewportNullAttachSkipsBufferBounds(t *testing.T) {
446 s, _, _, _, dir := contentServer(t)
447 c := protocolClient(t, s, dir)
448 comp := bindProtocol(t, c, "wl_compositor")
449 vpm := bindProtocol(t, c, "wp_viewporter")
450 surf, vp := c.AllocateID(), c.AllocateID()
451 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
452 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
453 registerProtocol(t, c, vp)
454 buf := shmBuffer(t, c)
455 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetSource, int32(0), int32(0), int32(256), int32(256))
456 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
457 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
458 roundtrip(t, c)
459
460 // Oversized source with a NULL attach: no error.
461 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetSource, int32(0), int32(0), int32(512), int32(256))
462 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
463 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
464 roundtrip(t, c)
465
466 // Reattach the 1x1 buffer: the oversized source now applies to it.
467 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
468 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
469 expectProtocolError(t, c, vp, uint32(viewporter.WpViewportErrorOutOfBuffer))
470}
471
472func TestViewportBadDestination(t *testing.T) {
473 s, _, _, _, dir := contentServer(t)
474 c := protocolClient(t, s, dir)
475 comp := bindProtocol(t, c, "wl_compositor")
476 vpm := bindProtocol(t, c, "wp_viewporter")
477 surf, vp := c.AllocateID(), c.AllocateID()
478 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
479 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
480 registerProtocol(t, c, vp)
481 requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(0), int32(10))
482 expectProtocolError(t, c, vp, uint32(viewporter.WpViewportErrorBadValue))
483}