detached 74d746f

refactor(app): own output apply in the backend owner loop (#40)

* refactor(app): own output apply in the backend owner loop

The DRM and headless owner loops now own the whole output configuration
lifecycle through outputApply: runtime overrides, protocol requests,
supersede by reload, rollback after a failed apply, and replies. The
relay goroutine and its backend config/result channels are gone, so a
result can no longer be matched to the wrong configuration: apply hands
out one configuration at a time and drops results of superseded ones.

applyProgress (renamed file applyprogress.go) keeps DRM readiness; its
unused rollback flag is removed. outputApply is tested as a value.

* fix(app): deliver output inventory without blocking the owner loop

Wayland heads now leave through the outputApply outbox, latest only, like core configuration and replies. A Wayland server that stops draining inventory can no longer stall reloads and apply requests in the backend loop.

* test(app): deliver outbox inventory through a channel

* fix(app): ignore readiness results of stopped output instances

* test(app): cover output management end to end through the headless loop

* test(app): bound the output management end-to-end test with a watchdog

* feat(wayland): honour buffer transform and opaque region (#39)

* feat(wayland): honour buffer transform and opaque region

wl_surface.set_buffer_transform is double-buffered state carried by each
commit, like the buffer scale. SurfaceContent.Transform tells renderers to
apply its inverse: logical size, viewport source and surface damage map
through it, and the compose shaders sample through a full affine map
(mapy). Direct scanout and the overlay plane show buffers unrotated, so a
transformed buffer is composed; the cursor falls back to the arrow.
Invalid values raise invalid_transform.

wl_surface.set_opaque_region is carried the same way. A region covering
the whole surface marks the content opaque, like an x format: alpha is
ignored and the overlay plane may pick it. A partial region is ignored.

* fix(wayland): repaint the whole surface when its opacity changes
commit
74d746f8d82bd59010d7cd20a5cc6aab18083c28
author
bnema · · 2026-09-28 07:22 UTC
committer
GitHub · · 2026-09-28 07:22 UTC
parent
dd089ad
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
31 files · +1300 -425
M internal/adapters/drm/overlay.go +1 -1
@@ -73,7 +73,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7373 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
7474 }
7575 c := surfaces[w.ID]
76- if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 {
76+ if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 {
7777 pick = w
7878 continue
7979 }
@@ -73,7 +73,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
73 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"73 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
74 }74 }
75 c := surfaces[w.ID]75 c := surfaces[w.ID]
76- if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 {76+ if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 {
77 pick = w77 pick = w
78 continue78 continue
79 }79 }
M internal/adapters/drm/overlay_test.go +8 -0
@@ -49,6 +49,14 @@ func TestOverlayCandidate(t *testing.T) {
4949 if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
5050 t.Fatalf("YUV overlay reason %q", reason)
5151 }
52+ // A transformed buffer is composed: the plane would show it unrotated.
53+ _, c = overlayScene()
54+ rotated := c[2]
55+ rotated.Transform = 1
56+ c[2] = rotated
57+ if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
58+ t.Fatalf("transformed overlay reason %q", reason)
59+ }
5260 _, c = overlayScene()
5361 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
5462 if _, _, reason := overlayCandidate(s, c); reason != "window_above" {
@@ -49,6 +49,14 @@ func TestOverlayCandidate(t *testing.T) {
49 if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {49 if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
50 t.Fatalf("YUV overlay reason %q", reason)50 t.Fatalf("YUV overlay reason %q", reason)
51 }51 }
52+ // A transformed buffer is composed: the plane would show it unrotated.
53+ _, c = overlayScene()
54+ rotated := c[2]
55+ rotated.Transform = 1
56+ c[2] = rotated
57+ if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
58+ t.Fatalf("transformed overlay reason %q", reason)
59+ }
52 _, c = overlayScene()60 _, c = overlayScene()
53 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})61 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
54 if _, _, reason := overlayCandidate(s, c); reason != "window_above" {62 if _, _, reason := overlayCandidate(s, c); reason != "window_above" {
M internal/adapters/drm/scanout.go +3 -0
@@ -89,6 +89,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8989 return c, "not_dmabuf"
9090 case len(c.Children) > 0:
9191 return c, "subsurfaces"
92+ case c.Transform != 0:
93+ // Planes show buffers as they are: rotation is composed.
94+ return c, "buffer_transform"
9295 case c.Width != w || c.Height != h:
9396 return c, "size_mismatch"
9497 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
@@ -89,6 +89,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
89 return c, "not_dmabuf"89 return c, "not_dmabuf"
90 case len(c.Children) > 0:90 case len(c.Children) > 0:
91 return c, "subsurfaces"91 return c, "subsurfaces"
92+ case c.Transform != 0:
93+ // Planes show buffers as they are: rotation is composed.
94+ return c, "buffer_transform"
92 case c.Width != w || c.Height != h:95 case c.Width != w || c.Height != h:
93 return c, "size_mismatch"96 return c, "size_mismatch"
94 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:97 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
M internal/adapters/drm/scanout_test.go +1 -0
@@ -37,6 +37,7 @@ func TestScanoutCandidate(t *testing.T) {
3737 }, nil, ""},
3838 {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},
3939 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},
40+ {"buffer transform", nil, func(c *ports.SurfaceContent) { c.Transform = 2 }, "buffer_transform"},
4041 {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},
4142 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},
4243 {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},
@@ -37,6 +37,7 @@ func TestScanoutCandidate(t *testing.T) {
37 }, nil, ""},37 }, nil, ""},
38 {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},38 {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},
39 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},39 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},
40+ {"buffer transform", nil, func(c *ports.SurfaceContent) { c.Transform = 2 }, "buffer_transform"},
40 {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},41 {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},
41 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},42 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},
42 {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},43 {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},
M internal/adapters/vulkan/compose.go +22 -5
@@ -8,6 +8,7 @@ import (
88 "os"
99 "unsafe"
1010
11+ "github.com/bnema/neferwl/internal/ports"
1112 vk "github.com/bnema/purego-vulkan/vulkan"
1213 )
1314
@@ -51,6 +52,7 @@ const (
5152 type pushConstants struct {
5253 rect [4]int32
5354 mapv [4]float32
55+ mapy [4]float32
5456 color [4]float32
5557 crop [4]float32
5658 buf [4]uint32
@@ -298,21 +300,36 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw {
298300 }
299301
300302 // contentDraw draws the visible rect of a w×h buffer mapped onto full
301-// (target pixels). exact: the buffer is drawn at its size.
302-func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, source [4]float32, mode uint32, opaque bool) draw {
303+// (target pixels) through the inverse of its buffer transform t. exact: the
304+// buffer is drawn at its size.
305+func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, source [4]float32, t ports.BufferTransform, mode uint32, opaque bool) draw {
303306 var dr draw
304307 if source[2] <= 0 {
305308 source = [4]float32{0, 0, float32(w), float32(h)}
306309 }
307- sx, sy := source[2]/float32(full.Dx()), source[3]/float32(full.Dy())
310+ // The crop in the surface's axes: its sides swap for a rotation.
311+ cw, ch := float64(source[2]), float64(source[3])
312+ if t.Rotated() {
313+ cw, ch = ch, cw
314+ }
315+ // src maps a target point to buffer pixels: affine, so its value at
316+ // the target origin and its steps along x and y define it.
317+ src := func(x, y float64) (float64, float64) {
318+ bx, by := t.ToBuffer((x-float64(full.Min.X))*cw/float64(full.Dx()), (y-float64(full.Min.Y))*ch/float64(full.Dy()), cw, ch)
319+ return float64(source[0]) + bx, float64(source[1]) + by
320+ }
321+ ox, oy := src(0, 0)
322+ xx, xy := src(1, 0)
323+ yx, yy := src(0, 1)
308324 dr.pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}
309- dr.pc.mapv = [4]float32{source[0] - float32(full.Min.X)*sx, source[1] - float32(full.Min.Y)*sy, sx, sy}
325+ dr.pc.mapv = [4]float32{float32(ox), float32(oy), float32(xx - ox), float32(xy - oy)}
326+ dr.pc.mapy = [4]float32{float32(yx - ox), float32(yy - oy), 0, 0}
310327 dr.pc.crop = [4]float32{source[0], source[1], source[0] + source[2], source[1] + source[3]}
311328 flags := uint32(0)
312329 if opaque {
313330 flags |= flagOpaque
314331 }
315- if full.Dx() == int(source[2]) && full.Dy() == int(source[3]) && source[2] == float32(int(source[2])) && source[3] == float32(int(source[3])) {
332+ if float64(full.Dx()) == cw && float64(full.Dy()) == ch && cw == float64(int(cw)) && ch == float64(int(ch)) {
316333 flags |= flagExact
317334 }
318335 dr.pc.misc = [4]uint32{mode, flags, uint32(r.width), uint32(r.height)}
@@ -8,6 +8,7 @@ import (
8 "os"8 "os"
9 "unsafe"9 "unsafe"
10 10
11+ "github.com/bnema/neferwl/internal/ports"
11 vk "github.com/bnema/purego-vulkan/vulkan"12 vk "github.com/bnema/purego-vulkan/vulkan"
12 )13 )
13 14
@@ -51,6 +52,7 @@ const (
51 type pushConstants struct {52 type pushConstants struct {
52 rect [4]int3253 rect [4]int32
53 mapv [4]float3254 mapv [4]float32
55+ mapy [4]float32
54 color [4]float3256 color [4]float32
55 crop [4]float3257 crop [4]float32
56 buf [4]uint3258 buf [4]uint32
@@ -298,21 +300,36 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw {
298 }300 }
299 301
300 // contentDraw draws the visible rect of a w×h buffer mapped onto full302 // contentDraw draws the visible rect of a w×h buffer mapped onto full
301-// (target pixels). exact: the buffer is drawn at its size.303+// (target pixels) through the inverse of its buffer transform t. exact: the
302-func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, source [4]float32, mode uint32, opaque bool) draw {304+// buffer is drawn at its size.
305+func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, source [4]float32, t ports.BufferTransform, mode uint32, opaque bool) draw {
303 var dr draw306 var dr draw
304 if source[2] <= 0 {307 if source[2] <= 0 {
305 source = [4]float32{0, 0, float32(w), float32(h)}308 source = [4]float32{0, 0, float32(w), float32(h)}
306 }309 }
307- sx, sy := source[2]/float32(full.Dx()), source[3]/float32(full.Dy())310+ // The crop in the surface's axes: its sides swap for a rotation.
311+ cw, ch := float64(source[2]), float64(source[3])
312+ if t.Rotated() {
313+ cw, ch = ch, cw
314+ }
315+ // src maps a target point to buffer pixels: affine, so its value at
316+ // the target origin and its steps along x and y define it.
317+ src := func(x, y float64) (float64, float64) {
318+ bx, by := t.ToBuffer((x-float64(full.Min.X))*cw/float64(full.Dx()), (y-float64(full.Min.Y))*ch/float64(full.Dy()), cw, ch)
319+ return float64(source[0]) + bx, float64(source[1]) + by
320+ }
321+ ox, oy := src(0, 0)
322+ xx, xy := src(1, 0)
323+ yx, yy := src(0, 1)
308 dr.pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}324 dr.pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}
309- dr.pc.mapv = [4]float32{source[0] - float32(full.Min.X)*sx, source[1] - float32(full.Min.Y)*sy, sx, sy}325+ dr.pc.mapv = [4]float32{float32(ox), float32(oy), float32(xx - ox), float32(xy - oy)}
326+ dr.pc.mapy = [4]float32{float32(yx - ox), float32(yy - oy), 0, 0}
310 dr.pc.crop = [4]float32{source[0], source[1], source[0] + source[2], source[1] + source[3]}327 dr.pc.crop = [4]float32{source[0], source[1], source[0] + source[2], source[1] + source[3]}
311 flags := uint32(0)328 flags := uint32(0)
312 if opaque {329 if opaque {
313 flags |= flagOpaque330 flags |= flagOpaque
314 }331 }
315- if full.Dx() == int(source[2]) && full.Dy() == int(source[3]) && source[2] == float32(int(source[2])) && source[3] == float32(int(source[3])) {332+ if float64(full.Dx()) == cw && float64(full.Dy()) == ch && cw == float64(int(cw)) && ch == float64(int(ch)) {
316 flags |= flagExact333 flags |= flagExact
317 }334 }
318 dr.pc.misc = [4]uint32{mode, flags, uint32(r.width), uint32(r.height)}335 dr.pc.misc = [4]uint32{mode, flags, uint32(r.width), uint32(r.height)}
M internal/adapters/vulkan/damage.go +5 -1
@@ -97,7 +97,7 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im
9797
9898 // content maps a root surface's buffer damage onto full (where its w×h
9999 // buffer is drawn), clipped to dst. Scaled buffers filter linearly: one
100-// more pixel around each rect.
100+// more pixel around each rect. A transformed buffer redraws all of dst.
101101 func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full, dst image.Rectangle) {
102102 if d.held == nil {
103103 return
@@ -111,6 +111,10 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
111111 if !known || c.Width <= 0 || c.Height <= 0 {
112112 return
113113 }
114+ if c.Transform != 0 {
115+ d.add(dst)
116+ return
117+ }
114118 // Linear filtering spreads a texel over half a buffer pixel around
115119 // it: scale/2 target pixels, plus one for rounding.
116120 pad := 0
@@ -97,7 +97,7 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im
97 97
98 // content maps a root surface's buffer damage onto full (where its w×h98 // content maps a root surface's buffer damage onto full (where its w×h
99 // buffer is drawn), clipped to dst. Scaled buffers filter linearly: one99 // buffer is drawn), clipped to dst. Scaled buffers filter linearly: one
100-// more pixel around each rect.100+// more pixel around each rect. A transformed buffer redraws all of dst.
101 func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full, dst image.Rectangle) {101 func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full, dst image.Rectangle) {
102 if d.held == nil {102 if d.held == nil {
103 return103 return
@@ -111,6 +111,10 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
111 if !known || c.Width <= 0 || c.Height <= 0 {111 if !known || c.Width <= 0 || c.Height <= 0 {
112 return112 return
113 }113 }
114+ if c.Transform != 0 {
115+ d.add(dst)
116+ return
117+ }
114 // Linear filtering spreads a texel over half a buffer pixel around118 // Linear filtering spreads a texel over half a buffer pixel around
115 // it: scale/2 target pixels, plus one for rounding.119 // it: scale/2 target pixels, plus one for rounding.
116 pad := 0120 pad := 0
M internal/adapters/vulkan/damage_test.go +11 -2
@@ -2,6 +2,7 @@ package vulkan
22
33 import (
44 "bytes"
5+ "fmt"
56 "image/color"
67 "os"
78 "testing"
@@ -25,6 +26,14 @@ func damagedContent(t *testing.T, f *os.File, base ports.SurfaceContent, seq uin
2526 // ages (A then B, then one render) and with shm double buffers
2627 // (render, skip, render).
2728 func TestRendererDamageMatchesFullRedraw(t *testing.T) {
29+ for _, tr := range []ports.BufferTransform{0, 1, 6} {
30+ t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr) })
31+ }
32+}
33+
34+// damageMatchesFullRedraw checks partial frames of a window whose buffer
35+// carries transform tr against full redraws.
36+func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {
2837 const w, h = 32, 32
2938 newR := func() *Renderer {
3039 r, err := New(64, 48)
@@ -52,7 +61,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) {
5261 }
5362 pixels := px(color.RGBA{10, 20, 30, 255}, 0, 0, w, h, make([]byte, w*h*4))
5463 base := shmContent(t, w, h, w*4, pixels)
55- base.ID = 1
64+ base.ID, base.Transform = 1, tr
5665 f := base.SHM.File
5766 scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}
5867 hist := []ports.SeqDamage{{Seq: 1, Full: true}}
@@ -86,7 +95,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) {
8695 c = damagedContent(t, f, *base, seq, pixels, hist)
8796 }
8897 step(2, c)
89- if got := damaged.redrawn - before; got >= 64*48 {
98+ if got := damaged.redrawn - before; tr == 0 && got >= 64*48 {
9099 t.Fatalf("partial frame redrew %d pixels", got)
91100 }
92101 // Render, skip (no frame), render: target 1 is two contents behind.
@@ -2,6 +2,7 @@ package vulkan
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "fmt"
5 "image/color"6 "image/color"
6 "os"7 "os"
7 "testing"8 "testing"
@@ -25,6 +26,14 @@ func damagedContent(t *testing.T, f *os.File, base ports.SurfaceContent, seq uin
25 // ages (A then B, then one render) and with shm double buffers26 // ages (A then B, then one render) and with shm double buffers
26 // (render, skip, render).27 // (render, skip, render).
27 func TestRendererDamageMatchesFullRedraw(t *testing.T) {28 func TestRendererDamageMatchesFullRedraw(t *testing.T) {
29+ for _, tr := range []ports.BufferTransform{0, 1, 6} {
30+ t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr) })
31+ }
32+}
33+
34+// damageMatchesFullRedraw checks partial frames of a window whose buffer
35+// carries transform tr against full redraws.
36+func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {
28 const w, h = 32, 3237 const w, h = 32, 32
29 newR := func() *Renderer {38 newR := func() *Renderer {
30 r, err := New(64, 48)39 r, err := New(64, 48)
@@ -52,7 +61,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) {
52 }61 }
53 pixels := px(color.RGBA{10, 20, 30, 255}, 0, 0, w, h, make([]byte, w*h*4))62 pixels := px(color.RGBA{10, 20, 30, 255}, 0, 0, w, h, make([]byte, w*h*4))
54 base := shmContent(t, w, h, w*4, pixels)63 base := shmContent(t, w, h, w*4, pixels)
55- base.ID = 164+ base.ID, base.Transform = 1, tr
56 f := base.SHM.File65 f := base.SHM.File
57 scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}66 scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}
58 hist := []ports.SeqDamage{{Seq: 1, Full: true}}67 hist := []ports.SeqDamage{{Seq: 1, Full: true}}
@@ -86,7 +95,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) {
86 c = damagedContent(t, f, *base, seq, pixels, hist)95 c = damagedContent(t, f, *base, seq, pixels, hist)
87 }96 }
88 step(2, c)97 step(2, c)
89- if got := damaged.redrawn - before; got >= 64*48 {98+ if got := damaged.redrawn - before; tr == 0 && got >= 64*48 {
90 t.Fatalf("partial frame redrew %d pixels", got)99 t.Fatalf("partial frame redrew %d pixels", got)
91 }100 }
92 // Render, skip (no frame), render: target 1 is two contents behind.101 // Render, skip (no frame), render: target 1 is two contents behind.
M internal/adapters/vulkan/scene.go +5 -2
@@ -216,6 +216,9 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip i
216216 if content.Source[2] > 0 {
217217 sourceW, sourceH = int(content.Source[2]), int(content.Source[3])
218218 }
219+ if content.Transform.Rotated() {
220+ sourceW, sourceH = sourceH, sourceW
221+ }
219222 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {
220223 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))
221224 }
@@ -256,7 +259,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
256259 // The window shows its background until a buffer imports.
257260 return
258261 }
259- dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, modeImage, content.Opaque)
262+ dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
260263 r.setContentColor(&dr, content.Color)
261264 if im.yuv {
262265 dr.pc.misc[1] |= flagYUV
@@ -292,7 +295,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
292295 if err != nil {
293296 return
294297 }
295- dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, modeBuffer, content.Opaque)
298+ dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
296299 r.setContentColor(&dr, content.Color)
297300 dr.set = c.set
298301 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
@@ -216,6 +216,9 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip i
216 if content.Source[2] > 0 {216 if content.Source[2] > 0 {
217 sourceW, sourceH = int(content.Source[2]), int(content.Source[3])217 sourceW, sourceH = int(content.Source[2]), int(content.Source[3])
218 }218 }
219+ if content.Transform.Rotated() {
220+ sourceW, sourceH = sourceH, sourceW
221+ }
219 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {222 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {
220 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))223 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))
221 }224 }
@@ -256,7 +259,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
256 // The window shows its background until a buffer imports.259 // The window shows its background until a buffer imports.
257 return260 return
258 }261 }
259- dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, modeImage, content.Opaque)262+ dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
260 r.setContentColor(&dr, content.Color)263 r.setContentColor(&dr, content.Color)
261 if im.yuv {264 if im.yuv {
262 dr.pc.misc[1] |= flagYUV265 dr.pc.misc[1] |= flagYUV
@@ -292,7 +295,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
292 if err != nil {295 if err != nil {
293 return296 return
294 }297 }
295- dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, modeBuffer, content.Opaque)298+ dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
296 r.setContentColor(&dr, content.Color)299 r.setContentColor(&dr, content.Color)
297 dr.set = c.set300 dr.set = c.set
298 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}301 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
M internal/adapters/vulkan/shaders/compose.frag +2 -1
@@ -8,6 +8,7 @@
88 layout(push_constant) uniform Draw {
99 ivec4 rect;
1010 vec4 map;
11+ vec4 mapy;
1112 vec4 color;
1213 vec4 crop;
1314 uvec4 buf;
@@ -84,7 +85,7 @@ vec4 texel(ivec2 p) {
8485 }
8586
8687 void main() {
87- vec2 src = d.map.xy + gl_FragCoord.xy * d.map.zw;
88+ vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
8889 bool exact = (d.misc.y & flagExact) != 0u;
8990 vec4 c;
9091 if (d.misc.x == modeSolid) {
@@ -8,6 +8,7 @@
8 layout(push_constant) uniform Draw {8 layout(push_constant) uniform Draw {
9 ivec4 rect;9 ivec4 rect;
10 vec4 map;10 vec4 map;
11+ vec4 mapy;
11 vec4 color;12 vec4 color;
12 vec4 crop;13 vec4 crop;
13 uvec4 buf;14 uvec4 buf;
@@ -84,7 +85,7 @@ vec4 texel(ivec2 p) {
84 }85 }
85 86
86 void main() {87 void main() {
87- vec2 src = d.map.xy + gl_FragCoord.xy * d.map.zw;88+ vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
88 bool exact = (d.misc.y & flagExact) != 0u;89 bool exact = (d.misc.y & flagExact) != 0u;
89 vec4 c;90 vec4 c;
90 if (d.misc.x == modeSolid) {91 if (d.misc.x == modeSolid) {
M internal/adapters/vulkan/shaders/compose.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose.vert +2 -1
@@ -3,7 +3,8 @@
33
44 layout(push_constant) uniform Draw {
55 ivec4 rect; // visible x0, y0, x1, y1 in target pixels
6- vec4 map; // source texel = map.xy + fragment centre * map.zw
6+ vec4 map; // source texel = map.xy + fragment centre.x * map.zw
7+ vec4 mapy; // + fragment centre.y * mapy.xy (zw unused): any buffer transform
78 vec4 color; // solid fills: premultiplied RGBA
89 vec4 crop; // source crop bounds in buffer pixels
910 uvec4 buf; // buffer draws: first word, row width, height, unused
@@ -3,7 +3,8 @@
3 3
4 layout(push_constant) uniform Draw {4 layout(push_constant) uniform Draw {
5 ivec4 rect; // visible x0, y0, x1, y1 in target pixels5 ivec4 rect; // visible x0, y0, x1, y1 in target pixels
6- vec4 map; // source texel = map.xy + fragment centre * map.zw6+ vec4 map; // source texel = map.xy + fragment centre.x * map.zw
7+ vec4 mapy; // + fragment centre.y * mapy.xy (zw unused): any buffer transform
7 vec4 color; // solid fills: premultiplied RGBA8 vec4 color; // solid fills: premultiplied RGBA
8 vec4 crop; // source crop bounds in buffer pixels9 vec4 crop; // source crop bounds in buffer pixels
9 uvec4 buf; // buffer draws: first word, row width, height, unused10 uvec4 buf; // buffer draws: first word, row width, height, unused
M internal/adapters/vulkan/shaders/compose.vert.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_hdr.frag +2 -1
@@ -8,6 +8,7 @@
88 layout(push_constant) uniform Draw {
99 ivec4 rect;
1010 vec4 map;
11+ vec4 mapy;
1112 vec4 color;
1213 vec4 crop;
1314 uvec4 buf;
@@ -83,7 +84,7 @@ vec4 texel(ivec2 p) {
8384 }
8485
8586 void main() {
86- vec2 src = d.map.xy + gl_FragCoord.xy * d.map.zw;
87+ vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
8788 bool exact = (d.misc.y & flagExact) != 0u;
8889 vec4 c;
8990 if (d.misc.x == modeSolid) {
@@ -8,6 +8,7 @@
8 layout(push_constant) uniform Draw {8 layout(push_constant) uniform Draw {
9 ivec4 rect;9 ivec4 rect;
10 vec4 map;10 vec4 map;
11+ vec4 mapy;
11 vec4 color;12 vec4 color;
12 vec4 crop;13 vec4 crop;
13 uvec4 buf;14 uvec4 buf;
@@ -83,7 +84,7 @@ vec4 texel(ivec2 p) {
83 }84 }
84 85
85 void main() {86 void main() {
86- vec2 src = d.map.xy + gl_FragCoord.xy * d.map.zw;87+ vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
87 bool exact = (d.misc.y & flagExact) != 0u;88 bool exact = (d.misc.y & flagExact) != 0u;
88 vec4 c;89 vec4 c;
89 if (d.misc.x == modeSolid) {90 if (d.misc.x == modeSolid) {
M internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

A internal/adapters/vulkan/transform_test.go +60 -0
@@ -0,0 +1,60 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Every wl_output.transform draws the buffer through its inverse: each
11+// surface pixel shows the buffer pixel ToBuffer names, pixel exact at 1:1,
12+// with and without a viewport crop.
13+func TestRendererBufferTransform(t *testing.T) {
14+ r, err := New(8, 8)
15+ if err != nil {
16+ t.Skipf("Vulkan unavailable: %v", err)
17+ }
18+ defer r.Close()
19+ // 3×2 buffer, one colour per pixel.
20+ colours := [2][3]color.RGBA{
21+ {{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}},
22+ {{255, 255, 0, 255}, {0, 255, 255, 255}, {255, 0, 255, 255}},
23+ }
24+ var px []byte
25+ for y := range 2 {
26+ for x := range 3 {
27+ c := colours[y][x]
28+ px = append(px, c.B, c.G, c.R, 255)
29+ }
30+ }
31+ for _, crop := range []bool{false, true} {
32+ for tr := ports.BufferTransform(0); tr < 8; tr++ {
33+ c := shmContent(t, 3, 2, 12, px)
34+ // Crop: the buffer's right 2×2 pixels.
35+ bx0, bw, bh := 0, 3, 2
36+ if crop {
37+ c.Source = [4]float32{1, 0, 2, 2}
38+ bx0, bw = 1, 2
39+ }
40+ sw, sh := bw, bh
41+ if tr.Rotated() {
42+ sw, sh = bh, bw
43+ }
44+ c.Transform, c.LogicalW, c.LogicalH = tr, sw, sh
45+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: sw, H: sh}}}}
46+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
47+ t.Fatal(err)
48+ }
49+ for y := range sh {
50+ for x := range sw {
51+ bx, by := tr.ToBuffer(float64(x)+0.5, float64(y)+0.5, float64(sw), float64(sh))
52+ want := colours[int(by)][bx0+int(bx)]
53+ if got := r.Pixels().RGBAAt(x, y); !near(got, want, 2) {
54+ t.Errorf("crop=%v transform %d: surface (%d,%d) = %v, want %v", crop, tr, x, y, got, want)
55+ }
56+ }
57+ }
58+ }
59+ }
60+}
@@ -0,0 +1,60 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Every wl_output.transform draws the buffer through its inverse: each
11+// surface pixel shows the buffer pixel ToBuffer names, pixel exact at 1:1,
12+// with and without a viewport crop.
13+func TestRendererBufferTransform(t *testing.T) {
14+ r, err := New(8, 8)
15+ if err != nil {
16+ t.Skipf("Vulkan unavailable: %v", err)
17+ }
18+ defer r.Close()
19+ // 3×2 buffer, one colour per pixel.
20+ colours := [2][3]color.RGBA{
21+ {{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}},
22+ {{255, 255, 0, 255}, {0, 255, 255, 255}, {255, 0, 255, 255}},
23+ }
24+ var px []byte
25+ for y := range 2 {
26+ for x := range 3 {
27+ c := colours[y][x]
28+ px = append(px, c.B, c.G, c.R, 255)
29+ }
30+ }
31+ for _, crop := range []bool{false, true} {
32+ for tr := ports.BufferTransform(0); tr < 8; tr++ {
33+ c := shmContent(t, 3, 2, 12, px)
34+ // Crop: the buffer's right 2×2 pixels.
35+ bx0, bw, bh := 0, 3, 2
36+ if crop {
37+ c.Source = [4]float32{1, 0, 2, 2}
38+ bx0, bw = 1, 2
39+ }
40+ sw, sh := bw, bh
41+ if tr.Rotated() {
42+ sw, sh = bh, bw
43+ }
44+ c.Transform, c.LogicalW, c.LogicalH = tr, sw, sh
45+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: sw, H: sh}}}}
46+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
47+ t.Fatal(err)
48+ }
49+ for y := range sh {
50+ for x := range sw {
51+ bx, by := tr.ToBuffer(float64(x)+0.5, float64(y)+0.5, float64(sw), float64(sh))
52+ want := colours[int(by)][bx0+int(bx)]
53+ if got := r.Pixels().RGBAAt(x, y); !near(got, want, 2) {
54+ t.Errorf("crop=%v transform %d: surface (%d,%d) = %v, want %v", crop, tr, x, y, got, want)
55+ }
56+ }
57+ }
58+ }
59+ }
60+}
M internal/adapters/wayland/cursor.go +3 -2
@@ -108,13 +108,14 @@ func (s *Server) hasPointerFocus(c server.Client) bool {
108108 }
109109
110110 // cursorImage copies the committed shm buffer of a cursor surface. No
111-// buffer hides the cursor; an unreadable one falls back to the arrow.
111+// buffer hides the cursor; an unreadable or transformed one falls back to
112+// the arrow.
112113 func (s *surface) cursorImage() ports.CursorChange {
113114 if s.current == nil {
114115 return ports.CursorChange{Hidden: true}
115116 }
116117 b, ok := s.server.buffers[s.current.Resource].(*buffer)
117- if !ok || b.width > maxCursorSide || b.height > maxCursorSide {
118+ if !ok || b.width > maxCursorSide || b.height > maxCursorSide || s.transform != 0 {
118119 return ports.CursorChange{}
119120 }
120121 img := ports.CursorImage{W: b.width, H: b.height, Pixels: make([]byte, b.width*b.height*4)}
@@ -108,13 +108,14 @@ func (s *Server) hasPointerFocus(c server.Client) bool {
108 }108 }
109 109
110 // cursorImage copies the committed shm buffer of a cursor surface. No110 // cursorImage copies the committed shm buffer of a cursor surface. No
111-// buffer hides the cursor; an unreadable one falls back to the arrow.111+// buffer hides the cursor; an unreadable or transformed one falls back to
112+// the arrow.
112 func (s *surface) cursorImage() ports.CursorChange {113 func (s *surface) cursorImage() ports.CursorChange {
113 if s.current == nil {114 if s.current == nil {
114 return ports.CursorChange{Hidden: true}115 return ports.CursorChange{Hidden: true}
115 }116 }
116 b, ok := s.server.buffers[s.current.Resource].(*buffer)117 b, ok := s.server.buffers[s.current.Resource].(*buffer)
117- if !ok || b.width > maxCursorSide || b.height > maxCursorSide {118+ if !ok || b.width > maxCursorSide || b.height > maxCursorSide || s.transform != 0 {
118 return ports.CursorChange{}119 return ports.CursorChange{}
119 }120 }
120 img := ports.CursorImage{W: b.width, H: b.height, Pixels: make([]byte, b.width*b.height*4)}121 img := ports.CursorImage{W: b.width, H: b.height, Pixels: make([]byte, b.width*b.height*4)}
M internal/adapters/wayland/fifo.go +8 -1
@@ -204,6 +204,9 @@ type update struct {
204204 attached bool
205205 buffer *wayland.Buffer
206206 scale int
207+ transform ports.BufferTransform
208+ opaque []ports.Rect
209+ opaqueSet bool
207210 async bool
208211 kind uint32
209212 callbacks []*wayland.Callback
@@ -311,6 +314,9 @@ func (s *surface) takePending() update {
311314 u.inputSet, u.inputAll, u.inputRects = s.pendingInputSet, s.pendingInputAll, s.pendingInputRects
312315 s.pendingInputSet = false
313316 s.pendingInputRects = nil
317+ u.transform = s.pendingTransform
318+ u.opaque, u.opaqueSet = s.pendingOpaque, s.pendingOpaqueSet
319+ s.pendingOpaque, s.pendingOpaqueSet = nil, false
314320 s.attached, s.pending, s.callbacks = false, nil, nil
315321 s.pendingFeedback, s.pendingSync = nil, nil
316322 s.pendingDamage, s.pendingBufDamage = nil, nil
@@ -398,8 +404,9 @@ func (s *surface) applyUpdate(u *update) {
398404 if u.buffer != nil && !u.buffer.Resource.Alive() {
399405 s.commitSkipped = true
400406 u.buffer, u.attached = nil, false
401- // Scale and crop describe the same retained buffer.
407+ // Scale, transform and crop describe the same retained buffer.
402408 u.scale = s.bufferScale
409+ u.transform = s.transform
403410 // The viewport change was made for that buffer: keep the crop that
404411 // matches the retained content instead of validating a mismatch.
405412 if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
@@ -204,6 +204,9 @@ type update struct {
204 attached bool204 attached bool
205 buffer *wayland.Buffer205 buffer *wayland.Buffer
206 scale int206 scale int
207+ transform ports.BufferTransform
208+ opaque []ports.Rect
209+ opaqueSet bool
207 async bool210 async bool
208 kind uint32211 kind uint32
209 callbacks []*wayland.Callback212 callbacks []*wayland.Callback
@@ -311,6 +314,9 @@ func (s *surface) takePending() update {
311 u.inputSet, u.inputAll, u.inputRects = s.pendingInputSet, s.pendingInputAll, s.pendingInputRects314 u.inputSet, u.inputAll, u.inputRects = s.pendingInputSet, s.pendingInputAll, s.pendingInputRects
312 s.pendingInputSet = false315 s.pendingInputSet = false
313 s.pendingInputRects = nil316 s.pendingInputRects = nil
317+ u.transform = s.pendingTransform
318+ u.opaque, u.opaqueSet = s.pendingOpaque, s.pendingOpaqueSet
319+ s.pendingOpaque, s.pendingOpaqueSet = nil, false
314 s.attached, s.pending, s.callbacks = false, nil, nil320 s.attached, s.pending, s.callbacks = false, nil, nil
315 s.pendingFeedback, s.pendingSync = nil, nil321 s.pendingFeedback, s.pendingSync = nil, nil
316 s.pendingDamage, s.pendingBufDamage = nil, nil322 s.pendingDamage, s.pendingBufDamage = nil, nil
@@ -398,8 +404,9 @@ func (s *surface) applyUpdate(u *update) {
398 if u.buffer != nil && !u.buffer.Resource.Alive() {404 if u.buffer != nil && !u.buffer.Resource.Alive() {
399 s.commitSkipped = true405 s.commitSkipped = true
400 u.buffer, u.attached = nil, false406 u.buffer, u.attached = nil, false
401- // Scale and crop describe the same retained buffer.407+ // Scale, transform and crop describe the same retained buffer.
402 u.scale = s.bufferScale408 u.scale = s.bufferScale
409+ u.transform = s.transform
403 // The viewport change was made for that buffer: keep the crop that410 // The viewport change was made for that buffer: keep the crop that
404 // matches the retained content instead of validating a mismatch.411 // matches the retained content instead of validating a mismatch.
405 if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {412 if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
M internal/adapters/wayland/scale.go +27 -7
@@ -210,21 +210,40 @@ func (v *viewport) SetDestination(r *viewporter.WpViewport, w, h int32) {
210210 v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1
211211 }
212212
213-// source returns buffer-pixel crop coordinates, precomputed at commit.
213+// source returns the crop in untransformed buffer pixels, precomputed at
214+// commit. The viewport source is in transformed, scaled surface
215+// coordinates: its corners map back through the buffer transform.
214216 func (s *surface) source(bw, bh int) ([4]float32, bool) {
215217 if !s.committedViewport.crop {
216218 return [4]float32{}, false
217219 }
218220 v := s.committedViewport.src
219- scale := float32(max(s.bufferScale, 1)) / 256
220- src := [4]float32{float32(v[0]) * scale, float32(v[1]) * scale, float32(v[2]) * scale, float32(v[3]) * scale}
221+ scale := float64(max(s.bufferScale, 1))
222+ // Transformed buffer size, in the surface's axes.
223+ tw, th := float64(bw), float64(bh)
224+ if s.transform.Rotated() {
225+ tw, th = th, tw
226+ }
227+ x0, y0 := float64(v[0])*scale/256, float64(v[1])*scale/256
228+ x1, y1 := x0+float64(v[2])*scale/256, y0+float64(v[3])*scale/256
221229 // validateViewport tolerates a rounding overshoot: sample inside the buffer.
222- src[2] = min(src[2], float32(bw)-src[0])
223- src[3] = min(src[3], float32(bh)-src[1])
224- return src, true
230+ x1, y1 = min(x1, tw), min(y1, th)
231+ ax, ay := s.transform.ToBuffer(x0, y0, tw, th)
232+ bx, by := s.transform.ToBuffer(x1, y1, tw, th)
233+ return [4]float32{float32(min(ax, bx)), float32(min(ay, by)), float32(math.Abs(bx - ax)), float32(math.Abs(by - ay))}, true
234+}
235+
236+// transformedSize is the buffer size in the surface's axes: width and height
237+// swap for a 90° or 270° buffer transform.
238+func (s *surface) transformedSize(bw, bh int) (int, int) {
239+ if s.transform.Rotated() {
240+ return bh, bw
241+ }
242+ return bw, bh
225243 }
226244
227245 func (s *surface) validateViewport(bw, bh int) bool {
246+ bw, bh = s.transformedSize(bw, bh)
228247 v := &s.committedViewport
229248 if !v.crop {
230249 return true
@@ -276,8 +295,9 @@ func (s *surface) viewportError(code viewporter.WpViewportError, message string)
276295 }
277296
278297 // logicalSize is the surface size in logical pixels: the viewport
279-// destination, else the buffer divided by its integer buffer scale.
298+// destination, else the transformed buffer divided by its buffer scale.
280299 func (s *surface) logicalSize(bw, bh int) (int, int) {
300+ bw, bh = s.transformedSize(bw, bh)
281301 if s.committedViewport.dest {
282302 return int(s.committedViewport.destW), int(s.committedViewport.destH)
283303 }
@@ -210,21 +210,40 @@ func (v *viewport) SetDestination(r *viewporter.WpViewport, w, h int32) {
210 v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1210 v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1
211 }211 }
212 212
213-// source returns buffer-pixel crop coordinates, precomputed at commit.213+// source returns the crop in untransformed buffer pixels, precomputed at
214+// commit. The viewport source is in transformed, scaled surface
215+// coordinates: its corners map back through the buffer transform.
214 func (s *surface) source(bw, bh int) ([4]float32, bool) {216 func (s *surface) source(bw, bh int) ([4]float32, bool) {
215 if !s.committedViewport.crop {217 if !s.committedViewport.crop {
216 return [4]float32{}, false218 return [4]float32{}, false
217 }219 }
218 v := s.committedViewport.src220 v := s.committedViewport.src
219- scale := float32(max(s.bufferScale, 1)) / 256221+ scale := float64(max(s.bufferScale, 1))
220- src := [4]float32{float32(v[0]) * scale, float32(v[1]) * scale, float32(v[2]) * scale, float32(v[3]) * scale}222+ // Transformed buffer size, in the surface's axes.
223+ tw, th := float64(bw), float64(bh)
224+ if s.transform.Rotated() {
225+ tw, th = th, tw
226+ }
227+ x0, y0 := float64(v[0])*scale/256, float64(v[1])*scale/256
228+ x1, y1 := x0+float64(v[2])*scale/256, y0+float64(v[3])*scale/256
221 // validateViewport tolerates a rounding overshoot: sample inside the buffer.229 // validateViewport tolerates a rounding overshoot: sample inside the buffer.
222- src[2] = min(src[2], float32(bw)-src[0])230+ x1, y1 = min(x1, tw), min(y1, th)
223- src[3] = min(src[3], float32(bh)-src[1])231+ ax, ay := s.transform.ToBuffer(x0, y0, tw, th)
224- return src, true232+ bx, by := s.transform.ToBuffer(x1, y1, tw, th)
233+ return [4]float32{float32(min(ax, bx)), float32(min(ay, by)), float32(math.Abs(bx - ax)), float32(math.Abs(by - ay))}, true
234+}
235+
236+// transformedSize is the buffer size in the surface's axes: width and height
237+// swap for a 90° or 270° buffer transform.
238+func (s *surface) transformedSize(bw, bh int) (int, int) {
239+ if s.transform.Rotated() {
240+ return bh, bw
241+ }
242+ return bw, bh
225 }243 }
226 244
227 func (s *surface) validateViewport(bw, bh int) bool {245 func (s *surface) validateViewport(bw, bh int) bool {
246+ bw, bh = s.transformedSize(bw, bh)
228 v := &s.committedViewport247 v := &s.committedViewport
229 if !v.crop {248 if !v.crop {
230 return true249 return true
@@ -276,8 +295,9 @@ func (s *surface) viewportError(code viewporter.WpViewportError, message string)
276 }295 }
277 296
278 // logicalSize is the surface size in logical pixels: the viewport297 // logicalSize is the surface size in logical pixels: the viewport
279-// destination, else the buffer divided by its integer buffer scale.298+// destination, else the transformed buffer divided by its buffer scale.
280 func (s *surface) logicalSize(bw, bh int) (int, int) {299 func (s *surface) logicalSize(bw, bh int) (int, int) {
300+ bw, bh = s.transformedSize(bw, bh)
281 if s.committedViewport.dest {301 if s.committedViewport.dest {
282 return int(s.committedViewport.destW), int(s.committedViewport.destH)302 return int(s.committedViewport.destW), int(s.committedViewport.destH)
283 }303 }
M internal/adapters/wayland/surface.go +117 -22
@@ -4,6 +4,7 @@ import (
44 "github.com/bnema/purego-libwayland/protocol/presentationtime"
55 "image"
66 "math"
7+ "slices"
78 "time"
89
910 "github.com/bnema/neferwl/internal/ports"
@@ -28,16 +29,25 @@ type surface struct {
2829 committedViewport viewportState // value snapshot; no allocation on viewport commits
2930 // bufferScale is committed state; pendingScale is set by set_buffer_scale.
3031 bufferScale, pendingScale int
31- kind roleKind
32- xdg *xdgSurface
33- layer *layerSurface
34- server *Server
35- current, pending *wayland.Buffer
36- attached bool
37- lastW, lastH int // last logged buffer size
38- callbacks []*wayland.Callback
39- role func(bool)
40- destroyed bool
32+ // transform is committed; pendingTransform is set by set_buffer_transform
33+ // and, like the buffer scale, copied by every commit.
34+ transform, pendingTransform ports.BufferTransform
35+ // opaque is the committed opaque region (surface-local, logical);
36+ // pendingOpaque is set by set_opaque_region. formatOpaque is the
37+ // current buffer's own opacity (x formats).
38+ opaque, pendingOpaque []ports.Rect
39+ pendingOpaqueSet bool
40+ formatOpaque bool
41+ kind roleKind
42+ xdg *xdgSurface
43+ layer *layerSurface
44+ server *Server
45+ current, pending *wayland.Buffer
46+ attached bool
47+ lastW, lastH int // last logged buffer size
48+ callbacks []*wayland.Callback
49+ role func(bool)
50+ destroyed bool
4151 // hotX, hotY is the click point of a cursor surface (logical).
4252 hotX, hotY int
4353 // on is the output the surface entered; scale is the last scale sent.
@@ -315,7 +325,7 @@ func (s *surface) Commit(*wayland.Surface) {
315325 // applyCommit makes only the captured update current. Live pending requests
316326 // remain untouched, even when this update waited behind another commit.
317327 func (s *surface) applyCommit(u *update) {
318- oldW, oldH, oldSource := s.content.LogicalW, s.content.LogicalH, s.content.Source
328+ oldW, oldH, oldSource, oldTransform := s.content.LogicalW, s.content.LogicalH, s.content.Source, s.content.Transform
319329 oldColor, oldRepresentation := s.color, s.representation
320330 fresh := u.attached && u.buffer != nil
321331 s.commitFresh = fresh
@@ -336,6 +346,11 @@ func (s *surface) applyCommit(u *update) {
336346 if u.scale > 0 {
337347 s.bufferScale = u.scale
338348 }
349+ s.transform = u.transform
350+ oldOpaque := s.content.Opaque
351+ if u.opaqueSet {
352+ s.opaque = u.opaque
353+ }
339354 hinted := s.async != u.async || s.color != u.color || s.representation != u.representation
340355 s.async = u.async
341356 s.color = u.color
@@ -378,8 +393,11 @@ func (s *surface) applyCommit(u *update) {
378393 if !s.validateViewport(c.Width, c.Height) {
379394 return
380395 }
396+ c.Transform = s.transform
381397 c.Source, _ = s.source(c.Width, c.Height)
382398 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
399+ s.formatOpaque = c.Opaque
400+ c.Opaque = c.Opaque || s.opaqueCovers(c.LogicalW, c.LogicalH)
383401 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH
384402 s.commitDamage(u, true, resized, c.Width, c.Height)
385403 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {
@@ -404,14 +422,16 @@ func (s *surface) applyCommit(u *update) {
404422 if !s.validateViewport(s.content.Width, s.content.Height) {
405423 return
406424 }
425+ s.content.Transform = s.transform
407426 s.content.Source, _ = s.source(s.content.Width, s.content.Height)
408427 s.content.LogicalW, s.content.LogicalH = s.logicalSize(s.content.Width, s.content.Height)
428+ s.content.Opaque = s.formatOpaque || s.opaqueCovers(s.content.LogicalW, s.content.LogicalH)
409429 }
410430 }
411431 if s.current == nil {
412432 s.content, s.has = ports.SurfaceContent{}, false
413433 }
414- if fresh || damaged || oldW != s.content.LogicalW || oldH != s.content.LogicalH || oldSource != s.content.Source || oldColor != s.color || oldRepresentation != s.representation {
434+ if fresh || damaged || oldW != s.content.LogicalW || oldH != s.content.LogicalH || oldSource != s.content.Source || oldColor != s.color || oldRepresentation != s.representation || oldTransform != s.content.Transform || oldOpaque != s.content.Opaque {
415435 s.version++
416436 s.root().treeDirty = true
417437 }
@@ -433,9 +453,12 @@ func (s *surface) applyCommit(u *update) {
433453 if s.xdg != nil && s.xdg.window != nil {
434454 s.xdg.window.afterCommit()
435455 }
436- drawn := fresh || damaged || moved || geometry || hinted || s.sub.parent != nil || (s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource))
456+ reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform)
457+ // Opacity changes how every pixel blends, so it repaints the whole surface.
458+ opacity := s.has && s.content.Opaque != oldOpaque
459+ drawn := fresh || damaged || moved || geometry || hinted || reshaped || opacity || s.sub.parent != nil
437460 if drawn {
438- if moved || geometry || oldW != s.content.LogicalW || oldH != s.content.LogicalH || oldSource != s.content.Source {
461+ if moved || geometry || reshaped || opacity {
439462 s.committed = damage{full: true}
440463 }
441464 if s.server.applyingGraph {
@@ -487,6 +510,15 @@ func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
487510 for _, r := range buf {
488511 add(image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H))
489512 }
513+ // Surface damage maps through the crop, in the transformed buffer's
514+ // axes, then back through the buffer transform.
515+ tw, th := s.transformedSize(bw, bh)
516+ sx, sy, sw, sh := 0.0, 0.0, float64(tw), float64(th)
517+ if s.committedViewport.crop {
518+ v := s.committedViewport.src
519+ scale := float64(max(s.bufferScale, 1)) / 256
520+ sx, sy, sw, sh = float64(v[0])*scale, float64(v[1])*scale, float64(v[2])*scale, float64(v[3])*scale
521+ }
490522 for _, r := range surf {
491523 if lw <= 0 || lh <= 0 {
492524 s.committed = damage{full: true}
@@ -495,17 +527,80 @@ func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
495527 // Clients may damage INT32_MAX-sized rects: clamp before scaling.
496528 x0, y0 := max(r.X, 0), max(r.Y, 0)
497529 x1, y1 := min(r.X+min(r.W, lw), lw), min(r.Y+min(r.H, lh), lh)
498- if src, ok := s.source(bw, bh); ok {
499- add(image.Rect(int(src[0]+float32(x0)*src[2]/float32(lw)), int(src[1]+float32(y0)*src[3]/float32(lh)),
500- int(math.Ceil(float64(src[0]+float32(x1)*src[2]/float32(lw)))), int(math.Ceil(float64(src[1]+float32(y1)*src[3]/float32(lh))))))
501- } else {
502- add(image.Rect(x0*bw/lw, y0*bh/lh, (x1*bw+lw-1)/lw, (y1*bh+lh-1)/lh))
503- }
530+ ax, ay := s.transform.ToBuffer(sx+float64(x0)*sw/float64(lw), sy+float64(y0)*sh/float64(lh), float64(tw), float64(th))
531+ bx, by := s.transform.ToBuffer(sx+float64(x1)*sw/float64(lw), sy+float64(y1)*sh/float64(lh), float64(tw), float64(th))
532+ add(image.Rect(int(math.Floor(min(ax, bx))), int(math.Floor(min(ay, by))), int(math.Ceil(max(ax, bx))), int(math.Ceil(max(ay, by)))))
504533 }
505534 s.committed = d
506535 }
507-func (*surface) SetOpaqueRegion(*wayland.Surface, *wayland.Region) {}
508-func (*surface) SetBufferTransform(*wayland.Surface, int32) {}
536+
537+// SetOpaqueRegion copies the region: a nil region means nothing is opaque.
538+func (s *surface) SetOpaqueRegion(_ *wayland.Surface, reg *wayland.Region) {
539+ s.pendingOpaqueSet = true
540+ s.pendingOpaque = nil
541+ if reg != nil && reg.Resource != nil {
542+ if g := s.server.regions[reg.Resource]; g != nil {
543+ s.pendingOpaque = slices.Clone(g.rects)
544+ }
545+ }
546+}
547+
548+func (s *surface) SetBufferTransform(r *wayland.Surface, v int32) {
549+ if v < 0 || v > 7 {
550+ r.PostError(uint32(wayland.SurfaceErrorInvalidTransform), "invalid buffer transform")
551+ return
552+ }
553+ s.pendingTransform = ports.BufferTransform(v)
554+}
555+
556+// opaqueCovers reports whether the committed opaque region covers the whole
557+// w×h surface. A partial region is ignored: it is only an optimization hint.
558+func (s *surface) opaqueCovers(w, h int) bool {
559+ return w > 0 && h > 0 && covers(s.opaque, ports.Rect{W: w, H: h})
560+}
561+
562+// maxCoverRects bounds the region covers checks: beyond it, not covered.
563+const maxCoverRects = 16
564+
565+// covers reports whether the union of rects contains all of r. It tests
566+// one point per cell of the grid the rect edges draw.
567+func covers(rects []ports.Rect, r ports.Rect) bool {
568+ if len(rects) == 0 || len(rects) > maxCoverRects {
569+ return false
570+ }
571+ var xsBuf, ysBuf [2*maxCoverRects + 2]int
572+ xs, ys := append(xsBuf[:0], r.X, r.X+r.W), append(ysBuf[:0], r.Y, r.Y+r.H)
573+ for _, c := range rects {
574+ if c.X > r.X && c.X < r.X+r.W {
575+ xs = append(xs, c.X)
576+ }
577+ if e := c.X + c.W; e > r.X && e < r.X+r.W {
578+ xs = append(xs, e)
579+ }
580+ if c.Y > r.Y && c.Y < r.Y+r.H {
581+ ys = append(ys, c.Y)
582+ }
583+ if e := c.Y + c.H; e > r.Y && e < r.Y+r.H {
584+ ys = append(ys, e)
585+ }
586+ }
587+ slices.Sort(xs)
588+ slices.Sort(ys)
589+ for i := 1; i < len(xs); i++ {
590+ for j := 1; j < len(ys); j++ {
591+ if xs[i] == xs[i-1] || ys[j] == ys[j-1] {
592+ continue
593+ }
594+ x, y := xs[i-1], ys[j-1]
595+ if !slices.ContainsFunc(rects, func(c ports.Rect) bool {
596+ return x >= c.X && x < c.X+c.W && y >= c.Y && y < c.Y+c.H
597+ }) {
598+ return false
599+ }
600+ }
601+ }
602+ return true
603+}
509604 func (s *surface) SetBufferScale(r *wayland.Surface, v int32) {
510605 if v < 1 {
511606 r.PostError(uint32(wayland.SurfaceErrorInvalidScale), "buffer scale must be positive")
@@ -4,6 +4,7 @@ import (
4 "github.com/bnema/purego-libwayland/protocol/presentationtime"4 "github.com/bnema/purego-libwayland/protocol/presentationtime"
5 "image"5 "image"
6 "math"6 "math"
7+ "slices"
7 "time"8 "time"
8 9
9 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
@@ -28,16 +29,25 @@ type surface struct {
28 committedViewport viewportState // value snapshot; no allocation on viewport commits29 committedViewport viewportState // value snapshot; no allocation on viewport commits
29 // bufferScale is committed state; pendingScale is set by set_buffer_scale.30 // bufferScale is committed state; pendingScale is set by set_buffer_scale.
30 bufferScale, pendingScale int31 bufferScale, pendingScale int
31- kind roleKind32+ // transform is committed; pendingTransform is set by set_buffer_transform
32- xdg *xdgSurface33+ // and, like the buffer scale, copied by every commit.
33- layer *layerSurface34+ transform, pendingTransform ports.BufferTransform
34- server *Server35+ // opaque is the committed opaque region (surface-local, logical);
35- current, pending *wayland.Buffer36+ // pendingOpaque is set by set_opaque_region. formatOpaque is the
36- attached bool37+ // current buffer's own opacity (x formats).
37- lastW, lastH int // last logged buffer size38+ opaque, pendingOpaque []ports.Rect
38- callbacks []*wayland.Callback39+ pendingOpaqueSet bool
39- role func(bool)40+ formatOpaque bool
40- destroyed bool41+ kind roleKind
42+ xdg *xdgSurface
43+ layer *layerSurface
44+ server *Server
45+ current, pending *wayland.Buffer
46+ attached bool
47+ lastW, lastH int // last logged buffer size
48+ callbacks []*wayland.Callback
49+ role func(bool)
50+ destroyed bool
41 // hotX, hotY is the click point of a cursor surface (logical).51 // hotX, hotY is the click point of a cursor surface (logical).
42 hotX, hotY int52 hotX, hotY int
43 // on is the output the surface entered; scale is the last scale sent.53 // on is the output the surface entered; scale is the last scale sent.
@@ -315,7 +325,7 @@ func (s *surface) Commit(*wayland.Surface) {
315 // applyCommit makes only the captured update current. Live pending requests325 // applyCommit makes only the captured update current. Live pending requests
316 // remain untouched, even when this update waited behind another commit.326 // remain untouched, even when this update waited behind another commit.
317 func (s *surface) applyCommit(u *update) {327 func (s *surface) applyCommit(u *update) {
318- oldW, oldH, oldSource := s.content.LogicalW, s.content.LogicalH, s.content.Source328+ oldW, oldH, oldSource, oldTransform := s.content.LogicalW, s.content.LogicalH, s.content.Source, s.content.Transform
319 oldColor, oldRepresentation := s.color, s.representation329 oldColor, oldRepresentation := s.color, s.representation
320 fresh := u.attached && u.buffer != nil330 fresh := u.attached && u.buffer != nil
321 s.commitFresh = fresh331 s.commitFresh = fresh
@@ -336,6 +346,11 @@ func (s *surface) applyCommit(u *update) {
336 if u.scale > 0 {346 if u.scale > 0 {
337 s.bufferScale = u.scale347 s.bufferScale = u.scale
338 }348 }
349+ s.transform = u.transform
350+ oldOpaque := s.content.Opaque
351+ if u.opaqueSet {
352+ s.opaque = u.opaque
353+ }
339 hinted := s.async != u.async || s.color != u.color || s.representation != u.representation354 hinted := s.async != u.async || s.color != u.color || s.representation != u.representation
340 s.async = u.async355 s.async = u.async
341 s.color = u.color356 s.color = u.color
@@ -378,8 +393,11 @@ func (s *surface) applyCommit(u *update) {
378 if !s.validateViewport(c.Width, c.Height) {393 if !s.validateViewport(c.Width, c.Height) {
379 return394 return
380 }395 }
396+ c.Transform = s.transform
381 c.Source, _ = s.source(c.Width, c.Height)397 c.Source, _ = s.source(c.Width, c.Height)
382 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)398 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
399+ s.formatOpaque = c.Opaque
400+ c.Opaque = c.Opaque || s.opaqueCovers(c.LogicalW, c.LogicalH)
383 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH401 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH
384 s.commitDamage(u, true, resized, c.Width, c.Height)402 s.commitDamage(u, true, resized, c.Width, c.Height)
385 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {403 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {
@@ -404,14 +422,16 @@ func (s *surface) applyCommit(u *update) {
404 if !s.validateViewport(s.content.Width, s.content.Height) {422 if !s.validateViewport(s.content.Width, s.content.Height) {
405 return423 return
406 }424 }
425+ s.content.Transform = s.transform
407 s.content.Source, _ = s.source(s.content.Width, s.content.Height)426 s.content.Source, _ = s.source(s.content.Width, s.content.Height)
408 s.content.LogicalW, s.content.LogicalH = s.logicalSize(s.content.Width, s.content.Height)427 s.content.LogicalW, s.content.LogicalH = s.logicalSize(s.content.Width, s.content.Height)
428+ s.content.Opaque = s.formatOpaque || s.opaqueCovers(s.content.LogicalW, s.content.LogicalH)
409 }429 }
410 }430 }
411 if s.current == nil {431 if s.current == nil {
412 s.content, s.has = ports.SurfaceContent{}, false432 s.content, s.has = ports.SurfaceContent{}, false
413 }433 }
414- if fresh || damaged || oldW != s.content.LogicalW || oldH != s.content.LogicalH || oldSource != s.content.Source || oldColor != s.color || oldRepresentation != s.representation {434+ if fresh || damaged || oldW != s.content.LogicalW || oldH != s.content.LogicalH || oldSource != s.content.Source || oldColor != s.color || oldRepresentation != s.representation || oldTransform != s.content.Transform || oldOpaque != s.content.Opaque {
415 s.version++435 s.version++
416 s.root().treeDirty = true436 s.root().treeDirty = true
417 }437 }
@@ -433,9 +453,12 @@ func (s *surface) applyCommit(u *update) {
433 if s.xdg != nil && s.xdg.window != nil {453 if s.xdg != nil && s.xdg.window != nil {
434 s.xdg.window.afterCommit()454 s.xdg.window.afterCommit()
435 }455 }
436- drawn := fresh || damaged || moved || geometry || hinted || s.sub.parent != nil || (s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource))456+ reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform)
457+ // Opacity changes how every pixel blends, so it repaints the whole surface.
458+ opacity := s.has && s.content.Opaque != oldOpaque
459+ drawn := fresh || damaged || moved || geometry || hinted || reshaped || opacity || s.sub.parent != nil
437 if drawn {460 if drawn {
438- if moved || geometry || oldW != s.content.LogicalW || oldH != s.content.LogicalH || oldSource != s.content.Source {461+ if moved || geometry || reshaped || opacity {
439 s.committed = damage{full: true}462 s.committed = damage{full: true}
440 }463 }
441 if s.server.applyingGraph {464 if s.server.applyingGraph {
@@ -487,6 +510,15 @@ func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
487 for _, r := range buf {510 for _, r := range buf {
488 add(image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H))511 add(image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H))
489 }512 }
513+ // Surface damage maps through the crop, in the transformed buffer's
514+ // axes, then back through the buffer transform.
515+ tw, th := s.transformedSize(bw, bh)
516+ sx, sy, sw, sh := 0.0, 0.0, float64(tw), float64(th)
517+ if s.committedViewport.crop {
518+ v := s.committedViewport.src
519+ scale := float64(max(s.bufferScale, 1)) / 256
520+ sx, sy, sw, sh = float64(v[0])*scale, float64(v[1])*scale, float64(v[2])*scale, float64(v[3])*scale
521+ }
490 for _, r := range surf {522 for _, r := range surf {
491 if lw <= 0 || lh <= 0 {523 if lw <= 0 || lh <= 0 {
492 s.committed = damage{full: true}524 s.committed = damage{full: true}
@@ -495,17 +527,80 @@ func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
495 // Clients may damage INT32_MAX-sized rects: clamp before scaling.527 // Clients may damage INT32_MAX-sized rects: clamp before scaling.
496 x0, y0 := max(r.X, 0), max(r.Y, 0)528 x0, y0 := max(r.X, 0), max(r.Y, 0)
497 x1, y1 := min(r.X+min(r.W, lw), lw), min(r.Y+min(r.H, lh), lh)529 x1, y1 := min(r.X+min(r.W, lw), lw), min(r.Y+min(r.H, lh), lh)
498- if src, ok := s.source(bw, bh); ok {530+ ax, ay := s.transform.ToBuffer(sx+float64(x0)*sw/float64(lw), sy+float64(y0)*sh/float64(lh), float64(tw), float64(th))
499- add(image.Rect(int(src[0]+float32(x0)*src[2]/float32(lw)), int(src[1]+float32(y0)*src[3]/float32(lh)),531+ bx, by := s.transform.ToBuffer(sx+float64(x1)*sw/float64(lw), sy+float64(y1)*sh/float64(lh), float64(tw), float64(th))
500- int(math.Ceil(float64(src[0]+float32(x1)*src[2]/float32(lw)))), int(math.Ceil(float64(src[1]+float32(y1)*src[3]/float32(lh))))))532+ add(image.Rect(int(math.Floor(min(ax, bx))), int(math.Floor(min(ay, by))), int(math.Ceil(max(ax, bx))), int(math.Ceil(max(ay, by)))))
501- } else {
502- add(image.Rect(x0*bw/lw, y0*bh/lh, (x1*bw+lw-1)/lw, (y1*bh+lh-1)/lh))
503- }
504 }533 }
505 s.committed = d534 s.committed = d
506 }535 }
507-func (*surface) SetOpaqueRegion(*wayland.Surface, *wayland.Region) {}536+
508-func (*surface) SetBufferTransform(*wayland.Surface, int32) {}537+// SetOpaqueRegion copies the region: a nil region means nothing is opaque.
538+func (s *surface) SetOpaqueRegion(_ *wayland.Surface, reg *wayland.Region) {
539+ s.pendingOpaqueSet = true
540+ s.pendingOpaque = nil
541+ if reg != nil && reg.Resource != nil {
542+ if g := s.server.regions[reg.Resource]; g != nil {
543+ s.pendingOpaque = slices.Clone(g.rects)
544+ }
545+ }
546+}
547+
548+func (s *surface) SetBufferTransform(r *wayland.Surface, v int32) {
549+ if v < 0 || v > 7 {
550+ r.PostError(uint32(wayland.SurfaceErrorInvalidTransform), "invalid buffer transform")
551+ return
552+ }
553+ s.pendingTransform = ports.BufferTransform(v)
554+}
555+
556+// opaqueCovers reports whether the committed opaque region covers the whole
557+// w×h surface. A partial region is ignored: it is only an optimization hint.
558+func (s *surface) opaqueCovers(w, h int) bool {
559+ return w > 0 && h > 0 && covers(s.opaque, ports.Rect{W: w, H: h})
560+}
561+
562+// maxCoverRects bounds the region covers checks: beyond it, not covered.
563+const maxCoverRects = 16
564+
565+// covers reports whether the union of rects contains all of r. It tests
566+// one point per cell of the grid the rect edges draw.
567+func covers(rects []ports.Rect, r ports.Rect) bool {
568+ if len(rects) == 0 || len(rects) > maxCoverRects {
569+ return false
570+ }
571+ var xsBuf, ysBuf [2*maxCoverRects + 2]int
572+ xs, ys := append(xsBuf[:0], r.X, r.X+r.W), append(ysBuf[:0], r.Y, r.Y+r.H)
573+ for _, c := range rects {
574+ if c.X > r.X && c.X < r.X+r.W {
575+ xs = append(xs, c.X)
576+ }
577+ if e := c.X + c.W; e > r.X && e < r.X+r.W {
578+ xs = append(xs, e)
579+ }
580+ if c.Y > r.Y && c.Y < r.Y+r.H {
581+ ys = append(ys, c.Y)
582+ }
583+ if e := c.Y + c.H; e > r.Y && e < r.Y+r.H {
584+ ys = append(ys, e)
585+ }
586+ }
587+ slices.Sort(xs)
588+ slices.Sort(ys)
589+ for i := 1; i < len(xs); i++ {
590+ for j := 1; j < len(ys); j++ {
591+ if xs[i] == xs[i-1] || ys[j] == ys[j-1] {
592+ continue
593+ }
594+ x, y := xs[i-1], ys[j-1]
595+ if !slices.ContainsFunc(rects, func(c ports.Rect) bool {
596+ return x >= c.X && x < c.X+c.W && y >= c.Y && y < c.Y+c.H
597+ }) {
598+ return false
599+ }
600+ }
601+ }
602+ return true
603+}
509 func (s *surface) SetBufferScale(r *wayland.Surface, v int32) {604 func (s *surface) SetBufferScale(r *wayland.Surface, v int32) {
510 if v < 1 {605 if v < 1 {
511 r.PostError(uint32(wayland.SurfaceErrorInvalidScale), "buffer scale must be positive")606 r.PostError(uint32(wayland.SurfaceErrorInvalidScale), "buffer scale must be positive")
A internal/adapters/wayland/transform_test.go +238 -0
@@ -0,0 +1,238 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/viewporter"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
11+ "github.com/bnema/wlturbo"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// transformClient maps a toplevel whose 30×20 argb8888 buffer is shown
16+// through a viewport, and returns what it needs to commit more state.
17+type transformClient struct {
18+ c *wlturbo.Display
19+ comp, surf, vp, bf uint32
20+ contents chan ports.SurfaceContent
21+}
22+
23+func newTransformClient(t *testing.T) transformClient {
24+ t.Helper()
25+ s, events, _, contents, dir := contentServer(t)
26+ c := protocolClient(t, s, dir)
27+ // set_buffer_transform needs wl_compositor v2.
28+ g, _ := c.Registry().FindGlobal("wl_compositor")
29+ comp, err := c.Registry().BindID(g.Name, g.Interface, 6)
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ wm := bindProtocol(t, c, "xdg_wm_base")
34+ shm := bindProtocol(t, c, "wl_shm")
35+ vpm := bindProtocol(t, c, "wp_viewporter")
36+ registerProtocol(t, c, shm)
37+ surf := c.AllocateID()
38+ registerProtocol(t, c, surf)
39+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
40+ vp := c.AllocateID()
41+ registerProtocol(t, c, vp)
42+ requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
43+ xdg := c.AllocateID()
44+ serials := make(chan uint32, 4)
45+ cp := &configureProxy{serial: serials}
46+ cp.SetID(xdg)
47+ c.Context().Register(cp)
48+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
49+ top := c.AllocateID()
50+ registerProtocol(t, c, top)
51+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
52+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
53+ if err := c.Roundtrip(); err != nil {
54+ t.Fatal(err)
55+ }
56+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials)
57+ fd, err := unix.MemfdCreate("transform", 0)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ t.Cleanup(func() { unix.Close(fd) })
62+ size := int32(30 * 20 * 4)
63+ if err := unix.Ftruncate(fd, int64(size)); err != nil {
64+ t.Fatal(err)
65+ }
66+ pool, bf := c.AllocateID(), c.AllocateID()
67+ registerProtocol(t, c, pool)
68+ registerProtocol(t, c, bf)
69+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
70+ t.Fatal(err)
71+ }
72+ // argb8888: not opaque by format.
73+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, bf, int32(0), int32(30), int32(20), int32(120), uint32(wayland.ShmFormatArgb8888))
74+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bf, int32(0), int32(0))
75+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
76+ if err := c.Roundtrip(); err != nil {
77+ t.Fatal(err)
78+ }
79+ mapped(t, events, 2*time.Second)
80+ tc := transformClient{c: c, comp: comp, surf: surf, vp: vp, bf: bf, contents: contents}
81+ if got := tc.next(t); got.Transform != 0 || got.Opaque || got.LogicalW != 30 || got.LogicalH != 20 {
82+ t.Fatalf("initial content: %+v", got)
83+ }
84+ return tc
85+}
86+
87+// next returns the next published content with a buffer.
88+func (tc transformClient) next(t *testing.T) ports.SurfaceContent {
89+ t.Helper()
90+ deadline := time.After(2 * time.Second)
91+ for {
92+ select {
93+ case got := <-tc.contents:
94+ if got.SHM != nil {
95+ return got
96+ }
97+ case <-deadline:
98+ t.Fatal("no content")
99+ }
100+ }
101+}
102+
103+func (tc transformClient) commit(t *testing.T) {
104+ t.Helper()
105+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestCommit)
106+ if err := tc.c.Roundtrip(); err != nil {
107+ t.Fatal(err)
108+ }
109+}
110+
111+// region creates a wl_region holding rects.
112+func (tc transformClient) region(t *testing.T, rects ...ports.Rect) uint32 {
113+ t.Helper()
114+ id := tc.c.AllocateID()
115+ registerProtocol(t, tc.c, id)
116+ requestProtocol(t, tc.c, tc.comp, wayland.CompositorRequestCreateRegion, id)
117+ for _, r := range rects {
118+ requestProtocol(t, tc.c, id, wayland.RegionRequestAdd, int32(r.X), int32(r.Y), int32(r.W), int32(r.H))
119+ }
120+ return id
121+}
122+
123+// A 90° buffer transform swaps the surface size and maps the viewport
124+// source back to buffer pixels; it applies on commit, like the scale.
125+func TestBufferTransformContent(t *testing.T) {
126+ tc := newTransformClient(t)
127+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetBufferTransform, int32(1))
128+ tc.commit(t)
129+ if got := tc.next(t); got.Transform != 1 || got.Width != 30 || got.Height != 20 || got.LogicalW != 20 || got.LogicalH != 30 {
130+ t.Fatalf("rotated content: %+v", got)
131+ }
132+ // Surface crop x 2..12, y 3..8 of the 20×30 rotated surface. Surface
133+ // (x, y) is buffer (y, 20-x): buffer x 3..8, y 8..18.
134+ requestProtocol(t, tc.c, tc.vp, viewporter.WpViewportRequestSetSource, int32(2*256), int32(3*256), int32(10*256), int32(5*256))
135+ tc.commit(t)
136+ if got := tc.next(t); got.LogicalW != 10 || got.LogicalH != 5 || got.Source != [4]float32{3, 8, 5, 10} {
137+ t.Fatalf("rotated crop: %+v", got)
138+ }
139+ // A crop that fits the unrotated buffer but not the rotated surface
140+ // (x up to 25 > 20) is out of buffer.
141+ requestProtocol(t, tc.c, tc.vp, viewporter.WpViewportRequestSetSource, int32(15*256), int32(0), int32(10*256), int32(5*256))
142+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestCommit)
143+ expectProtocolError(t, tc.c, tc.vp, uint32(viewporter.WpViewportErrorOutOfBuffer))
144+}
145+
146+func TestBufferTransformInvalid(t *testing.T) {
147+ tc := newTransformClient(t)
148+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetBufferTransform, int32(8))
149+ expectProtocolError(t, tc.c, tc.surf, uint32(wayland.SurfaceErrorInvalidTransform))
150+}
151+
152+// An opaque region covering the surface marks an alpha buffer opaque; a
153+// partial or NULL region does not. The region applies on commit.
154+func TestOpaqueRegionContent(t *testing.T) {
155+ tc := newTransformClient(t)
156+ half := tc.region(t, ports.Rect{W: 15, H: 20})
157+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, half)
158+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestDamage, int32(0), int32(0), int32(1), int32(1))
159+ tc.commit(t)
160+ prev := tc.next(t)
161+ if prev.Opaque {
162+ t.Fatalf("partial region made the surface opaque: %+v", prev)
163+ }
164+ // Two halves cover the whole surface, even through a larger rect.
165+ // Opacity changes blending everywhere: the commit damages it all.
166+ whole := tc.region(t, ports.Rect{W: 15, H: 20}, ports.Rect{X: 15, W: 100, H: 20})
167+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, whole)
168+ tc.commit(t)
169+ got := tc.next(t)
170+ if !got.Opaque {
171+ t.Fatalf("covering region: %+v", got)
172+ }
173+ if rects, ok := got.DamageSince(prev.Seq); ok {
174+ t.Fatalf("opacity change damaged only %v", rects)
175+ }
176+ // The surface grows past the region through the viewport: not opaque.
177+ requestProtocol(t, tc.c, tc.vp, viewporter.WpViewportRequestSetDestination, int32(200), int32(20))
178+ tc.commit(t)
179+ if got := tc.next(t); got.Opaque {
180+ t.Fatalf("region no longer covers: %+v", got)
181+ }
182+ requestProtocol(t, tc.c, tc.vp, viewporter.WpViewportRequestSetDestination, int32(-1), int32(-1))
183+ tc.commit(t)
184+ if got := tc.next(t); !got.Opaque {
185+ t.Fatalf("region covers again: %+v", got)
186+ }
187+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, uint32(0))
188+ tc.commit(t)
189+ if got := tc.next(t); got.Opaque {
190+ t.Fatalf("NULL region: %+v", got)
191+ }
192+}
193+
194+// Transform and opaque region requested after a queued commit belong to
195+// the next commit.
196+func TestCapturedTransformAndOpaqueKeepLaterRequests(t *testing.T) {
197+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1), constraints: map[*surface]*constraint{}}
198+ surf := &surface{server: srv}
199+ surf.pendingTransform = 1
200+ surf.pendingOpaque, surf.pendingOpaqueSet = []ports.Rect{{W: 4, H: 4}}, true
201+ surf.queueUpdate()
202+ surf.pendingTransform = 2
203+ surf.pendingOpaque, surf.pendingOpaqueSet = nil, true
204+ surf.queue[0].applyGraph()
205+ if surf.transform != 1 || len(surf.opaque) != 1 {
206+ t.Fatalf("captured state: transform %d opaque %v", surf.transform, surf.opaque)
207+ }
208+ if surf.pendingTransform != 2 || !surf.pendingOpaqueSet || surf.pendingOpaque != nil {
209+ t.Fatal("application consumed a later request")
210+ }
211+ surf.queueUpdate()
212+ if u := surf.queue[0]; u.transform != 2 || !u.opaqueSet || u.opaque != nil {
213+ t.Fatalf("next commit: transform %d opaque %v/%v", u.transform, u.opaqueSet, u.opaque)
214+ }
215+ surf.dropQueue()
216+}
217+
218+func TestCovers(t *testing.T) {
219+ r := ports.Rect{W: 10, H: 10}
220+ for _, tc := range []struct {
221+ name string
222+ rects []ports.Rect
223+ want bool
224+ }{
225+ {"empty", nil, false},
226+ {"exact", []ports.Rect{{W: 10, H: 10}}, true},
227+ {"larger", []ports.Rect{{X: -5, Y: -5, W: 30, H: 30}}, true},
228+ {"two halves", []ports.Rect{{W: 5, H: 10}, {X: 5, W: 5, H: 10}}, true},
229+ {"gap", []ports.Rect{{W: 4, H: 10}, {X: 5, W: 5, H: 10}}, false},
230+ {"hole", []ports.Rect{{W: 10, H: 4}, {Y: 6, W: 10, H: 4}, {Y: 4, W: 4, H: 2}, {X: 5, Y: 4, W: 5, H: 2}}, false},
231+ {"four quarters", []ports.Rect{{W: 5, H: 5}, {X: 5, W: 5, H: 5}, {Y: 5, W: 5, H: 5}, {X: 5, Y: 5, W: 5, H: 5}}, true},
232+ {"too many", make([]ports.Rect, maxCoverRects+1), false},
233+ } {
234+ if got := covers(tc.rects, r); got != tc.want {
235+ t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
236+ }
237+ }
238+}
@@ -0,0 +1,238 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/viewporter"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
11+ "github.com/bnema/wlturbo"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// transformClient maps a toplevel whose 30×20 argb8888 buffer is shown
16+// through a viewport, and returns what it needs to commit more state.
17+type transformClient struct {
18+ c *wlturbo.Display
19+ comp, surf, vp, bf uint32
20+ contents chan ports.SurfaceContent
21+}
22+
23+func newTransformClient(t *testing.T) transformClient {
24+ t.Helper()
25+ s, events, _, contents, dir := contentServer(t)
26+ c := protocolClient(t, s, dir)
27+ // set_buffer_transform needs wl_compositor v2.
28+ g, _ := c.Registry().FindGlobal("wl_compositor")
29+ comp, err := c.Registry().BindID(g.Name, g.Interface, 6)
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ wm := bindProtocol(t, c, "xdg_wm_base")
34+ shm := bindProtocol(t, c, "wl_shm")
35+ vpm := bindProtocol(t, c, "wp_viewporter")
36+ registerProtocol(t, c, shm)
37+ surf := c.AllocateID()
38+ registerProtocol(t, c, surf)
39+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
40+ vp := c.AllocateID()
41+ registerProtocol(t, c, vp)
42+ requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
43+ xdg := c.AllocateID()
44+ serials := make(chan uint32, 4)
45+ cp := &configureProxy{serial: serials}
46+ cp.SetID(xdg)
47+ c.Context().Register(cp)
48+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
49+ top := c.AllocateID()
50+ registerProtocol(t, c, top)
51+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
52+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
53+ if err := c.Roundtrip(); err != nil {
54+ t.Fatal(err)
55+ }
56+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials)
57+ fd, err := unix.MemfdCreate("transform", 0)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ t.Cleanup(func() { unix.Close(fd) })
62+ size := int32(30 * 20 * 4)
63+ if err := unix.Ftruncate(fd, int64(size)); err != nil {
64+ t.Fatal(err)
65+ }
66+ pool, bf := c.AllocateID(), c.AllocateID()
67+ registerProtocol(t, c, pool)
68+ registerProtocol(t, c, bf)
69+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
70+ t.Fatal(err)
71+ }
72+ // argb8888: not opaque by format.
73+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, bf, int32(0), int32(30), int32(20), int32(120), uint32(wayland.ShmFormatArgb8888))
74+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bf, int32(0), int32(0))
75+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
76+ if err := c.Roundtrip(); err != nil {
77+ t.Fatal(err)
78+ }
79+ mapped(t, events, 2*time.Second)
80+ tc := transformClient{c: c, comp: comp, surf: surf, vp: vp, bf: bf, contents: contents}
81+ if got := tc.next(t); got.Transform != 0 || got.Opaque || got.LogicalW != 30 || got.LogicalH != 20 {
82+ t.Fatalf("initial content: %+v", got)
83+ }
84+ return tc
85+}
86+
87+// next returns the next published content with a buffer.
88+func (tc transformClient) next(t *testing.T) ports.SurfaceContent {
89+ t.Helper()
90+ deadline := time.After(2 * time.Second)
91+ for {
92+ select {
93+ case got := <-tc.contents:
94+ if got.SHM != nil {
95+ return got
96+ }
97+ case <-deadline:
98+ t.Fatal("no content")
99+ }
100+ }
101+}
102+
103+func (tc transformClient) commit(t *testing.T) {
104+ t.Helper()
105+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestCommit)
106+ if err := tc.c.Roundtrip(); err != nil {
107+ t.Fatal(err)
108+ }
109+}
110+
111+// region creates a wl_region holding rects.
112+func (tc transformClient) region(t *testing.T, rects ...ports.Rect) uint32 {
113+ t.Helper()
114+ id := tc.c.AllocateID()
115+ registerProtocol(t, tc.c, id)
116+ requestProtocol(t, tc.c, tc.comp, wayland.CompositorRequestCreateRegion, id)
117+ for _, r := range rects {
118+ requestProtocol(t, tc.c, id, wayland.RegionRequestAdd, int32(r.X), int32(r.Y), int32(r.W), int32(r.H))
119+ }
120+ return id
121+}
122+
123+// A 90° buffer transform swaps the surface size and maps the viewport
124+// source back to buffer pixels; it applies on commit, like the scale.
125+func TestBufferTransformContent(t *testing.T) {
126+ tc := newTransformClient(t)
127+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetBufferTransform, int32(1))
128+ tc.commit(t)
129+ if got := tc.next(t); got.Transform != 1 || got.Width != 30 || got.Height != 20 || got.LogicalW != 20 || got.LogicalH != 30 {
130+ t.Fatalf("rotated content: %+v", got)
131+ }
132+ // Surface crop x 2..12, y 3..8 of the 20×30 rotated surface. Surface
133+ // (x, y) is buffer (y, 20-x): buffer x 3..8, y 8..18.
134+ requestProtocol(t, tc.c, tc.vp, viewporter.WpViewportRequestSetSource, int32(2*256), int32(3*256), int32(10*256), int32(5*256))
135+ tc.commit(t)
136+ if got := tc.next(t); got.LogicalW != 10 || got.LogicalH != 5 || got.Source != [4]float32{3, 8, 5, 10} {
137+ t.Fatalf("rotated crop: %+v", got)
138+ }
139+ // A crop that fits the unrotated buffer but not the rotated surface
140+ // (x up to 25 > 20) is out of buffer.
141+ requestProtocol(t, tc.c, tc.vp, viewporter.WpViewportRequestSetSource, int32(15*256), int32(0), int32(10*256), int32(5*256))
142+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestCommit)
143+ expectProtocolError(t, tc.c, tc.vp, uint32(viewporter.WpViewportErrorOutOfBuffer))
144+}
145+
146+func TestBufferTransformInvalid(t *testing.T) {
147+ tc := newTransformClient(t)
148+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetBufferTransform, int32(8))
149+ expectProtocolError(t, tc.c, tc.surf, uint32(wayland.SurfaceErrorInvalidTransform))
150+}
151+
152+// An opaque region covering the surface marks an alpha buffer opaque; a
153+// partial or NULL region does not. The region applies on commit.
154+func TestOpaqueRegionContent(t *testing.T) {
155+ tc := newTransformClient(t)
156+ half := tc.region(t, ports.Rect{W: 15, H: 20})
157+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, half)
158+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestDamage, int32(0), int32(0), int32(1), int32(1))
159+ tc.commit(t)
160+ prev := tc.next(t)
161+ if prev.Opaque {
162+ t.Fatalf("partial region made the surface opaque: %+v", prev)
163+ }
164+ // Two halves cover the whole surface, even through a larger rect.
165+ // Opacity changes blending everywhere: the commit damages it all.
166+ whole := tc.region(t, ports.Rect{W: 15, H: 20}, ports.Rect{X: 15, W: 100, H: 20})
167+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, whole)
168+ tc.commit(t)
169+ got := tc.next(t)
170+ if !got.Opaque {
171+ t.Fatalf("covering region: %+v", got)
172+ }
173+ if rects, ok := got.DamageSince(prev.Seq); ok {
174+ t.Fatalf("opacity change damaged only %v", rects)
175+ }
176+ // The surface grows past the region through the viewport: not opaque.
177+ requestProtocol(t, tc.c, tc.vp, viewporter.WpViewportRequestSetDestination, int32(200), int32(20))
178+ tc.commit(t)
179+ if got := tc.next(t); got.Opaque {
180+ t.Fatalf("region no longer covers: %+v", got)
181+ }
182+ requestProtocol(t, tc.c, tc.vp, viewporter.WpViewportRequestSetDestination, int32(-1), int32(-1))
183+ tc.commit(t)
184+ if got := tc.next(t); !got.Opaque {
185+ t.Fatalf("region covers again: %+v", got)
186+ }
187+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, uint32(0))
188+ tc.commit(t)
189+ if got := tc.next(t); got.Opaque {
190+ t.Fatalf("NULL region: %+v", got)
191+ }
192+}
193+
194+// Transform and opaque region requested after a queued commit belong to
195+// the next commit.
196+func TestCapturedTransformAndOpaqueKeepLaterRequests(t *testing.T) {
197+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1), constraints: map[*surface]*constraint{}}
198+ surf := &surface{server: srv}
199+ surf.pendingTransform = 1
200+ surf.pendingOpaque, surf.pendingOpaqueSet = []ports.Rect{{W: 4, H: 4}}, true
201+ surf.queueUpdate()
202+ surf.pendingTransform = 2
203+ surf.pendingOpaque, surf.pendingOpaqueSet = nil, true
204+ surf.queue[0].applyGraph()
205+ if surf.transform != 1 || len(surf.opaque) != 1 {
206+ t.Fatalf("captured state: transform %d opaque %v", surf.transform, surf.opaque)
207+ }
208+ if surf.pendingTransform != 2 || !surf.pendingOpaqueSet || surf.pendingOpaque != nil {
209+ t.Fatal("application consumed a later request")
210+ }
211+ surf.queueUpdate()
212+ if u := surf.queue[0]; u.transform != 2 || !u.opaqueSet || u.opaque != nil {
213+ t.Fatalf("next commit: transform %d opaque %v/%v", u.transform, u.opaqueSet, u.opaque)
214+ }
215+ surf.dropQueue()
216+}
217+
218+func TestCovers(t *testing.T) {
219+ r := ports.Rect{W: 10, H: 10}
220+ for _, tc := range []struct {
221+ name string
222+ rects []ports.Rect
223+ want bool
224+ }{
225+ {"empty", nil, false},
226+ {"exact", []ports.Rect{{W: 10, H: 10}}, true},
227+ {"larger", []ports.Rect{{X: -5, Y: -5, W: 30, H: 30}}, true},
228+ {"two halves", []ports.Rect{{W: 5, H: 10}, {X: 5, W: 5, H: 10}}, true},
229+ {"gap", []ports.Rect{{W: 4, H: 10}, {X: 5, W: 5, H: 10}}, false},
230+ {"hole", []ports.Rect{{W: 10, H: 4}, {Y: 6, W: 10, H: 4}, {Y: 4, W: 4, H: 2}, {X: 5, Y: 4, W: 5, H: 2}}, false},
231+ {"four quarters", []ports.Rect{{W: 5, H: 5}, {X: 5, W: 5, H: 5}, {Y: 5, W: 5, H: 5}, {X: 5, Y: 5, W: 5, H: 5}}, true},
232+ {"too many", make([]ports.Rect, maxCoverRects+1), false},
233+ } {
234+ if got := covers(tc.rects, r); got != tc.want {
235+ t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
236+ }
237+ }
238+}
M internal/app/app.go +4 -11
@@ -95,11 +95,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
9595 captured := make(chan ports.CaptureDone, 64)
9696 outputFormats := make(chan ports.OutputFormats, 8)
9797 outputHeads := make(chan ports.OutputHeads, 8)
98- inventory := make(chan ports.OutputHeads, 8)
9998 applyOutput := make(chan ports.OutputApply, 8)
10099 appliedOutput := make(chan ports.OutputApplied, 8)
101- backendConfig := make(chan ports.Config, 8)
102- backendApplied := make(chan error, 8)
103100 var scales chan ports.ScaleChanged
104101 if hw != nil {
105102 // Only real sessions save scales: headless runs never touch the config file.
@@ -172,11 +169,6 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
172169 defer workers.Done()
173170 relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config"))
174171 }()
175- workers.Add(1)
176- go func() {
177- defer workers.Done()
178- relayOutputSettings(ctx, newOutputOverrides(opts.Config, hw == nil), filtered, inventory, applyOutput, configChanges, backendConfig, backendApplied, appliedOutput, logging.For(ctx, "app"))
179- }()
180172 script := make(chan string)
181173 curs := newCursors()
182174 go func() {
@@ -238,7 +230,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
238230
239231 go func() {
240232 defer workers.Done()
241- outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: presented, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, report: inventory, configs: backendConfig, applied: backendApplied}
233+ outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: presented, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}
234+ apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
242235 newRenderer := func(w, h int) (ports.Renderer, error) {
243236 r, err := vulkan.New(w, h)
244237 if err != nil {
@@ -253,11 +246,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
253246 w.Sampled = dmabuf.Formats
254247 return w
255248 }
256- return hw.runOutputs(ctx, want, opts.Config, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
249+ return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
257250 })
258251 return
259252 }
260- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, outputIO, curs, newRenderer, logging.For(ctx, "render"))
253+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
261254 }()
262255
263256 if hw != nil {
@@ -95,11 +95,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
95 captured := make(chan ports.CaptureDone, 64)95 captured := make(chan ports.CaptureDone, 64)
96 outputFormats := make(chan ports.OutputFormats, 8)96 outputFormats := make(chan ports.OutputFormats, 8)
97 outputHeads := make(chan ports.OutputHeads, 8)97 outputHeads := make(chan ports.OutputHeads, 8)
98- inventory := make(chan ports.OutputHeads, 8)
99 applyOutput := make(chan ports.OutputApply, 8)98 applyOutput := make(chan ports.OutputApply, 8)
100 appliedOutput := make(chan ports.OutputApplied, 8)99 appliedOutput := make(chan ports.OutputApplied, 8)
101- backendConfig := make(chan ports.Config, 8)
102- backendApplied := make(chan error, 8)
103 var scales chan ports.ScaleChanged100 var scales chan ports.ScaleChanged
104 if hw != nil {101 if hw != nil {
105 // Only real sessions save scales: headless runs never touch the config file.102 // Only real sessions save scales: headless runs never touch the config file.
@@ -172,11 +169,6 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
172 defer workers.Done()169 defer workers.Done()
173 relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config"))170 relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config"))
174 }()171 }()
175- workers.Add(1)
176- go func() {
177- defer workers.Done()
178- relayOutputSettings(ctx, newOutputOverrides(opts.Config, hw == nil), filtered, inventory, applyOutput, configChanges, backendConfig, backendApplied, appliedOutput, logging.For(ctx, "app"))
179- }()
180 script := make(chan string)172 script := make(chan string)
181 curs := newCursors()173 curs := newCursors()
182 go func() {174 go func() {
@@ -238,7 +230,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
238 230
239 go func() {231 go func() {
240 defer workers.Done()232 defer workers.Done()
241- outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: presented, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, report: inventory, configs: backendConfig, applied: backendApplied}233+ outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: presented, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}
234+ apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
242 newRenderer := func(w, h int) (ports.Renderer, error) {235 newRenderer := func(w, h int) (ports.Renderer, error) {
243 r, err := vulkan.New(w, h)236 r, err := vulkan.New(w, h)
244 if err != nil {237 if err != nil {
@@ -253,11 +246,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
253 w.Sampled = dmabuf.Formats246 w.Sampled = dmabuf.Formats
254 return w247 return w
255 }248 }
256- return hw.runOutputs(ctx, want, opts.Config, outputIO, curs, newRenderer, logging.For(ctx, "drm"))249+ return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
257 })250 })
258 return251 return
259 }252 }
260- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, outputIO, curs, newRenderer, logging.For(ctx, "render"))253+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
261 }()254 }()
262 255
263 if hw != nil {256 if hw != nil {
R internal/app/pendingapply.go → internal/app/applyprogress.go +14 -8 renamed, 81% similar
@@ -5,8 +5,9 @@ import (
55 "fmt"
66 )
77
8-// applyProgress belongs exclusively to the output loop. An instance is identified
9-// by its Ready channel, which is distinct for each start of a connector.
8+// applyProgress tracks whether the outputs of a DRM configuration are ready.
9+// It belongs exclusively to the output loop. An instance is identified by its
10+// Ready channel, which is distinct for each start of a connector.
1011 type applyProgress struct {
1112 id uint64
1213 active bool
@@ -17,10 +18,10 @@ type applyProgress struct {
1718 err error
1819 }
1920
21+// applyDecision reports whether the configuration is finished and its result.
2022 type applyDecision struct {
21- reply bool
22- err error
23- rollback bool
23+ reply bool
24+ err error
2425 }
2526
2627 func (p *applyProgress) start(id uint64, required map[string]bool, stopping map[string]bool) applyDecision {
@@ -47,7 +48,12 @@ func (p *applyProgress) started(name string, instance <-chan error) {
4748 }
4849 }
4950
50-func (p *applyProgress) readyEvent(name string, instance <-chan error, err error) applyDecision {
51+// readyEvent records a readiness result. current is the connector's running
52+// instance: results of a stopped instance are ignored.
53+func (p *applyProgress) readyEvent(name string, instance, current <-chan error, err error) applyDecision {
54+ if instance != current {
55+ return applyDecision{}
56+ }
5157 if p.completed == nil {
5258 p.completed = map[<-chan error]bool{}
5359 }
@@ -130,7 +136,7 @@ func (p *applyProgress) timeout(id uint64) applyDecision {
130136 func (p *applyProgress) fail(err error) applyDecision {
131137 p.err = errors.Join(p.err, err)
132138 p.active = false
133- return applyDecision{reply: true, err: p.err, rollback: true}
139+ return applyDecision{reply: true, err: p.err}
134140 }
135141
136142 func (p *applyProgress) complete() applyDecision {
@@ -138,5 +144,5 @@ func (p *applyProgress) complete() applyDecision {
138144 return applyDecision{}
139145 }
140146 p.active = false
141- return applyDecision{reply: true, err: p.err, rollback: p.err != nil}
147+ return applyDecision{reply: true, err: p.err}
142148 }
@@ -5,8 +5,9 @@ import (
5 "fmt"5 "fmt"
6 )6 )
7 7
8-// applyProgress belongs exclusively to the output loop. An instance is identified8+// applyProgress tracks whether the outputs of a DRM configuration are ready.
9-// by its Ready channel, which is distinct for each start of a connector.9+// It belongs exclusively to the output loop. An instance is identified by its
10+// Ready channel, which is distinct for each start of a connector.
10 type applyProgress struct {11 type applyProgress struct {
11 id uint6412 id uint64
12 active bool13 active bool
@@ -17,10 +18,10 @@ type applyProgress struct {
17 err error18 err error
18 }19 }
19 20
21+// applyDecision reports whether the configuration is finished and its result.
20 type applyDecision struct {22 type applyDecision struct {
21- reply bool23+ reply bool
22- err error24+ err error
23- rollback bool
24 }25 }
25 26
26 func (p *applyProgress) start(id uint64, required map[string]bool, stopping map[string]bool) applyDecision {27 func (p *applyProgress) start(id uint64, required map[string]bool, stopping map[string]bool) applyDecision {
@@ -47,7 +48,12 @@ func (p *applyProgress) started(name string, instance <-chan error) {
47 }48 }
48 }49 }
49 50
50-func (p *applyProgress) readyEvent(name string, instance <-chan error, err error) applyDecision {51+// readyEvent records a readiness result. current is the connector's running
52+// instance: results of a stopped instance are ignored.
53+func (p *applyProgress) readyEvent(name string, instance, current <-chan error, err error) applyDecision {
54+ if instance != current {
55+ return applyDecision{}
56+ }
51 if p.completed == nil {57 if p.completed == nil {
52 p.completed = map[<-chan error]bool{}58 p.completed = map[<-chan error]bool{}
53 }59 }
@@ -130,7 +136,7 @@ func (p *applyProgress) timeout(id uint64) applyDecision {
130 func (p *applyProgress) fail(err error) applyDecision {136 func (p *applyProgress) fail(err error) applyDecision {
131 p.err = errors.Join(p.err, err)137 p.err = errors.Join(p.err, err)
132 p.active = false138 p.active = false
133- return applyDecision{reply: true, err: p.err, rollback: true}139+ return applyDecision{reply: true, err: p.err}
134 }140 }
135 141
136 func (p *applyProgress) complete() applyDecision {142 func (p *applyProgress) complete() applyDecision {
@@ -138,5 +144,5 @@ func (p *applyProgress) complete() applyDecision {
138 return applyDecision{}144 return applyDecision{}
139 }145 }
140 p.active = false146 p.active = false
141- return applyDecision{reply: true, err: p.err, rollback: p.err != nil}147+ return applyDecision{reply: true, err: p.err}
142 }148 }
R internal/app/pendingapply_test.go → internal/app/applyprogress_test.go +15 -6 renamed, 80% similar
@@ -15,7 +15,7 @@ func TestApplyProgress(t *testing.T) {
1515 {"success", func(p *applyProgress, a, _ <-chan error) applyDecision {
1616 p.start(1, map[string]bool{"A": true}, nil)
1717 p.scanned(map[string]<-chan error{"A": a})
18- return p.readyEvent("A", a, nil)
18+ return p.readyEvent("A", a, a, nil)
1919 }, false},
2020 {"scan error with previously ready output", func(p *applyProgress, a, _ <-chan error) applyDecision {
2121 p.completed = map[<-chan error]bool{a: true}
@@ -29,7 +29,7 @@ func TestApplyProgress(t *testing.T) {
2929 {"ready error rollback", func(p *applyProgress, a, _ <-chan error) applyDecision {
3030 p.start(1, map[string]bool{"A": true}, nil)
3131 p.scanned(map[string]<-chan error{"A": a})
32- return p.readyEvent("A", a, boom)
32+ return p.readyEvent("A", a, a, boom)
3333 }, true},
3434 {"timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {
3535 p.start(1, map[string]bool{"A": true}, nil)
@@ -48,15 +48,24 @@ func TestApplyProgress(t *testing.T) {
4848 {"hotplug during apply", func(p *applyProgress, a, b <-chan error) applyDecision {
4949 p.start(1, map[string]bool{"A": true, "B": true}, nil)
5050 p.scanned(map[string]<-chan error{"A": a, "B": b})
51- p.readyEvent("A", a, nil)
52- return p.readyEvent("B", b, nil)
51+ p.readyEvent("A", a, a, nil)
52+ return p.readyEvent("B", b, b, nil)
5353 }, false},
5454 {"stale timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {
5555 p.start(1, map[string]bool{"A": true}, nil)
5656 p.start(2, map[string]bool{"A": true}, nil)
5757 p.scanned(map[string]<-chan error{"A": a})
5858 p.timeout(1)
59- return p.readyEvent("A", a, nil)
59+ return p.readyEvent("A", a, a, nil)
60+ }, false},
61+ {"stopped instance error ignored", func(p *applyProgress, a, b <-chan error) applyDecision {
62+ p.start(1, map[string]bool{"A": true}, map[string]bool{"A": true})
63+ p.stopped("A", a, true, nil)
64+ p.scanned(map[string]<-chan error{"A": b})
65+ if d := p.readyEvent("A", a, b, boom); d.reply {
66+ t.Fatalf("stopped instance failed the apply: %+v", d)
67+ }
68+ return p.readyEvent("A", b, b, nil)
6069 }, false},
6170 {"disabled head stop", func(p *applyProgress, a, _ <-chan error) applyDecision {
6271 p.start(1, nil, map[string]bool{"A": false})
@@ -73,7 +82,7 @@ func TestApplyProgress(t *testing.T) {
7382 }
7483 return
7584 }
76- if !d.reply || (d.err != nil) != tc.failure || d.rollback != tc.failure {
85+ if !d.reply || (d.err != nil) != tc.failure {
7786 t.Fatalf("decision: %+v", d)
7887 }
7988 if tc.name == "scan error with previously ready output" && !errors.Is(d.err, boom) {
@@ -15,7 +15,7 @@ func TestApplyProgress(t *testing.T) {
15 {"success", func(p *applyProgress, a, _ <-chan error) applyDecision {15 {"success", func(p *applyProgress, a, _ <-chan error) applyDecision {
16 p.start(1, map[string]bool{"A": true}, nil)16 p.start(1, map[string]bool{"A": true}, nil)
17 p.scanned(map[string]<-chan error{"A": a})17 p.scanned(map[string]<-chan error{"A": a})
18- return p.readyEvent("A", a, nil)18+ return p.readyEvent("A", a, a, nil)
19 }, false},19 }, false},
20 {"scan error with previously ready output", func(p *applyProgress, a, _ <-chan error) applyDecision {20 {"scan error with previously ready output", func(p *applyProgress, a, _ <-chan error) applyDecision {
21 p.completed = map[<-chan error]bool{a: true}21 p.completed = map[<-chan error]bool{a: true}
@@ -29,7 +29,7 @@ func TestApplyProgress(t *testing.T) {
29 {"ready error rollback", func(p *applyProgress, a, _ <-chan error) applyDecision {29 {"ready error rollback", func(p *applyProgress, a, _ <-chan error) applyDecision {
30 p.start(1, map[string]bool{"A": true}, nil)30 p.start(1, map[string]bool{"A": true}, nil)
31 p.scanned(map[string]<-chan error{"A": a})31 p.scanned(map[string]<-chan error{"A": a})
32- return p.readyEvent("A", a, boom)32+ return p.readyEvent("A", a, a, boom)
33 }, true},33 }, true},
34 {"timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {34 {"timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {
35 p.start(1, map[string]bool{"A": true}, nil)35 p.start(1, map[string]bool{"A": true}, nil)
@@ -48,15 +48,24 @@ func TestApplyProgress(t *testing.T) {
48 {"hotplug during apply", func(p *applyProgress, a, b <-chan error) applyDecision {48 {"hotplug during apply", func(p *applyProgress, a, b <-chan error) applyDecision {
49 p.start(1, map[string]bool{"A": true, "B": true}, nil)49 p.start(1, map[string]bool{"A": true, "B": true}, nil)
50 p.scanned(map[string]<-chan error{"A": a, "B": b})50 p.scanned(map[string]<-chan error{"A": a, "B": b})
51- p.readyEvent("A", a, nil)51+ p.readyEvent("A", a, a, nil)
52- return p.readyEvent("B", b, nil)52+ return p.readyEvent("B", b, b, nil)
53 }, false},53 }, false},
54 {"stale timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {54 {"stale timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {
55 p.start(1, map[string]bool{"A": true}, nil)55 p.start(1, map[string]bool{"A": true}, nil)
56 p.start(2, map[string]bool{"A": true}, nil)56 p.start(2, map[string]bool{"A": true}, nil)
57 p.scanned(map[string]<-chan error{"A": a})57 p.scanned(map[string]<-chan error{"A": a})
58 p.timeout(1)58 p.timeout(1)
59- return p.readyEvent("A", a, nil)59+ return p.readyEvent("A", a, a, nil)
60+ }, false},
61+ {"stopped instance error ignored", func(p *applyProgress, a, b <-chan error) applyDecision {
62+ p.start(1, map[string]bool{"A": true}, map[string]bool{"A": true})
63+ p.stopped("A", a, true, nil)
64+ p.scanned(map[string]<-chan error{"A": b})
65+ if d := p.readyEvent("A", a, b, boom); d.reply {
66+ t.Fatalf("stopped instance failed the apply: %+v", d)
67+ }
68+ return p.readyEvent("A", b, b, nil)
60 }, false},69 }, false},
61 {"disabled head stop", func(p *applyProgress, a, _ <-chan error) applyDecision {70 {"disabled head stop", func(p *applyProgress, a, _ <-chan error) applyDecision {
62 p.start(1, nil, map[string]bool{"A": false})71 p.start(1, nil, map[string]bool{"A": false})
@@ -73,7 +82,7 @@ func TestApplyProgress(t *testing.T) {
73 }82 }
74 return83 return
75 }84 }
76- if !d.reply || (d.err != nil) != tc.failure || d.rollback != tc.failure {85+ if !d.reply || (d.err != nil) != tc.failure {
77 t.Fatalf("decision: %+v", d)86 t.Fatalf("decision: %+v", d)
78 }87 }
79 if tc.name == "scan error with previously ready output" && !errors.Is(d.err, boom) {88 if tc.name == "scan error with previously ready output" && !errors.Is(d.err, boom) {
M internal/app/drm.go +66 -45
@@ -133,9 +133,11 @@ func (b *drmBackend) close() {
133133
134134 // runOutputs drives every output of every card: one goroutine per output,
135135 // one flip reader per card, and a udev watcher that rescans connectors on
136-// hotplug. Core learns about outputs through events. It returns when ctx
137-// ends, after every output is closed.
138-func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
136+// hotplug. Core learns about outputs through events. The loop also owns
137+// output configuration: reloads and protocol requests go through apply, and
138+// each configuration is finished when its outputs are ready, fail or time
139+// out. It returns when ctx ends, after every output is closed.
140+func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
139141 ctx, cancel := context.WithCancel(ctx)
140142 defer cancel()
141143 var readers sync.WaitGroup
@@ -189,10 +191,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
189191 timer.Stop()
190192 timer = nil
191193 }
192- select {
193- case ch.applied <- d.err:
194- case <-ctx.Done():
195- }
194+ apply.finished(d.err)
196195 }
197196 publishInventory := func() {
198197 var inventory ports.OutputHeads
@@ -205,7 +204,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
205204 inventory.Heads = append(inventory.Heads, lastHeads[card]...)
206205 }
207206 currentHeads = inventory
208- ch.sendInventory(ctx, inventory)
207+ apply.heads(inventory)
209208 }
210209 scan := func() {
211210 var scanErrors []error
@@ -286,7 +285,55 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
286285 if len(set.outs) == 0 {
287286 return errors.Join(errors.New("no connected display"), set.wait())
288287 }
288+ // configure starts cfg; complete reports its result to apply. apply
289+ // hands out one configuration at a time, and a rollback or reload starts
290+ // on a later iteration, never inside scan or another configure.
291+ configure := func(cfg ports.Config) {
292+ currentConfig = cfg
293+ op++
294+ if timer != nil {
295+ timer.Stop()
296+ timer = nil
297+ }
298+ required := map[string]bool{}
299+ // Desired enabled heads, not the inventory's current mode, require a first modeset.
300+ w := want(currentConfig)
301+ for _, h := range currentHeads.Heads {
302+ if !w.Disabled[h.Info.Name] {
303+ required[h.Info.Name] = true
304+ }
305+ }
306+ for name := range set.outs {
307+ if !w.Disabled[name] {
308+ required[name] = true
309+ }
310+ }
311+ progress.start(op, required, stopping)
312+ scan()
313+ running := map[string]<-chan error{}
314+ for name := range set.outs {
315+ running[name] = readySources[name]
316+ }
317+ d := progress.scanned(running)
318+ if progress.active {
319+ id := op
320+ timer = time.AfterFunc(5*time.Second, func() {
321+ select {
322+ case deadline <- id:
323+ case <-ctx.Done():
324+ }
325+ })
326+ }
327+ complete(d)
328+ }
289329 for {
330+ if cfg, ok := apply.next(); ok {
331+ configure(cfg)
332+ continue
333+ }
334+ configs, configNext := apply.configOut(ch.configured), apply.config
335+ replies, replyNext := apply.replyOut(ch.replies)
336+ heads, headsNext := apply.headsOut(ch.heads)
290337 select {
291338 case <-ctx.Done():
292339 return set.wait()
@@ -303,44 +350,18 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
303350 }
304351 complete(progress.scanned(running))
305352 }
306- case currentConfig = <-ch.configs:
307- op++
308- if timer != nil {
309- timer.Stop()
310- timer = nil
311- }
312- required := map[string]bool{}
313- // Desired enabled heads, not the inventory's current mode, require a first modeset.
314- w := want(currentConfig)
315- for _, h := range currentHeads.Heads {
316- if !w.Disabled[h.Info.Name] {
317- required[h.Info.Name] = true
318- }
319- }
320- for name := range set.outs {
321- if !w.Disabled[name] {
322- required[name] = true
323- }
324- }
325- progress.start(op, required, stopping)
326- scan()
327- running := map[string]<-chan error{}
328- for name := range set.outs {
329- running[name] = readySources[name]
330- }
331- d := progress.scanned(running)
332- if progress.active {
333- id := op
334- timer = time.AfterFunc(5*time.Second, func() {
335- select {
336- case deadline <- id:
337- case <-ctx.Done():
338- }
339- })
340- }
341- complete(d)
353+ case ev := <-ch.reloads:
354+ apply.reload(ev.Config)
355+ case req := <-ch.requests:
356+ apply.request(req)
357+ case configs <- configNext:
358+ apply.configSent()
359+ case replies <- replyNext:
360+ apply.replySent()
361+ case heads <- headsNext:
362+ apply.headsSent()
342363 case result := <-ready:
343- complete(progress.readyEvent(result.name, result.source, result.err))
364+ complete(progress.readyEvent(result.name, result.source, readySources[result.name], result.err))
344365 case id := <-deadline:
345366 complete(progress.timeout(id))
346367 case s := <-ch.scenes:
@@ -133,9 +133,11 @@ func (b *drmBackend) close() {
133 133
134 // runOutputs drives every output of every card: one goroutine per output,134 // runOutputs drives every output of every card: one goroutine per output,
135 // one flip reader per card, and a udev watcher that rescans connectors on135 // one flip reader per card, and a udev watcher that rescans connectors on
136-// hotplug. Core learns about outputs through events. It returns when ctx136+// hotplug. Core learns about outputs through events. The loop also owns
137-// ends, after every output is closed.137+// output configuration: reloads and protocol requests go through apply, and
138-func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {138+// each configuration is finished when its outputs are ready, fail or time
139+// out. It returns when ctx ends, after every output is closed.
140+func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
139 ctx, cancel := context.WithCancel(ctx)141 ctx, cancel := context.WithCancel(ctx)
140 defer cancel()142 defer cancel()
141 var readers sync.WaitGroup143 var readers sync.WaitGroup
@@ -189,10 +191,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
189 timer.Stop()191 timer.Stop()
190 timer = nil192 timer = nil
191 }193 }
192- select {194+ apply.finished(d.err)
193- case ch.applied <- d.err:
194- case <-ctx.Done():
195- }
196 }195 }
197 publishInventory := func() {196 publishInventory := func() {
198 var inventory ports.OutputHeads197 var inventory ports.OutputHeads
@@ -205,7 +204,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
205 inventory.Heads = append(inventory.Heads, lastHeads[card]...)204 inventory.Heads = append(inventory.Heads, lastHeads[card]...)
206 }205 }
207 currentHeads = inventory206 currentHeads = inventory
208- ch.sendInventory(ctx, inventory)207+ apply.heads(inventory)
209 }208 }
210 scan := func() {209 scan := func() {
211 var scanErrors []error210 var scanErrors []error
@@ -286,7 +285,55 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
286 if len(set.outs) == 0 {285 if len(set.outs) == 0 {
287 return errors.Join(errors.New("no connected display"), set.wait())286 return errors.Join(errors.New("no connected display"), set.wait())
288 }287 }
288+ // configure starts cfg; complete reports its result to apply. apply
289+ // hands out one configuration at a time, and a rollback or reload starts
290+ // on a later iteration, never inside scan or another configure.
291+ configure := func(cfg ports.Config) {
292+ currentConfig = cfg
293+ op++
294+ if timer != nil {
295+ timer.Stop()
296+ timer = nil
297+ }
298+ required := map[string]bool{}
299+ // Desired enabled heads, not the inventory's current mode, require a first modeset.
300+ w := want(currentConfig)
301+ for _, h := range currentHeads.Heads {
302+ if !w.Disabled[h.Info.Name] {
303+ required[h.Info.Name] = true
304+ }
305+ }
306+ for name := range set.outs {
307+ if !w.Disabled[name] {
308+ required[name] = true
309+ }
310+ }
311+ progress.start(op, required, stopping)
312+ scan()
313+ running := map[string]<-chan error{}
314+ for name := range set.outs {
315+ running[name] = readySources[name]
316+ }
317+ d := progress.scanned(running)
318+ if progress.active {
319+ id := op
320+ timer = time.AfterFunc(5*time.Second, func() {
321+ select {
322+ case deadline <- id:
323+ case <-ctx.Done():
324+ }
325+ })
326+ }
327+ complete(d)
328+ }
289 for {329 for {
330+ if cfg, ok := apply.next(); ok {
331+ configure(cfg)
332+ continue
333+ }
334+ configs, configNext := apply.configOut(ch.configured), apply.config
335+ replies, replyNext := apply.replyOut(ch.replies)
336+ heads, headsNext := apply.headsOut(ch.heads)
290 select {337 select {
291 case <-ctx.Done():338 case <-ctx.Done():
292 return set.wait()339 return set.wait()
@@ -303,44 +350,18 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
303 }350 }
304 complete(progress.scanned(running))351 complete(progress.scanned(running))
305 }352 }
306- case currentConfig = <-ch.configs:353+ case ev := <-ch.reloads:
307- op++354+ apply.reload(ev.Config)
308- if timer != nil {355+ case req := <-ch.requests:
309- timer.Stop()356+ apply.request(req)
310- timer = nil357+ case configs <- configNext:
311- }358+ apply.configSent()
312- required := map[string]bool{}359+ case replies <- replyNext:
313- // Desired enabled heads, not the inventory's current mode, require a first modeset.360+ apply.replySent()
314- w := want(currentConfig)361+ case heads <- headsNext:
315- for _, h := range currentHeads.Heads {362+ apply.headsSent()
316- if !w.Disabled[h.Info.Name] {
317- required[h.Info.Name] = true
318- }
319- }
320- for name := range set.outs {
321- if !w.Disabled[name] {
322- required[name] = true
323- }
324- }
325- progress.start(op, required, stopping)
326- scan()
327- running := map[string]<-chan error{}
328- for name := range set.outs {
329- running[name] = readySources[name]
330- }
331- d := progress.scanned(running)
332- if progress.active {
333- id := op
334- timer = time.AfterFunc(5*time.Second, func() {
335- select {
336- case deadline <- id:
337- case <-ctx.Done():
338- }
339- })
340- }
341- complete(d)
342 case result := <-ready:363 case result := <-ready:
343- complete(progress.readyEvent(result.name, result.source, result.err))364+ complete(progress.readyEvent(result.name, result.source, readySources[result.name], result.err))
344 case id := <-deadline:365 case id := <-deadline:
345 complete(progress.timeout(id))366 complete(progress.timeout(id))
346 case s := <-ch.scenes:367 case s := <-ch.scenes:
M internal/app/headless.go +17 -5
@@ -12,8 +12,9 @@ import (
1212 )
1313
1414 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15-// With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
15+// With several outputs, screenshots go to a subdirectory per output. Modes
16+// are fixed, so apply answers every configuration without backend work.
17+func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
1718 set := newOutputSet(ctx, ch.captured)
1819 inventory := ports.OutputHeads{}
1920 for i, size := range sizes {
@@ -40,15 +41,26 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ch
4041 return set.wait()
4142 }
4243 }
43- if !ch.sendInventory(ctx, inventory) {
44- return set.wait()
45- }
44+ apply.heads(inventory)
4645 // The pointer starts centred on the first output, like libinput's.
4746 curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)
4847 for {
48+ configs, configNext := apply.configOut(ch.configured), apply.config
49+ replies, replyNext := apply.replyOut(ch.replies)
50+ heads, headsNext := apply.headsOut(ch.heads)
4951 select {
5052 case <-ctx.Done():
5153 return set.wait()
54+ case ev := <-ch.reloads:
55+ apply.reload(ev.Config)
56+ case req := <-ch.requests:
57+ apply.request(req)
58+ case configs <- configNext:
59+ apply.configSent()
60+ case replies <- replyNext:
61+ apply.replySent()
62+ case heads <- headsNext:
63+ apply.headsSent()
5264 case s := <-ch.scenes:
5365 set.scenes(s)
5466 case c := <-ch.contents:
@@ -12,8 +12,9 @@ import (
12 )12 )
13 13
14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15-// With several outputs, screenshots go to a subdirectory per output.15+// With several outputs, screenshots go to a subdirectory per output. Modes
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {16+// are fixed, so apply answers every configuration without backend work.
17+func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17 set := newOutputSet(ctx, ch.captured)18 set := newOutputSet(ctx, ch.captured)
18 inventory := ports.OutputHeads{}19 inventory := ports.OutputHeads{}
19 for i, size := range sizes {20 for i, size := range sizes {
@@ -40,15 +41,26 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ch
40 return set.wait()41 return set.wait()
41 }42 }
42 }43 }
43- if !ch.sendInventory(ctx, inventory) {44+ apply.heads(inventory)
44- return set.wait()
45- }
46 // The pointer starts centred on the first output, like libinput's.45 // The pointer starts centred on the first output, like libinput's.
47 curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)46 curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)
48 for {47 for {
48+ configs, configNext := apply.configOut(ch.configured), apply.config
49+ replies, replyNext := apply.replyOut(ch.replies)
50+ heads, headsNext := apply.headsOut(ch.heads)
49 select {51 select {
50 case <-ctx.Done():52 case <-ctx.Done():
51 return set.wait()53 return set.wait()
54+ case ev := <-ch.reloads:
55+ apply.reload(ev.Config)
56+ case req := <-ch.requests:
57+ apply.request(req)
58+ case configs <- configNext:
59+ apply.configSent()
60+ case replies <- replyNext:
61+ apply.replySent()
62+ case heads <- headsNext:
63+ apply.headsSent()
52 case s := <-ch.scenes:64 case s := <-ch.scenes:
53 set.scenes(s)65 set.scenes(s)
54 case c := <-ch.contents:66 case c := <-ch.contents:
A internal/app/outputapply_e2e_test.go +186 -0
@@ -0,0 +1,186 @@
1+package app
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/config"
11+ "github.com/bnema/neferwl/internal/ports"
12+ wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
13+ "github.com/bnema/wlturbo"
14+)
15+
16+// managerEvents records zwlr_output_manager_v1 head and done events.
17+type managerEvents struct {
18+ wlturbo.BaseProxy
19+ c *wlturbo.Display
20+ heads []uint32
21+ done uint32
22+}
23+
24+func (p *managerEvents) Dispatch(e *wlturbo.Event) {
25+ switch uint32(e.Opcode) {
26+ case wlr.ZwlrOutputManagerV1EventHead:
27+ h := &ignoredEvents{c: p.c}
28+ h.SetID(e.Uint32())
29+ p.c.Context().Register(h)
30+ p.heads = append(p.heads, h.ID())
31+ case wlr.ZwlrOutputManagerV1EventDone:
32+ p.done = e.Uint32()
33+ }
34+}
35+
36+// ignoredEvents drops the events of head, mode and configuration head objects.
37+type ignoredEvents struct {
38+ wlturbo.BaseProxy
39+ c *wlturbo.Display
40+}
41+
42+func (p *ignoredEvents) Dispatch(e *wlturbo.Event) {
43+ if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) {
44+ m := &ignoredEvents{c: p.c}
45+ m.SetID(e.Uint32())
46+ p.c.Context().Register(m)
47+ }
48+}
49+
50+// configurationEvents records the result of a zwlr_output_configuration_v1.
51+type configurationEvents struct {
52+ wlturbo.BaseProxy
53+ result int // 0 pending, then 1 + the event opcode
54+}
55+
56+func (p *configurationEvents) Dispatch(e *wlturbo.Event) { p.result = int(e.Opcode) + 1 }
57+
58+// A wlr-output-management client goes through Wayland, the headless owner
59+// loop and core: an accepted scale reaches the scene, a disable is refused.
60+func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
61+ runtime := t.TempDir()
62+ t.Setenv("XDG_RUNTIME_DIR", runtime)
63+ scenes := make(chan []ports.Scene, 64)
64+ ctx, cancel := context.WithCancel(context.Background())
65+ done := make(chan error, 1)
66+ go func() {
67+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Sizes: [][2]int{{640, 480}}, testScenes: scenes})
68+ }()
69+ // A stuck loop never answers the client: the watchdog stops the app,
70+ // which closes the socket, so a blocked Roundtrip fails instead of hanging.
71+ watchdog := time.AfterFunc(10*time.Second, cancel)
72+ exited := false
73+ t.Cleanup(func() {
74+ watchdog.Stop()
75+ cancel()
76+ if exited {
77+ return
78+ }
79+ select {
80+ case err := <-done:
81+ if err != nil {
82+ t.Errorf("Run: %v", err)
83+ }
84+ case <-time.After(3 * time.Second):
85+ t.Error("Run did not stop")
86+ }
87+ })
88+ // The first scene means the Wayland socket exists.
89+ scene := func(want func(ports.Scene) bool) {
90+ t.Helper()
91+ deadline := time.After(5 * time.Second)
92+ for {
93+ select {
94+ case set := <-scenes:
95+ if len(set) == 1 && want(set[0]) {
96+ return
97+ }
98+ case err := <-done:
99+ exited = true
100+ if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
101+ t.Skipf("Vulkan unavailable: %v", err)
102+ }
103+ t.Fatalf("app exited early: %v", err)
104+ case <-deadline:
105+ t.Fatal("scene missing")
106+ }
107+ }
108+ }
109+ scene(func(s ports.Scene) bool { return s.Scale == 1 })
110+ sockets, _ := filepath.Glob(filepath.Join(runtime, "wayland-*[0-9]"))
111+ if len(sockets) != 1 {
112+ t.Fatalf("sockets: %v", sockets)
113+ }
114+ c, err := wlturbo.Connect(sockets[0])
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ t.Cleanup(func() { _ = c.Close() })
119+ if err := c.Roundtrip(); err != nil {
120+ t.Fatal(err)
121+ }
122+ g, ok := c.Registry().FindGlobal("zwlr_output_manager_v1")
123+ if !ok {
124+ t.Fatal("no zwlr_output_manager_v1")
125+ }
126+ id, err := c.Registry().BindID(g.Name, g.Interface, 4)
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ manager := &managerEvents{c: c}
131+ manager.SetID(id)
132+ c.Context().Register(manager)
133+ roundtrip := func(until func() bool) {
134+ t.Helper()
135+ // The owner loops answer asynchronously: roundtrip until they do,
136+ // or until the watchdog stops the app.
137+ for !until() {
138+ if err := c.Roundtrip(); err != nil {
139+ t.Fatalf("no answer: %v", err)
140+ }
141+ }
142+ }
143+ // The inventory reaches Wayland through the owner loop's outbox.
144+ roundtrip(func() bool { return manager.done != 0 && len(manager.heads) == 1 })
145+ head, serial := manager.heads[0], manager.done
146+ configure := func(set func(cfg uint32)) int {
147+ t.Helper()
148+ cfg := &configurationEvents{}
149+ cfg.SetID(c.AllocateID())
150+ c.Context().Register(cfg)
151+ if err := c.SendRequest(id, uint16(wlr.ZwlrOutputManagerV1RequestCreateConfiguration), cfg.ID(), manager.done); err != nil {
152+ t.Fatal(err)
153+ }
154+ set(cfg.ID())
155+ if err := c.SendRequest(cfg.ID(), uint16(wlr.ZwlrOutputConfigurationV1RequestApply)); err != nil {
156+ t.Fatal(err)
157+ }
158+ roundtrip(func() bool { return cfg.result != 0 })
159+ return cfg.result - 1
160+ }
161+ got := configure(func(cfg uint32) {
162+ ch := &ignoredEvents{c: c}
163+ ch.SetID(c.AllocateID())
164+ c.Context().Register(ch)
165+ if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestEnableHead), ch.ID(), head); err != nil {
166+ t.Fatal(err)
167+ }
168+ if err := c.SendRequest(ch.ID(), uint16(wlr.ZwlrOutputConfigurationHeadV1RequestSetScale), int32(512)); err != nil {
169+ t.Fatal(err)
170+ }
171+ })
172+ if got != int(wlr.ZwlrOutputConfigurationV1EventSucceeded) {
173+ t.Fatalf("scale 2: event %d", got)
174+ }
175+ scene(func(s ports.Scene) bool { return s.Scale == 2 })
176+ // A headless output cannot be disabled. The new scale gives a new serial.
177+ roundtrip(func() bool { return manager.done != serial })
178+ got = configure(func(cfg uint32) {
179+ if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestDisableHead), head); err != nil {
180+ t.Fatal(err)
181+ }
182+ })
183+ if got != int(wlr.ZwlrOutputConfigurationV1EventFailed) {
184+ t.Fatalf("disable: event %d", got)
185+ }
186+}
@@ -0,0 +1,186 @@
1+package app
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/config"
11+ "github.com/bnema/neferwl/internal/ports"
12+ wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
13+ "github.com/bnema/wlturbo"
14+)
15+
16+// managerEvents records zwlr_output_manager_v1 head and done events.
17+type managerEvents struct {
18+ wlturbo.BaseProxy
19+ c *wlturbo.Display
20+ heads []uint32
21+ done uint32
22+}
23+
24+func (p *managerEvents) Dispatch(e *wlturbo.Event) {
25+ switch uint32(e.Opcode) {
26+ case wlr.ZwlrOutputManagerV1EventHead:
27+ h := &ignoredEvents{c: p.c}
28+ h.SetID(e.Uint32())
29+ p.c.Context().Register(h)
30+ p.heads = append(p.heads, h.ID())
31+ case wlr.ZwlrOutputManagerV1EventDone:
32+ p.done = e.Uint32()
33+ }
34+}
35+
36+// ignoredEvents drops the events of head, mode and configuration head objects.
37+type ignoredEvents struct {
38+ wlturbo.BaseProxy
39+ c *wlturbo.Display
40+}
41+
42+func (p *ignoredEvents) Dispatch(e *wlturbo.Event) {
43+ if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) {
44+ m := &ignoredEvents{c: p.c}
45+ m.SetID(e.Uint32())
46+ p.c.Context().Register(m)
47+ }
48+}
49+
50+// configurationEvents records the result of a zwlr_output_configuration_v1.
51+type configurationEvents struct {
52+ wlturbo.BaseProxy
53+ result int // 0 pending, then 1 + the event opcode
54+}
55+
56+func (p *configurationEvents) Dispatch(e *wlturbo.Event) { p.result = int(e.Opcode) + 1 }
57+
58+// A wlr-output-management client goes through Wayland, the headless owner
59+// loop and core: an accepted scale reaches the scene, a disable is refused.
60+func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
61+ runtime := t.TempDir()
62+ t.Setenv("XDG_RUNTIME_DIR", runtime)
63+ scenes := make(chan []ports.Scene, 64)
64+ ctx, cancel := context.WithCancel(context.Background())
65+ done := make(chan error, 1)
66+ go func() {
67+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Sizes: [][2]int{{640, 480}}, testScenes: scenes})
68+ }()
69+ // A stuck loop never answers the client: the watchdog stops the app,
70+ // which closes the socket, so a blocked Roundtrip fails instead of hanging.
71+ watchdog := time.AfterFunc(10*time.Second, cancel)
72+ exited := false
73+ t.Cleanup(func() {
74+ watchdog.Stop()
75+ cancel()
76+ if exited {
77+ return
78+ }
79+ select {
80+ case err := <-done:
81+ if err != nil {
82+ t.Errorf("Run: %v", err)
83+ }
84+ case <-time.After(3 * time.Second):
85+ t.Error("Run did not stop")
86+ }
87+ })
88+ // The first scene means the Wayland socket exists.
89+ scene := func(want func(ports.Scene) bool) {
90+ t.Helper()
91+ deadline := time.After(5 * time.Second)
92+ for {
93+ select {
94+ case set := <-scenes:
95+ if len(set) == 1 && want(set[0]) {
96+ return
97+ }
98+ case err := <-done:
99+ exited = true
100+ if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
101+ t.Skipf("Vulkan unavailable: %v", err)
102+ }
103+ t.Fatalf("app exited early: %v", err)
104+ case <-deadline:
105+ t.Fatal("scene missing")
106+ }
107+ }
108+ }
109+ scene(func(s ports.Scene) bool { return s.Scale == 1 })
110+ sockets, _ := filepath.Glob(filepath.Join(runtime, "wayland-*[0-9]"))
111+ if len(sockets) != 1 {
112+ t.Fatalf("sockets: %v", sockets)
113+ }
114+ c, err := wlturbo.Connect(sockets[0])
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ t.Cleanup(func() { _ = c.Close() })
119+ if err := c.Roundtrip(); err != nil {
120+ t.Fatal(err)
121+ }
122+ g, ok := c.Registry().FindGlobal("zwlr_output_manager_v1")
123+ if !ok {
124+ t.Fatal("no zwlr_output_manager_v1")
125+ }
126+ id, err := c.Registry().BindID(g.Name, g.Interface, 4)
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ manager := &managerEvents{c: c}
131+ manager.SetID(id)
132+ c.Context().Register(manager)
133+ roundtrip := func(until func() bool) {
134+ t.Helper()
135+ // The owner loops answer asynchronously: roundtrip until they do,
136+ // or until the watchdog stops the app.
137+ for !until() {
138+ if err := c.Roundtrip(); err != nil {
139+ t.Fatalf("no answer: %v", err)
140+ }
141+ }
142+ }
143+ // The inventory reaches Wayland through the owner loop's outbox.
144+ roundtrip(func() bool { return manager.done != 0 && len(manager.heads) == 1 })
145+ head, serial := manager.heads[0], manager.done
146+ configure := func(set func(cfg uint32)) int {
147+ t.Helper()
148+ cfg := &configurationEvents{}
149+ cfg.SetID(c.AllocateID())
150+ c.Context().Register(cfg)
151+ if err := c.SendRequest(id, uint16(wlr.ZwlrOutputManagerV1RequestCreateConfiguration), cfg.ID(), manager.done); err != nil {
152+ t.Fatal(err)
153+ }
154+ set(cfg.ID())
155+ if err := c.SendRequest(cfg.ID(), uint16(wlr.ZwlrOutputConfigurationV1RequestApply)); err != nil {
156+ t.Fatal(err)
157+ }
158+ roundtrip(func() bool { return cfg.result != 0 })
159+ return cfg.result - 1
160+ }
161+ got := configure(func(cfg uint32) {
162+ ch := &ignoredEvents{c: c}
163+ ch.SetID(c.AllocateID())
164+ c.Context().Register(ch)
165+ if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestEnableHead), ch.ID(), head); err != nil {
166+ t.Fatal(err)
167+ }
168+ if err := c.SendRequest(ch.ID(), uint16(wlr.ZwlrOutputConfigurationHeadV1RequestSetScale), int32(512)); err != nil {
169+ t.Fatal(err)
170+ }
171+ })
172+ if got != int(wlr.ZwlrOutputConfigurationV1EventSucceeded) {
173+ t.Fatalf("scale 2: event %d", got)
174+ }
175+ scene(func(s ports.Scene) bool { return s.Scale == 2 })
176+ // A headless output cannot be disabled. The new scale gives a new serial.
177+ roundtrip(func() bool { return manager.done != serial })
178+ got = configure(func(cfg uint32) {
179+ if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestDisableHead), head); err != nil {
180+ t.Fatal(err)
181+ }
182+ })
183+ if got != int(wlr.ZwlrOutputConfigurationV1EventFailed) {
184+ t.Fatalf("disable: event %d", got)
185+ }
186+}
M internal/app/outputmanagement.go +179 -125
@@ -1,9 +1,10 @@
11 package app
22
33 import (
4- "context"
4+ "errors"
55 "fmt"
66 "image"
7+ "maps"
78 "math"
89 "reflect"
910 "slices"
@@ -12,8 +13,8 @@ import (
1213 "github.com/bnema/zerowrap"
1314 )
1415
15-// outputOverrides owns runtime-only output settings. It is used only by the
16-// app's config/apply relay goroutine; the file configuration is never mutated.
16+// outputOverrides holds runtime-only output settings. Only outputApply uses
17+// it; the file configuration is never mutated.
1718 type outputOverrides struct {
1819 file ports.Config
1920 overrides map[string]ports.OutputConfig
@@ -157,131 +158,184 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
157158 return o.effective(), nil
158159 }
159160
160-// relayOutputSettings serializes reloads, inventory, and protocol requests.
161-// The app owns the only writer to overrides; core receives effective settings.
162-func relayOutputSettings(ctx context.Context, state *outputOverrides, reloads <-chan ports.ConfigChanged, inventory <-chan ports.OutputHeads, apply <-chan ports.OutputApply, configs chan<- ports.ConfigChanged, backend chan<- ports.Config, backendDone <-chan error, replies chan<- ports.OutputApplied, log zerowrap.Logger) {
163- type operation struct {
164- cfg ports.Config
165- req *ports.OutputApply
166- previous map[string]ports.OutputConfig
167- rollback bool
168- failure error
161+// errApplySuperseded answers a request whose configuration a reload replaced.
162+var errApplySuperseded = errors.New("output apply superseded by reload")
163+
164+// outputApply owns output management from request to reply: runtime
165+// overrides, supersede by reload, and rollback after a failed backend apply.
166+// It belongs to the backend owner loop, which starts the configuration next
167+// returns, reports each result through finished and drains the outbox (core
168+// configuration, Wayland replies and inventory) with sends that never block
169+// the loop.
170+type outputApply struct {
171+ state *outputOverrides
172+ log zerowrap.Logger
173+ // running is set while the backend applies a configuration. The next
174+ // one waits in start until it finishes: configurations never overlap.
175+ running bool
176+ start ports.Config
177+ pending bool
178+ // req is the request behind the backend work; nil for a reload or after
179+ // a reload superseded it.
180+ req *ports.OutputApply
181+ previous map[string]ports.OutputConfig
182+ rollback bool
183+ failure error
184+ // config is the latest effective configuration core has not received.
185+ config ports.ConfigChanged
186+ publish bool
187+ replies []ports.OutputApplied
188+ // headsDue is set while Wayland has not received the latest inventory.
189+ headsDue bool
190+}
191+
192+func newOutputApply(state *outputOverrides, log zerowrap.Logger) *outputApply {
193+ return &outputApply{state: state, log: log}
194+}
195+
196+// reload replaces the file configuration and supersedes the pending request.
197+// Only the latest reload needs backend work.
198+func (a *outputApply) reload(file ports.Config) {
199+ cfg := a.state.reload(file)
200+ a.setConfig(cfg)
201+ if a.req != nil {
202+ a.reply(a.req.ID, errApplySuperseded)
203+ a.req = nil
169204 }
170- var queue []operation
171- var active *operation
172- var sending chan<- ports.Config
173- var next ports.Config
174- var configSend chan<- ports.ConfigChanged
175- var pendingConfig ports.ConfigChanged
176- var pendingReplies []ports.OutputApplied
177- publish := func(cfg ports.Config) {
178- pendingConfig = ports.ConfigChanged{Config: cfg}
179- configSend = configs
205+ if !a.state.headless {
206+ a.due(cfg)
180207 }
181- reply := func(id uint64, err error) {
182- if err != nil {
183- log.Warn().Err(err).Msg("output configuration failed")
184- }
185- pendingReplies = append(pendingReplies, ports.OutputApplied{ID: id, Err: err})
208+}
209+
210+// heads records the backend inventory: it validates requests and waits for
211+// Wayland in the outbox, latest only.
212+func (a *outputApply) heads(heads ports.OutputHeads) {
213+ a.state.heads = heads
214+ a.headsDue = true
215+}
216+
217+// request validates a protocol request. Test, invalid and headless requests
218+// are answered at once; others wait for the backend.
219+func (a *outputApply) request(req ports.OutputApply) {
220+ if a.running || a.pending {
221+ a.reply(req.ID, errors.New("output apply already pending"))
222+ return
186223 }
187- enqueue := func(op operation) {
188- if state.headless {
189- return
190- }
191- queue = append(queue, op)
224+ previous := make(map[string]ports.OutputConfig, len(a.state.overrides))
225+ maps.Copy(previous, a.state.overrides)
226+ cfg, err := a.state.apply(req)
227+ if err != nil || req.Test {
228+ a.reply(req.ID, err)
229+ return
192230 }
193- for {
194- if active == nil && len(queue) > 0 {
195- op := queue[0]
196- active = &op
197- queue = queue[1:]
198- next = active.cfg
199- sending = backend
200- }
201- var replySend chan<- ports.OutputApplied
202- var nextReply ports.OutputApplied
203- if len(pendingReplies) > 0 {
204- replySend = replies
205- nextReply = pendingReplies[0]
206- }
207- select {
208- case <-ctx.Done():
209- return
210- case configSend <- pendingConfig:
211- configSend = nil
212- case replySend <- nextReply:
213- pendingReplies[0] = ports.OutputApplied{}
214- pendingReplies = pendingReplies[1:]
215- case sending <- next:
216- sending = nil
217- case ev := <-reloads:
218- cfg := state.reload(ev.Config)
219- publish(cfg)
220- // The reload supersedes runtime overrides. Old backend completion cannot
221- // roll back the new file configuration.
222- if active != nil && active.req != nil {
223- reply(active.req.ID, fmt.Errorf("output apply superseded by reload"))
224- active.req = nil
225- }
226- for i := range queue {
227- if queue[i].req != nil {
228- reply(queue[i].req.ID, fmt.Errorf("output apply superseded by reload"))
229- }
230- }
231- queue = nil // Only the latest reload needs backend work.
232- if active != nil && sending != nil {
233- // The backend has not accepted the active config yet.
234- active.cfg = cfg
235- next = cfg
236- } else {
237- enqueue(operation{cfg: cfg})
238- }
239- case heads := <-inventory:
240- state.heads = heads
241- case req := <-apply:
242- if active != nil || len(queue) > 0 {
243- reply(req.ID, fmt.Errorf("output apply already pending"))
244- continue
245- }
246- previous := make(map[string]ports.OutputConfig, len(state.overrides))
247- for name, entry := range state.overrides {
248- previous[name] = entry
249- }
250- cfg, err := state.apply(req)
251- if err != nil || req.Test {
252- reply(req.ID, err)
253- continue
254- }
255- publish(cfg)
256- if state.headless {
257- reply(req.ID, nil)
258- } else {
259- enqueue(operation{cfg: cfg, req: &req, previous: previous})
260- }
261- case err := <-backendDone:
262- if active == nil || sending != nil {
263- continue
264- }
265- op := active
266- active = nil
267- if op.req == nil {
268- continue
269- }
270- if op.rollback {
271- reply(op.req.ID, op.failure)
272- continue
273- }
274- if err != nil {
275- state.overrides = op.previous
276- cfg := state.effective()
277- publish(cfg)
278- enqueue(operation{cfg: cfg, req: op.req, rollback: true, failure: err})
279- continue
280- }
281- for _, h := range op.req.Heads {
282- log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")
283- }
284- reply(op.req.ID, nil)
285- }
231+ a.setConfig(cfg)
232+ if a.state.headless {
233+ a.reply(req.ID, nil)
234+ return
235+ }
236+ a.req, a.previous, a.rollback, a.failure = &req, previous, false, nil
237+ a.due(cfg)
238+}
239+
240+// next returns the configuration the backend must start now, once. It
241+// returns nothing while the backend applies the previous one.
242+func (a *outputApply) next() (ports.Config, bool) {
243+ if a.running || !a.pending {
244+ return ports.Config{}, false
245+ }
246+ cfg := a.start
247+ a.start, a.pending, a.running = ports.Config{}, false, true
248+ return cfg, true
249+}
250+
251+// finished records the backend result of the running configuration. When a
252+// reload is waiting, the result belongs to a superseded configuration and is
253+// dropped. After a failed request the previous overrides return and the
254+// rollback configuration becomes due; the request is answered with the
255+// original failure once the rollback is done.
256+func (a *outputApply) finished(err error) {
257+ if !a.running {
258+ return
259+ }
260+ a.running = false
261+ if a.pending {
262+ return
263+ }
264+ req := a.req
265+ if req == nil {
266+ return
267+ }
268+ if a.rollback {
269+ a.req = nil
270+ a.reply(req.ID, a.failure)
271+ return
272+ }
273+ if err != nil {
274+ a.state.overrides = a.previous
275+ cfg := a.state.effective()
276+ a.setConfig(cfg)
277+ a.rollback, a.failure = true, err
278+ a.due(cfg)
279+ return
280+ }
281+ a.req = nil
282+ for _, h := range req.Heads {
283+ a.log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")
284+ }
285+ a.reply(req.ID, nil)
286+}
287+
288+// due replaces the configuration waiting to start: only the latest counts.
289+func (a *outputApply) due(cfg ports.Config) {
290+ a.start, a.pending = cfg, true
291+}
292+
293+func (a *outputApply) setConfig(cfg ports.Config) {
294+ a.config = ports.ConfigChanged{Config: cfg}
295+ a.publish = true
296+}
297+
298+func (a *outputApply) reply(id uint64, err error) {
299+ if err != nil {
300+ a.log.Warn().Err(err).Msg("output configuration failed")
301+ }
302+ a.replies = append(a.replies, ports.OutputApplied{ID: id, Err: err})
303+}
304+
305+// configOut returns the core channel when a configuration waits, else nil so
306+// the owner loop's send case stays disabled.
307+func (a *outputApply) configOut(ch chan<- ports.ConfigChanged) chan<- ports.ConfigChanged {
308+ if !a.publish {
309+ return nil
286310 }
311+ return ch
312+}
313+
314+// configSent records that core received the latest configuration.
315+func (a *outputApply) configSent() { a.publish = false }
316+
317+// replyOut returns the reply channel and the oldest reply, or a nil channel.
318+func (a *outputApply) replyOut(ch chan<- ports.OutputApplied) (chan<- ports.OutputApplied, ports.OutputApplied) {
319+ if len(a.replies) == 0 {
320+ return nil, ports.OutputApplied{}
321+ }
322+ return ch, a.replies[0]
323+}
324+
325+// headsOut returns the Wayland inventory channel and the latest inventory,
326+// or a nil channel when Wayland has it.
327+func (a *outputApply) headsOut(ch chan<- ports.OutputHeads) (chan<- ports.OutputHeads, ports.OutputHeads) {
328+ if !a.headsDue {
329+ return nil, ports.OutputHeads{}
330+ }
331+ return ch, a.state.heads
332+}
333+
334+// headsSent records that Wayland received the latest inventory.
335+func (a *outputApply) headsSent() { a.headsDue = false }
336+
337+// replySent drops the delivered reply.
338+func (a *outputApply) replySent() {
339+ a.replies[0] = ports.OutputApplied{}
340+ a.replies = a.replies[1:]
287341 }
@@ -1,9 +1,10 @@
1 package app1 package app
2 2
3 import (3 import (
4- "context"4+ "errors"
5 "fmt"5 "fmt"
6 "image"6 "image"
7+ "maps"
7 "math"8 "math"
8 "reflect"9 "reflect"
9 "slices"10 "slices"
@@ -12,8 +13,8 @@ import (
12 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
13 )14 )
14 15
15-// outputOverrides owns runtime-only output settings. It is used only by the16+// outputOverrides holds runtime-only output settings. Only outputApply uses
16-// app's config/apply relay goroutine; the file configuration is never mutated.17+// it; the file configuration is never mutated.
17 type outputOverrides struct {18 type outputOverrides struct {
18 file ports.Config19 file ports.Config
19 overrides map[string]ports.OutputConfig20 overrides map[string]ports.OutputConfig
@@ -157,131 +158,184 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
157 return o.effective(), nil158 return o.effective(), nil
158 }159 }
159 160
160-// relayOutputSettings serializes reloads, inventory, and protocol requests.161+// errApplySuperseded answers a request whose configuration a reload replaced.
161-// The app owns the only writer to overrides; core receives effective settings.162+var errApplySuperseded = errors.New("output apply superseded by reload")
162-func relayOutputSettings(ctx context.Context, state *outputOverrides, reloads <-chan ports.ConfigChanged, inventory <-chan ports.OutputHeads, apply <-chan ports.OutputApply, configs chan<- ports.ConfigChanged, backend chan<- ports.Config, backendDone <-chan error, replies chan<- ports.OutputApplied, log zerowrap.Logger) {163+
163- type operation struct {164+// outputApply owns output management from request to reply: runtime
164- cfg ports.Config165+// overrides, supersede by reload, and rollback after a failed backend apply.
165- req *ports.OutputApply166+// It belongs to the backend owner loop, which starts the configuration next
166- previous map[string]ports.OutputConfig167+// returns, reports each result through finished and drains the outbox (core
167- rollback bool168+// configuration, Wayland replies and inventory) with sends that never block
168- failure error169+// the loop.
170+type outputApply struct {
171+ state *outputOverrides
172+ log zerowrap.Logger
173+ // running is set while the backend applies a configuration. The next
174+ // one waits in start until it finishes: configurations never overlap.
175+ running bool
176+ start ports.Config
177+ pending bool
178+ // req is the request behind the backend work; nil for a reload or after
179+ // a reload superseded it.
180+ req *ports.OutputApply
181+ previous map[string]ports.OutputConfig
182+ rollback bool
183+ failure error
184+ // config is the latest effective configuration core has not received.
185+ config ports.ConfigChanged
186+ publish bool
187+ replies []ports.OutputApplied
188+ // headsDue is set while Wayland has not received the latest inventory.
189+ headsDue bool
190+}
191+
192+func newOutputApply(state *outputOverrides, log zerowrap.Logger) *outputApply {
193+ return &outputApply{state: state, log: log}
194+}
195+
196+// reload replaces the file configuration and supersedes the pending request.
197+// Only the latest reload needs backend work.
198+func (a *outputApply) reload(file ports.Config) {
199+ cfg := a.state.reload(file)
200+ a.setConfig(cfg)
201+ if a.req != nil {
202+ a.reply(a.req.ID, errApplySuperseded)
203+ a.req = nil
169 }204 }
170- var queue []operation205+ if !a.state.headless {
171- var active *operation206+ a.due(cfg)
172- var sending chan<- ports.Config
173- var next ports.Config
174- var configSend chan<- ports.ConfigChanged
175- var pendingConfig ports.ConfigChanged
176- var pendingReplies []ports.OutputApplied
177- publish := func(cfg ports.Config) {
178- pendingConfig = ports.ConfigChanged{Config: cfg}
179- configSend = configs
180 }207 }
181- reply := func(id uint64, err error) {208+}
182- if err != nil {209+
183- log.Warn().Err(err).Msg("output configuration failed")210+// heads records the backend inventory: it validates requests and waits for
184- }211+// Wayland in the outbox, latest only.
185- pendingReplies = append(pendingReplies, ports.OutputApplied{ID: id, Err: err})212+func (a *outputApply) heads(heads ports.OutputHeads) {
213+ a.state.heads = heads
214+ a.headsDue = true
215+}
216+
217+// request validates a protocol request. Test, invalid and headless requests
218+// are answered at once; others wait for the backend.
219+func (a *outputApply) request(req ports.OutputApply) {
220+ if a.running || a.pending {
221+ a.reply(req.ID, errors.New("output apply already pending"))
222+ return
186 }223 }
187- enqueue := func(op operation) {224+ previous := make(map[string]ports.OutputConfig, len(a.state.overrides))
188- if state.headless {225+ maps.Copy(previous, a.state.overrides)
189- return226+ cfg, err := a.state.apply(req)
190- }227+ if err != nil || req.Test {
191- queue = append(queue, op)228+ a.reply(req.ID, err)
229+ return
192 }230 }
193- for {231+ a.setConfig(cfg)
194- if active == nil && len(queue) > 0 {232+ if a.state.headless {
195- op := queue[0]233+ a.reply(req.ID, nil)
196- active = &op234+ return
197- queue = queue[1:]235+ }
198- next = active.cfg236+ a.req, a.previous, a.rollback, a.failure = &req, previous, false, nil
199- sending = backend237+ a.due(cfg)
200- }238+}
201- var replySend chan<- ports.OutputApplied239+
202- var nextReply ports.OutputApplied240+// next returns the configuration the backend must start now, once. It
203- if len(pendingReplies) > 0 {241+// returns nothing while the backend applies the previous one.
204- replySend = replies242+func (a *outputApply) next() (ports.Config, bool) {
205- nextReply = pendingReplies[0]243+ if a.running || !a.pending {
206- }244+ return ports.Config{}, false
207- select {245+ }
208- case <-ctx.Done():246+ cfg := a.start
209- return247+ a.start, a.pending, a.running = ports.Config{}, false, true
210- case configSend <- pendingConfig:248+ return cfg, true
211- configSend = nil249+}
212- case replySend <- nextReply:250+
213- pendingReplies[0] = ports.OutputApplied{}251+// finished records the backend result of the running configuration. When a
214- pendingReplies = pendingReplies[1:]252+// reload is waiting, the result belongs to a superseded configuration and is
215- case sending <- next:253+// dropped. After a failed request the previous overrides return and the
216- sending = nil254+// rollback configuration becomes due; the request is answered with the
217- case ev := <-reloads:255+// original failure once the rollback is done.
218- cfg := state.reload(ev.Config)256+func (a *outputApply) finished(err error) {
219- publish(cfg)257+ if !a.running {
220- // The reload supersedes runtime overrides. Old backend completion cannot258+ return
221- // roll back the new file configuration.259+ }
222- if active != nil && active.req != nil {260+ a.running = false
223- reply(active.req.ID, fmt.Errorf("output apply superseded by reload"))261+ if a.pending {
224- active.req = nil262+ return
225- }263+ }
226- for i := range queue {264+ req := a.req
227- if queue[i].req != nil {265+ if req == nil {
228- reply(queue[i].req.ID, fmt.Errorf("output apply superseded by reload"))266+ return
229- }267+ }
230- }268+ if a.rollback {
231- queue = nil // Only the latest reload needs backend work.269+ a.req = nil
232- if active != nil && sending != nil {270+ a.reply(req.ID, a.failure)
233- // The backend has not accepted the active config yet.271+ return
234- active.cfg = cfg272+ }
235- next = cfg273+ if err != nil {
236- } else {274+ a.state.overrides = a.previous
237- enqueue(operation{cfg: cfg})275+ cfg := a.state.effective()
238- }276+ a.setConfig(cfg)
239- case heads := <-inventory:277+ a.rollback, a.failure = true, err
240- state.heads = heads278+ a.due(cfg)
241- case req := <-apply:279+ return
242- if active != nil || len(queue) > 0 {280+ }
243- reply(req.ID, fmt.Errorf("output apply already pending"))281+ a.req = nil
244- continue282+ for _, h := range req.Heads {
245- }283+ a.log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")
246- previous := make(map[string]ports.OutputConfig, len(state.overrides))284+ }
247- for name, entry := range state.overrides {285+ a.reply(req.ID, nil)
248- previous[name] = entry286+}
249- }287+
250- cfg, err := state.apply(req)288+// due replaces the configuration waiting to start: only the latest counts.
251- if err != nil || req.Test {289+func (a *outputApply) due(cfg ports.Config) {
252- reply(req.ID, err)290+ a.start, a.pending = cfg, true
253- continue291+}
254- }292+
255- publish(cfg)293+func (a *outputApply) setConfig(cfg ports.Config) {
256- if state.headless {294+ a.config = ports.ConfigChanged{Config: cfg}
257- reply(req.ID, nil)295+ a.publish = true
258- } else {296+}
259- enqueue(operation{cfg: cfg, req: &req, previous: previous})297+
260- }298+func (a *outputApply) reply(id uint64, err error) {
261- case err := <-backendDone:299+ if err != nil {
262- if active == nil || sending != nil {300+ a.log.Warn().Err(err).Msg("output configuration failed")
263- continue301+ }
264- }302+ a.replies = append(a.replies, ports.OutputApplied{ID: id, Err: err})
265- op := active303+}
266- active = nil304+
267- if op.req == nil {305+// configOut returns the core channel when a configuration waits, else nil so
268- continue306+// the owner loop's send case stays disabled.
269- }307+func (a *outputApply) configOut(ch chan<- ports.ConfigChanged) chan<- ports.ConfigChanged {
270- if op.rollback {308+ if !a.publish {
271- reply(op.req.ID, op.failure)309+ return nil
272- continue
273- }
274- if err != nil {
275- state.overrides = op.previous
276- cfg := state.effective()
277- publish(cfg)
278- enqueue(operation{cfg: cfg, req: op.req, rollback: true, failure: err})
279- continue
280- }
281- for _, h := range op.req.Heads {
282- log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")
283- }
284- reply(op.req.ID, nil)
285- }
286 }310 }
311+ return ch
312+}
313+
314+// configSent records that core received the latest configuration.
315+func (a *outputApply) configSent() { a.publish = false }
316+
317+// replyOut returns the reply channel and the oldest reply, or a nil channel.
318+func (a *outputApply) replyOut(ch chan<- ports.OutputApplied) (chan<- ports.OutputApplied, ports.OutputApplied) {
319+ if len(a.replies) == 0 {
320+ return nil, ports.OutputApplied{}
321+ }
322+ return ch, a.replies[0]
323+}
324+
325+// headsOut returns the Wayland inventory channel and the latest inventory,
326+// or a nil channel when Wayland has it.
327+func (a *outputApply) headsOut(ch chan<- ports.OutputHeads) (chan<- ports.OutputHeads, ports.OutputHeads) {
328+ if !a.headsDue {
329+ return nil, ports.OutputHeads{}
330+ }
331+ return ch, a.state.heads
332+}
333+
334+// headsSent records that Wayland received the latest inventory.
335+func (a *outputApply) headsSent() { a.headsDue = false }
336+
337+// replySent drops the delivered reply.
338+func (a *outputApply) replySent() {
339+ a.replies[0] = ports.OutputApplied{}
340+ a.replies = a.replies[1:]
287 }341 }
M internal/app/outputmanagement_test.go +226 -153
@@ -5,13 +5,75 @@ import (
55 "errors"
66 "image"
77 "testing"
8- "time"
98
109 "github.com/bnema/neferwl/internal/logging"
1110 "github.com/bnema/neferwl/internal/ports"
1211 )
1312
14-func TestOutputSettingsRelay(t *testing.T) {
13+// applyRig drives outputApply the way a backend owner loop does.
14+type applyRig struct {
15+ t *testing.T
16+ a *outputApply
17+}
18+
19+func newApplyRig(t *testing.T, headless bool) applyRig {
20+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
21+ a := newOutputApply(newOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
22+ a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
23+ return applyRig{t, a}
24+}
25+
26+// started returns the configuration the backend must start now.
27+func (r applyRig) started() ports.Config {
28+ r.t.Helper()
29+ cfg, ok := r.a.next()
30+ if !ok {
31+ r.t.Fatal("no configuration to start")
32+ }
33+ if _, again := r.a.next(); again {
34+ r.t.Fatal("configuration started twice")
35+ }
36+ return cfg
37+}
38+
39+func (r applyRig) idle() {
40+ r.t.Helper()
41+ if cfg, ok := r.a.next(); ok {
42+ r.t.Fatalf("unexpected configuration to start: %+v", cfg)
43+ }
44+}
45+
46+// core returns the configuration waiting for core, if any.
47+func (r applyRig) core() (ports.Config, bool) {
48+ ch := make(chan ports.ConfigChanged, 1)
49+ out := r.a.configOut(ch)
50+ if out == nil {
51+ return ports.Config{}, false
52+ }
53+ out <- r.a.config
54+ r.a.configSent()
55+ return (<-ch).Config, true
56+}
57+
58+func (r applyRig) replies() []ports.OutputApplied {
59+ var got []ports.OutputApplied
60+ ch := make(chan ports.OutputApplied, 1)
61+ for {
62+ out, next := r.a.replyOut(ch)
63+ if out == nil {
64+ return got
65+ }
66+ out <- next
67+ r.a.replySent()
68+ got = append(got, <-ch)
69+ }
70+}
71+
72+func moveDP1(id uint64, x int) ports.OutputApply {
73+ return ports.OutputApply{ID: id, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: x}}}}
74+}
75+
76+func TestOutputApply(t *testing.T) {
1577 for _, tc := range []struct {
1678 name string
1779 failure error
@@ -21,140 +83,96 @@ func TestOutputSettingsRelay(t *testing.T) {
2183 {"timeout rollback", errTimeout},
2284 } {
2385 t.Run(tc.name, func(t *testing.T) {
24- ctx, cancel := context.WithCancel(context.Background())
25- defer cancel()
26- state := newOutputOverrides(ports.Config{}, false)
27- reloads := make(chan ports.ConfigChanged)
28- inventory := make(chan ports.OutputHeads)
29- applies := make(chan ports.OutputApply)
30- configs := make(chan ports.ConfigChanged, 4)
31- backend := make(chan ports.Config, 4)
32- backendDone := make(chan error)
33- replies := make(chan ports.OutputApplied)
34- done := make(chan struct{})
35- go func() {
36- defer close(done)
37- relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
38- }()
39- defer func() { cancel(); <-done }()
40- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
41- inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
42- applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}
43- next := <-configs
44- if next.Config.Outputs[0].Pos.X != 100 {
45- t.Fatalf("next: %+v", next)
86+ r := newApplyRig(t, false)
87+ r.a.request(moveDP1(1, 100))
88+ if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos.X != 100 {
89+ t.Fatalf("core config: %+v", cfg)
4690 }
47- <-backend
48- select {
49- case <-replies:
50- t.Fatal("replied before backend ready")
51- default:
91+ if cfg := r.started(); cfg.Outputs[0].Pos.X != 100 {
92+ t.Fatalf("started: %+v", cfg)
5293 }
94+ if got := r.replies(); len(got) != 0 {
95+ t.Fatalf("replied before backend finished: %+v", got)
96+ }
97+ r.a.finished(tc.failure)
5398 if tc.failure != nil {
54- backendDone <- tc.failure
55- rollback := <-configs
56- if len(rollback.Config.Outputs) != 0 {
57- t.Fatalf("rollback: %+v", rollback)
99+ if got := r.replies(); len(got) != 0 {
100+ t.Fatalf("replied before rollback: %+v", got)
58101 }
59- <-backend
60- backendDone <- nil
61- } else {
62- backendDone <- nil
63- }
64- select {
65- case r := <-replies:
66- if !errors.Is(r.Err, tc.failure) || (r.Err != nil) != (tc.failure != nil) {
67- t.Fatalf("result: %+v, want %v", r, tc.failure)
102+ if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 0 {
103+ t.Fatalf("rollback core config: %+v", cfg)
68104 }
69- case <-time.After(2 * time.Second):
70- t.Fatal("missing reply")
105+ if cfg := r.started(); len(cfg.Outputs) != 0 {
106+ t.Fatalf("rollback started: %+v", cfg)
107+ }
108+ // A failed rollback still answers with the original failure.
109+ r.a.finished(errors.New("rollback failed"))
110+ }
111+ r.idle()
112+ got := r.replies()
113+ if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) {
114+ t.Fatalf("replies: %+v, want %v", got, tc.failure)
71115 }
72- if tc.failure != nil && len(state.effective().Outputs) != 0 {
116+ if tc.failure != nil && len(r.a.state.effective().Outputs) != 0 {
73117 t.Fatal("override survived failure")
74118 }
75- reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}}}
76- reloaded := <-configs
77- if reloaded.Config.Outputs[0].Pos != nil || reloaded.Config.Outputs[0].Scale != 1.25 {
78- t.Fatalf("reload: %+v", reloaded)
119+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}})
120+ if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos != nil || cfg.Outputs[0].Scale != 1.25 {
121+ t.Fatalf("reload core config: %+v", cfg)
122+ }
123+ r.started()
124+ r.a.finished(nil)
125+ if got := r.replies(); len(got) != 0 {
126+ t.Fatalf("reload replied: %+v", got)
79127 }
80- <-backend
81- backendDone <- nil
82128 })
83129 }
84130 }
85131
86-func TestOutputSettingsRelayBlockedConsumers(t *testing.T) {
87- ctx, cancel := context.WithCancel(context.Background())
88- state := newOutputOverrides(ports.Config{}, false)
89- reloads := make(chan ports.ConfigChanged)
90- inventory := make(chan ports.OutputHeads)
91- applies := make(chan ports.OutputApply)
92- configs := make(chan ports.ConfigChanged)
93- backend := make(chan ports.Config)
94- backendDone := make(chan error)
95- replies := make(chan ports.OutputApplied)
96- done := make(chan struct{})
97- go func() {
98- defer close(done)
99- relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
100- }()
101- defer func() { cancel(); <-done }()
102-
103- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
104- select {
105- case inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}:
106- case <-time.After(time.Second):
107- t.Fatal("inventory blocked")
108- }
109- select {
110- case applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}:
111- case <-time.After(time.Second):
112- t.Fatal("apply blocked")
113- }
114- // Neither config consumer nor backend nor Wayland is reading. Reload must
115- // still supersede the apply and subsequent requests must still be answered.
116- select {
117- case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}:
118- case <-time.After(time.Second):
119- t.Fatal("reload blocked by unread config or backend")
120- }
121- select {
122- case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}}:
123- case <-time.After(time.Second):
124- t.Fatal("second reload blocked by unread config or backend")
125- }
126- for _, id := range []uint64{2, 3} {
127- select {
128- case applies <- ports.OutputApply{ID: id}:
129- case <-time.After(time.Second):
130- t.Fatalf("request %d blocked by unread reply", id)
131- }
132+// Consumers that do not read (core, Wayland) never block reloads or requests:
133+// replies queue in order and core only gets the latest configuration.
134+func TestOutputApplyOutbox(t *testing.T) {
135+ r := newApplyRig(t, false)
136+ r.a.request(moveDP1(1, 100))
137+ r.started()
138+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}})
139+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}})
140+ r.a.request(ports.OutputApply{ID: 2})
141+ r.a.request(ports.OutputApply{ID: 3})
142+ // The reloads wait for the running configuration: no overlap.
143+ r.idle()
144+ r.a.finished(nil)
145+ // Inventory updates while Wayland is not reading: only the latest waits.
146+ mode := ports.OutputMode{Width: 1280, Height: 720, RefreshMilli: 60000}
147+ for _, name := range []string{"DP-1", "DP-2"} {
148+ r.a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
132149 }
133- for _, id := range []uint64{1, 2, 3} {
134- select {
135- case result := <-replies:
136- if result.ID != id || result.Err == nil {
137- t.Fatalf("reply %d: %+v", id, result)
138- }
139- case <-time.After(time.Second):
140- t.Fatalf("missing reply %d", id)
141- }
150+ headsCh := make(chan ports.OutputHeads, 1)
151+ out, heads := r.a.headsOut(headsCh)
152+ if out == nil || len(heads.Heads) != 1 || heads.Heads[0].Info.Name != "DP-2" {
153+ t.Fatalf("latest heads: %+v", heads)
142154 }
143- select {
144- case cfg := <-configs:
145- if len(cfg.Config.Outputs) != 1 || cfg.Config.Outputs[0].Scale != 2 || cfg.Config.Outputs[0].Pos != nil {
146- t.Fatalf("latest config: %+v", cfg)
147- }
148- case <-time.After(time.Second):
149- t.Fatal("missing latest config")
155+ out <- heads
156+ r.a.headsSent()
157+ if got := <-headsCh; got.Heads[0].Info.Name != "DP-2" {
158+ t.Fatalf("delivered heads: %+v", got)
150159 }
151- select {
152- case cfg := <-backend:
153- if len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil {
154- t.Fatalf("latest backend config: %+v", cfg)
160+ if out, _ := r.a.headsOut(headsCh); out != nil {
161+ t.Fatal("heads sent twice")
162+ }
163+ for i, got := range r.replies() {
164+ if got.ID != uint64(i+1) || got.Err == nil {
165+ t.Fatalf("reply %d: %+v", i+1, got)
155166 }
156- case <-time.After(time.Second):
157- t.Fatal("missing latest backend config")
167+ }
168+ if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil {
169+ t.Fatalf("latest core config: %+v", cfg)
170+ }
171+ if _, ok := r.core(); ok {
172+ t.Fatal("core config sent twice")
173+ }
174+ if cfg := r.started(); cfg.Outputs[0].Scale != 2 {
175+ t.Fatalf("latest backend config: %+v", cfg)
158176 }
159177 }
160178
@@ -172,43 +190,98 @@ func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
172190 }
173191 }
174192
193+// A reload supersedes a request the backend is applying: the request fails at
194+// once, and a late failure of its configuration cannot roll back the reload.
175195 func TestReloadDuringPendingApply(t *testing.T) {
176- ctx, cancel := context.WithCancel(context.Background())
177- defer cancel()
178- state := newOutputOverrides(ports.Config{}, false)
179- reloads := make(chan ports.ConfigChanged)
180- inventory := make(chan ports.OutputHeads)
181- applies := make(chan ports.OutputApply)
182- configs := make(chan ports.ConfigChanged, 4)
183- backend := make(chan ports.Config, 4)
184- backendDone := make(chan error)
185- replies := make(chan ports.OutputApplied, 2)
186- done := make(chan struct{})
187- go func() {
188- defer close(done)
189- relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
190- }()
191- defer func() { cancel(); <-done }()
192- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
193- inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}
194- applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}
195- <-configs
196- <-backend
197- reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}
198- select {
199- case cfg := <-configs:
200- if cfg.Config.Outputs[0].Pos != nil {
201- t.Fatal("override survived reload")
202- }
203- case <-time.After(time.Second):
204- t.Fatal("reload blocked by backend")
196+ r := newApplyRig(t, false)
197+ r.a.request(moveDP1(1, 100))
198+ r.core()
199+ r.started()
200+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}})
201+ if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos != nil {
202+ t.Fatalf("override survived reload: %+v", cfg)
203+ }
204+ if got := r.replies(); len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, errApplySuperseded) {
205+ t.Fatalf("superseded reply: %+v", got)
206+ }
207+ // The reload waits for the old configuration, whose failure is dropped.
208+ r.idle()
209+ r.a.finished(errors.New("old apply failed"))
210+ if cfg := r.started(); cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[0].Pos != nil {
211+ t.Fatalf("reload started: %+v", cfg)
212+ }
213+ r.a.finished(errors.New("reload failed"))
214+ r.idle()
215+ if _, ok := r.core(); ok {
216+ t.Fatal("failed reload rolled back")
217+ }
218+ if got := r.replies(); len(got) != 0 {
219+ t.Fatalf("reload replied: %+v", got)
220+ }
221+ // A stray result with nothing running is ignored.
222+ r.a.finished(errors.New("stray"))
223+ r.idle()
224+ // The next request is accepted.
225+ r.a.request(moveDP1(2, 50))
226+ r.started()
227+ r.a.finished(nil)
228+ if got := r.replies(); len(got) != 1 || got[0].ID != 2 || got[0].Err != nil {
229+ t.Fatalf("next request: %+v", got)
230+ }
231+}
232+
233+// A reload during a rollback supersedes the failed request.
234+func TestReloadDuringRollback(t *testing.T) {
235+ r := newApplyRig(t, false)
236+ r.a.request(moveDP1(1, 100))
237+ r.started()
238+ r.a.finished(errors.New("modeset failed"))
239+ r.started() // rollback
240+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}})
241+ if got := r.replies(); len(got) != 1 || !errors.Is(got[0].Err, errApplySuperseded) {
242+ t.Fatalf("rollback superseded: %+v", got)
243+ }
244+ r.idle()
245+ r.a.finished(nil) // rollback done
246+ if cfg := r.started(); cfg.Outputs[0].Scale != 2 {
247+ t.Fatalf("reload started: %+v", cfg)
248+ }
249+ r.a.finished(nil)
250+ if got := r.replies(); len(got) != 0 {
251+ t.Fatalf("extra reply: %+v", got)
252+ }
253+}
254+
255+func TestOutputApplyAnsweredAtOnce(t *testing.T) {
256+ r := newApplyRig(t, false)
257+ r.a.request(ports.OutputApply{ID: 1, Test: true, Heads: moveDP1(1, 10).Heads})
258+ r.a.request(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{{Name: "DP-9", Enabled: true}}})
259+ r.idle()
260+ if _, ok := r.core(); ok {
261+ t.Fatal("test or invalid request changed core config")
262+ }
263+ got := r.replies()
264+ if len(got) != 2 || got[0].ID != 1 || got[0].Err != nil || got[1].ID != 2 || got[1].Err == nil {
265+ t.Fatalf("replies: %+v", got)
266+ }
267+ r.a.request(moveDP1(3, 10))
268+ r.a.request(moveDP1(4, 20))
269+ if got := r.replies(); len(got) != 1 || got[0].ID != 4 || got[0].Err == nil {
270+ t.Fatalf("second request while pending: %+v", got)
271+ }
272+
273+ h := newApplyRig(t, true)
274+ h.a.request(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Scale: 2}}})
275+ var gaps ports.Config
276+ gaps.Layout.Gaps = 4
277+ h.a.reload(gaps)
278+ h.idle()
279+ if cfg, ok := h.core(); !ok || cfg.Layout.Gaps != 4 {
280+ t.Fatalf("headless core config: %+v", cfg)
205281 }
206- if r := <-replies; r.ID != 1 || r.Err == nil {
207- t.Fatalf("superseded reply: %+v", r)
282+ if got := h.replies(); len(got) != 1 || got[0].ID != 1 || got[0].Err != nil {
283+ t.Fatalf("headless reply: %+v", got)
208284 }
209- backendDone <- errors.New("old apply failed")
210- <-backend
211- backendDone <- nil
212285 }
213286
214287 // A reload that only changes output scales (a saved scale bind) keeps the
@@ -5,13 +5,75 @@ import (
5 "errors"5 "errors"
6 "image"6 "image"
7 "testing"7 "testing"
8- "time"
9 8
10 "github.com/bnema/neferwl/internal/logging"9 "github.com/bnema/neferwl/internal/logging"
11 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
12 )11 )
13 12
14-func TestOutputSettingsRelay(t *testing.T) {13+// applyRig drives outputApply the way a backend owner loop does.
14+type applyRig struct {
15+ t *testing.T
16+ a *outputApply
17+}
18+
19+func newApplyRig(t *testing.T, headless bool) applyRig {
20+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
21+ a := newOutputApply(newOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
22+ a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
23+ return applyRig{t, a}
24+}
25+
26+// started returns the configuration the backend must start now.
27+func (r applyRig) started() ports.Config {
28+ r.t.Helper()
29+ cfg, ok := r.a.next()
30+ if !ok {
31+ r.t.Fatal("no configuration to start")
32+ }
33+ if _, again := r.a.next(); again {
34+ r.t.Fatal("configuration started twice")
35+ }
36+ return cfg
37+}
38+
39+func (r applyRig) idle() {
40+ r.t.Helper()
41+ if cfg, ok := r.a.next(); ok {
42+ r.t.Fatalf("unexpected configuration to start: %+v", cfg)
43+ }
44+}
45+
46+// core returns the configuration waiting for core, if any.
47+func (r applyRig) core() (ports.Config, bool) {
48+ ch := make(chan ports.ConfigChanged, 1)
49+ out := r.a.configOut(ch)
50+ if out == nil {
51+ return ports.Config{}, false
52+ }
53+ out <- r.a.config
54+ r.a.configSent()
55+ return (<-ch).Config, true
56+}
57+
58+func (r applyRig) replies() []ports.OutputApplied {
59+ var got []ports.OutputApplied
60+ ch := make(chan ports.OutputApplied, 1)
61+ for {
62+ out, next := r.a.replyOut(ch)
63+ if out == nil {
64+ return got
65+ }
66+ out <- next
67+ r.a.replySent()
68+ got = append(got, <-ch)
69+ }
70+}
71+
72+func moveDP1(id uint64, x int) ports.OutputApply {
73+ return ports.OutputApply{ID: id, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: x}}}}
74+}
75+
76+func TestOutputApply(t *testing.T) {
15 for _, tc := range []struct {77 for _, tc := range []struct {
16 name string78 name string
17 failure error79 failure error
@@ -21,140 +83,96 @@ func TestOutputSettingsRelay(t *testing.T) {
21 {"timeout rollback", errTimeout},83 {"timeout rollback", errTimeout},
22 } {84 } {
23 t.Run(tc.name, func(t *testing.T) {85 t.Run(tc.name, func(t *testing.T) {
24- ctx, cancel := context.WithCancel(context.Background())86+ r := newApplyRig(t, false)
25- defer cancel()87+ r.a.request(moveDP1(1, 100))
26- state := newOutputOverrides(ports.Config{}, false)88+ if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos.X != 100 {
27- reloads := make(chan ports.ConfigChanged)89+ t.Fatalf("core config: %+v", cfg)
28- inventory := make(chan ports.OutputHeads)
29- applies := make(chan ports.OutputApply)
30- configs := make(chan ports.ConfigChanged, 4)
31- backend := make(chan ports.Config, 4)
32- backendDone := make(chan error)
33- replies := make(chan ports.OutputApplied)
34- done := make(chan struct{})
35- go func() {
36- defer close(done)
37- relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
38- }()
39- defer func() { cancel(); <-done }()
40- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
41- inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
42- applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}
43- next := <-configs
44- if next.Config.Outputs[0].Pos.X != 100 {
45- t.Fatalf("next: %+v", next)
46 }90 }
47- <-backend91+ if cfg := r.started(); cfg.Outputs[0].Pos.X != 100 {
48- select {92+ t.Fatalf("started: %+v", cfg)
49- case <-replies:
50- t.Fatal("replied before backend ready")
51- default:
52 }93 }
94+ if got := r.replies(); len(got) != 0 {
95+ t.Fatalf("replied before backend finished: %+v", got)
96+ }
97+ r.a.finished(tc.failure)
53 if tc.failure != nil {98 if tc.failure != nil {
54- backendDone <- tc.failure99+ if got := r.replies(); len(got) != 0 {
55- rollback := <-configs100+ t.Fatalf("replied before rollback: %+v", got)
56- if len(rollback.Config.Outputs) != 0 {
57- t.Fatalf("rollback: %+v", rollback)
58 }101 }
59- <-backend102+ if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 0 {
60- backendDone <- nil103+ t.Fatalf("rollback core config: %+v", cfg)
61- } else {
62- backendDone <- nil
63- }
64- select {
65- case r := <-replies:
66- if !errors.Is(r.Err, tc.failure) || (r.Err != nil) != (tc.failure != nil) {
67- t.Fatalf("result: %+v, want %v", r, tc.failure)
68 }104 }
69- case <-time.After(2 * time.Second):105+ if cfg := r.started(); len(cfg.Outputs) != 0 {
70- t.Fatal("missing reply")106+ t.Fatalf("rollback started: %+v", cfg)
107+ }
108+ // A failed rollback still answers with the original failure.
109+ r.a.finished(errors.New("rollback failed"))
110+ }
111+ r.idle()
112+ got := r.replies()
113+ if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) {
114+ t.Fatalf("replies: %+v, want %v", got, tc.failure)
71 }115 }
72- if tc.failure != nil && len(state.effective().Outputs) != 0 {116+ if tc.failure != nil && len(r.a.state.effective().Outputs) != 0 {
73 t.Fatal("override survived failure")117 t.Fatal("override survived failure")
74 }118 }
75- reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}}}119+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}})
76- reloaded := <-configs120+ if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos != nil || cfg.Outputs[0].Scale != 1.25 {
77- if reloaded.Config.Outputs[0].Pos != nil || reloaded.Config.Outputs[0].Scale != 1.25 {121+ t.Fatalf("reload core config: %+v", cfg)
78- t.Fatalf("reload: %+v", reloaded)122+ }
123+ r.started()
124+ r.a.finished(nil)
125+ if got := r.replies(); len(got) != 0 {
126+ t.Fatalf("reload replied: %+v", got)
79 }127 }
80- <-backend
81- backendDone <- nil
82 })128 })
83 }129 }
84 }130 }
85 131
86-func TestOutputSettingsRelayBlockedConsumers(t *testing.T) {132+// Consumers that do not read (core, Wayland) never block reloads or requests:
87- ctx, cancel := context.WithCancel(context.Background())133+// replies queue in order and core only gets the latest configuration.
88- state := newOutputOverrides(ports.Config{}, false)134+func TestOutputApplyOutbox(t *testing.T) {
89- reloads := make(chan ports.ConfigChanged)135+ r := newApplyRig(t, false)
90- inventory := make(chan ports.OutputHeads)136+ r.a.request(moveDP1(1, 100))
91- applies := make(chan ports.OutputApply)137+ r.started()
92- configs := make(chan ports.ConfigChanged)138+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}})
93- backend := make(chan ports.Config)139+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}})
94- backendDone := make(chan error)140+ r.a.request(ports.OutputApply{ID: 2})
95- replies := make(chan ports.OutputApplied)141+ r.a.request(ports.OutputApply{ID: 3})
96- done := make(chan struct{})142+ // The reloads wait for the running configuration: no overlap.
97- go func() {143+ r.idle()
98- defer close(done)144+ r.a.finished(nil)
99- relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))145+ // Inventory updates while Wayland is not reading: only the latest waits.
100- }()146+ mode := ports.OutputMode{Width: 1280, Height: 720, RefreshMilli: 60000}
101- defer func() { cancel(); <-done }()147+ for _, name := range []string{"DP-1", "DP-2"} {
102-148+ r.a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
103- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
104- select {
105- case inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}:
106- case <-time.After(time.Second):
107- t.Fatal("inventory blocked")
108- }
109- select {
110- case applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}:
111- case <-time.After(time.Second):
112- t.Fatal("apply blocked")
113- }
114- // Neither config consumer nor backend nor Wayland is reading. Reload must
115- // still supersede the apply and subsequent requests must still be answered.
116- select {
117- case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}:
118- case <-time.After(time.Second):
119- t.Fatal("reload blocked by unread config or backend")
120- }
121- select {
122- case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}}:
123- case <-time.After(time.Second):
124- t.Fatal("second reload blocked by unread config or backend")
125- }
126- for _, id := range []uint64{2, 3} {
127- select {
128- case applies <- ports.OutputApply{ID: id}:
129- case <-time.After(time.Second):
130- t.Fatalf("request %d blocked by unread reply", id)
131- }
132 }149 }
133- for _, id := range []uint64{1, 2, 3} {150+ headsCh := make(chan ports.OutputHeads, 1)
134- select {151+ out, heads := r.a.headsOut(headsCh)
135- case result := <-replies:152+ if out == nil || len(heads.Heads) != 1 || heads.Heads[0].Info.Name != "DP-2" {
136- if result.ID != id || result.Err == nil {153+ t.Fatalf("latest heads: %+v", heads)
137- t.Fatalf("reply %d: %+v", id, result)
138- }
139- case <-time.After(time.Second):
140- t.Fatalf("missing reply %d", id)
141- }
142 }154 }
143- select {155+ out <- heads
144- case cfg := <-configs:156+ r.a.headsSent()
145- if len(cfg.Config.Outputs) != 1 || cfg.Config.Outputs[0].Scale != 2 || cfg.Config.Outputs[0].Pos != nil {157+ if got := <-headsCh; got.Heads[0].Info.Name != "DP-2" {
146- t.Fatalf("latest config: %+v", cfg)158+ t.Fatalf("delivered heads: %+v", got)
147- }
148- case <-time.After(time.Second):
149- t.Fatal("missing latest config")
150 }159 }
151- select {160+ if out, _ := r.a.headsOut(headsCh); out != nil {
152- case cfg := <-backend:161+ t.Fatal("heads sent twice")
153- if len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil {162+ }
154- t.Fatalf("latest backend config: %+v", cfg)163+ for i, got := range r.replies() {
164+ if got.ID != uint64(i+1) || got.Err == nil {
165+ t.Fatalf("reply %d: %+v", i+1, got)
155 }166 }
156- case <-time.After(time.Second):167+ }
157- t.Fatal("missing latest backend config")168+ if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil {
169+ t.Fatalf("latest core config: %+v", cfg)
170+ }
171+ if _, ok := r.core(); ok {
172+ t.Fatal("core config sent twice")
173+ }
174+ if cfg := r.started(); cfg.Outputs[0].Scale != 2 {
175+ t.Fatalf("latest backend config: %+v", cfg)
158 }176 }
159 }177 }
160 178
@@ -172,43 +190,98 @@ func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
172 }190 }
173 }191 }
174 192
193+// A reload supersedes a request the backend is applying: the request fails at
194+// once, and a late failure of its configuration cannot roll back the reload.
175 func TestReloadDuringPendingApply(t *testing.T) {195 func TestReloadDuringPendingApply(t *testing.T) {
176- ctx, cancel := context.WithCancel(context.Background())196+ r := newApplyRig(t, false)
177- defer cancel()197+ r.a.request(moveDP1(1, 100))
178- state := newOutputOverrides(ports.Config{}, false)198+ r.core()
179- reloads := make(chan ports.ConfigChanged)199+ r.started()
180- inventory := make(chan ports.OutputHeads)200+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}})
181- applies := make(chan ports.OutputApply)201+ if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos != nil {
182- configs := make(chan ports.ConfigChanged, 4)202+ t.Fatalf("override survived reload: %+v", cfg)
183- backend := make(chan ports.Config, 4)203+ }
184- backendDone := make(chan error)204+ if got := r.replies(); len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, errApplySuperseded) {
185- replies := make(chan ports.OutputApplied, 2)205+ t.Fatalf("superseded reply: %+v", got)
186- done := make(chan struct{})206+ }
187- go func() {207+ // The reload waits for the old configuration, whose failure is dropped.
188- defer close(done)208+ r.idle()
189- relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))209+ r.a.finished(errors.New("old apply failed"))
190- }()210+ if cfg := r.started(); cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[0].Pos != nil {
191- defer func() { cancel(); <-done }()211+ t.Fatalf("reload started: %+v", cfg)
192- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}212+ }
193- inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}213+ r.a.finished(errors.New("reload failed"))
194- applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}214+ r.idle()
195- <-configs215+ if _, ok := r.core(); ok {
196- <-backend216+ t.Fatal("failed reload rolled back")
197- reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}217+ }
198- select {218+ if got := r.replies(); len(got) != 0 {
199- case cfg := <-configs:219+ t.Fatalf("reload replied: %+v", got)
200- if cfg.Config.Outputs[0].Pos != nil {220+ }
201- t.Fatal("override survived reload")221+ // A stray result with nothing running is ignored.
202- }222+ r.a.finished(errors.New("stray"))
203- case <-time.After(time.Second):223+ r.idle()
204- t.Fatal("reload blocked by backend")224+ // The next request is accepted.
225+ r.a.request(moveDP1(2, 50))
226+ r.started()
227+ r.a.finished(nil)
228+ if got := r.replies(); len(got) != 1 || got[0].ID != 2 || got[0].Err != nil {
229+ t.Fatalf("next request: %+v", got)
230+ }
231+}
232+
233+// A reload during a rollback supersedes the failed request.
234+func TestReloadDuringRollback(t *testing.T) {
235+ r := newApplyRig(t, false)
236+ r.a.request(moveDP1(1, 100))
237+ r.started()
238+ r.a.finished(errors.New("modeset failed"))
239+ r.started() // rollback
240+ r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}})
241+ if got := r.replies(); len(got) != 1 || !errors.Is(got[0].Err, errApplySuperseded) {
242+ t.Fatalf("rollback superseded: %+v", got)
243+ }
244+ r.idle()
245+ r.a.finished(nil) // rollback done
246+ if cfg := r.started(); cfg.Outputs[0].Scale != 2 {
247+ t.Fatalf("reload started: %+v", cfg)
248+ }
249+ r.a.finished(nil)
250+ if got := r.replies(); len(got) != 0 {
251+ t.Fatalf("extra reply: %+v", got)
252+ }
253+}
254+
255+func TestOutputApplyAnsweredAtOnce(t *testing.T) {
256+ r := newApplyRig(t, false)
257+ r.a.request(ports.OutputApply{ID: 1, Test: true, Heads: moveDP1(1, 10).Heads})
258+ r.a.request(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{{Name: "DP-9", Enabled: true}}})
259+ r.idle()
260+ if _, ok := r.core(); ok {
261+ t.Fatal("test or invalid request changed core config")
262+ }
263+ got := r.replies()
264+ if len(got) != 2 || got[0].ID != 1 || got[0].Err != nil || got[1].ID != 2 || got[1].Err == nil {
265+ t.Fatalf("replies: %+v", got)
266+ }
267+ r.a.request(moveDP1(3, 10))
268+ r.a.request(moveDP1(4, 20))
269+ if got := r.replies(); len(got) != 1 || got[0].ID != 4 || got[0].Err == nil {
270+ t.Fatalf("second request while pending: %+v", got)
271+ }
272+
273+ h := newApplyRig(t, true)
274+ h.a.request(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Scale: 2}}})
275+ var gaps ports.Config
276+ gaps.Layout.Gaps = 4
277+ h.a.reload(gaps)
278+ h.idle()
279+ if cfg, ok := h.core(); !ok || cfg.Layout.Gaps != 4 {
280+ t.Fatalf("headless core config: %+v", cfg)
205 }281 }
206- if r := <-replies; r.ID != 1 || r.Err == nil {282+ if got := h.replies(); len(got) != 1 || got[0].ID != 1 || got[0].Err != nil {
207- t.Fatalf("superseded reply: %+v", r)283+ t.Fatalf("headless reply: %+v", got)
208 }284 }
209- backendDone <- errors.New("old apply failed")
210- <-backend
211- backendDone <- nil
212 }285 }
213 286
214 // A reload that only changes output scales (a saved scale bind) keeps the287 // A reload that only changes output scales (a saved scale bind) keeps the
M internal/app/outputrouting.go +10 -25
@@ -1,13 +1,11 @@
11 package app
22
3-import (
4- "context"
5-
6- "github.com/bnema/neferwl/internal/ports"
7-)
3+import "github.com/bnema/neferwl/internal/ports"
84
95 // outputChannels is the shared app-to-backend wiring. The backend loop remains
10-// the sole owner of outputSet; routing does not introduce a forwarding queue.
6+// the sole owner of outputSet and outputApply; routing does not introduce a
7+// forwarding queue. heads, configured and replies are fed from the
8+// outputApply outbox.
119 type outputChannels struct {
1210 events chan<- ports.OutputEvent
1311 scenes <-chan []ports.Scene
@@ -18,23 +16,10 @@ type outputChannels struct {
1816 captured chan<- ports.CaptureDone
1917 formats chan<- ports.OutputFormats
2018 heads chan<- ports.OutputHeads
21- report chan<- ports.OutputHeads
22- configs <-chan ports.Config
23- applied chan<- error
24-}
25-
26-// sendInventory preserves heads-before-report delivery; false means the
27-// context interrupted either send.
28-func (ch outputChannels) sendInventory(ctx context.Context, inventory ports.OutputHeads) bool {
29- select {
30- case ch.heads <- inventory:
31- case <-ctx.Done():
32- return false
33- }
34- select {
35- case ch.report <- inventory:
36- return true
37- case <-ctx.Done():
38- return false
39- }
19+ // reloads carries file configuration; configured carries the effective
20+ // configuration, overrides included, to core.
21+ reloads <-chan ports.ConfigChanged
22+ configured chan<- ports.ConfigChanged
23+ requests <-chan ports.OutputApply
24+ replies chan<- ports.OutputApplied
4025 }
@@ -1,13 +1,11 @@
1 package app1 package app
2 2
3-import (3+import "github.com/bnema/neferwl/internal/ports"
4- "context"
5-
6- "github.com/bnema/neferwl/internal/ports"
7-)
8 4
9 // outputChannels is the shared app-to-backend wiring. The backend loop remains5 // outputChannels is the shared app-to-backend wiring. The backend loop remains
10-// the sole owner of outputSet; routing does not introduce a forwarding queue.6+// the sole owner of outputSet and outputApply; routing does not introduce a
7+// forwarding queue. heads, configured and replies are fed from the
8+// outputApply outbox.
11 type outputChannels struct {9 type outputChannels struct {
12 events chan<- ports.OutputEvent10 events chan<- ports.OutputEvent
13 scenes <-chan []ports.Scene11 scenes <-chan []ports.Scene
@@ -18,23 +16,10 @@ type outputChannels struct {
18 captured chan<- ports.CaptureDone16 captured chan<- ports.CaptureDone
19 formats chan<- ports.OutputFormats17 formats chan<- ports.OutputFormats
20 heads chan<- ports.OutputHeads18 heads chan<- ports.OutputHeads
21- report chan<- ports.OutputHeads19+ // reloads carries file configuration; configured carries the effective
22- configs <-chan ports.Config20+ // configuration, overrides included, to core.
23- applied chan<- error21+ reloads <-chan ports.ConfigChanged
24-}22+ configured chan<- ports.ConfigChanged
25-23+ requests <-chan ports.OutputApply
26-// sendInventory preserves heads-before-report delivery; false means the24+ replies chan<- ports.OutputApplied
27-// context interrupted either send.
28-func (ch outputChannels) sendInventory(ctx context.Context, inventory ports.OutputHeads) bool {
29- select {
30- case ch.heads <- inventory:
31- case <-ctx.Done():
32- return false
33- }
34- select {
35- case ch.report <- inventory:
36- return true
37- case <-ctx.Done():
38- return false
39- }
40 }25 }
M internal/ports/messages.go +32 -1
@@ -819,7 +819,12 @@ type SurfaceContent struct {
819819 // Source is the committed crop in buffer pixels: x, y, width, height.
820820 // Zero width means the entire buffer is used.
821821 Source [4]float32
822- Opaque bool // x formats: ignore the alpha byte
822+ // Transform is the wl_output.transform the client applied to the
823+ // buffer: readers apply its inverse. Width and Height stay the buffer's.
824+ Transform BufferTransform
825+ // Opaque ignores the alpha byte: an x format, or an opaque region that
826+ // covers the whole surface.
827+ Opaque bool
823828 Color SurfaceColor
824829 SHM *SHMBuffer
825830 DMABuf *DMABuf
@@ -846,6 +851,32 @@ type SurfaceContent struct {
846851 DamageHistory []SeqDamage
847852 }
848853
854+// BufferTransform is a wl_output.transform value: bit 0 rotates 90°
855+// counter-clockwise, bit 1 rotates 180°, bit 2 flips around the vertical
856+// axis first.
857+type BufferTransform uint8
858+
859+// Rotated reports whether width and height swap between buffer and surface.
860+func (t BufferTransform) Rotated() bool { return t&1 != 0 }
861+
862+// ToBuffer maps a point of a w×h surface (logical, before scale) to the
863+// same point of its buffer, in the buffer's untransformed w'×h' axes, where
864+// w' and h' swap when the transform is Rotated.
865+func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
866+ if t&4 != 0 {
867+ x = w - x
868+ }
869+ switch t & 3 {
870+ case 1:
871+ return y, w - x
872+ case 2:
873+ return w - x, h - y
874+ case 3:
875+ return h - y, x
876+ }
877+ return x, y
878+}
879+
849880 // SeqDamage is what content Seq changed from the one before: Rects in
850881 // root buffer pixels, or everything when Full.
851882 type SeqDamage struct {
@@ -819,7 +819,12 @@ type SurfaceContent struct {
819 // Source is the committed crop in buffer pixels: x, y, width, height.819 // Source is the committed crop in buffer pixels: x, y, width, height.
820 // Zero width means the entire buffer is used.820 // Zero width means the entire buffer is used.
821 Source [4]float32821 Source [4]float32
822- Opaque bool // x formats: ignore the alpha byte822+ // Transform is the wl_output.transform the client applied to the
823+ // buffer: readers apply its inverse. Width and Height stay the buffer's.
824+ Transform BufferTransform
825+ // Opaque ignores the alpha byte: an x format, or an opaque region that
826+ // covers the whole surface.
827+ Opaque bool
823 Color SurfaceColor828 Color SurfaceColor
824 SHM *SHMBuffer829 SHM *SHMBuffer
825 DMABuf *DMABuf830 DMABuf *DMABuf
@@ -846,6 +851,32 @@ type SurfaceContent struct {
846 DamageHistory []SeqDamage851 DamageHistory []SeqDamage
847 }852 }
848 853
854+// BufferTransform is a wl_output.transform value: bit 0 rotates 90°
855+// counter-clockwise, bit 1 rotates 180°, bit 2 flips around the vertical
856+// axis first.
857+type BufferTransform uint8
858+
859+// Rotated reports whether width and height swap between buffer and surface.
860+func (t BufferTransform) Rotated() bool { return t&1 != 0 }
861+
862+// ToBuffer maps a point of a w×h surface (logical, before scale) to the
863+// same point of its buffer, in the buffer's untransformed w'×h' axes, where
864+// w' and h' swap when the transform is Rotated.
865+func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
866+ if t&4 != 0 {
867+ x = w - x
868+ }
869+ switch t & 3 {
870+ case 1:
871+ return y, w - x
872+ case 2:
873+ return w - x, h - y
874+ case 3:
875+ return h - y, x
876+ }
877+ return x, y
878+}
879+
849 // SeqDamage is what content Seq changed from the one before: Rects in880 // SeqDamage is what content Seq changed from the one before: Rects in
850 // root buffer pixels, or everything when Full.881 // root buffer pixels, or everything when Full.
851 type SeqDamage struct {882 type SeqDamage struct {
A internal/ports/transform_test.go +36 -0
@@ -0,0 +1,36 @@
1+package ports
2+
3+import "testing"
4+
5+// ToBuffer matches wl_output.transform: the buffer holds the surface rotated
6+// counter-clockwise by the transform (after a flip around the vertical axis
7+// for flipped values). Checked on the surface's top-left corner of a 3×2
8+// surface.
9+func TestBufferTransformToBuffer(t *testing.T) {
10+ for _, tc := range []struct {
11+ t BufferTransform
12+ rotated bool
13+ bx, by float64 // buffer point of surface (0.5, 0.5)
14+ bufW, bufH int
15+ }{
16+ {0, false, 0.5, 0.5, 3, 2},
17+ {1, true, 0.5, 2.5, 2, 3},
18+ {2, false, 2.5, 1.5, 3, 2},
19+ {3, true, 1.5, 0.5, 2, 3},
20+ {4, false, 2.5, 0.5, 3, 2},
21+ {5, true, 0.5, 0.5, 2, 3},
22+ {6, false, 0.5, 1.5, 3, 2},
23+ {7, true, 1.5, 2.5, 2, 3},
24+ } {
25+ if tc.t.Rotated() != tc.rotated {
26+ t.Errorf("%d: rotated %v", tc.t, tc.t.Rotated())
27+ }
28+ x, y := tc.t.ToBuffer(0.5, 0.5, 3, 2)
29+ if x != tc.bx || y != tc.by {
30+ t.Errorf("%d: (0.5,0.5) -> (%v,%v), want (%v,%v)", tc.t, x, y, tc.bx, tc.by)
31+ }
32+ if x < 0 || y < 0 || x > float64(tc.bufW) || y > float64(tc.bufH) {
33+ t.Errorf("%d: (%v,%v) outside the %dx%d buffer", tc.t, x, y, tc.bufW, tc.bufH)
34+ }
35+ }
36+}
@@ -0,0 +1,36 @@
1+package ports
2+
3+import "testing"
4+
5+// ToBuffer matches wl_output.transform: the buffer holds the surface rotated
6+// counter-clockwise by the transform (after a flip around the vertical axis
7+// for flipped values). Checked on the surface's top-left corner of a 3×2
8+// surface.
9+func TestBufferTransformToBuffer(t *testing.T) {
10+ for _, tc := range []struct {
11+ t BufferTransform
12+ rotated bool
13+ bx, by float64 // buffer point of surface (0.5, 0.5)
14+ bufW, bufH int
15+ }{
16+ {0, false, 0.5, 0.5, 3, 2},
17+ {1, true, 0.5, 2.5, 2, 3},
18+ {2, false, 2.5, 1.5, 3, 2},
19+ {3, true, 1.5, 0.5, 2, 3},
20+ {4, false, 2.5, 0.5, 3, 2},
21+ {5, true, 0.5, 0.5, 2, 3},
22+ {6, false, 0.5, 1.5, 3, 2},
23+ {7, true, 1.5, 2.5, 2, 3},
24+ } {
25+ if tc.t.Rotated() != tc.rotated {
26+ t.Errorf("%d: rotated %v", tc.t, tc.t.Rotated())
27+ }
28+ x, y := tc.t.ToBuffer(0.5, 0.5, 3, 2)
29+ if x != tc.bx || y != tc.by {
30+ t.Errorf("%d: (0.5,0.5) -> (%v,%v), want (%v,%v)", tc.t, x, y, tc.bx, tc.by)
31+ }
32+ if x < 0 || y < 0 || x > float64(tc.bufW) || y > float64(tc.bufH) {
33+ t.Errorf("%d: (%v,%v) outside the %dx%d buffer", tc.t, x, y, tc.bufW, tc.bufH)
34+ }
35+ }
36+}

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