detached 32f342a

fix(render): order overlapping uploads, force xrgb alpha, accept unpadded last rows

commit
32f342a8928b65329dd0a71f753be6864c346480
author
brice · · 2026-09-23 15:46 UTC
parent
18a2776
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +131 -20
M internal/adapters/headless/output.go +3 -15
@@ -95,7 +95,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
9595 if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil {
9696 return fmt.Errorf("screenshot: %w", err)
9797 }
98- if err := writeLatest(opts.ScreenshotDir, r.Pixels()); err != nil {
98+ if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), r.Pixels()); err != nil {
9999 return fmt.Errorf("latest screenshot: %w", err)
100100 }
101101 }
@@ -104,19 +104,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
104104 }
105105
106106 func writePNG(path string, img *image.RGBA) error {
107- f, err := os.Create(path)
108- if err != nil {
109- return err
110- }
111- if err := png.Encode(f, img); err != nil {
112- _ = f.Close()
113- return err
114- }
115- return f.Close()
116-}
117-
118-func writeLatest(dir string, img *image.RGBA) error {
119- f, err := os.CreateTemp(dir, ".latest-*.png")
107+ f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
120108 if err != nil {
121109 return err
122110 }
@@ -128,5 +116,5 @@ func writeLatest(dir string, img *image.RGBA) error {
128116 if err := f.Close(); err != nil {
129117 return err
130118 }
131- return os.Rename(f.Name(), filepath.Join(dir, "latest.png"))
119+ return os.Rename(f.Name(), path)
132120 }
@@ -95,7 +95,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
95 if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil {95 if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil {
96 return fmt.Errorf("screenshot: %w", err)96 return fmt.Errorf("screenshot: %w", err)
97 }97 }
98- if err := writeLatest(opts.ScreenshotDir, r.Pixels()); err != nil {98+ if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), r.Pixels()); err != nil {
99 return fmt.Errorf("latest screenshot: %w", err)99 return fmt.Errorf("latest screenshot: %w", err)
100 }100 }
101 }101 }
@@ -104,19 +104,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
104 }104 }
105 105
106 func writePNG(path string, img *image.RGBA) error {106 func writePNG(path string, img *image.RGBA) error {
107- f, err := os.Create(path)107+ f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
108- if err != nil {
109- return err
110- }
111- if err := png.Encode(f, img); err != nil {
112- _ = f.Close()
113- return err
114- }
115- return f.Close()
116-}
117-
118-func writeLatest(dir string, img *image.RGBA) error {
119- f, err := os.CreateTemp(dir, ".latest-*.png")
120 if err != nil {108 if err != nil {
121 return err109 return err
122 }110 }
@@ -128,5 +116,5 @@ func writeLatest(dir string, img *image.RGBA) error {
128 if err := f.Close(); err != nil {116 if err := f.Close(); err != nil {
129 return err117 return err
130 }118 }
131- return os.Rename(f.Name(), filepath.Join(dir, "latest.png"))119+ return os.Rename(f.Name(), path)
132 }120 }
M internal/adapters/headless/output_test.go +26 -0
@@ -115,3 +115,29 @@ func TestErrors(t *testing.T) {
115115 t.Fatal("not closed")
116116 }
117117 }
118+
119+func TestWritePNGAtomic(t *testing.T) {
120+ dir := t.TempDir()
121+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
122+ for _, name := range []string{"frame-000001.png", "latest.png"} {
123+ path := filepath.Join(dir, name)
124+ if err := writePNG(path, img); err != nil {
125+ t.Fatal(err)
126+ }
127+ f, err := os.Open(path)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if _, err := png.Decode(f); err != nil {
132+ t.Fatal(err)
133+ }
134+ _ = f.Close()
135+ }
136+ entries, err := os.ReadDir(dir)
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ if len(entries) != 2 {
141+ t.Fatalf("unexpected files: %v", entries)
142+ }
143+}
@@ -115,3 +115,29 @@ func TestErrors(t *testing.T) {
115 t.Fatal("not closed")115 t.Fatal("not closed")
116 }116 }
117 }117 }
118+
119+func TestWritePNGAtomic(t *testing.T) {
120+ dir := t.TempDir()
121+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
122+ for _, name := range []string{"frame-000001.png", "latest.png"} {
123+ path := filepath.Join(dir, name)
124+ if err := writePNG(path, img); err != nil {
125+ t.Fatal(err)
126+ }
127+ f, err := os.Open(path)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if _, err := png.Decode(f); err != nil {
132+ t.Fatal(err)
133+ }
134+ _ = f.Close()
135+ }
136+ entries, err := os.ReadDir(dir)
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ if len(entries) != 2 {
141+ t.Fatalf("unexpected files: %v", entries)
142+ }
143+}
M internal/adapters/vulkan/renderer.go +11 -2
@@ -317,6 +317,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
317317 pixels []byte
318318 stride, sourceX, sourceY int
319319 color [3]uint8
320+ opaque bool
320321 }
321322 var uploads []upload
322323 used := 0
@@ -329,7 +330,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
329330 n := rect.Dx() * rect.Dy() * 4
330331 u := upload{rect: rect, offset: used, color: c}
331332 if content != nil {
332- u.pixels, u.stride = content.Pixels, content.Stride
333+ u.pixels, u.stride, u.opaque = content.Pixels, content.Stride, content.Opaque
333334 u.sourceX, u.sourceY = rect.Min.X-origin.X, rect.Min.Y-origin.Y
334335 }
335336 uploads = append(uploads, u)
@@ -368,6 +369,11 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
368369 if u.pixels != nil {
369370 start := (u.sourceY+row)*u.stride + u.sourceX*4
370371 copy(line, u.pixels[start:start+len(line)])
372+ if u.opaque {
373+ for i := 3; i < len(line); i += 4 {
374+ line[i] = 255
375+ }
376+ }
371377 } else {
372378 for i := 0; i < len(line); i += 4 {
373379 line[i], line[i+1], line[i+2], line[i+3] = u.color[2], u.color[1], u.color[0], 255
@@ -403,7 +409,10 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
403409 barrier.OldLayout = vk.ImageLayoutTransferDstOptimal
404410 barrier.NewLayout = vk.ImageLayoutTransferDstOptimal
405411 d.CmdPipelineBarrier(r.command, vk.PipelineStageTransferBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &barrier)
406- for _, f := range uploads {
412+ for i, f := range uploads {
413+ if i > 0 {
414+ d.CmdPipelineBarrier(r.command, vk.PipelineStageTransferBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &barrier)
415+ }
407416 rect := f.rect
408417 region := vk.BufferImageCopy{BufferOffset: vk.DeviceSize(f.offset), ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageOffset: vk.Offset3D{X: int32(rect.Min.X), Y: int32(rect.Min.Y)}, ImageExtent: vk.Extent3D{Width: uint32(rect.Dx()), Height: uint32(rect.Dy()), Depth: 1}}
409418 d.CmdCopyBufferToImage(r.command, r.staging, r.image, vk.ImageLayoutTransferDstOptimal, 1, &region)
@@ -317,6 +317,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
317 pixels []byte317 pixels []byte
318 stride, sourceX, sourceY int318 stride, sourceX, sourceY int
319 color [3]uint8319 color [3]uint8
320+ opaque bool
320 }321 }
321 var uploads []upload322 var uploads []upload
322 used := 0323 used := 0
@@ -329,7 +330,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
329 n := rect.Dx() * rect.Dy() * 4330 n := rect.Dx() * rect.Dy() * 4
330 u := upload{rect: rect, offset: used, color: c}331 u := upload{rect: rect, offset: used, color: c}
331 if content != nil {332 if content != nil {
332- u.pixels, u.stride = content.Pixels, content.Stride333+ u.pixels, u.stride, u.opaque = content.Pixels, content.Stride, content.Opaque
333 u.sourceX, u.sourceY = rect.Min.X-origin.X, rect.Min.Y-origin.Y334 u.sourceX, u.sourceY = rect.Min.X-origin.X, rect.Min.Y-origin.Y
334 }335 }
335 uploads = append(uploads, u)336 uploads = append(uploads, u)
@@ -368,6 +369,11 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
368 if u.pixels != nil {369 if u.pixels != nil {
369 start := (u.sourceY+row)*u.stride + u.sourceX*4370 start := (u.sourceY+row)*u.stride + u.sourceX*4
370 copy(line, u.pixels[start:start+len(line)])371 copy(line, u.pixels[start:start+len(line)])
372+ if u.opaque {
373+ for i := 3; i < len(line); i += 4 {
374+ line[i] = 255
375+ }
376+ }
371 } else {377 } else {
372 for i := 0; i < len(line); i += 4 {378 for i := 0; i < len(line); i += 4 {
373 line[i], line[i+1], line[i+2], line[i+3] = u.color[2], u.color[1], u.color[0], 255379 line[i], line[i+1], line[i+2], line[i+3] = u.color[2], u.color[1], u.color[0], 255
@@ -403,7 +409,10 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
403 barrier.OldLayout = vk.ImageLayoutTransferDstOptimal409 barrier.OldLayout = vk.ImageLayoutTransferDstOptimal
404 barrier.NewLayout = vk.ImageLayoutTransferDstOptimal410 barrier.NewLayout = vk.ImageLayoutTransferDstOptimal
405 d.CmdPipelineBarrier(r.command, vk.PipelineStageTransferBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &barrier)411 d.CmdPipelineBarrier(r.command, vk.PipelineStageTransferBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &barrier)
406- for _, f := range uploads {412+ for i, f := range uploads {
413+ if i > 0 {
414+ d.CmdPipelineBarrier(r.command, vk.PipelineStageTransferBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &barrier)
415+ }
407 rect := f.rect416 rect := f.rect
408 region := vk.BufferImageCopy{BufferOffset: vk.DeviceSize(f.offset), ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageOffset: vk.Offset3D{X: int32(rect.Min.X), Y: int32(rect.Min.Y)}, ImageExtent: vk.Extent3D{Width: uint32(rect.Dx()), Height: uint32(rect.Dy()), Depth: 1}}417 region := vk.BufferImageCopy{BufferOffset: vk.DeviceSize(f.offset), ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageOffset: vk.Offset3D{X: int32(rect.Min.X), Y: int32(rect.Min.Y)}, ImageExtent: vk.Extent3D{Width: uint32(rect.Dx()), Height: uint32(rect.Dy()), Depth: 1}}
409 d.CmdCopyBufferToImage(r.command, r.staging, r.image, vk.ImageLayoutTransferDstOptimal, 1, &region)418 d.CmdCopyBufferToImage(r.command, r.staging, r.image, vk.ImageLayoutTransferDstOptimal, 1, &region)
M internal/adapters/vulkan/renderer_test.go +35 -0
@@ -110,3 +110,38 @@ func TestRendererContents(t *testing.T) {
110110 c := windowColor(1)
111111 check(0, 0, color.RGBA{c[0], c[1], c[2], 255})
112112 }
113+
114+func TestRendererUploadOrderAndOpaque(t *testing.T) {
115+ r, err := New(32, 32)
116+ if err != nil {
117+ t.Skipf("Vulkan unavailable: %v", err)
118+ }
119+ defer r.Close()
120+ scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{
121+ {ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}},
122+ {ID: 2, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}},
123+ }}
124+ if err := r.Render(scene, nil); err != nil {
125+ t.Fatal(err)
126+ }
127+ c := windowColor(2)
128+ if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want {
129+ t.Errorf("overlap = %v, want %v", got, want)
130+ }
131+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}
132+ contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}}
133+ for i := 0; i < len(contents[1].Pixels); i += 4 {
134+ copy(contents[1].Pixels[i:i+4], []byte{3, 5, 7, 0})
135+ }
136+ if err := r.Render(scene, contents); err != nil {
137+ t.Fatal(err)
138+ }
139+ for _, tc := range []struct {
140+ x, y int
141+ want color.RGBA
142+ }{{3, 3, color.RGBA{255, 255, 255, 255}}, {10, 10, color.RGBA{7, 5, 3, 255}}} {
143+ if got := r.Pixels().RGBAAt(tc.x, tc.y); got != tc.want {
144+ t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
145+ }
146+ }
147+}
@@ -110,3 +110,38 @@ func TestRendererContents(t *testing.T) {
110 c := windowColor(1)110 c := windowColor(1)
111 check(0, 0, color.RGBA{c[0], c[1], c[2], 255})111 check(0, 0, color.RGBA{c[0], c[1], c[2], 255})
112 }112 }
113+
114+func TestRendererUploadOrderAndOpaque(t *testing.T) {
115+ r, err := New(32, 32)
116+ if err != nil {
117+ t.Skipf("Vulkan unavailable: %v", err)
118+ }
119+ defer r.Close()
120+ scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{
121+ {ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}},
122+ {ID: 2, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}},
123+ }}
124+ if err := r.Render(scene, nil); err != nil {
125+ t.Fatal(err)
126+ }
127+ c := windowColor(2)
128+ if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want {
129+ t.Errorf("overlap = %v, want %v", got, want)
130+ }
131+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}
132+ contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}}
133+ for i := 0; i < len(contents[1].Pixels); i += 4 {
134+ copy(contents[1].Pixels[i:i+4], []byte{3, 5, 7, 0})
135+ }
136+ if err := r.Render(scene, contents); err != nil {
137+ t.Fatal(err)
138+ }
139+ for _, tc := range []struct {
140+ x, y int
141+ want color.RGBA
142+ }{{3, 3, color.RGBA{255, 255, 255, 255}}, {10, 10, color.RGBA{7, 5, 3, 255}}} {
143+ if got := r.Pixels().RGBAAt(tc.x, tc.y); got != tc.want {
144+ t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
145+ }
146+ }
147+}
M internal/adapters/wayland/globals.go +3 -3
@@ -176,7 +176,7 @@ func (p *pool) CreateBuffer(r *wayland.ShmPool, id uint32, offset, width, height
176176 p.shm.PostError(uint32(wayland.ShmErrorInvalidFormat), "unsupported format")
177177 return
178178 }
179- if width <= 0 || height <= 0 || int64(stride) < int64(width)*4 || offset < 0 || stride <= 0 || int64(offset)+int64(stride)*int64(height) > int64(p.size) {
179+ if width <= 0 || height <= 0 || int64(stride) < int64(width)*4 || offset < 0 || stride <= 0 || int64(offset)+int64(height-1)*int64(stride)+int64(width)*4 > int64(p.size) {
180180 p.shm.PostError(uint32(wayland.ShmErrorInvalidStride), "invalid buffer dimensions")
181181 return
182182 }
@@ -228,11 +228,11 @@ func (b *buffer) content(id ports.WindowID) (content ports.SurfaceContent, ok bo
228228 ok = false
229229 }
230230 }()
231- size := b.stride * b.height
231+ size := (b.height-1)*b.stride + b.width*4
232232 pixels := make([]byte, size)
233233 for y := 0; y < b.height; y++ {
234234 start := b.offset + y*b.stride
235- copy(pixels[y*b.stride:(y+1)*b.stride], b.pool.data[start:start+b.stride])
235+ copy(pixels[y*b.stride:y*b.stride+b.width*4], b.pool.data[start:start+b.width*4])
236236 }
237237 return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Stride: b.stride, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), Pixels: pixels}, true
238238 }
@@ -176,7 +176,7 @@ func (p *pool) CreateBuffer(r *wayland.ShmPool, id uint32, offset, width, height
176 p.shm.PostError(uint32(wayland.ShmErrorInvalidFormat), "unsupported format")176 p.shm.PostError(uint32(wayland.ShmErrorInvalidFormat), "unsupported format")
177 return177 return
178 }178 }
179- if width <= 0 || height <= 0 || int64(stride) < int64(width)*4 || offset < 0 || stride <= 0 || int64(offset)+int64(stride)*int64(height) > int64(p.size) {179+ if width <= 0 || height <= 0 || int64(stride) < int64(width)*4 || offset < 0 || stride <= 0 || int64(offset)+int64(height-1)*int64(stride)+int64(width)*4 > int64(p.size) {
180 p.shm.PostError(uint32(wayland.ShmErrorInvalidStride), "invalid buffer dimensions")180 p.shm.PostError(uint32(wayland.ShmErrorInvalidStride), "invalid buffer dimensions")
181 return181 return
182 }182 }
@@ -228,11 +228,11 @@ func (b *buffer) content(id ports.WindowID) (content ports.SurfaceContent, ok bo
228 ok = false228 ok = false
229 }229 }
230 }()230 }()
231- size := b.stride * b.height231+ size := (b.height-1)*b.stride + b.width*4
232 pixels := make([]byte, size)232 pixels := make([]byte, size)
233 for y := 0; y < b.height; y++ {233 for y := 0; y < b.height; y++ {
234 start := b.offset + y*b.stride234 start := b.offset + y*b.stride
235- copy(pixels[y*b.stride:(y+1)*b.stride], b.pool.data[start:start+b.stride])235+ copy(pixels[y*b.stride:y*b.stride+b.width*4], b.pool.data[start:start+b.width*4])
236 }236 }
237 return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Stride: b.stride, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), Pixels: pixels}, true237 return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Stride: b.stride, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), Pixels: pixels}, true
238 }238 }
M internal/adapters/wayland/protocol_test.go +53 -0
@@ -280,3 +280,56 @@ func TestSHMInvalidStride(t *testing.T) {
280280 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, c.AllocateID(), int32(0), int32(2), int32(1), int32(4), uint32(wayland.ShmFormatArgb8888))
281281 expectProtocolError(t, c, shm, uint32(wayland.ShmErrorInvalidStride))
282282 }
283+
284+func TestSHMUnpaddedLastRow(t *testing.T) {
285+ for _, tc := range []struct {
286+ name string
287+ size int32
288+ valid bool
289+ }{{"exact", 28, true}, {"short", 27, false}} {
290+ t.Run(tc.name, func(t *testing.T) {
291+ s, _, _, dir := lifecycleServer(t)
292+ c := protocolClient(t, s, dir)
293+ shm := bindProtocol(t, c, "wl_shm")
294+ fd, err := unix.MemfdCreate("unpadded-last-row", 0)
295+ if err != nil {
296+ t.Fatal(err)
297+ }
298+ defer unix.Close(fd)
299+ if err := unix.Ftruncate(fd, int64(tc.size)); err != nil {
300+ t.Fatal(err)
301+ }
302+ pool := c.AllocateID()
303+ registerProtocol(t, c, shm)
304+ registerProtocol(t, c, pool)
305+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, tc.size); err != nil {
306+ t.Fatal(err)
307+ }
308+ buffer := c.AllocateID()
309+ registerProtocol(t, c, buffer)
310+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(4), int32(2), int32(2), int32(16), uint32(wayland.ShmFormatXrgb8888))
311+ if !tc.valid {
312+ expectProtocolError(t, c, shm, uint32(wayland.ShmErrorInvalidStride))
313+ return
314+ }
315+ if err := c.Roundtrip(); err != nil {
316+ t.Fatal(err)
317+ }
318+ })
319+ }
320+}
321+
322+func TestBufferContentUnpaddedLastRow(t *testing.T) {
323+ p := &pool{data: make([]byte, 28)}
324+ b := &buffer{pool: p, offset: 4, width: 2, height: 2, stride: 16}
325+ copy(p.data[20:], []byte{1, 2, 3, 4, 5, 6, 7, 8})
326+ content, ok := b.content(1)
327+ if !ok || len(content.Pixels) != 24 {
328+ t.Fatalf("content ok=%v size=%d", ok, len(content.Pixels))
329+ }
330+ for i, v := range []byte{1, 2, 3, 4, 5, 6, 7, 8} {
331+ if content.Pixels[16+i] != v {
332+ t.Fatalf("last row byte %d = %d, want %d", i, content.Pixels[16+i], v)
333+ }
334+ }
335+}
@@ -280,3 +280,56 @@ func TestSHMInvalidStride(t *testing.T) {
280 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, c.AllocateID(), int32(0), int32(2), int32(1), int32(4), uint32(wayland.ShmFormatArgb8888))280 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, c.AllocateID(), int32(0), int32(2), int32(1), int32(4), uint32(wayland.ShmFormatArgb8888))
281 expectProtocolError(t, c, shm, uint32(wayland.ShmErrorInvalidStride))281 expectProtocolError(t, c, shm, uint32(wayland.ShmErrorInvalidStride))
282 }282 }
283+
284+func TestSHMUnpaddedLastRow(t *testing.T) {
285+ for _, tc := range []struct {
286+ name string
287+ size int32
288+ valid bool
289+ }{{"exact", 28, true}, {"short", 27, false}} {
290+ t.Run(tc.name, func(t *testing.T) {
291+ s, _, _, dir := lifecycleServer(t)
292+ c := protocolClient(t, s, dir)
293+ shm := bindProtocol(t, c, "wl_shm")
294+ fd, err := unix.MemfdCreate("unpadded-last-row", 0)
295+ if err != nil {
296+ t.Fatal(err)
297+ }
298+ defer unix.Close(fd)
299+ if err := unix.Ftruncate(fd, int64(tc.size)); err != nil {
300+ t.Fatal(err)
301+ }
302+ pool := c.AllocateID()
303+ registerProtocol(t, c, shm)
304+ registerProtocol(t, c, pool)
305+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, tc.size); err != nil {
306+ t.Fatal(err)
307+ }
308+ buffer := c.AllocateID()
309+ registerProtocol(t, c, buffer)
310+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(4), int32(2), int32(2), int32(16), uint32(wayland.ShmFormatXrgb8888))
311+ if !tc.valid {
312+ expectProtocolError(t, c, shm, uint32(wayland.ShmErrorInvalidStride))
313+ return
314+ }
315+ if err := c.Roundtrip(); err != nil {
316+ t.Fatal(err)
317+ }
318+ })
319+ }
320+}
321+
322+func TestBufferContentUnpaddedLastRow(t *testing.T) {
323+ p := &pool{data: make([]byte, 28)}
324+ b := &buffer{pool: p, offset: 4, width: 2, height: 2, stride: 16}
325+ copy(p.data[20:], []byte{1, 2, 3, 4, 5, 6, 7, 8})
326+ content, ok := b.content(1)
327+ if !ok || len(content.Pixels) != 24 {
328+ t.Fatalf("content ok=%v size=%d", ok, len(content.Pixels))
329+ }
330+ for i, v := range []byte{1, 2, 3, 4, 5, 6, 7, 8} {
331+ if content.Pixels[16+i] != v {
332+ t.Fatalf("last row byte %d = %d, want %d", i, content.Pixels[16+i], v)
333+ }
334+ }
335+}

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 23

Tags 10

↑↓ moveenter openesc close