detached 6ac336a

feat(graphics): add headless snapshot harness

commit
6ac336ace244bbe07e4643b5cd8dabe336353086
author
brice · · 2026-08-24 10:14 UTC
parent
251cbdf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +488 -2

this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.

M README.md +16 -0
@@ -60,6 +60,22 @@ separate from cells and history bytes; VTH3 remains text/history-only. Static
6060 placements retain their configured pixel rectangles across text scrolling and
6161 reflow; row-anchored and relative-placement movement is unsupported.
6262
63+### Headless graphics harness
64+
65+`cmd/graphics-harness` feeds a terminal byte capture into a fresh `Screen` and
66+writes its active graphics snapshot as a PNG using an internal reference
67+compositor. It is a development inspection tool, not a terminal emulator.
68+
69+```sh
70+go run ./cmd/graphics-harness \
71+ -input internal/graphicsharness/testdata/demo.apc \
72+ -output /tmp/graphics-harness.png \
73+ -cols 4 -rows 4 -pixel-width 4 -pixel-height 4 -scale 64
74+```
75+
76+The included demo has an opaque blue background and a semi-transparent red
77+overlay. Its output is suitable for direct image inspection.
78+
6379 ## Checks
6480
6581 ```sh
@@ -60,6 +60,22 @@ separate from cells and history bytes; VTH3 remains text/history-only. Static
60 placements retain their configured pixel rectangles across text scrolling and60 placements retain their configured pixel rectangles across text scrolling and
61 reflow; row-anchored and relative-placement movement is unsupported.61 reflow; row-anchored and relative-placement movement is unsupported.
62 62
63+### Headless graphics harness
64+
65+`cmd/graphics-harness` feeds a terminal byte capture into a fresh `Screen` and
66+writes its active graphics snapshot as a PNG using an internal reference
67+compositor. It is a development inspection tool, not a terminal emulator.
68+
69+```sh
70+go run ./cmd/graphics-harness \
71+ -input internal/graphicsharness/testdata/demo.apc \
72+ -output /tmp/graphics-harness.png \
73+ -cols 4 -rows 4 -pixel-width 4 -pixel-height 4 -scale 64
74+```
75+
76+The included demo has an opaque blue background and a semi-transparent red
77+overlay. Its output is suitable for direct image inspection.
78+
63 ## Checks79 ## Checks
64 80
65 ```sh81 ```sh
A cmd/graphics-harness/main.go +81 -0
@@ -0,0 +1,81 @@
1+// Command graphics-harness renders Kitty graphics terminal input to a PNG for
2+// local development inspection. It is not a terminal emulator.
3+package main
4+
5+import (
6+ "flag"
7+ "fmt"
8+ "image"
9+ "image/png"
10+ "os"
11+
12+ vt "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev-vt/internal/graphicsharness"
14+)
15+
16+func main() {
17+ inputPath := flag.String("input", "", "path to a file containing raw terminal bytes")
18+ outputPath := flag.String("output", "graphics-harness.png", "path for the rendered PNG")
19+ cols := flag.Int("cols", 80, "terminal column count")
20+ rows := flag.Int("rows", 24, "terminal row count")
21+ pixelWidth := flag.Int("pixel-width", 640, "terminal viewport width in pixels")
22+ pixelHeight := flag.Int("pixel-height", 384, "terminal viewport height in pixels")
23+ scale := flag.Int("scale", 1, "nearest-neighbor output scale for inspection")
24+ flag.Parse()
25+
26+ if *inputPath == "" {
27+ fail("-input is required")
28+ }
29+ input, err := os.ReadFile(*inputPath)
30+ if err != nil {
31+ fail("read input: %v", err)
32+ }
33+ frame, err := graphicsharness.RenderTerminal(input, vt.Geometry{
34+ Cols: *cols, Rows: *rows, PixelWidth: *pixelWidth, PixelHeight: *pixelHeight,
35+ })
36+ if err != nil {
37+ fail("render input: %v", err)
38+ }
39+ frame, err = scaleFrame(frame, *scale)
40+ if err != nil {
41+ fail("scale output: %v", err)
42+ }
43+ output, err := os.Create(*outputPath)
44+ if err != nil {
45+ fail("create output: %v", err)
46+ }
47+ if err := png.Encode(output, frame); err != nil {
48+ _ = output.Close()
49+ fail("encode output: %v", err)
50+ }
51+ if err := output.Close(); err != nil {
52+ fail("close output: %v", err)
53+ }
54+}
55+
56+func scaleFrame(source *image.RGBA, scale int) (*image.RGBA, error) {
57+ if scale < 1 {
58+ return nil, fmt.Errorf("scale must be positive")
59+ }
60+ width, height := source.Bounds().Dx(), source.Bounds().Dy()
61+ if uint64(width)*uint64(height)*uint64(scale)*uint64(scale) > 64<<20 {
62+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
63+ }
64+ result := image.NewRGBA(image.Rect(0, 0, width*scale, height*scale))
65+ for y := range height {
66+ for x := range width {
67+ pixel := source.RGBAAt(x, y)
68+ for dy := range scale {
69+ for dx := range scale {
70+ result.SetRGBA(x*scale+dx, y*scale+dy, pixel)
71+ }
72+ }
73+ }
74+ }
75+ return result, nil
76+}
77+
78+func fail(format string, args ...any) {
79+ fmt.Fprintf(os.Stderr, "graphics-harness: "+format+"\n", args...)
80+ os.Exit(1)
81+}
@@ -0,0 +1,81 @@
1+// Command graphics-harness renders Kitty graphics terminal input to a PNG for
2+// local development inspection. It is not a terminal emulator.
3+package main
4+
5+import (
6+ "flag"
7+ "fmt"
8+ "image"
9+ "image/png"
10+ "os"
11+
12+ vt "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev-vt/internal/graphicsharness"
14+)
15+
16+func main() {
17+ inputPath := flag.String("input", "", "path to a file containing raw terminal bytes")
18+ outputPath := flag.String("output", "graphics-harness.png", "path for the rendered PNG")
19+ cols := flag.Int("cols", 80, "terminal column count")
20+ rows := flag.Int("rows", 24, "terminal row count")
21+ pixelWidth := flag.Int("pixel-width", 640, "terminal viewport width in pixels")
22+ pixelHeight := flag.Int("pixel-height", 384, "terminal viewport height in pixels")
23+ scale := flag.Int("scale", 1, "nearest-neighbor output scale for inspection")
24+ flag.Parse()
25+
26+ if *inputPath == "" {
27+ fail("-input is required")
28+ }
29+ input, err := os.ReadFile(*inputPath)
30+ if err != nil {
31+ fail("read input: %v", err)
32+ }
33+ frame, err := graphicsharness.RenderTerminal(input, vt.Geometry{
34+ Cols: *cols, Rows: *rows, PixelWidth: *pixelWidth, PixelHeight: *pixelHeight,
35+ })
36+ if err != nil {
37+ fail("render input: %v", err)
38+ }
39+ frame, err = scaleFrame(frame, *scale)
40+ if err != nil {
41+ fail("scale output: %v", err)
42+ }
43+ output, err := os.Create(*outputPath)
44+ if err != nil {
45+ fail("create output: %v", err)
46+ }
47+ if err := png.Encode(output, frame); err != nil {
48+ _ = output.Close()
49+ fail("encode output: %v", err)
50+ }
51+ if err := output.Close(); err != nil {
52+ fail("close output: %v", err)
53+ }
54+}
55+
56+func scaleFrame(source *image.RGBA, scale int) (*image.RGBA, error) {
57+ if scale < 1 {
58+ return nil, fmt.Errorf("scale must be positive")
59+ }
60+ width, height := source.Bounds().Dx(), source.Bounds().Dy()
61+ if uint64(width)*uint64(height)*uint64(scale)*uint64(scale) > 64<<20 {
62+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
63+ }
64+ result := image.NewRGBA(image.Rect(0, 0, width*scale, height*scale))
65+ for y := range height {
66+ for x := range width {
67+ pixel := source.RGBAAt(x, y)
68+ for dy := range scale {
69+ for dx := range scale {
70+ result.SetRGBA(x*scale+dx, y*scale+dy, pixel)
71+ }
72+ }
73+ }
74+ }
75+ return result, nil
76+}
77+
78+func fail(format string, args ...any) {
79+ fmt.Fprintf(os.Stderr, "graphics-harness: "+format+"\n", args...)
80+ os.Exit(1)
81+}
A cmd/graphics-harness/main_test.go +28 -0
@@ -0,0 +1,28 @@
1+package main
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestScaleFrameUsesNearestNeighbor(t *testing.T) {
12+ source := image.NewRGBA(image.Rect(0, 0, 1, 1))
13+ source.SetRGBA(0, 0, color.RGBA{22, 62, 143, 255})
14+
15+ scaled, err := scaleFrame(source, 2)
16+ require.NoError(t, err)
17+ require.Equal(t, image.Rect(0, 0, 2, 2), scaled.Bounds())
18+ for y := 0; y < 2; y++ {
19+ for x := 0; x < 2; x++ {
20+ require.Equal(t, color.RGBA{22, 62, 143, 255}, scaled.RGBAAt(x, y))
21+ }
22+ }
23+}
24+
25+func TestScaleFrameRejectsInvalidScale(t *testing.T) {
26+ _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), 0)
27+ require.Error(t, err)
28+}
@@ -0,0 +1,28 @@
1+package main
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestScaleFrameUsesNearestNeighbor(t *testing.T) {
12+ source := image.NewRGBA(image.Rect(0, 0, 1, 1))
13+ source.SetRGBA(0, 0, color.RGBA{22, 62, 143, 255})
14+
15+ scaled, err := scaleFrame(source, 2)
16+ require.NoError(t, err)
17+ require.Equal(t, image.Rect(0, 0, 2, 2), scaled.Bounds())
18+ for y := 0; y < 2; y++ {
19+ for x := 0; x < 2; x++ {
20+ require.Equal(t, color.RGBA{22, 62, 143, 255}, scaled.RGBAAt(x, y))
21+ }
22+ }
23+}
24+
25+func TestScaleFrameRejectsInvalidScale(t *testing.T) {
26+ _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), 0)
27+ require.Error(t, err)
28+}
M graphics/scene.go +2 -2
@@ -894,8 +894,8 @@ func visiblePlacement(placement PlacementView, pixels PixelRect, cells CellRect,
894894 }
895895
896896 // mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate
897-// space into another rectangle's coordinate space. It uses exact 128+-bit
898-// arithmetic through math/big so products cannot wrap before division.
897+// space into another rectangle's coordinate space. Its range mapping uses
898+// exact 128-bit arithmetic so products cannot wrap before division.
899899 func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) {
900900 if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
901901 return PixelRect{}, false
@@ -894,8 +894,8 @@ func visiblePlacement(placement PlacementView, pixels PixelRect, cells CellRect,
894 }894 }
895 895
896 // mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate896 // mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate
897-// space into another rectangle's coordinate space. It uses exact 128+-bit897+// space into another rectangle's coordinate space. Its range mapping uses
898-// arithmetic through math/big so products cannot wrap before division.898+// exact 128-bit arithmetic so products cannot wrap before division.
899 func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) {899 func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) {
900 if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {900 if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
901 return PixelRect{}, false901 return PixelRect{}, false
A internal/graphicsharness/render.go +170 -0
@@ -0,0 +1,170 @@
1+// Package graphicsharness provides a headless reference compositor for
2+// inspecting graphics snapshots during development. It is intentionally
3+// internal: vev-vt remains renderer-neutral in its public API.
4+package graphicsharness
5+
6+import (
7+ "bytes"
8+ "fmt"
9+ "image"
10+ "image/color"
11+ "image/png"
12+ "math/bits"
13+
14+ "github.com/bnema/vev-vt/graphics"
15+)
16+
17+const maxCanvasPixels = uint64(64 << 20)
18+
19+// Render composites a graphics snapshot into an RGBA frame covering viewport.
20+// Placement pixels are sampled with nearest-neighbor scaling in layer and
21+// insertion order. The viewport's origin is preserved for placement geometry
22+// but maps to (0, 0) in the returned frame.
23+func Render(snapshot *graphics.Snapshot, viewport graphics.PixelRect) (*image.RGBA, error) {
24+ if !viewport.Valid() || viewport.Empty() {
25+ return nil, fmt.Errorf("render graphics: invalid viewport")
26+ }
27+ width, height, ok := imageDimensions(viewport.Width, viewport.Height)
28+ if !ok {
29+ return nil, fmt.Errorf("render graphics: viewport exceeds %d pixels", maxCanvasPixels)
30+ }
31+ frame := image.NewRGBA(image.Rect(0, 0, width, height))
32+ if snapshot == nil {
33+ return frame, nil
34+ }
35+ decoded := make(map[graphics.AssetID]*image.RGBA)
36+ for _, placement := range snapshot.Placements() {
37+ asset, ok := snapshot.Asset(placement.AssetID())
38+ if !ok {
39+ return nil, fmt.Errorf("render graphics: placement %s references an unavailable asset", placement.ID())
40+ }
41+ source, ok := decoded[asset.ID()]
42+ if !ok {
43+ var err error
44+ source, err = decodeAsset(asset)
45+ if err != nil {
46+ return nil, err
47+ }
48+ decoded[asset.ID()] = source
49+ }
50+ if err := composite(frame, viewport, source, placement.Source(), placement.Destination()); err != nil {
51+ return nil, fmt.Errorf("render graphics: placement %s: %w", placement.ID(), err)
52+ }
53+ }
54+ return frame, nil
55+}
56+
57+func imageDimensions(width, height int64) (int, int, bool) {
58+ if width <= 0 || height <= 0 || uint64(width) > uint64(maxInt()) || uint64(height) > uint64(maxInt()) {
59+ return 0, 0, false
60+ }
61+ overflow, pixels := bits.Mul64(uint64(width), uint64(height))
62+ if overflow != 0 || pixels > maxCanvasPixels {
63+ return 0, 0, false
64+ }
65+ return int(width), int(height), true
66+}
67+
68+func maxInt() int { return int(^uint(0) >> 1) }
69+
70+func decodeAsset(asset graphics.AssetView) (*image.RGBA, error) {
71+ width, height, ok := imageDimensions(asset.Width(), asset.Height())
72+ if !ok {
73+ return nil, fmt.Errorf("decode asset %s: invalid dimensions", asset.ID())
74+ }
75+ encoded := asset.Encoded()
76+ switch asset.Format() {
77+ case graphics.AssetFormatPNG:
78+ decoded, err := png.Decode(bytes.NewReader(encoded))
79+ if err != nil {
80+ return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
81+ }
82+ if decoded.Bounds().Dx() != width || decoded.Bounds().Dy() != height {
83+ return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID())
84+ }
85+ result := image.NewRGBA(image.Rect(0, 0, width, height))
86+ for y := range height {
87+ for x := range width {
88+ result.Set(x, y, decoded.At(decoded.Bounds().Min.X+x, decoded.Bounds().Min.Y+y))
89+ }
90+ }
91+ return result, nil
92+ case graphics.AssetFormatRGB, graphics.AssetFormatRGBA:
93+ channels := 3
94+ if asset.Format() == graphics.AssetFormatRGBA {
95+ channels = 4
96+ }
97+ expected := uint64(width) * uint64(height) * uint64(channels)
98+ if uint64(len(encoded)) != expected {
99+ return nil, fmt.Errorf("decode asset %s: raw payload has %d bytes, want %d", asset.ID(), len(encoded), expected)
100+ }
101+ result := image.NewRGBA(image.Rect(0, 0, width, height))
102+ for y := range height {
103+ for x := range width {
104+ offset := (y*width + x) * channels
105+ r, g, b, a := encoded[offset], encoded[offset+1], encoded[offset+2], byte(255)
106+ if channels == 4 {
107+ a = encoded[offset+3]
108+ }
109+ result.SetRGBA(x, y, color.RGBA{R: premultiply(r, a), G: premultiply(g, a), B: premultiply(b, a), A: a})
110+ }
111+ }
112+ return result, nil
113+ default:
114+ return nil, fmt.Errorf("decode asset %s: unsupported format %d", asset.ID(), asset.Format())
115+ }
116+}
117+
118+func composite(frame *image.RGBA, viewport graphics.PixelRect, source *image.RGBA, sourceRect, destination graphics.PixelRect) error {
119+ visible, ok := destination.Intersect(viewport)
120+ if !ok {
121+ return nil
122+ }
123+ for y := visible.Y; y < visible.Y+visible.Height; y++ {
124+ sy, ok := scaledCoordinate(y, destination.Y, destination.Height, sourceRect.Y, sourceRect.Height)
125+ if !ok {
126+ return fmt.Errorf("invalid vertical mapping")
127+ }
128+ for x := visible.X; x < visible.X+visible.Width; x++ {
129+ sx, ok := scaledCoordinate(x, destination.X, destination.Width, sourceRect.X, sourceRect.Width)
130+ if !ok || sx < 0 || sy < 0 || sx >= int64(source.Bounds().Dx()) || sy >= int64(source.Bounds().Dy()) {
131+ return fmt.Errorf("invalid source mapping")
132+ }
133+ dx, dy := int(x-viewport.X), int(y-viewport.Y)
134+ blend(frame, dx, dy, source.RGBAAt(int(sx), int(sy)))
135+ }
136+ }
137+ return nil
138+}
139+
140+func scaledCoordinate(value, destinationStart, destinationSize, sourceStart, sourceSize int64) (int64, bool) {
141+ if destinationSize <= 0 || sourceSize <= 0 || value < destinationStart {
142+ return 0, false
143+ }
144+ offset := value - destinationStart
145+ if offset >= destinationSize {
146+ return 0, false
147+ }
148+ hi, lo := bits.Mul64(uint64(offset), uint64(sourceSize))
149+ if hi >= uint64(destinationSize) {
150+ return 0, false
151+ }
152+ quotient, _ := bits.Div64(hi, lo, uint64(destinationSize))
153+ if quotient > uint64(^uint64(0)>>1) || sourceStart > int64(^uint64(0)>>1)-int64(quotient) {
154+ return 0, false
155+ }
156+ return sourceStart + int64(quotient), true
157+}
158+
159+func premultiply(component, alpha byte) byte {
160+ return byte((uint16(component)*uint16(alpha) + 127) / 255)
161+}
162+
163+func blend(dst *image.RGBA, x, y int, src color.RGBA) {
164+ offset := dst.PixOffset(x, y)
165+ inverse := uint16(255 - src.A)
166+ dst.Pix[offset] = src.R + byte((uint16(dst.Pix[offset])*inverse+127)/255)
167+ dst.Pix[offset+1] = src.G + byte((uint16(dst.Pix[offset+1])*inverse+127)/255)
168+ dst.Pix[offset+2] = src.B + byte((uint16(dst.Pix[offset+2])*inverse+127)/255)
169+ dst.Pix[offset+3] = src.A + byte((uint16(dst.Pix[offset+3])*inverse+127)/255)
170+}
@@ -0,0 +1,170 @@
1+// Package graphicsharness provides a headless reference compositor for
2+// inspecting graphics snapshots during development. It is intentionally
3+// internal: vev-vt remains renderer-neutral in its public API.
4+package graphicsharness
5+
6+import (
7+ "bytes"
8+ "fmt"
9+ "image"
10+ "image/color"
11+ "image/png"
12+ "math/bits"
13+
14+ "github.com/bnema/vev-vt/graphics"
15+)
16+
17+const maxCanvasPixels = uint64(64 << 20)
18+
19+// Render composites a graphics snapshot into an RGBA frame covering viewport.
20+// Placement pixels are sampled with nearest-neighbor scaling in layer and
21+// insertion order. The viewport's origin is preserved for placement geometry
22+// but maps to (0, 0) in the returned frame.
23+func Render(snapshot *graphics.Snapshot, viewport graphics.PixelRect) (*image.RGBA, error) {
24+ if !viewport.Valid() || viewport.Empty() {
25+ return nil, fmt.Errorf("render graphics: invalid viewport")
26+ }
27+ width, height, ok := imageDimensions(viewport.Width, viewport.Height)
28+ if !ok {
29+ return nil, fmt.Errorf("render graphics: viewport exceeds %d pixels", maxCanvasPixels)
30+ }
31+ frame := image.NewRGBA(image.Rect(0, 0, width, height))
32+ if snapshot == nil {
33+ return frame, nil
34+ }
35+ decoded := make(map[graphics.AssetID]*image.RGBA)
36+ for _, placement := range snapshot.Placements() {
37+ asset, ok := snapshot.Asset(placement.AssetID())
38+ if !ok {
39+ return nil, fmt.Errorf("render graphics: placement %s references an unavailable asset", placement.ID())
40+ }
41+ source, ok := decoded[asset.ID()]
42+ if !ok {
43+ var err error
44+ source, err = decodeAsset(asset)
45+ if err != nil {
46+ return nil, err
47+ }
48+ decoded[asset.ID()] = source
49+ }
50+ if err := composite(frame, viewport, source, placement.Source(), placement.Destination()); err != nil {
51+ return nil, fmt.Errorf("render graphics: placement %s: %w", placement.ID(), err)
52+ }
53+ }
54+ return frame, nil
55+}
56+
57+func imageDimensions(width, height int64) (int, int, bool) {
58+ if width <= 0 || height <= 0 || uint64(width) > uint64(maxInt()) || uint64(height) > uint64(maxInt()) {
59+ return 0, 0, false
60+ }
61+ overflow, pixels := bits.Mul64(uint64(width), uint64(height))
62+ if overflow != 0 || pixels > maxCanvasPixels {
63+ return 0, 0, false
64+ }
65+ return int(width), int(height), true
66+}
67+
68+func maxInt() int { return int(^uint(0) >> 1) }
69+
70+func decodeAsset(asset graphics.AssetView) (*image.RGBA, error) {
71+ width, height, ok := imageDimensions(asset.Width(), asset.Height())
72+ if !ok {
73+ return nil, fmt.Errorf("decode asset %s: invalid dimensions", asset.ID())
74+ }
75+ encoded := asset.Encoded()
76+ switch asset.Format() {
77+ case graphics.AssetFormatPNG:
78+ decoded, err := png.Decode(bytes.NewReader(encoded))
79+ if err != nil {
80+ return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
81+ }
82+ if decoded.Bounds().Dx() != width || decoded.Bounds().Dy() != height {
83+ return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID())
84+ }
85+ result := image.NewRGBA(image.Rect(0, 0, width, height))
86+ for y := range height {
87+ for x := range width {
88+ result.Set(x, y, decoded.At(decoded.Bounds().Min.X+x, decoded.Bounds().Min.Y+y))
89+ }
90+ }
91+ return result, nil
92+ case graphics.AssetFormatRGB, graphics.AssetFormatRGBA:
93+ channels := 3
94+ if asset.Format() == graphics.AssetFormatRGBA {
95+ channels = 4
96+ }
97+ expected := uint64(width) * uint64(height) * uint64(channels)
98+ if uint64(len(encoded)) != expected {
99+ return nil, fmt.Errorf("decode asset %s: raw payload has %d bytes, want %d", asset.ID(), len(encoded), expected)
100+ }
101+ result := image.NewRGBA(image.Rect(0, 0, width, height))
102+ for y := range height {
103+ for x := range width {
104+ offset := (y*width + x) * channels
105+ r, g, b, a := encoded[offset], encoded[offset+1], encoded[offset+2], byte(255)
106+ if channels == 4 {
107+ a = encoded[offset+3]
108+ }
109+ result.SetRGBA(x, y, color.RGBA{R: premultiply(r, a), G: premultiply(g, a), B: premultiply(b, a), A: a})
110+ }
111+ }
112+ return result, nil
113+ default:
114+ return nil, fmt.Errorf("decode asset %s: unsupported format %d", asset.ID(), asset.Format())
115+ }
116+}
117+
118+func composite(frame *image.RGBA, viewport graphics.PixelRect, source *image.RGBA, sourceRect, destination graphics.PixelRect) error {
119+ visible, ok := destination.Intersect(viewport)
120+ if !ok {
121+ return nil
122+ }
123+ for y := visible.Y; y < visible.Y+visible.Height; y++ {
124+ sy, ok := scaledCoordinate(y, destination.Y, destination.Height, sourceRect.Y, sourceRect.Height)
125+ if !ok {
126+ return fmt.Errorf("invalid vertical mapping")
127+ }
128+ for x := visible.X; x < visible.X+visible.Width; x++ {
129+ sx, ok := scaledCoordinate(x, destination.X, destination.Width, sourceRect.X, sourceRect.Width)
130+ if !ok || sx < 0 || sy < 0 || sx >= int64(source.Bounds().Dx()) || sy >= int64(source.Bounds().Dy()) {
131+ return fmt.Errorf("invalid source mapping")
132+ }
133+ dx, dy := int(x-viewport.X), int(y-viewport.Y)
134+ blend(frame, dx, dy, source.RGBAAt(int(sx), int(sy)))
135+ }
136+ }
137+ return nil
138+}
139+
140+func scaledCoordinate(value, destinationStart, destinationSize, sourceStart, sourceSize int64) (int64, bool) {
141+ if destinationSize <= 0 || sourceSize <= 0 || value < destinationStart {
142+ return 0, false
143+ }
144+ offset := value - destinationStart
145+ if offset >= destinationSize {
146+ return 0, false
147+ }
148+ hi, lo := bits.Mul64(uint64(offset), uint64(sourceSize))
149+ if hi >= uint64(destinationSize) {
150+ return 0, false
151+ }
152+ quotient, _ := bits.Div64(hi, lo, uint64(destinationSize))
153+ if quotient > uint64(^uint64(0)>>1) || sourceStart > int64(^uint64(0)>>1)-int64(quotient) {
154+ return 0, false
155+ }
156+ return sourceStart + int64(quotient), true
157+}
158+
159+func premultiply(component, alpha byte) byte {
160+ return byte((uint16(component)*uint16(alpha) + 127) / 255)
161+}
162+
163+func blend(dst *image.RGBA, x, y int, src color.RGBA) {
164+ offset := dst.PixOffset(x, y)
165+ inverse := uint16(255 - src.A)
166+ dst.Pix[offset] = src.R + byte((uint16(dst.Pix[offset])*inverse+127)/255)
167+ dst.Pix[offset+1] = src.G + byte((uint16(dst.Pix[offset+1])*inverse+127)/255)
168+ dst.Pix[offset+2] = src.B + byte((uint16(dst.Pix[offset+2])*inverse+127)/255)
169+ dst.Pix[offset+3] = src.A + byte((uint16(dst.Pix[offset+3])*inverse+127)/255)
170+}
A internal/graphicsharness/render_test.go +104 -0
@@ -0,0 +1,104 @@
1+package graphicsharness
2+
3+import (
4+ "bytes"
5+ _ "embed"
6+ "encoding/base64"
7+ "image/color"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ vt "github.com/bnema/vev-vt"
14+ "github.com/bnema/vev-vt/graphics"
15+)
16+
17+//go:embed testdata/pngsuite-exif2c08.png.b64
18+var pngSuiteEXIFBase64 string
19+
20+//go:embed testdata/demo.apc
21+var demoAPC []byte
22+
23+func TestRenderTerminalDemo(t *testing.T) {
24+ geometry := vt.Geometry{Cols: 4, Rows: 4, PixelWidth: 4, PixelHeight: 4}
25+ screen := vt.NewScreen(geometry.Cols, geometry.Rows)
26+ screen.SetGeometry(geometry)
27+ screen.Write(demoAPC)
28+ require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Assets)
29+ require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Placements)
30+ placements := screen.GraphicsSnapshot().Placements()
31+ require.Equal(t, graphics.PixelRect{X: 1, Y: 1, Width: 2, Height: 2}, placements[1].Destination())
32+ require.Equal(t, graphics.PixelRect{Width: 2, Height: 2}, placements[1].Source())
33+ require.Equal(t, int64(1), placements[1].Layer())
34+
35+ frame, err := RenderTerminal(demoAPC, geometry)
36+ require.NoError(t, err)
37+ require.Equal(t, color.RGBA{22, 62, 143, 255}, frame.RGBAAt(0, 0))
38+ require.Equal(t, color.RGBA{168, 67, 106, 255}, frame.RGBAAt(1, 1))
39+}
40+
41+func TestRenderTerminalAPC(t *testing.T) {
42+ frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1})
43+ require.NoError(t, err)
44+ require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0))
45+}
46+
47+func TestRenderDecodesMetadataBearingPNG(t *testing.T) {
48+ encoded, err := base64.StdEncoding.DecodeString(pngSuiteEXIFBase64)
49+ require.NoError(t, err)
50+ require.True(t, bytes.Contains(encoded, []byte("eXIf")), "fixture must retain its eXIf metadata chunk")
51+
52+ input := []byte("\x1b_Ga=T,i=1,f=100;" + strings.ReplaceAll(pngSuiteEXIFBase64, "\n", "") + "\x1b\\")
53+ frame, err := RenderTerminal(input, vt.Geometry{Cols: 32, Rows: 32, PixelWidth: 32, PixelHeight: 32})
54+ require.NoError(t, err)
55+ require.Equal(t, 32, frame.Bounds().Dx())
56+ require.Equal(t, 32, frame.Bounds().Dy())
57+}
58+
59+func TestRenderScalesCropsAndClips(t *testing.T) {
60+ scene := graphics.NewScene(graphics.Limits{})
61+ assetID, err := scene.AddAsset(graphics.AssetBlob{
62+ Format: graphics.AssetFormatRGBA,
63+ Width: 3,
64+ Height: 2,
65+ Encoded: []byte{
66+ 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255,
67+ 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 0, 255,
68+ },
69+ })
70+ require.NoError(t, err)
71+ _, err = scene.Place(graphics.PlacementSpec{
72+ Asset: assetID,
73+ Source: graphics.PixelRect{X: 1, Width: 2, Height: 2},
74+ Destination: graphics.PixelRect{X: -1, Y: 1, Width: 5, Height: 2},
75+ })
76+ require.NoError(t, err)
77+
78+ frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 4, Height: 4})
79+ require.NoError(t, err)
80+
81+ for x, want := range []color.RGBA{{0, 255, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {0, 0, 255, 255}} {
82+ require.Equal(t, want, frame.RGBAAt(x, 1))
83+ }
84+ for x, want := range []color.RGBA{{255, 0, 255, 255}, {255, 0, 255, 255}, {255, 255, 0, 255}, {255, 255, 0, 255}} {
85+ require.Equal(t, want, frame.RGBAAt(x, 2))
86+ }
87+}
88+
89+func TestRenderCompositesPlacementsInLayerOrder(t *testing.T) {
90+ scene := graphics.NewScene(graphics.Limits{})
91+ background, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 2, Height: 1, Encoded: []byte{0, 0, 255, 255, 0, 0, 255, 255}})
92+ require.NoError(t, err)
93+ foreground, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 1, Height: 1, Encoded: []byte{255, 0, 0, 128}})
94+ require.NoError(t, err)
95+ _, err = scene.Place(graphics.PlacementSpec{Asset: background, Destination: graphics.PixelRect{Width: 2, Height: 1}, HasLayer: true})
96+ require.NoError(t, err)
97+ _, err = scene.Place(graphics.PlacementSpec{Asset: foreground, Destination: graphics.PixelRect{X: 1, Width: 1, Height: 1}, Layer: 1, HasLayer: true})
98+ require.NoError(t, err)
99+
100+ frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 2, Height: 1})
101+ require.NoError(t, err)
102+ require.Equal(t, color.RGBA{0, 0, 255, 255}, frame.RGBAAt(0, 0))
103+ require.Equal(t, color.RGBA{128, 0, 127, 255}, frame.RGBAAt(1, 0))
104+}
@@ -0,0 +1,104 @@
1+package graphicsharness
2+
3+import (
4+ "bytes"
5+ _ "embed"
6+ "encoding/base64"
7+ "image/color"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ vt "github.com/bnema/vev-vt"
14+ "github.com/bnema/vev-vt/graphics"
15+)
16+
17+//go:embed testdata/pngsuite-exif2c08.png.b64
18+var pngSuiteEXIFBase64 string
19+
20+//go:embed testdata/demo.apc
21+var demoAPC []byte
22+
23+func TestRenderTerminalDemo(t *testing.T) {
24+ geometry := vt.Geometry{Cols: 4, Rows: 4, PixelWidth: 4, PixelHeight: 4}
25+ screen := vt.NewScreen(geometry.Cols, geometry.Rows)
26+ screen.SetGeometry(geometry)
27+ screen.Write(demoAPC)
28+ require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Assets)
29+ require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Placements)
30+ placements := screen.GraphicsSnapshot().Placements()
31+ require.Equal(t, graphics.PixelRect{X: 1, Y: 1, Width: 2, Height: 2}, placements[1].Destination())
32+ require.Equal(t, graphics.PixelRect{Width: 2, Height: 2}, placements[1].Source())
33+ require.Equal(t, int64(1), placements[1].Layer())
34+
35+ frame, err := RenderTerminal(demoAPC, geometry)
36+ require.NoError(t, err)
37+ require.Equal(t, color.RGBA{22, 62, 143, 255}, frame.RGBAAt(0, 0))
38+ require.Equal(t, color.RGBA{168, 67, 106, 255}, frame.RGBAAt(1, 1))
39+}
40+
41+func TestRenderTerminalAPC(t *testing.T) {
42+ frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1})
43+ require.NoError(t, err)
44+ require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0))
45+}
46+
47+func TestRenderDecodesMetadataBearingPNG(t *testing.T) {
48+ encoded, err := base64.StdEncoding.DecodeString(pngSuiteEXIFBase64)
49+ require.NoError(t, err)
50+ require.True(t, bytes.Contains(encoded, []byte("eXIf")), "fixture must retain its eXIf metadata chunk")
51+
52+ input := []byte("\x1b_Ga=T,i=1,f=100;" + strings.ReplaceAll(pngSuiteEXIFBase64, "\n", "") + "\x1b\\")
53+ frame, err := RenderTerminal(input, vt.Geometry{Cols: 32, Rows: 32, PixelWidth: 32, PixelHeight: 32})
54+ require.NoError(t, err)
55+ require.Equal(t, 32, frame.Bounds().Dx())
56+ require.Equal(t, 32, frame.Bounds().Dy())
57+}
58+
59+func TestRenderScalesCropsAndClips(t *testing.T) {
60+ scene := graphics.NewScene(graphics.Limits{})
61+ assetID, err := scene.AddAsset(graphics.AssetBlob{
62+ Format: graphics.AssetFormatRGBA,
63+ Width: 3,
64+ Height: 2,
65+ Encoded: []byte{
66+ 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255,
67+ 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 0, 255,
68+ },
69+ })
70+ require.NoError(t, err)
71+ _, err = scene.Place(graphics.PlacementSpec{
72+ Asset: assetID,
73+ Source: graphics.PixelRect{X: 1, Width: 2, Height: 2},
74+ Destination: graphics.PixelRect{X: -1, Y: 1, Width: 5, Height: 2},
75+ })
76+ require.NoError(t, err)
77+
78+ frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 4, Height: 4})
79+ require.NoError(t, err)
80+
81+ for x, want := range []color.RGBA{{0, 255, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {0, 0, 255, 255}} {
82+ require.Equal(t, want, frame.RGBAAt(x, 1))
83+ }
84+ for x, want := range []color.RGBA{{255, 0, 255, 255}, {255, 0, 255, 255}, {255, 255, 0, 255}, {255, 255, 0, 255}} {
85+ require.Equal(t, want, frame.RGBAAt(x, 2))
86+ }
87+}
88+
89+func TestRenderCompositesPlacementsInLayerOrder(t *testing.T) {
90+ scene := graphics.NewScene(graphics.Limits{})
91+ background, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 2, Height: 1, Encoded: []byte{0, 0, 255, 255, 0, 0, 255, 255}})
92+ require.NoError(t, err)
93+ foreground, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 1, Height: 1, Encoded: []byte{255, 0, 0, 128}})
94+ require.NoError(t, err)
95+ _, err = scene.Place(graphics.PlacementSpec{Asset: background, Destination: graphics.PixelRect{Width: 2, Height: 1}, HasLayer: true})
96+ require.NoError(t, err)
97+ _, err = scene.Place(graphics.PlacementSpec{Asset: foreground, Destination: graphics.PixelRect{X: 1, Width: 1, Height: 1}, Layer: 1, HasLayer: true})
98+ require.NoError(t, err)
99+
100+ frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 2, Height: 1})
101+ require.NoError(t, err)
102+ require.Equal(t, color.RGBA{0, 0, 255, 255}, frame.RGBAAt(0, 0))
103+ require.Equal(t, color.RGBA{128, 0, 127, 255}, frame.RGBAAt(1, 0))
104+}
A internal/graphicsharness/terminal.go +21 -0
@@ -0,0 +1,21 @@
1+package graphicsharness
2+
3+import (
4+ "fmt"
5+ "image"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/graphics"
9+)
10+
11+// RenderTerminal feeds terminal bytes into a fresh screen and composites its
12+// active graphics snapshot using geometry's pixel viewport.
13+func RenderTerminal(input []byte, geometry vt.Geometry) (*image.RGBA, error) {
14+ if !geometry.Valid() || !geometry.PixelsKnown() {
15+ return nil, fmt.Errorf("render terminal: complete cell and pixel geometry is required")
16+ }
17+ screen := vt.NewScreen(geometry.Cols, geometry.Rows)
18+ screen.SetGeometry(geometry)
19+ screen.Write(input)
20+ return Render(screen.GraphicsSnapshot(), graphics.PixelRect{Width: int64(geometry.PixelWidth), Height: int64(geometry.PixelHeight)})
21+}
@@ -0,0 +1,21 @@
1+package graphicsharness
2+
3+import (
4+ "fmt"
5+ "image"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/graphics"
9+)
10+
11+// RenderTerminal feeds terminal bytes into a fresh screen and composites its
12+// active graphics snapshot using geometry's pixel viewport.
13+func RenderTerminal(input []byte, geometry vt.Geometry) (*image.RGBA, error) {
14+ if !geometry.Valid() || !geometry.PixelsKnown() {
15+ return nil, fmt.Errorf("render terminal: complete cell and pixel geometry is required")
16+ }
17+ screen := vt.NewScreen(geometry.Cols, geometry.Rows)
18+ screen.SetGeometry(geometry)
19+ screen.Write(input)
20+ return Render(screen.GraphicsSnapshot(), graphics.PixelRect{Width: int64(geometry.PixelWidth), Height: int64(geometry.PixelHeight)})
21+}
A internal/graphicsharness/testdata/README.md +25 -0
@@ -0,0 +1,25 @@
1+# Graphics harness fixtures
2+
3+`pngsuite-exif2c08.png.b64` is the Base64 representation of PNGSuite's
4+`exif2c08.png`: a 32×32 truecolour PNG carrying an `eXIf` ancillary chunk. It
5+is stored as text so fixtures remain inspectable in ordinary diffs; tests decode
6+it before handing it to the PNG decoder.
7+
8+Source: [`lunapaint/pngsuite`](https://github.com/lunapaint/pngsuite) commit
9+`8cd768dd0d0063195174d0d01cacbd5a7d1e5605`, blob
10+`56eb734991497525b489b9baaf275d2e71d25683`. Decoded SHA-256:
11+`d04140d74bc60597c47b5aac371a3553d3add9354437bbd8c57007d94e197809`.
12+
13+Copyright © Willem van Schaik, 1996, 2011. PNGSuite grants permission to use,
14+copy, modify, and distribute these images for any purpose and without fee. The
15+source license text is retained below.
16+
17+```text
18+PngSuite
19+--------
20+
21+Permission to use, copy, modify and distribute these images for any
22+purpose and without fee is hereby granted.
23+
24+(c) Willem van Schaik, 1996, 2011
25+```
@@ -0,0 +1,25 @@
1+# Graphics harness fixtures
2+
3+`pngsuite-exif2c08.png.b64` is the Base64 representation of PNGSuite's
4+`exif2c08.png`: a 32×32 truecolour PNG carrying an `eXIf` ancillary chunk. It
5+is stored as text so fixtures remain inspectable in ordinary diffs; tests decode
6+it before handing it to the PNG decoder.
7+
8+Source: [`lunapaint/pngsuite`](https://github.com/lunapaint/pngsuite) commit
9+`8cd768dd0d0063195174d0d01cacbd5a7d1e5605`, blob
10+`56eb734991497525b489b9baaf275d2e71d25683`. Decoded SHA-256:
11+`d04140d74bc60597c47b5aac371a3553d3add9354437bbd8c57007d94e197809`.
12+
13+Copyright © Willem van Schaik, 1996, 2011. PNGSuite grants permission to use,
14+copy, modify, and distribute these images for any purpose and without fee. The
15+source license text is retained below.
16+
17+```text
18+PngSuite
19+--------
20+
21+Permission to use, copy, modify and distribute these images for any
22+purpose and without fee is hereby granted.
23+
24+(c) Willem van Schaik, 1996, 2011
25+```
A internal/graphicsharness/testdata/demo.apc +1 -0
@@ -0,0 +1,1 @@
1+<U+001B>_Ga=T,i=1,f=32,s=4,v=4,C=1;Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/w<U+001B>\<U+001B>_Ga=T,i=2,f=32,s=2,v=2,X=1,Y=1,z=1,C=1;/0ZVoP9GVaD/RlWg/0ZVoA<U+001B>\⏎̸
@@ -0,0 +1,1 @@
1+<U+001B>_Ga=T,i=1,f=32,s=4,v=4,C=1;Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/w<U+001B>\<U+001B>_Ga=T,i=2,f=32,s=2,v=2,X=1,Y=1,z=1,C=1;/0ZVoP9GVaD/RlWg/0ZVoA<U+001B>\⏎̸
A internal/graphicsharness/testdata/pngsuite-exif2c08.png.b64 +40 -0
@@ -0,0 +1,40 @@
1+iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAD0mVYSWZNTQAq
2+AAAACAAHARIAAwAAAAEAAQAAARoABQAAAAEAAABiARsABQAAAAEAAABqASgA
3+AwAAAAEAAgAAAhMAAwAAAAEAAQAAgpgAAgAAABcAAAByh2kABAAAAAEAAACK
4+AAAA3AAAAEgAAAABAAAASAAAAAEyMDE3IFdpbGxlbSB2YW4gU2NoYWlrAAAA
5+BZAAAAcAAAAEMDIyMJEBAAcAAAAEAQIDAJKGAAcAAAAQAAAAzKAAAAcAAAAE
6+MDEwMKABAAMAAAAB//8AAAAAAABBU0NJSQAAAFBuZ1N1aXRlAAYBAwADAAAA
7+AQAGAAABGgAFAAAAAQAAASoBGwAFAAAAAQAAATIBKAADAAAAAQACAAACAQAE
8+AAAAAQAAAToCAgAEAAAAAQAAApcAAAAAAAAASAAAAAEAAABIAAAAAf/Y/+AA
9+EEpGSUYAAQEAAAEAAQAA/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcG
10+CAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwED
11+BAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU
12+FBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgACAAIAwEiAAIRAQMRAf/EAB8AAAEF
13+AQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAAB
14+fQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBka
15+JSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6
16+g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS
17+09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEA
18+AAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUh
19+MQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1
20+Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJ
21+ipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ
22+2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+7EGoxTRqz3ySM6A
23+uwITn7+fbkf04ooor+Y6k27M66VCLWrb+Z//2QC6iKqDAAAC5UlEQVRIib2W
24+3W8SQRDA+a/HEBONGqPGRNP4YNQ3EyUYTUqQKjbVBx5IpbRQwCscl+OA40NC
25+kQbK5+HM7ma5u3K5WsBkc5ndvZnf7uzuzAQWC9hqC/wnwMUFGAaUy6INhzRo
26+mqKraVCpQLsN4zFYFk1Np9Dp0CBOVauk7gMYjUih1QJddwPw22wSHm2hPJnA
27+bEYCdnGw0aAv6l7XRdyoHcBlNFqrkdHLS+j1aB1IRRhO4Z64sDEAbhSFfl+4
28+y/8MvpkAKUdLtqA3JuHxsXCRZkAwBXfS5MxI2f0/IlfaOfztDcDxJ1mST1Va
29+b6JE8luVVn0VgBu9CSBcJPlnm+RYTSigHNX+BYDO3TOok2hBZwiKATkV+szv
30+SZ3GQxrJzwskd8ckt7uQ1yBUEFpFwwFIMPfyNp0zQESlie+a4y6iglEnvz/I
31+QH8Ct1LwNCfODVXwdobzpHWgipstAWnnlQ3M5xBjK/3yS1jHe8KvB8o7JzTF
32+/bNrLNXwoXFHfVVoWd2uN8BrgrcfDZq6naZvoeeYuqp1E0B9II4reASj2XoA
33+e5MvyFrAfeall4qb7QWwt5nlB8D2nvl639wa4A17DRFjbYD9/kqdiSVOWN5R
34+X4DdjuV7yMU/y+XYwRu7RdEqTT1kQemwswXAs7wIKfh9p20UgM/4lIWQR8dQ
35+1ukd3Duhw+dJAuNzrEKz8bNlzoizBx9XHHl09SFP5mRoj4WzEAsGOxmS9T6N
36+KyrkNPjI8FEFsiUCyJi2X3Lk0dXXFH2Chl4z1ys9Uv7MlA9MGg/n3P8jAPPo
37+J4XkFAvpMo96Auom3E1DME27QUCGhfRXZ54AdNqHwrVDBQKyzOKLnCgpyhrj
38+HYFeWw2Q4GRTFCg8j3oWXvaiiNcQmI3lIXOLGKV5NcW7XBgMliWWP4Bfj5Xj
39+5+d0mLKOcpUa/Le1ALghLGRkJegqljYAQJnKGU10eR7lLwD/kXl0LQA6BMtT
40+2eUFK0/sMo9uvbr+CztK5Y3mPSskAAAAAElFTkSuQmCC
@@ -0,0 +1,40 @@
1+iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAD0mVYSWZNTQAq
2+AAAACAAHARIAAwAAAAEAAQAAARoABQAAAAEAAABiARsABQAAAAEAAABqASgA
3+AwAAAAEAAgAAAhMAAwAAAAEAAQAAgpgAAgAAABcAAAByh2kABAAAAAEAAACK
4+AAAA3AAAAEgAAAABAAAASAAAAAEyMDE3IFdpbGxlbSB2YW4gU2NoYWlrAAAA
5+BZAAAAcAAAAEMDIyMJEBAAcAAAAEAQIDAJKGAAcAAAAQAAAAzKAAAAcAAAAE
6+MDEwMKABAAMAAAAB//8AAAAAAABBU0NJSQAAAFBuZ1N1aXRlAAYBAwADAAAA
7+AQAGAAABGgAFAAAAAQAAASoBGwAFAAAAAQAAATIBKAADAAAAAQACAAACAQAE
8+AAAAAQAAAToCAgAEAAAAAQAAApcAAAAAAAAASAAAAAEAAABIAAAAAf/Y/+AA
9+EEpGSUYAAQEAAAEAAQAA/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcG
10+CAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwED
11+BAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU
12+FBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgACAAIAwEiAAIRAQMRAf/EAB8AAAEF
13+AQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAAB
14+fQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBka
15+JSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6
16+g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS
17+09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEA
18+AAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUh
19+MQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1
20+Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJ
21+ipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ
22+2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+7EGoxTRqz3ySM6A
23+uwITn7+fbkf04ooor+Y6k27M66VCLWrb+Z//2QC6iKqDAAAC5UlEQVRIib2W
24+3W8SQRDA+a/HEBONGqPGRNP4YNQ3EyUYTUqQKjbVBx5IpbRQwCscl+OA40NC
25+kQbK5+HM7ma5u3K5WsBkc5ndvZnf7uzuzAQWC9hqC/wnwMUFGAaUy6INhzRo
26+mqKraVCpQLsN4zFYFk1Np9Dp0CBOVauk7gMYjUih1QJddwPw22wSHm2hPJnA
27+bEYCdnGw0aAv6l7XRdyoHcBlNFqrkdHLS+j1aB1IRRhO4Z64sDEAbhSFfl+4
28+y/8MvpkAKUdLtqA3JuHxsXCRZkAwBXfS5MxI2f0/IlfaOfztDcDxJ1mST1Va
29+b6JE8luVVn0VgBu9CSBcJPlnm+RYTSigHNX+BYDO3TOok2hBZwiKATkV+szv
30+SZ3GQxrJzwskd8ckt7uQ1yBUEFpFwwFIMPfyNp0zQESlie+a4y6iglEnvz/I
31+QH8Ct1LwNCfODVXwdobzpHWgipstAWnnlQ3M5xBjK/3yS1jHe8KvB8o7JzTF
32+/bNrLNXwoXFHfVVoWd2uN8BrgrcfDZq6naZvoeeYuqp1E0B9II4reASj2XoA
33+e5MvyFrAfeall4qb7QWwt5nlB8D2nvl639wa4A17DRFjbYD9/kqdiSVOWN5R
34+X4DdjuV7yMU/y+XYwRu7RdEqTT1kQemwswXAs7wIKfh9p20UgM/4lIWQR8dQ
35+1ukd3Duhw+dJAuNzrEKz8bNlzoizBx9XHHl09SFP5mRoj4WzEAsGOxmS9T6N
36+KyrkNPjI8FEFsiUCyJi2X3Lk0dXXFH2Chl4z1ys9Uv7MlA9MGg/n3P8jAPPo
37+J4XkFAvpMo96Auom3E1DME27QUCGhfRXZ54AdNqHwrVDBQKyzOKLnCgpyhrj
38+HYFeWw2Q4GRTFCg8j3oWXvaiiNcQmI3lIXOLGKV5NcW7XBgMliWWP4Bfj5Xj
39+5+d0mLKOcpUa/Le1ALghLGRkJegqljYAQJnKGU10eR7lLwD/kXl0LQA6BMtT
40+2eUFK0/sMo9uvbr+CztK5Y3mPSskAAAAAElFTkSuQmCC

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 2

Tags 13

↑↓ moveenter openesc close