detached a59ec80

feat(wayland): implement wp_single_pixel_buffer_v1 (#121)

* refactor(ports): centralize surface content buffer checks

Add SurfaceContent.HasBuffer and build Empty on it so new buffer kinds
only need one place to be counted. The vulkan scene walk uses it and only
inspects SHM stride and offset when the content is SHM. Server.addBuffer
now takes any clientBuffer and closes it on destroy when it implements
close().

* feat(wayland): implement wp_single_pixel_buffer_v1

Advertise wp_single_pixel_buffer_manager_v1 at version 1. A single-pixel
buffer reaches the renderer as SurfaceContent.Solid (1x1, premultiplied
sRGB-encoded SolidColor), opaque only when alpha is full. Vulkan draws it
as a solid quad with the alpha modifier applied and HDR pre-encoding so the
shader's decode yields the right linear value; DRM scanout treats it as a
non-dmabuf root. Rename pendingIsSHM to pendingNotDMABuf since shm and
single-pixel buffers both lack syncobj support.
commit
a59ec800dc63b2e208ea459691780fa4eb46d893
author
bnema · · 2026-10-03 20:13 UTC
committer
GitHub · · 2026-10-03 20:13 UTC
parent
1dd76b1
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
13 files · +327 -12
M README.md +1 -0
@@ -54,6 +54,7 @@ The terminal command comes from `terminal`, then `$TERMINAL`, then `foot`. `term
5454 ## The performance path
5555
5656 - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait.
57+- `wp_single_pixel_buffer_v1`: solid backgrounds and letterbox bars are drawn as fills, without client buffers.
5758 - Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane.
5859 - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output.
5960 - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it.
@@ -54,6 +54,7 @@ The terminal command comes from `terminal`, then `$TERMINAL`, then `foot`. `term
54 ## The performance path54 ## The performance path
55 55
56 - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait.56 - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait.
57+- `wp_single_pixel_buffer_v1`: solid backgrounds and letterbox bars are drawn as fills, without client buffers.
57 - Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane.58 - Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane.
58 - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output.59 - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output.
59 - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it.60 - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it.
M internal/adapters/drm/scanout_test.go +13 -0
@@ -106,6 +106,19 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
106106 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {
107107 t.Fatalf("candidate %+v, reason %q", c, reason)
108108 }
109+
110+ // A single-pixel root is hidden by the opaque dmabuf over it, like an shm
111+ // one; alone it is not a dmabuf.
112+ solidRoot := root
113+ solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1}
114+ c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100)
115+ if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 {
116+ t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason)
117+ }
118+ solidRoot.Children = nil
119+ if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" {
120+ t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason)
121+ }
109122 }
110123
111124 // VRR follows a visible fullscreen window covering the output, even
@@ -106,6 +106,19 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
106 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {106 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {
107 t.Fatalf("candidate %+v, reason %q", c, reason)107 t.Fatalf("candidate %+v, reason %q", c, reason)
108 }108 }
109+
110+ // A single-pixel root is hidden by the opaque dmabuf over it, like an shm
111+ // one; alone it is not a dmabuf.
112+ solidRoot := root
113+ solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1}
114+ c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100)
115+ if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 {
116+ t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason)
117+ }
118+ solidRoot.Children = nil
119+ if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" {
120+ t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason)
121+ }
109 }122 }
110 123
111 // VRR follows a visible fullscreen window covering the output, even124 // VRR follows a visible fullscreen window covering the output, even
M internal/adapters/vulkan/compose.go +24 -0
@@ -302,6 +302,30 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw {
302302 return dr
303303 }
304304
305+// solidDraw is the quad of a single-pixel buffer. Opaque forces alpha to 1
306+// keeping rgb, like the shader's flagOpaque does for client pixels; Fade
307+// scales the four premultiplied channels. The shader decodes the rgb of a
308+// solid on HDR outputs, so there it is pre-encoded such that the decode
309+// yields the linear premultiplied color.
310+func (r *Renderer) solidDraw(rect image.Rectangle, content *ports.SurfaceContent) draw {
311+ c := *content.Solid
312+ if content.Opaque {
313+ c.A = 1
314+ }
315+ k := 1 - max(0, min(1, content.Fade))
316+ c.R, c.G, c.B, c.A = c.R*k, c.G*k, c.B*k, c.A*k
317+ if a := float64(c.A); r.hdrNits > 0 && a > 0 {
318+ enc := func(ch float32) float32 {
319+ v := max(0, min(1, float64(ch)/a))
320+ return float32(linearToSRGB(srgbToLinear(v) * a))
321+ }
322+ c.R, c.G, c.B = enc(c.R), enc(c.G), enc(c.B)
323+ }
324+ dr := r.fillDraw(rect, [3]uint8{})
325+ dr.pc.color = [4]float32{c.R, c.G, c.B, c.A}
326+ return dr
327+}
328+
305329 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA
306330 // leaves the destination at (1-alpha) of its previous value.
307331 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
@@ -302,6 +302,30 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw {
302 return dr302 return dr
303 }303 }
304 304
305+// solidDraw is the quad of a single-pixel buffer. Opaque forces alpha to 1
306+// keeping rgb, like the shader's flagOpaque does for client pixels; Fade
307+// scales the four premultiplied channels. The shader decodes the rgb of a
308+// solid on HDR outputs, so there it is pre-encoded such that the decode
309+// yields the linear premultiplied color.
310+func (r *Renderer) solidDraw(rect image.Rectangle, content *ports.SurfaceContent) draw {
311+ c := *content.Solid
312+ if content.Opaque {
313+ c.A = 1
314+ }
315+ k := 1 - max(0, min(1, content.Fade))
316+ c.R, c.G, c.B, c.A = c.R*k, c.G*k, c.B*k, c.A*k
317+ if a := float64(c.A); r.hdrNits > 0 && a > 0 {
318+ enc := func(ch float32) float32 {
319+ v := max(0, min(1, float64(ch)/a))
320+ return float32(linearToSRGB(srgbToLinear(v) * a))
321+ }
322+ c.R, c.G, c.B = enc(c.R), enc(c.G), enc(c.B)
323+ }
324+ dr := r.fillDraw(rect, [3]uint8{})
325+ dr.pc.color = [4]float32{c.R, c.G, c.B, c.A}
326+ return dr
327+}
328+
305 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA329 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA
306 // leaves the destination at (1-alpha) of its previous value.330 // leaves the destination at (1-alpha) of its previous value.
307 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {331 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
M internal/adapters/vulkan/pq.go +9 -0
@@ -17,6 +17,15 @@ func srgbToLinear(v float64) float64 {
1717 return math.Pow((v+0.055)/1.055, 2.4)
1818 }
1919
20+// linearToSRGB encodes a unit-range linear component; the inverse of
21+// srgbToLinear.
22+func linearToSRGB(v float64) float64 {
23+ if v <= 0.04045/12.92 {
24+ return v * 12.92
25+ }
26+ return 1.055*math.Pow(v, 1/2.4) - 0.055
27+}
28+
2029 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
2130 func pqEncode(nits float64) float64 {
2231 if nits <= 0 {
@@ -17,6 +17,15 @@ func srgbToLinear(v float64) float64 {
17 return math.Pow((v+0.055)/1.055, 2.4)17 return math.Pow((v+0.055)/1.055, 2.4)
18 }18 }
19 19
20+// linearToSRGB encodes a unit-range linear component; the inverse of
21+// srgbToLinear.
22+func linearToSRGB(v float64) float64 {
23+ if v <= 0.04045/12.92 {
24+ return v * 12.92
25+ }
26+ return 1.055*math.Pow(v, 1/2.4) - 0.055
27+}
28+
20 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.29 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
21 func pqEncode(nits float64) float64 {30 func pqEncode(nits float64) float64 {
22 if nits <= 0 {31 if nits <= 0 {
M internal/adapters/vulkan/scene.go +11 -2
@@ -292,6 +292,9 @@ func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64
292292 // drawable reports whether the child's buffer will draw. GPU buffer
293293 // imports are cached, so pre-importing here costs nothing.
294294 func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool {
295+ if c.Solid != nil {
296+ return true
297+ }
295298 if c.DMABuf != nil {
296299 _, err := w.r.importDMABuf(c.DMABuf)
297300 return err == nil
@@ -312,10 +315,10 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl
312315 if lw <= 0 || lh <= 0 {
313316 lw, lh = content.Width, content.Height
314317 }
315- if content.SHM == nil && content.DMABuf == nil || lw <= 0 || lh <= 0 || content.Width <= 0 || content.Height <= 0 {
318+ if !content.HasBuffer() || lw <= 0 || lh <= 0 || content.Width <= 0 || content.Height <= 0 {
316319 return full, dst, false
317320 }
318- if content.DMABuf == nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) {
321+ if content.SHM != nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) {
319322 return full, dst, false
320323 }
321324 full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom)
@@ -361,6 +364,12 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
361364 if rect.Empty() {
362365 return
363366 }
367+ if content.Solid != nil {
368+ if dr := r.solidDraw(rect, content); dr.pc.color[3] > 0 {
369+ w.draws = append(w.draws, dr)
370+ }
371+ return
372+ }
364373 if content.DMABuf != nil {
365374 im, err := r.importDMABuf(content.DMABuf)
366375 if err != nil {
@@ -292,6 +292,9 @@ func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64
292 // drawable reports whether the child's buffer will draw. GPU buffer292 // drawable reports whether the child's buffer will draw. GPU buffer
293 // imports are cached, so pre-importing here costs nothing.293 // imports are cached, so pre-importing here costs nothing.
294 func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool {294 func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool {
295+ if c.Solid != nil {
296+ return true
297+ }
295 if c.DMABuf != nil {298 if c.DMABuf != nil {
296 _, err := w.r.importDMABuf(c.DMABuf)299 _, err := w.r.importDMABuf(c.DMABuf)
297 return err == nil300 return err == nil
@@ -312,10 +315,10 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl
312 if lw <= 0 || lh <= 0 {315 if lw <= 0 || lh <= 0 {
313 lw, lh = content.Width, content.Height316 lw, lh = content.Width, content.Height
314 }317 }
315- if content.SHM == nil && content.DMABuf == nil || lw <= 0 || lh <= 0 || content.Width <= 0 || content.Height <= 0 {318+ if !content.HasBuffer() || lw <= 0 || lh <= 0 || content.Width <= 0 || content.Height <= 0 {
316 return full, dst, false319 return full, dst, false
317 }320 }
318- if content.DMABuf == nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) {321+ if content.SHM != nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) {
319 return full, dst, false322 return full, dst, false
320 }323 }
321 full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom)324 full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom)
@@ -361,6 +364,12 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
361 if rect.Empty() {364 if rect.Empty() {
362 return365 return
363 }366 }
367+ if content.Solid != nil {
368+ if dr := r.solidDraw(rect, content); dr.pc.color[3] > 0 {
369+ w.draws = append(w.draws, dr)
370+ }
371+ return
372+ }
364 if content.DMABuf != nil {373 if content.DMABuf != nil {
365 im, err := r.importDMABuf(content.DMABuf)374 im, err := r.importDMABuf(content.DMABuf)
366 if err != nil {375 if err != nil {
A internal/adapters/vulkan/scene_solid_test.go +94 -0
@@ -0,0 +1,94 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "math"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// These walk tests need no Vulkan device: a single-pixel buffer is a solid
12+// quad, which imports nothing.
13+func TestSceneSolid(t *testing.T) {
14+ walk := func(r *Renderer, c ports.SurfaceContent) []draw {
15+ c.ID = 1
16+ c.Width, c.Height, c.LogicalW, c.LogicalH = 1, 1, 20, 20
17+ s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}}}
18+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
19+ return r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
20+ }
21+ // The window's own background fill comes first; the content follows.
22+ solids := func(ds []draw) []draw {
23+ if len(ds) == 0 || ds[0].pc.misc[0] != modeSolid || ds[0].pc.rect != [4]int32{0, 0, 20, 20} {
24+ t.Fatalf("no window background first: %+v", ds)
25+ }
26+ return ds[1:]
27+ }
28+ near := func(a, b [4]float32) bool {
29+ for i := range a {
30+ if math.Abs(float64(a[i]-b[i])) > 1e-6 {
31+ return false
32+ }
33+ }
34+ return true
35+ }
36+ half := &ports.SolidColor{R: 0.25, G: 0.125, B: 0, A: 0.5} // premultiplied
37+ t.Run("premultiplied", func(t *testing.T) {
38+ r := &Renderer{width: 80, height: 60}
39+ got := solids(walk(r, ports.SurfaceContent{Solid: half}))
40+ if len(got) != 1 || got[0].pc.color != [4]float32{0.25, 0.125, 0, 0.5} {
41+ t.Fatalf("draws = %+v", got)
42+ }
43+ })
44+ t.Run("fade", func(t *testing.T) {
45+ r := &Renderer{width: 80, height: 60}
46+ got := solids(walk(r, ports.SurfaceContent{Solid: half, Fade: 0.5}))
47+ if len(got) != 1 || got[0].pc.color != [4]float32{0.125, 0.0625, 0, 0.25} {
48+ t.Fatalf("draws = %+v", got)
49+ }
50+ })
51+ t.Run("opaque", func(t *testing.T) {
52+ r := &Renderer{width: 80, height: 60}
53+ got := solids(walk(r, ports.SurfaceContent{Solid: half, Opaque: true}))
54+ if len(got) != 1 || got[0].pc.color != [4]float32{0.25, 0.125, 0, 1} {
55+ t.Fatalf("draws = %+v", got)
56+ }
57+ })
58+ t.Run("hdr", func(t *testing.T) {
59+ r := &Renderer{width: 80, height: 60, hdrNits: 203}
60+ got := solids(walk(r, ports.SurfaceContent{Solid: half}))
61+ if len(got) != 1 {
62+ t.Fatalf("draws = %+v", got)
63+ }
64+ // The HDR shader decodes rgb (decodeSRGB) and blends linear light:
65+ // decoded values must be the linear straight color times alpha.
66+ c := got[0].pc.color
67+ decoded := [4]float32{float32(srgbToLinear(float64(c[0]))), float32(srgbToLinear(float64(c[1]))), float32(srgbToLinear(float64(c[2]))), c[3]}
68+ want := [4]float32{float32(srgbToLinear(0.5) * 0.5), float32(srgbToLinear(0.25) * 0.5), 0, 0.5}
69+ if !near(decoded, want) {
70+ t.Fatalf("decoded = %v, want %v", decoded, want)
71+ }
72+ })
73+ t.Run("transparent", func(t *testing.T) {
74+ r := &Renderer{width: 80, height: 60}
75+ if got := solids(walk(r, ports.SurfaceContent{Solid: &ports.SolidColor{}})); len(got) != 0 {
76+ t.Fatalf("draws = %+v", got)
77+ }
78+ // Fully faded away too.
79+ if got := walk(r, ports.SurfaceContent{Solid: half, Fade: 1}); len(solids(got)) != 0 {
80+ t.Fatalf("faded draws = %+v", got)
81+ }
82+ })
83+}
84+
85+func TestLinearToSRGB(t *testing.T) {
86+ for _, v := range []float64{0, 0.002, 0.04045, 0.5, 1} {
87+ if got := srgbToLinear(linearToSRGB(v)); math.Abs(got-v) > 1e-6 {
88+ t.Errorf("round trip of %v = %v", v, got)
89+ }
90+ if got := linearToSRGB(srgbToLinear(v)); math.Abs(got-v) > 1e-6 {
91+ t.Errorf("inverse round trip of %v = %v", v, got)
92+ }
93+ }
94+}
@@ -0,0 +1,94 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "math"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// These walk tests need no Vulkan device: a single-pixel buffer is a solid
12+// quad, which imports nothing.
13+func TestSceneSolid(t *testing.T) {
14+ walk := func(r *Renderer, c ports.SurfaceContent) []draw {
15+ c.ID = 1
16+ c.Width, c.Height, c.LogicalW, c.LogicalH = 1, 1, 20, 20
17+ s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}}}
18+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
19+ return r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
20+ }
21+ // The window's own background fill comes first; the content follows.
22+ solids := func(ds []draw) []draw {
23+ if len(ds) == 0 || ds[0].pc.misc[0] != modeSolid || ds[0].pc.rect != [4]int32{0, 0, 20, 20} {
24+ t.Fatalf("no window background first: %+v", ds)
25+ }
26+ return ds[1:]
27+ }
28+ near := func(a, b [4]float32) bool {
29+ for i := range a {
30+ if math.Abs(float64(a[i]-b[i])) > 1e-6 {
31+ return false
32+ }
33+ }
34+ return true
35+ }
36+ half := &ports.SolidColor{R: 0.25, G: 0.125, B: 0, A: 0.5} // premultiplied
37+ t.Run("premultiplied", func(t *testing.T) {
38+ r := &Renderer{width: 80, height: 60}
39+ got := solids(walk(r, ports.SurfaceContent{Solid: half}))
40+ if len(got) != 1 || got[0].pc.color != [4]float32{0.25, 0.125, 0, 0.5} {
41+ t.Fatalf("draws = %+v", got)
42+ }
43+ })
44+ t.Run("fade", func(t *testing.T) {
45+ r := &Renderer{width: 80, height: 60}
46+ got := solids(walk(r, ports.SurfaceContent{Solid: half, Fade: 0.5}))
47+ if len(got) != 1 || got[0].pc.color != [4]float32{0.125, 0.0625, 0, 0.25} {
48+ t.Fatalf("draws = %+v", got)
49+ }
50+ })
51+ t.Run("opaque", func(t *testing.T) {
52+ r := &Renderer{width: 80, height: 60}
53+ got := solids(walk(r, ports.SurfaceContent{Solid: half, Opaque: true}))
54+ if len(got) != 1 || got[0].pc.color != [4]float32{0.25, 0.125, 0, 1} {
55+ t.Fatalf("draws = %+v", got)
56+ }
57+ })
58+ t.Run("hdr", func(t *testing.T) {
59+ r := &Renderer{width: 80, height: 60, hdrNits: 203}
60+ got := solids(walk(r, ports.SurfaceContent{Solid: half}))
61+ if len(got) != 1 {
62+ t.Fatalf("draws = %+v", got)
63+ }
64+ // The HDR shader decodes rgb (decodeSRGB) and blends linear light:
65+ // decoded values must be the linear straight color times alpha.
66+ c := got[0].pc.color
67+ decoded := [4]float32{float32(srgbToLinear(float64(c[0]))), float32(srgbToLinear(float64(c[1]))), float32(srgbToLinear(float64(c[2]))), c[3]}
68+ want := [4]float32{float32(srgbToLinear(0.5) * 0.5), float32(srgbToLinear(0.25) * 0.5), 0, 0.5}
69+ if !near(decoded, want) {
70+ t.Fatalf("decoded = %v, want %v", decoded, want)
71+ }
72+ })
73+ t.Run("transparent", func(t *testing.T) {
74+ r := &Renderer{width: 80, height: 60}
75+ if got := solids(walk(r, ports.SurfaceContent{Solid: &ports.SolidColor{}})); len(got) != 0 {
76+ t.Fatalf("draws = %+v", got)
77+ }
78+ // Fully faded away too.
79+ if got := walk(r, ports.SurfaceContent{Solid: half, Fade: 1}); len(solids(got)) != 0 {
80+ t.Fatalf("faded draws = %+v", got)
81+ }
82+ })
83+}
84+
85+func TestLinearToSRGB(t *testing.T) {
86+ for _, v := range []float64{0, 0.002, 0.04045, 0.5, 1} {
87+ if got := srgbToLinear(linearToSRGB(v)); math.Abs(got-v) > 1e-6 {
88+ t.Errorf("round trip of %v = %v", v, got)
89+ }
90+ if got := linearToSRGB(srgbToLinear(v)); math.Abs(got-v) > 1e-6 {
91+ t.Errorf("inverse round trip of %v = %v", v, got)
92+ }
93+ }
94+}
M internal/adapters/wayland/globals.go +8 -3
@@ -30,6 +30,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3030 func() error { return registerScale(d, s) },
3131 func() error { return registerCursorShape(d, s) },
3232 func() error { return registerDMABuf(d, s, o.DMABuf) },
33+ func() error { return registerSinglePixelBuffer(d, s) },
3334 func() error { return registerPointerConstraints(d, s) },
3435 func() error { return registerTearing(d, s) },
3536 func() error { return registerColorManagement(d, s) },
@@ -390,7 +391,8 @@ func (*buffer) Destroy(*wayland.Buffer) {}
390391
391392 func (b *buffer) size() (int, int) { return b.width, b.height }
392393
393-// clientBuffer is a wl_buffer's content source: wl_shm or linux-dmabuf.
394+// clientBuffer is a wl_buffer's content source: wl_shm, linux-dmabuf or
395+// single-pixel buffers.
394396 type clientBuffer interface {
395397 // content returns what the renderer draws; false means the client
396398 // broke its buffer (a truncated shm file).
@@ -399,11 +401,14 @@ type clientBuffer interface {
399401 }
400402
401403 // addBuffer tracks a buffer until the client destroys it.
402-func (s *Server) addBuffer(r *wayland.Buffer, b *dmabufBuffer) {
404+// A buffer that owns resources implements close() to release them.
405+func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) {
403406 s.buffers[r.Resource] = b
404407 r.OnDestroy = func() {
405408 delete(s.buffers, r.Resource)
406- b.close()
409+ if c, ok := b.(interface{ close() }); ok {
410+ c.close()
411+ }
407412 }
408413 }
409414
@@ -30,6 +30,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
30 func() error { return registerScale(d, s) },30 func() error { return registerScale(d, s) },
31 func() error { return registerCursorShape(d, s) },31 func() error { return registerCursorShape(d, s) },
32 func() error { return registerDMABuf(d, s, o.DMABuf) },32 func() error { return registerDMABuf(d, s, o.DMABuf) },
33+ func() error { return registerSinglePixelBuffer(d, s) },
33 func() error { return registerPointerConstraints(d, s) },34 func() error { return registerPointerConstraints(d, s) },
34 func() error { return registerTearing(d, s) },35 func() error { return registerTearing(d, s) },
35 func() error { return registerColorManagement(d, s) },36 func() error { return registerColorManagement(d, s) },
@@ -390,7 +391,8 @@ func (*buffer) Destroy(*wayland.Buffer) {}
390 391
391 func (b *buffer) size() (int, int) { return b.width, b.height }392 func (b *buffer) size() (int, int) { return b.width, b.height }
392 393
393-// clientBuffer is a wl_buffer's content source: wl_shm or linux-dmabuf.394+// clientBuffer is a wl_buffer's content source: wl_shm, linux-dmabuf or
395+// single-pixel buffers.
394 type clientBuffer interface {396 type clientBuffer interface {
395 // content returns what the renderer draws; false means the client397 // content returns what the renderer draws; false means the client
396 // broke its buffer (a truncated shm file).398 // broke its buffer (a truncated shm file).
@@ -399,11 +401,14 @@ type clientBuffer interface {
399 }401 }
400 402
401 // addBuffer tracks a buffer until the client destroys it.403 // addBuffer tracks a buffer until the client destroys it.
402-func (s *Server) addBuffer(r *wayland.Buffer, b *dmabufBuffer) {404+// A buffer that owns resources implements close() to release them.
405+func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) {
403 s.buffers[r.Resource] = b406 s.buffers[r.Resource] = b
404 r.OnDestroy = func() {407 r.OnDestroy = func() {
405 delete(s.buffers, r.Resource)408 delete(s.buffers, r.Resource)
406- b.close()409+ if c, ok := b.(interface{ close() }); ok {
410+ c.close()
411+ }
407 }412 }
408 }413 }
409 414
A internal/adapters/wayland/singlepixel.go +54 -0
@@ -0,0 +1,54 @@
1+package wayland
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer"
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// wp_single_pixel_buffer_v1: a 1x1 wl_buffer of one color. Clients use it
13+// for solid backgrounds and letterbox bars; the renderer draws it as a fill
14+// (ports.SurfaceContent.Solid) and no pixels are ever read or imported.
15+
16+func registerSinglePixelBuffer(d *server.Display, s *Server) error {
17+ return singlepixelbuffer.NewWpSinglePixelBufferManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
18+ _, _ = singlepixelbuffer.NewWpSinglePixelBufferManagerV1(c, int32(v), id, singlePixelManager{s})
19+ })
20+}
21+
22+type singlePixelManager struct{ server *Server }
23+
24+func (singlePixelManager) Destroy(*singlepixelbuffer.WpSinglePixelBufferManagerV1) {}
25+
26+// CreateU32RgbaBuffer takes 32-bit components already premultiplied by
27+// alpha, like client pixels; they become unit floats.
28+func (m singlePixelManager) CreateU32RgbaBuffer(r *singlepixelbuffer.WpSinglePixelBufferManagerV1, id, red, green, blue, alpha uint32) {
29+ unit := func(v uint32) float32 { return float32(float64(v) / math.MaxUint32) }
30+ buf := &singlePixelBuffer{
31+ color: ports.SolidColor{R: unit(red), G: unit(green), B: unit(blue), A: unit(alpha)},
32+ opaque: alpha == math.MaxUint32,
33+ }
34+ res, err := wayland.NewBuffer(r.Client(), 1, id, buf)
35+ if err != nil {
36+ return
37+ }
38+ m.server.addBuffer(res, buf)
39+}
40+
41+// singlePixelBuffer is a wl_buffer of one color. The color is never mutated,
42+// so content shares it between commits.
43+type singlePixelBuffer struct {
44+ color ports.SolidColor
45+ opaque bool
46+}
47+
48+func (*singlePixelBuffer) Destroy(*wayland.Buffer) {}
49+
50+func (*singlePixelBuffer) size() (int, int) { return 1, 1 }
51+
52+func (b *singlePixelBuffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
53+ return ports.SurfaceContent{ID: id, Width: 1, Height: 1, Opaque: b.opaque, Solid: &b.color}, true
54+}
@@ -0,0 +1,54 @@
1+package wayland
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer"
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// wp_single_pixel_buffer_v1: a 1x1 wl_buffer of one color. Clients use it
13+// for solid backgrounds and letterbox bars; the renderer draws it as a fill
14+// (ports.SurfaceContent.Solid) and no pixels are ever read or imported.
15+
16+func registerSinglePixelBuffer(d *server.Display, s *Server) error {
17+ return singlepixelbuffer.NewWpSinglePixelBufferManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
18+ _, _ = singlepixelbuffer.NewWpSinglePixelBufferManagerV1(c, int32(v), id, singlePixelManager{s})
19+ })
20+}
21+
22+type singlePixelManager struct{ server *Server }
23+
24+func (singlePixelManager) Destroy(*singlepixelbuffer.WpSinglePixelBufferManagerV1) {}
25+
26+// CreateU32RgbaBuffer takes 32-bit components already premultiplied by
27+// alpha, like client pixels; they become unit floats.
28+func (m singlePixelManager) CreateU32RgbaBuffer(r *singlepixelbuffer.WpSinglePixelBufferManagerV1, id, red, green, blue, alpha uint32) {
29+ unit := func(v uint32) float32 { return float32(float64(v) / math.MaxUint32) }
30+ buf := &singlePixelBuffer{
31+ color: ports.SolidColor{R: unit(red), G: unit(green), B: unit(blue), A: unit(alpha)},
32+ opaque: alpha == math.MaxUint32,
33+ }
34+ res, err := wayland.NewBuffer(r.Client(), 1, id, buf)
35+ if err != nil {
36+ return
37+ }
38+ m.server.addBuffer(res, buf)
39+}
40+
41+// singlePixelBuffer is a wl_buffer of one color. The color is never mutated,
42+// so content shares it between commits.
43+type singlePixelBuffer struct {
44+ color ports.SolidColor
45+ opaque bool
46+}
47+
48+func (*singlePixelBuffer) Destroy(*wayland.Buffer) {}
49+
50+func (*singlePixelBuffer) size() (int, int) { return 1, 1 }
51+
52+func (b *singlePixelBuffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
53+ return ports.SurfaceContent{ID: id, Width: 1, Height: 1, Opaque: b.opaque, Solid: &b.color}, true
54+}
A internal/adapters/wayland/singlepixel_test.go +60 -0
@@ -0,0 +1,60 @@
1+package wayland
2+
3+import (
4+ "math"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer"
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// A single-pixel buffer reaches the renderer as a 1x1 solid color, opaque
14+// only when its alpha is full.
15+func TestSinglePixelBuffer(t *testing.T) {
16+ s, events, contents, dir := dmabufServer(t, ports.DMABufSupport{})
17+ c := protocolClient(t, s, dir)
18+ g, ok := c.Registry().FindGlobal("wp_single_pixel_buffer_manager_v1")
19+ if !ok || g.Version != 1 {
20+ t.Fatalf("global %+v", g)
21+ }
22+ mgr, err := bindWireID(c, g.Name, g.Interface, 1)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ w, surf, _ := surfaceMapper(t, c, events)()
27+ solid := func(alpha uint32, red uint32) ports.SurfaceContent {
28+ t.Helper()
29+ buf := c.AllocateID()
30+ registerProtocol(t, c, buf)
31+ requestProtocol(t, c, mgr, singlepixelbuffer.WpSinglePixelBufferManagerV1RequestCreateU32RgbaBuffer, buf, red, uint32(0), uint32(0), alpha)
32+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
33+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
34+ if err := c.Roundtrip(); err != nil {
35+ t.Fatal(err)
36+ }
37+ timeout := time.After(2 * time.Second)
38+ for {
39+ select {
40+ case got := <-contents:
41+ if got.ID == w.ID && got.Solid != nil {
42+ return got
43+ }
44+ case <-timeout:
45+ t.Fatal("no solid content")
46+ }
47+ }
48+ }
49+ got := solid(math.MaxUint32, 0)
50+ if got.Width != 1 || got.Height != 1 || !got.Opaque || *got.Solid != (ports.SolidColor{A: 1}) {
51+ t.Fatalf("opaque black = %+v solid %+v", got, got.Solid)
52+ }
53+ got = solid(math.MaxUint32/2, math.MaxUint32/2)
54+ if got.Width != 1 || got.Height != 1 || got.Opaque {
55+ t.Fatalf("translucent = %+v", got)
56+ }
57+ if want := float32(0.5); got.Solid.R != want || got.Solid.A != want || got.Solid.G != 0 || got.Solid.B != 0 {
58+ t.Fatalf("translucent color = %+v", got.Solid)
59+ }
60+}
@@ -0,0 +1,60 @@
1+package wayland
2+
3+import (
4+ "math"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer"
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// A single-pixel buffer reaches the renderer as a 1x1 solid color, opaque
14+// only when its alpha is full.
15+func TestSinglePixelBuffer(t *testing.T) {
16+ s, events, contents, dir := dmabufServer(t, ports.DMABufSupport{})
17+ c := protocolClient(t, s, dir)
18+ g, ok := c.Registry().FindGlobal("wp_single_pixel_buffer_manager_v1")
19+ if !ok || g.Version != 1 {
20+ t.Fatalf("global %+v", g)
21+ }
22+ mgr, err := bindWireID(c, g.Name, g.Interface, 1)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ w, surf, _ := surfaceMapper(t, c, events)()
27+ solid := func(alpha uint32, red uint32) ports.SurfaceContent {
28+ t.Helper()
29+ buf := c.AllocateID()
30+ registerProtocol(t, c, buf)
31+ requestProtocol(t, c, mgr, singlepixelbuffer.WpSinglePixelBufferManagerV1RequestCreateU32RgbaBuffer, buf, red, uint32(0), uint32(0), alpha)
32+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
33+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
34+ if err := c.Roundtrip(); err != nil {
35+ t.Fatal(err)
36+ }
37+ timeout := time.After(2 * time.Second)
38+ for {
39+ select {
40+ case got := <-contents:
41+ if got.ID == w.ID && got.Solid != nil {
42+ return got
43+ }
44+ case <-timeout:
45+ t.Fatal("no solid content")
46+ }
47+ }
48+ }
49+ got := solid(math.MaxUint32, 0)
50+ if got.Width != 1 || got.Height != 1 || !got.Opaque || *got.Solid != (ports.SolidColor{A: 1}) {
51+ t.Fatalf("opaque black = %+v solid %+v", got, got.Solid)
52+ }
53+ got = solid(math.MaxUint32/2, math.MaxUint32/2)
54+ if got.Width != 1 || got.Height != 1 || got.Opaque {
55+ t.Fatalf("translucent = %+v", got)
56+ }
57+ if want := float32(0.5); got.Solid.R != want || got.Solid.A != want || got.Solid.G != 0 || got.Solid.B != 0 {
58+ t.Fatalf("translucent color = %+v", got.Solid)
59+ }
60+}
M internal/adapters/wayland/syncobj.go +5 -4
@@ -168,16 +168,17 @@ func (s *surface) checkSyncCommit() bool {
168168 res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorNoReleasePoint), "no release point")
169169 case a.tl == r.tl && a.point >= r.point:
170170 res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorConflictingPoints), "release point not after acquire point")
171- case s.pendingIsSHM():
172- res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorUnsupportedBuffer), "explicit sync on a wl_shm buffer")
171+ case s.pendingNotDMABuf():
172+ res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorUnsupportedBuffer), "explicit sync on a non-dmabuf buffer")
173173 default:
174174 return true
175175 }
176176 return false
177177 }
178178
179-// pendingIsSHM reports whether the buffer of the next commit is wl_shm.
180-func (s *surface) pendingIsSHM() bool {
179+// pendingNotDMABuf reports whether the buffer of the next commit is not a
180+// dmabuf: wl_shm and single-pixel buffers have no syncobj support.
181+func (s *surface) pendingNotDMABuf() bool {
181182 b := s.next.buffer
182183 if !s.next.attached {
183184 b = s.current
@@ -168,16 +168,17 @@ func (s *surface) checkSyncCommit() bool {
168 res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorNoReleasePoint), "no release point")168 res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorNoReleasePoint), "no release point")
169 case a.tl == r.tl && a.point >= r.point:169 case a.tl == r.tl && a.point >= r.point:
170 res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorConflictingPoints), "release point not after acquire point")170 res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorConflictingPoints), "release point not after acquire point")
171- case s.pendingIsSHM():171+ case s.pendingNotDMABuf():
172- res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorUnsupportedBuffer), "explicit sync on a wl_shm buffer")172+ res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorUnsupportedBuffer), "explicit sync on a non-dmabuf buffer")
173 default:173 default:
174 return true174 return true
175 }175 }
176 return false176 return false
177 }177 }
178 178
179-// pendingIsSHM reports whether the buffer of the next commit is wl_shm.179+// pendingNotDMABuf reports whether the buffer of the next commit is not a
180-func (s *surface) pendingIsSHM() bool {180+// dmabuf: wl_shm and single-pixel buffers have no syncobj support.
181+func (s *surface) pendingNotDMABuf() bool {
181 b := s.next.buffer182 b := s.next.buffer
182 if !s.next.attached {183 if !s.next.attached {
183 b = s.current184 b = s.current
M internal/adapters/wayland/wire_client_test.go +2 -0
@@ -37,6 +37,7 @@ import (
3737 wire_relativepointer "github.com/bnema/go-wayland-bindings/server/relativepointer"
3838 wire_securitycontext "github.com/bnema/go-wayland-bindings/server/securitycontext"
3939 wire_kdedecoration "github.com/bnema/go-wayland-bindings/server/serverdecoration"
40+ wire_singlepixelbuffer "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer"
4041 wire_tearingcontrol "github.com/bnema/go-wayland-bindings/server/tearingcontrol"
4142 wire_textinput "github.com/bnema/go-wayland-bindings/server/textinput"
4243 wire_viewporter "github.com/bnema/go-wayland-bindings/server/viewporter"
@@ -188,6 +189,7 @@ var wireSchemas = map[string]wireSchema{
188189 "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}},
189190 "wp_security_context_manager_v1": {wire_securitycontext.WpSecurityContextManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
190191 "wp_security_context_v1": {wire_securitycontext.WpSecurityContextV1Interface, nil, map[uint16]bool{0: true}, nil},
192+ "wp_single_pixel_buffer_manager_v1": {wire_singlepixelbuffer.WpSinglePixelBufferManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
191193 "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil},
192194 "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil},
193195 "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil},
@@ -37,6 +37,7 @@ import (
37 wire_relativepointer "github.com/bnema/go-wayland-bindings/server/relativepointer"37 wire_relativepointer "github.com/bnema/go-wayland-bindings/server/relativepointer"
38 wire_securitycontext "github.com/bnema/go-wayland-bindings/server/securitycontext"38 wire_securitycontext "github.com/bnema/go-wayland-bindings/server/securitycontext"
39 wire_kdedecoration "github.com/bnema/go-wayland-bindings/server/serverdecoration"39 wire_kdedecoration "github.com/bnema/go-wayland-bindings/server/serverdecoration"
40+ wire_singlepixelbuffer "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer"
40 wire_tearingcontrol "github.com/bnema/go-wayland-bindings/server/tearingcontrol"41 wire_tearingcontrol "github.com/bnema/go-wayland-bindings/server/tearingcontrol"
41 wire_textinput "github.com/bnema/go-wayland-bindings/server/textinput"42 wire_textinput "github.com/bnema/go-wayland-bindings/server/textinput"
42 wire_viewporter "github.com/bnema/go-wayland-bindings/server/viewporter"43 wire_viewporter "github.com/bnema/go-wayland-bindings/server/viewporter"
@@ -188,6 +189,7 @@ var wireSchemas = map[string]wireSchema{
188 "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}},189 "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}},
189 "wp_security_context_manager_v1": {wire_securitycontext.WpSecurityContextManagerV1Interface, nil, map[uint16]bool{0: true}, nil},190 "wp_security_context_manager_v1": {wire_securitycontext.WpSecurityContextManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
190 "wp_security_context_v1": {wire_securitycontext.WpSecurityContextV1Interface, nil, map[uint16]bool{0: true}, nil},191 "wp_security_context_v1": {wire_securitycontext.WpSecurityContextV1Interface, nil, map[uint16]bool{0: true}, nil},
192+ "wp_single_pixel_buffer_manager_v1": {wire_singlepixelbuffer.WpSinglePixelBufferManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
191 "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil},193 "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil},
192 "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil},194 "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil},
193 "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil},195 "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil},
M internal/ports/scene.go +16 -3
@@ -148,8 +148,9 @@ const (
148148 )
149149
150150 // SurfaceContent carries wayland → output the latest committed content of a
151-// window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.
152-// Renderers read both in place and never modify them. Empty means the window
151+// window: SHM, a client shared-memory buffer, DMABuf, a GPU buffer, or
152+// Solid, a single-pixel buffer. Renderers read them in place and never modify
153+// them. Empty means the window
153154 // has no content. LogicalW and LogicalH are the surface size in logical
154155 // pixels (buffer scale and viewport applied). Subsurfaces come in Children;
155156 // Geometry is the part of the surface that is the window (xdg window
@@ -179,6 +180,9 @@ type SurfaceContent struct {
179180 Color SurfaceColor
180181 SHM *SHMBuffer
181182 DMABuf *DMABuf
183+ // Solid is a single-pixel buffer (Width and Height are 1): the surface is
184+ // one flat color.
185+ Solid *SolidColor
182186 // Children are the subsurfaces, bottom to top, flattened.
183187 Children []Subsurface
184188 // Geometry is in logical pixels from the surface origin; empty means
@@ -293,9 +297,18 @@ type Subsurface struct {
293297 SurfaceContent
294298 }
295299
300+// HasBuffer reports whether the surface itself carries a client buffer.
301+func (c SurfaceContent) HasBuffer() bool {
302+ return c.SHM != nil || c.DMABuf != nil || c.Solid != nil
303+}
304+
305+// SolidColor is the color of a single-pixel buffer: premultiplied by alpha,
306+// 0 to 1, sRGB-encoded like the pixels of client buffers.
307+type SolidColor struct{ R, G, B, A float32 }
308+
296309 // Empty reports whether the content has nothing to draw.
297310 func (c SurfaceContent) Empty() bool {
298- return c.SHM == nil && c.DMABuf == nil && len(c.Children) == 0
311+ return !c.HasBuffer() && len(c.Children) == 0
299312 }
300313
301314 // SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888
@@ -148,8 +148,9 @@ const (
148 )148 )
149 149
150 // SurfaceContent carries wayland → output the latest committed content of a150 // SurfaceContent carries wayland → output the latest committed content of a
151-// window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.151+// window: SHM, a client shared-memory buffer, DMABuf, a GPU buffer, or
152-// Renderers read both in place and never modify them. Empty means the window152+// Solid, a single-pixel buffer. Renderers read them in place and never modify
153+// them. Empty means the window
153 // has no content. LogicalW and LogicalH are the surface size in logical154 // has no content. LogicalW and LogicalH are the surface size in logical
154 // pixels (buffer scale and viewport applied). Subsurfaces come in Children;155 // pixels (buffer scale and viewport applied). Subsurfaces come in Children;
155 // Geometry is the part of the surface that is the window (xdg window156 // Geometry is the part of the surface that is the window (xdg window
@@ -179,6 +180,9 @@ type SurfaceContent struct {
179 Color SurfaceColor180 Color SurfaceColor
180 SHM *SHMBuffer181 SHM *SHMBuffer
181 DMABuf *DMABuf182 DMABuf *DMABuf
183+ // Solid is a single-pixel buffer (Width and Height are 1): the surface is
184+ // one flat color.
185+ Solid *SolidColor
182 // Children are the subsurfaces, bottom to top, flattened.186 // Children are the subsurfaces, bottom to top, flattened.
183 Children []Subsurface187 Children []Subsurface
184 // Geometry is in logical pixels from the surface origin; empty means188 // Geometry is in logical pixels from the surface origin; empty means
@@ -293,9 +297,18 @@ type Subsurface struct {
293 SurfaceContent297 SurfaceContent
294 }298 }
295 299
300+// HasBuffer reports whether the surface itself carries a client buffer.
301+func (c SurfaceContent) HasBuffer() bool {
302+ return c.SHM != nil || c.DMABuf != nil || c.Solid != nil
303+}
304+
305+// SolidColor is the color of a single-pixel buffer: premultiplied by alpha,
306+// 0 to 1, sRGB-encoded like the pixels of client buffers.
307+type SolidColor struct{ R, G, B, A float32 }
308+
296 // Empty reports whether the content has nothing to draw.309 // Empty reports whether the content has nothing to draw.
297 func (c SurfaceContent) Empty() bool {310 func (c SurfaceContent) Empty() bool {
298- return c.SHM == nil && c.DMABuf == nil && len(c.Children) == 0311+ return !c.HasBuffer() && len(c.Children) == 0
299 }312 }
300 313
301 // SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888314 // SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888
A internal/ports/scene_content_test.go +30 -0
@@ -0,0 +1,30 @@
1+package ports
2+
3+import "testing"
4+
5+// HasBuffer is true for content carrying its own buffer; Empty also looks at
6+// subsurfaces.
7+func TestSurfaceContentHasBufferEmpty(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ content SurfaceContent
11+ hasBuffer bool
12+ empty bool
13+ }{
14+ {"zero", SurfaceContent{}, false, true},
15+ {"shm", SurfaceContent{SHM: &SHMBuffer{}}, true, false},
16+ {"dmabuf", SurfaceContent{DMABuf: &DMABuf{}}, true, false},
17+ {"solid", SurfaceContent{Solid: &SolidColor{A: 1}}, true, false},
18+ {"children only", SurfaceContent{Children: []Subsurface{{}}}, false, false},
19+ }
20+ for _, tt := range tests {
21+ t.Run(tt.name, func(t *testing.T) {
22+ if got := tt.content.HasBuffer(); got != tt.hasBuffer {
23+ t.Errorf("HasBuffer() = %v, want %v", got, tt.hasBuffer)
24+ }
25+ if got := tt.content.Empty(); got != tt.empty {
26+ t.Errorf("Empty() = %v, want %v", got, tt.empty)
27+ }
28+ })
29+ }
30+}
@@ -0,0 +1,30 @@
1+package ports
2+
3+import "testing"
4+
5+// HasBuffer is true for content carrying its own buffer; Empty also looks at
6+// subsurfaces.
7+func TestSurfaceContentHasBufferEmpty(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ content SurfaceContent
11+ hasBuffer bool
12+ empty bool
13+ }{
14+ {"zero", SurfaceContent{}, false, true},
15+ {"shm", SurfaceContent{SHM: &SHMBuffer{}}, true, false},
16+ {"dmabuf", SurfaceContent{DMABuf: &DMABuf{}}, true, false},
17+ {"solid", SurfaceContent{Solid: &SolidColor{A: 1}}, true, false},
18+ {"children only", SurfaceContent{Children: []Subsurface{{}}}, false, false},
19+ }
20+ for _, tt := range tests {
21+ t.Run(tt.name, func(t *testing.T) {
22+ if got := tt.content.HasBuffer(); got != tt.hasBuffer {
23+ t.Errorf("HasBuffer() = %v, want %v", got, tt.hasBuffer)
24+ }
25+ if got := tt.content.Empty(); got != tt.empty {
26+ t.Errorf("Empty() = %v, want %v", got, tt.empty)
27+ }
28+ })
29+ }
30+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close