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 Unverified
B5690EEEBB952194The 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 | |||
| 73 | 73 | return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above" | |
| 74 | 74 | } | |
| 75 | 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 | 77 | pick = w | |
| 78 | 78 | continue | |
| 79 | 79 | } | |
| @@ -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 = w | 77 | pick = w | ||
| 78 | continue | 78 | continue | ||
| 79 | } | 79 | } | ||
M internal/adapters/drm/overlay_test.go +8 -0
| @@ -49,6 +49,14 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 49 | 49 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 50 | 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 | 60 | _, c = overlayScene() | |
| 53 | 61 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}}) | |
| 54 | 62 | 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 | |||
| 89 | 89 | return c, "not_dmabuf" | |
| 90 | 90 | case len(c.Children) > 0: | |
| 91 | 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 | 95 | case c.Width != w || c.Height != h: | |
| 93 | 96 | return c, "size_mismatch" | |
| 94 | 97 | 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) { | |||
| 37 | 37 | }, nil, ""}, | |
| 38 | 38 | {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"}, | |
| 39 | 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 | 41 | {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"}, | |
| 41 | 42 | {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"}, | |
| 42 | 43 | {"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 ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "unsafe" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | 12 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| @@ -51,6 +52,7 @@ const ( | |||
| 51 | 52 | type pushConstants struct { | |
| 52 | 53 | rect [4]int32 | |
| 53 | 54 | mapv [4]float32 | |
| 55 | + | mapy [4]float32 | |
| 54 | 56 | color [4]float32 | |
| 55 | 57 | crop [4]float32 | |
| 56 | 58 | buf [4]uint32 | |
| @@ -298,21 +300,36 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw { | |||
| 298 | 300 | } | |
| 299 | 301 | ||
| 300 | 302 | // 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 { | |
| 303 | 306 | var dr draw | |
| 304 | 307 | if source[2] <= 0 { | |
| 305 | 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 | 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 | 327 | dr.pc.crop = [4]float32{source[0], source[1], source[0] + source[2], source[1] + source[3]} | |
| 311 | 328 | flags := uint32(0) | |
| 312 | 329 | if opaque { | |
| 313 | 330 | 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 | 333 | flags |= flagExact | |
| 317 | 334 | } | |
| 318 | 335 | 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]int32 | 53 | rect [4]int32 | ||
| 53 | mapv [4]float32 | 54 | mapv [4]float32 | ||
| 55 | + | mapy [4]float32 | |||
| 54 | color [4]float32 | 56 | color [4]float32 | ||
| 55 | crop [4]float32 | 57 | crop [4]float32 | ||
| 56 | buf [4]uint32 | 58 | 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 full | 302 | // 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 draw | 306 | 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 |= flagOpaque | 330 | 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 |= flagExact | 333 | 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 | |||
| 97 | 97 | ||
| 98 | 98 | // content maps a root surface's buffer damage onto full (where its w×h | |
| 99 | 99 | // 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 | 101 | func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full, dst image.Rectangle) { | |
| 102 | 102 | if d.held == nil { | |
| 103 | 103 | return | |
| @@ -111,6 +111,10 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full, | |||
| 111 | 111 | if !known || c.Width <= 0 || c.Height <= 0 { | |
| 112 | 112 | return | |
| 113 | 113 | } | |
| 114 | + | if c.Transform != 0 { | |
| 115 | + | d.add(dst) | |
| 116 | + | return | |
| 117 | + | } | |
| 114 | 118 | // Linear filtering spreads a texel over half a buffer pixel around | |
| 115 | 119 | // it: scale/2 target pixels, plus one for rounding. | |
| 116 | 120 | 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×h | 98 | // 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: one | 99 | // 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 | return | 103 | 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 | return | 112 | 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 around | 118 | // 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 := 0 | 120 | pad := 0 | ||
M internal/adapters/vulkan/damage_test.go +11 -2
| @@ -2,6 +2,7 @@ package vulkan | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "fmt" | |
| 5 | 6 | "image/color" | |
| 6 | 7 | "os" | |
| 7 | 8 | "testing" | |
| @@ -25,6 +26,14 @@ func damagedContent(t *testing.T, f *os.File, base ports.SurfaceContent, seq uin | |||
| 25 | 26 | // ages (A then B, then one render) and with shm double buffers | |
| 26 | 27 | // (render, skip, render). | |
| 27 | 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 | 37 | const w, h = 32, 32 | |
| 29 | 38 | newR := func() *Renderer { | |
| 30 | 39 | r, err := New(64, 48) | |
| @@ -52,7 +61,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) { | |||
| 52 | 61 | } | |
| 53 | 62 | pixels := px(color.RGBA{10, 20, 30, 255}, 0, 0, w, h, make([]byte, w*h*4)) | |
| 54 | 63 | base := shmContent(t, w, h, w*4, pixels) | |
| 55 | - | base.ID = 1 | |
| 64 | + | base.ID, base.Transform = 1, tr | |
| 56 | 65 | f := base.SHM.File | |
| 57 | 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 | 67 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | |
| @@ -86,7 +95,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) { | |||
| 86 | 95 | c = damagedContent(t, f, *base, seq, pixels, hist) | |
| 87 | 96 | } | |
| 88 | 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 | 99 | t.Fatalf("partial frame redrew %d pixels", got) | |
| 91 | 100 | } | |
| 92 | 101 | // 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 buffers | 26 | // 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, 32 | 37 | 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 = 1 | 64 | + | base.ID, base.Transform = 1, tr |
| 56 | f := base.SHM.File | 65 | 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 | |||
| 216 | 216 | if content.Source[2] > 0 { | |
| 217 | 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 | 222 | if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) { | |
| 220 | 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 | 259 | // The window shows its background until a buffer imports. | |
| 257 | 260 | 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 | 263 | r.setContentColor(&dr, content.Color) | |
| 261 | 264 | if im.yuv { | |
| 262 | 265 | dr.pc.misc[1] |= flagYUV | |
| @@ -292,7 +295,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 292 | 295 | if err != nil { | |
| 293 | 296 | 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 | 299 | r.setContentColor(&dr, content.Color) | |
| 297 | 300 | dr.set = c.set | |
| 298 | 301 | 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 | return | 260 | 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] |= flagYUV | 265 | 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 | return | 296 | 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.set | 300 | 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 @@ | |||
| 8 | 8 | layout(push_constant) uniform Draw { | |
| 9 | 9 | ivec4 rect; | |
| 10 | 10 | vec4 map; | |
| 11 | + | vec4 mapy; | |
| 11 | 12 | vec4 color; | |
| 12 | 13 | vec4 crop; | |
| 13 | 14 | uvec4 buf; | |
| @@ -84,7 +85,7 @@ vec4 texel(ivec2 p) { | |||
| 84 | 85 | } | |
| 85 | 86 | ||
| 86 | 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 | 89 | bool exact = (d.misc.y & flagExact) != 0u; | |
| 89 | 90 | vec4 c; | |
| 90 | 91 | 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 @@ | |||
| 3 | 3 | ||
| 4 | 4 | layout(push_constant) uniform Draw { | |
| 5 | 5 | 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 | |
| 7 | 8 | vec4 color; // solid fills: premultiplied RGBA | |
| 8 | 9 | vec4 crop; // source crop bounds in buffer pixels | |
| 9 | 10 | 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 pixels | 5 | 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 | |||
| 7 | vec4 color; // solid fills: premultiplied RGBA | 8 | vec4 color; // solid fills: premultiplied RGBA | ||
| 8 | vec4 crop; // source crop bounds in buffer pixels | 9 | vec4 crop; // source crop bounds in buffer pixels | ||
| 9 | uvec4 buf; // buffer draws: first word, row width, height, unused | 10 | 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 @@ | |||
| 8 | 8 | layout(push_constant) uniform Draw { | |
| 9 | 9 | ivec4 rect; | |
| 10 | 10 | vec4 map; | |
| 11 | + | vec4 mapy; | |
| 11 | 12 | vec4 color; | |
| 12 | 13 | vec4 crop; | |
| 13 | 14 | uvec4 buf; | |
| @@ -83,7 +84,7 @@ vec4 texel(ivec2 p) { | |||
| 83 | 84 | } | |
| 84 | 85 | ||
| 85 | 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 | 88 | bool exact = (d.misc.y & flagExact) != 0u; | |
| 88 | 89 | vec4 c; | |
| 89 | 90 | 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 { | |||
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | 110 | // 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 | 113 | func (s *surface) cursorImage() ports.CursorChange { | |
| 113 | 114 | if s.current == nil { | |
| 114 | 115 | return ports.CursorChange{Hidden: true} | |
| 115 | 116 | } | |
| 116 | 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 | 119 | return ports.CursorChange{} | |
| 119 | 120 | } | |
| 120 | 121 | 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. No | 110 | // 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 { | |||
| 204 | 204 | attached bool | |
| 205 | 205 | buffer *wayland.Buffer | |
| 206 | 206 | scale int | |
| 207 | + | transform ports.BufferTransform | |
| 208 | + | opaque []ports.Rect | |
| 209 | + | opaqueSet bool | |
| 207 | 210 | async bool | |
| 208 | 211 | kind uint32 | |
| 209 | 212 | callbacks []*wayland.Callback | |
| @@ -311,6 +314,9 @@ func (s *surface) takePending() update { | |||
| 311 | 314 | u.inputSet, u.inputAll, u.inputRects = s.pendingInputSet, s.pendingInputAll, s.pendingInputRects | |
| 312 | 315 | s.pendingInputSet = false | |
| 313 | 316 | 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 | 320 | s.attached, s.pending, s.callbacks = false, nil, nil | |
| 315 | 321 | s.pendingFeedback, s.pendingSync = nil, nil | |
| 316 | 322 | s.pendingDamage, s.pendingBufDamage = nil, nil | |
| @@ -398,8 +404,9 @@ func (s *surface) applyUpdate(u *update) { | |||
| 398 | 404 | if u.buffer != nil && !u.buffer.Resource.Alive() { | |
| 399 | 405 | s.commitSkipped = true | |
| 400 | 406 | 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 | 408 | u.scale = s.bufferScale | |
| 409 | + | u.transform = s.transform | |
| 403 | 410 | // The viewport change was made for that buffer: keep the crop that | |
| 404 | 411 | // matches the retained content instead of validating a mismatch. | |
| 405 | 412 | if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() { | |
| @@ -204,6 +204,9 @@ type update struct { | |||||
| 204 | attached bool | 204 | attached bool | ||
| 205 | buffer *wayland.Buffer | 205 | buffer *wayland.Buffer | ||
| 206 | scale int | 206 | scale int | ||
| 207 | + | transform ports.BufferTransform | |||
| 208 | + | opaque []ports.Rect | |||
| 209 | + | opaqueSet bool | |||
| 207 | async bool | 210 | async bool | ||
| 208 | kind uint32 | 211 | kind uint32 | ||
| 209 | callbacks []*wayland.Callback | 212 | callbacks []*wayland.Callback | ||
| @@ -311,6 +314,9 @@ func (s *surface) takePending() update { | |||||
| 311 | u.inputSet, u.inputAll, u.inputRects = s.pendingInputSet, s.pendingInputAll, s.pendingInputRects | 314 | u.inputSet, u.inputAll, u.inputRects = s.pendingInputSet, s.pendingInputAll, s.pendingInputRects | ||
| 312 | s.pendingInputSet = false | 315 | s.pendingInputSet = false | ||
| 313 | s.pendingInputRects = nil | 316 | 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, nil | 320 | s.attached, s.pending, s.callbacks = false, nil, nil | ||
| 315 | s.pendingFeedback, s.pendingSync = nil, nil | 321 | s.pendingFeedback, s.pendingSync = nil, nil | ||
| 316 | s.pendingDamage, s.pendingBufDamage = nil, nil | 322 | 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 = true | 405 | s.commitSkipped = true | ||
| 400 | u.buffer, u.attached = nil, false | 406 | 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.bufferScale | 408 | u.scale = s.bufferScale | ||
| 409 | + | u.transform = s.transform | |||
| 403 | // The viewport change was made for that buffer: keep the crop that | 410 | // 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) { | |||
| 210 | 210 | 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 | 216 | func (s *surface) source(bw, bh int) ([4]float32, bool) { | |
| 215 | 217 | if !s.committedViewport.crop { | |
| 216 | 218 | return [4]float32{}, false | |
| 217 | 219 | } | |
| 218 | 220 | 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 | |
| 221 | 229 | // 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 | |
| 225 | 243 | } | |
| 226 | 244 | ||
| 227 | 245 | func (s *surface) validateViewport(bw, bh int) bool { | |
| 246 | + | bw, bh = s.transformedSize(bw, bh) | |
| 228 | 247 | v := &s.committedViewport | |
| 229 | 248 | if !v.crop { | |
| 230 | 249 | return true | |
| @@ -276,8 +295,9 @@ func (s *surface) viewportError(code viewporter.WpViewportError, message string) | |||
| 276 | 295 | } | |
| 277 | 296 | ||
| 278 | 297 | // 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 | 299 | func (s *surface) logicalSize(bw, bh int) (int, int) { | |
| 300 | + | bw, bh = s.transformedSize(bw, bh) | |
| 281 | 301 | if s.committedViewport.dest { | |
| 282 | 302 | return int(s.committedViewport.destW), int(s.committedViewport.destH) | |
| 283 | 303 | } | |
| @@ -210,21 +210,40 @@ func (v *viewport) SetDestination(r *viewporter.WpViewport, w, h int32) { | |||||
| 210 | v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1 | 210 | 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{}, false | 218 | return [4]float32{}, false | ||
| 217 | } | 219 | } | ||
| 218 | v := s.committedViewport.src | 220 | v := s.committedViewport.src | ||
| 219 | - | scale := float32(max(s.bufferScale, 1)) / 256 | 221 | + | 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, true | 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 | |||
| 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.committedViewport | 247 | v := &s.committedViewport | ||
| 229 | if !v.crop { | 248 | if !v.crop { | ||
| 230 | return true | 249 | 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 viewport | 297 | // 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 ( | |||
| 4 | 4 | "github.com/bnema/purego-libwayland/protocol/presentationtime" | |
| 5 | 5 | "image" | |
| 6 | 6 | "math" | |
| 7 | + | "slices" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -28,16 +29,25 @@ type surface struct { | |||
| 28 | 29 | committedViewport viewportState // value snapshot; no allocation on viewport commits | |
| 29 | 30 | // bufferScale is committed state; pendingScale is set by set_buffer_scale. | |
| 30 | 31 | 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 | |
| 41 | 51 | // hotX, hotY is the click point of a cursor surface (logical). | |
| 42 | 52 | hotX, hotY int | |
| 43 | 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 | 325 | // applyCommit makes only the captured update current. Live pending requests | |
| 316 | 326 | // remain untouched, even when this update waited behind another commit. | |
| 317 | 327 | 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 | |
| 319 | 329 | oldColor, oldRepresentation := s.color, s.representation | |
| 320 | 330 | fresh := u.attached && u.buffer != nil | |
| 321 | 331 | s.commitFresh = fresh | |
| @@ -336,6 +346,11 @@ func (s *surface) applyCommit(u *update) { | |||
| 336 | 346 | if u.scale > 0 { | |
| 337 | 347 | 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 | 354 | hinted := s.async != u.async || s.color != u.color || s.representation != u.representation | |
| 340 | 355 | s.async = u.async | |
| 341 | 356 | s.color = u.color | |
| @@ -378,8 +393,11 @@ func (s *surface) applyCommit(u *update) { | |||
| 378 | 393 | if !s.validateViewport(c.Width, c.Height) { | |
| 379 | 394 | return | |
| 380 | 395 | } | |
| 396 | + | c.Transform = s.transform | |
| 381 | 397 | c.Source, _ = s.source(c.Width, c.Height) | |
| 382 | 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 | 401 | resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH | |
| 384 | 402 | s.commitDamage(u, true, resized, c.Width, c.Height) | |
| 385 | 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 | 422 | if !s.validateViewport(s.content.Width, s.content.Height) { | |
| 405 | 423 | return | |
| 406 | 424 | } | |
| 425 | + | s.content.Transform = s.transform | |
| 407 | 426 | s.content.Source, _ = s.source(s.content.Width, s.content.Height) | |
| 408 | 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 | 431 | if s.current == nil { | |
| 412 | 432 | 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 | 435 | s.version++ | |
| 416 | 436 | s.root().treeDirty = true | |
| 417 | 437 | } | |
| @@ -433,9 +453,12 @@ func (s *surface) applyCommit(u *update) { | |||
| 433 | 453 | if s.xdg != nil && s.xdg.window != nil { | |
| 434 | 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 | 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 | 462 | s.committed = damage{full: true} | |
| 440 | 463 | } | |
| 441 | 464 | if s.server.applyingGraph { | |
| @@ -487,6 +510,15 @@ func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) { | |||
| 487 | 510 | for _, r := range buf { | |
| 488 | 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 | 522 | for _, r := range surf { | |
| 491 | 523 | if lw <= 0 || lh <= 0 { | |
| 492 | 524 | s.committed = damage{full: true} | |
| @@ -495,17 +527,80 @@ func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) { | |||
| 495 | 527 | // Clients may damage INT32_MAX-sized rects: clamp before scaling. | |
| 496 | 528 | x0, y0 := max(r.X, 0), max(r.Y, 0) | |
| 497 | 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 { | |
| 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))))) | |
| 504 | 533 | } | |
| 505 | 534 | s.committed = d | |
| 506 | 535 | } | |
| 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 | + | } | |
| 509 | 604 | func (s *surface) SetBufferScale(r *wayland.Surface, v int32) { | |
| 510 | 605 | if v < 1 { | |
| 511 | 606 | 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 commits | 29 | 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 int | 31 | bufferScale, pendingScale int | ||
| 31 | - | kind roleKind | 32 | + | // transform is committed; pendingTransform is set by set_buffer_transform |
| 32 | - | xdg *xdgSurface | 33 | + | // and, like the buffer scale, copied by every commit. |
| 33 | - | layer *layerSurface | 34 | + | transform, pendingTransform ports.BufferTransform |
| 34 | - | server *Server | 35 | + | // opaque is the committed opaque region (surface-local, logical); |
| 35 | - | current, pending *wayland.Buffer | 36 | + | // pendingOpaque is set by set_opaque_region. formatOpaque is the |
| 36 | - | attached bool | 37 | + | // current buffer's own opacity (x formats). |
| 37 | - | lastW, lastH int // last logged buffer size | 38 | + | opaque, pendingOpaque []ports.Rect |
| 38 | - | callbacks []*wayland.Callback | 39 | + | pendingOpaqueSet bool |
| 39 | - | role func(bool) | 40 | + | formatOpaque bool |
| 40 | - | destroyed 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 | |||
| 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 int | 52 | 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 requests | 325 | // 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.Source | 328 | + | oldW, oldH, oldSource, oldTransform := s.content.LogicalW, s.content.LogicalH, s.content.Source, s.content.Transform |
| 319 | oldColor, oldRepresentation := s.color, s.representation | 329 | oldColor, oldRepresentation := s.color, s.representation | ||
| 320 | fresh := u.attached && u.buffer != nil | 330 | fresh := u.attached && u.buffer != nil | ||
| 321 | s.commitFresh = fresh | 331 | 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.scale | 347 | 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.representation | 354 | hinted := s.async != u.async || s.color != u.color || s.representation != u.representation | ||
| 340 | s.async = u.async | 355 | s.async = u.async | ||
| 341 | s.color = u.color | 356 | 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 | return | 394 | 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.LogicalH | 401 | 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 | return | 423 | 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{}, false | 432 | 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 = true | 436 | 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 = d | 534 | 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) | |||
| 95 | 95 | captured := make(chan ports.CaptureDone, 64) | |
| 96 | 96 | outputFormats := make(chan ports.OutputFormats, 8) | |
| 97 | 97 | outputHeads := make(chan ports.OutputHeads, 8) | |
| 98 | - | inventory := make(chan ports.OutputHeads, 8) | |
| 99 | 98 | applyOutput := make(chan ports.OutputApply, 8) | |
| 100 | 99 | appliedOutput := make(chan ports.OutputApplied, 8) | |
| 101 | - | backendConfig := make(chan ports.Config, 8) | |
| 102 | - | backendApplied := make(chan error, 8) | |
| 103 | 100 | var scales chan ports.ScaleChanged | |
| 104 | 101 | if hw != nil { | |
| 105 | 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 | 169 | defer workers.Done() | |
| 173 | 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 | 172 | script := make(chan string) | |
| 181 | 173 | curs := newCursors() | |
| 182 | 174 | go func() { | |
| @@ -238,7 +230,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 238 | 230 | ||
| 239 | 231 | go func() { | |
| 240 | 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 | 235 | newRenderer := func(w, h int) (ports.Renderer, error) { | |
| 243 | 236 | r, err := vulkan.New(w, h) | |
| 244 | 237 | if err != nil { | |
| @@ -253,11 +246,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 253 | 246 | w.Sampled = dmabuf.Formats | |
| 254 | 247 | 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 | 251 | 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 | 256 | 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.ScaleChanged | 100 | 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.Formats | 246 | w.Sampled = dmabuf.Formats | ||
| 254 | return w | 247 | 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 | return | 251 | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 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. | |
| 10 | 11 | type applyProgress struct { | |
| 11 | 12 | id uint64 | |
| 12 | 13 | active bool | |
| @@ -17,10 +18,10 @@ type applyProgress struct { | |||
| 17 | 18 | err error | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 21 | + | // applyDecision reports whether the configuration is finished and its result. | |
| 20 | 22 | type applyDecision struct { | |
| 21 | - | reply bool | |
| 22 | - | err error | |
| 23 | - | rollback bool | |
| 23 | + | reply bool | |
| 24 | + | err error | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 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 | 57 | if p.completed == nil { | |
| 52 | 58 | p.completed = map[<-chan error]bool{} | |
| 53 | 59 | } | |
| @@ -130,7 +136,7 @@ func (p *applyProgress) timeout(id uint64) applyDecision { | |||
| 130 | 136 | func (p *applyProgress) fail(err error) applyDecision { | |
| 131 | 137 | p.err = errors.Join(p.err, err) | |
| 132 | 138 | 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 | 142 | func (p *applyProgress) complete() applyDecision { | |
| @@ -138,5 +144,5 @@ func (p *applyProgress) complete() applyDecision { | |||
| 138 | 144 | return applyDecision{} | |
| 139 | 145 | } | |
| 140 | 146 | 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 | } | |
| @@ -5,8 +5,9 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | - | // applyProgress belongs exclusively to the output loop. An instance is identified | 8 | + | // 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 uint64 | 12 | id uint64 | ||
| 12 | active bool | 13 | active bool | ||
| @@ -17,10 +18,10 @@ type applyProgress struct { | |||||
| 17 | err error | 18 | 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 bool | 23 | + | reply bool |
| 22 | - | err error | 24 | + | 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 = false | 138 | 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 = false | 146 | 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) { | |||
| 15 | 15 | {"success", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 16 | 16 | p.start(1, map[string]bool{"A": true}, nil) | |
| 17 | 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 | 19 | }, false}, | |
| 20 | 20 | {"scan error with previously ready output", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 21 | 21 | p.completed = map[<-chan error]bool{a: true} | |
| @@ -29,7 +29,7 @@ func TestApplyProgress(t *testing.T) { | |||
| 29 | 29 | {"ready error rollback", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 30 | 30 | p.start(1, map[string]bool{"A": true}, nil) | |
| 31 | 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 | 33 | }, true}, | |
| 34 | 34 | {"timeout", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 35 | 35 | p.start(1, map[string]bool{"A": true}, nil) | |
| @@ -48,15 +48,24 @@ func TestApplyProgress(t *testing.T) { | |||
| 48 | 48 | {"hotplug during apply", func(p *applyProgress, a, b <-chan error) applyDecision { | |
| 49 | 49 | p.start(1, map[string]bool{"A": true, "B": true}, nil) | |
| 50 | 50 | 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) | |
| 53 | 53 | }, false}, | |
| 54 | 54 | {"stale timeout", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 55 | 55 | p.start(1, map[string]bool{"A": true}, nil) | |
| 56 | 56 | p.start(2, map[string]bool{"A": true}, nil) | |
| 57 | 57 | p.scanned(map[string]<-chan error{"A": a}) | |
| 58 | 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 | 69 | }, false}, | |
| 61 | 70 | {"disabled head stop", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 62 | 71 | p.start(1, nil, map[string]bool{"A": false}) | |
| @@ -73,7 +82,7 @@ func TestApplyProgress(t *testing.T) { | |||
| 73 | 82 | } | |
| 74 | 83 | 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 | 86 | t.Fatalf("decision: %+v", d) | |
| 78 | 87 | } | |
| 79 | 88 | 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 | return | 83 | 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() { | |||
| 133 | 133 | ||
| 134 | 134 | // runOutputs drives every output of every card: one goroutine per output, | |
| 135 | 135 | // 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 { | |
| 139 | 141 | ctx, cancel := context.WithCancel(ctx) | |
| 140 | 142 | defer cancel() | |
| 141 | 143 | var readers sync.WaitGroup | |
| @@ -189,10 +191,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 189 | 191 | timer.Stop() | |
| 190 | 192 | timer = nil | |
| 191 | 193 | } | |
| 192 | - | select { | |
| 193 | - | case ch.applied <- d.err: | |
| 194 | - | case <-ctx.Done(): | |
| 195 | - | } | |
| 194 | + | apply.finished(d.err) | |
| 196 | 195 | } | |
| 197 | 196 | publishInventory := func() { | |
| 198 | 197 | var inventory ports.OutputHeads | |
| @@ -205,7 +204,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 205 | 204 | inventory.Heads = append(inventory.Heads, lastHeads[card]...) | |
| 206 | 205 | } | |
| 207 | 206 | currentHeads = inventory | |
| 208 | - | ch.sendInventory(ctx, inventory) | |
| 207 | + | apply.heads(inventory) | |
| 209 | 208 | } | |
| 210 | 209 | scan := func() { | |
| 211 | 210 | var scanErrors []error | |
| @@ -286,7 +285,55 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 286 | 285 | if len(set.outs) == 0 { | |
| 287 | 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 | 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 | 337 | select { | |
| 291 | 338 | case <-ctx.Done(): | |
| 292 | 339 | return set.wait() | |
| @@ -303,44 +350,18 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 303 | 350 | } | |
| 304 | 351 | complete(progress.scanned(running)) | |
| 305 | 352 | } | |
| 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() | |
| 342 | 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 | 365 | case id := <-deadline: | |
| 345 | 366 | complete(progress.timeout(id)) | |
| 346 | 367 | 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 on | 135 | // 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 | 136 | + | // 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.WaitGroup | 143 | 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 = nil | 192 | 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.OutputHeads | 197 | 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 = inventory | 206 | currentHeads = inventory | ||
| 208 | - | ch.sendInventory(ctx, inventory) | 207 | + | apply.heads(inventory) |
| 209 | } | 208 | } | ||
| 210 | scan := func() { | 209 | scan := func() { | ||
| 211 | var scanErrors []error | 210 | 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 = nil | 357 | + | 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 ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // 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 { | |
| 17 | 18 | set := newOutputSet(ctx, ch.captured) | |
| 18 | 19 | inventory := ports.OutputHeads{} | |
| 19 | 20 | for i, size := range sizes { | |
| @@ -40,15 +41,26 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ch | |||
| 40 | 41 | return set.wait() | |
| 41 | 42 | } | |
| 42 | 43 | } | |
| 43 | - | if !ch.sendInventory(ctx, inventory) { | |
| 44 | - | return set.wait() | |
| 45 | - | } | |
| 44 | + | apply.heads(inventory) | |
| 46 | 45 | // The pointer starts centred on the first output, like libinput's. | |
| 47 | 46 | curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2) | |
| 48 | 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 | 51 | select { | |
| 50 | 52 | case <-ctx.Done(): | |
| 51 | 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 | 64 | case s := <-ch.scenes: | |
| 53 | 65 | set.scenes(s) | |
| 54 | 66 | 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 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 4 | + | "errors" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "image" | |
| 7 | + | "maps" | |
| 7 | 8 | "math" | |
| 8 | 9 | "reflect" | |
| 9 | 10 | "slices" | |
| @@ -12,8 +13,8 @@ import ( | |||
| 12 | 13 | "github.com/bnema/zerowrap" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 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. | |
| 17 | 18 | type outputOverrides struct { | |
| 18 | 19 | file ports.Config | |
| 19 | 20 | overrides map[string]ports.OutputConfig | |
| @@ -157,131 +158,184 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) { | |||
| 157 | 158 | return o.effective(), nil | |
| 158 | 159 | } | |
| 159 | 160 | ||
| 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 | |
| 169 | 204 | } | |
| 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) | |
| 180 | 207 | } | |
| 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 | |
| 186 | 223 | } | |
| 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 | |
| 192 | 230 | } | |
| 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 | |
| 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 | } | |
| @@ -1,9 +1,10 @@ | |||||
| 1 | package app | 1 | 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 the | 16 | + | // 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.Config | 19 | file ports.Config | ||
| 19 | overrides map[string]ports.OutputConfig | 20 | overrides map[string]ports.OutputConfig | ||
| @@ -157,131 +158,184 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) { | |||||
| 157 | return o.effective(), nil | 158 | 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.Config | 165 | + | // overrides, supersede by reload, and rollback after a failed backend apply. |
| 165 | - | req *ports.OutputApply | 166 | + | // It belongs to the backend owner loop, which starts the configuration next |
| 166 | - | previous map[string]ports.OutputConfig | 167 | + | // returns, reports each result through finished and drains the outbox (core |
| 167 | - | rollback bool | 168 | + | // configuration, Wayland replies and inventory) with sends that never block |
| 168 | - | failure error | 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 | |||
| 169 | } | 204 | } | ||
| 170 | - | var queue []operation | 205 | + | if !a.state.headless { |
| 171 | - | var active *operation | 206 | + | 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 | - | return | 226 | + | 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 = &op | 234 | + | return |
| 197 | - | queue = queue[1:] | 235 | + | } |
| 198 | - | next = active.cfg | 236 | + | a.req, a.previous, a.rollback, a.failure = &req, previous, false, nil |
| 199 | - | sending = backend | 237 | + | a.due(cfg) |
| 200 | - | } | 238 | + | } |
| 201 | - | var replySend chan<- ports.OutputApplied | 239 | + | |
| 202 | - | var nextReply ports.OutputApplied | 240 | + | // 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 = replies | 242 | + | 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 | - | return | 247 | + | a.start, a.pending, a.running = ports.Config{}, false, true |
| 210 | - | case configSend <- pendingConfig: | 248 | + | return cfg, true |
| 211 | - | configSend = nil | 249 | + | } |
| 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 = nil | 254 | + | // 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 cannot | 258 | + | 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 = nil | 262 | + | 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 = cfg | 272 | + | } |
| 235 | - | next = cfg | 273 | + | 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 = heads | 278 | + | 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 | - | continue | 282 | + | 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] = entry | 286 | + | } |
| 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 | - | continue | 291 | + | } |
| 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 | - | continue | 301 | + | } |
| 264 | - | } | 302 | + | a.replies = append(a.replies, ports.OutputApplied{ID: id, Err: err}) |
| 265 | - | op := active | 303 | + | } |
| 266 | - | active = nil | 304 | + | |
| 267 | - | if op.req == nil { | 305 | + | // configOut returns the core channel when a configuration waits, else nil so |
| 268 | - | continue | 306 | + | // 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "image" | |
| 7 | 7 | "testing" | |
| 8 | - | "time" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/bnema/neferwl/internal/logging" | |
| 11 | 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 | 77 | for _, tc := range []struct { | |
| 16 | 78 | name string | |
| 17 | 79 | failure error | |
| @@ -21,140 +83,96 @@ func TestOutputSettingsRelay(t *testing.T) { | |||
| 21 | 83 | {"timeout rollback", errTimeout}, | |
| 22 | 84 | } { | |
| 23 | 85 | 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) | |
| 46 | 90 | } | |
| 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) | |
| 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 | 98 | 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) | |
| 58 | 101 | } | |
| 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) | |
| 68 | 104 | } | |
| 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) | |
| 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 | 117 | t.Fatal("override survived failure") | |
| 74 | 118 | } | |
| 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) | |
| 79 | 127 | } | |
| 80 | - | <-backend | |
| 81 | - | backendDone <- nil | |
| 82 | 128 | }) | |
| 83 | 129 | } | |
| 84 | 130 | } | |
| 85 | 131 | ||
| 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}}}}) | |
| 132 | 149 | } | |
| 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) | |
| 142 | 154 | } | |
| 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) | |
| 150 | 159 | } | |
| 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) | |
| 155 | 166 | } | |
| 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) | |
| 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 | 195 | 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) | |
| 205 | 281 | } | |
| 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) | |
| 208 | 284 | } | |
| 209 | - | backendDone <- errors.New("old apply failed") | |
| 210 | - | <-backend | |
| 211 | - | backendDone <- nil | |
| 212 | 285 | } | |
| 213 | 286 | ||
| 214 | 287 | // 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 string | 78 | name string | ||
| 17 | failure error | 79 | 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 | - | <-backend | 91 | + | 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.failure | 99 | + | if got := r.replies(); len(got) != 0 { |
| 55 | - | rollback := <-configs | 100 | + | t.Fatalf("replied before rollback: %+v", got) |
| 56 | - | if len(rollback.Config.Outputs) != 0 { | |||
| 57 | - | t.Fatalf("rollback: %+v", rollback) | |||
| 58 | } | 101 | } | ||
| 59 | - | <-backend | 102 | + | if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 0 { |
| 60 | - | backendDone <- nil | 103 | + | 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 := <-configs | 120 | + | 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 | - | <-configs | 215 | + | if _, ok := r.core(); ok { |
| 196 | - | <-backend | 216 | + | 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 the | 287 | // A reload that only changes output scales (a saved scale bind) keeps the | ||
M internal/app/outputrouting.go +10 -25
| @@ -1,13 +1,11 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/neferwl/internal/ports" | |
| 7 | - | ) | |
| 3 | + | import "github.com/bnema/neferwl/internal/ports" | |
| 8 | 4 | ||
| 9 | 5 | // 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 | 9 | type outputChannels struct { | |
| 12 | 10 | events chan<- ports.OutputEvent | |
| 13 | 11 | scenes <-chan []ports.Scene | |
| @@ -18,23 +16,10 @@ type outputChannels struct { | |||
| 18 | 16 | captured chan<- ports.CaptureDone | |
| 19 | 17 | formats chan<- ports.OutputFormats | |
| 20 | 18 | 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 | |
| 40 | 25 | } | |
| @@ -1,13 +1,11 @@ | |||||
| 1 | package app | 1 | 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 remains | 5 | // 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.OutputEvent | 10 | events chan<- ports.OutputEvent | ||
| 13 | scenes <-chan []ports.Scene | 11 | scenes <-chan []ports.Scene | ||
| @@ -18,23 +16,10 @@ type outputChannels struct { | |||||
| 18 | captured chan<- ports.CaptureDone | 16 | captured chan<- ports.CaptureDone | ||
| 19 | formats chan<- ports.OutputFormats | 17 | formats chan<- ports.OutputFormats | ||
| 20 | heads chan<- ports.OutputHeads | 18 | heads chan<- ports.OutputHeads | ||
| 21 | - | report chan<- ports.OutputHeads | 19 | + | // reloads carries file configuration; configured carries the effective |
| 22 | - | configs <-chan ports.Config | 20 | + | // configuration, overrides included, to core. |
| 23 | - | applied chan<- error | 21 | + | 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 the | 24 | + | 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 { | |||
| 819 | 819 | // Source is the committed crop in buffer pixels: x, y, width, height. | |
| 820 | 820 | // Zero width means the entire buffer is used. | |
| 821 | 821 | 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 | |
| 823 | 828 | Color SurfaceColor | |
| 824 | 829 | SHM *SHMBuffer | |
| 825 | 830 | DMABuf *DMABuf | |
| @@ -846,6 +851,32 @@ type SurfaceContent struct { | |||
| 846 | 851 | 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 | 880 | // SeqDamage is what content Seq changed from the one before: Rects in | |
| 850 | 881 | // root buffer pixels, or everything when Full. | |
| 851 | 882 | 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]float32 | 821 | 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 | |||
| 823 | Color SurfaceColor | 828 | Color SurfaceColor | ||
| 824 | SHM *SHMBuffer | 829 | SHM *SHMBuffer | ||
| 825 | DMABuf *DMABuf | 830 | DMABuf *DMABuf | ||
| @@ -846,6 +851,32 @@ type SurfaceContent struct { | |||||
| 846 | DamageHistory []SeqDamage | 851 | 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 in | 880 | // 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 | + | } | |||