main ab6686c

render.go RawHistoryFolder

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.
4package graphicsharness
5
6import (
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
17const 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.
23func 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
57func 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
68func maxInt() int { return int(^uint(0) >> 1) }
69
70func 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		config, err := png.DecodeConfig(bytes.NewReader(encoded))
79		if err != nil {
80			return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
81		}
82		if config.Width != width || config.Height != height {
83			return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID())
84		}
85		decoded, err := png.Decode(bytes.NewReader(encoded))
86		if err != nil {
87			return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
88		}
89		result := image.NewRGBA(image.Rect(0, 0, width, height))
90		for y := range height {
91			for x := range width {
92				result.Set(x, y, decoded.At(decoded.Bounds().Min.X+x, decoded.Bounds().Min.Y+y))
93			}
94		}
95		return result, nil
96	case graphics.AssetFormatRGB, graphics.AssetFormatRGBA:
97		channels := 3
98		if asset.Format() == graphics.AssetFormatRGBA {
99			channels = 4
100		}
101		expected := uint64(width) * uint64(height) * uint64(channels)
102		if uint64(len(encoded)) != expected {
103			return nil, fmt.Errorf("decode asset %s: raw payload has %d bytes, want %d", asset.ID(), len(encoded), expected)
104		}
105		result := image.NewRGBA(image.Rect(0, 0, width, height))
106		for y := range height {
107			for x := range width {
108				offset := (y*width + x) * channels
109				r, g, b, a := encoded[offset], encoded[offset+1], encoded[offset+2], byte(255)
110				if channels == 4 {
111					a = encoded[offset+3]
112				}
113				result.SetRGBA(x, y, color.RGBA{R: premultiply(r, a), G: premultiply(g, a), B: premultiply(b, a), A: a})
114			}
115		}
116		return result, nil
117	default:
118		return nil, fmt.Errorf("decode asset %s: unsupported format %d", asset.ID(), asset.Format())
119	}
120}
121
122func composite(frame *image.RGBA, viewport graphics.PixelRect, source *image.RGBA, sourceRect, destination graphics.PixelRect) error {
123	visible, ok := destination.Intersect(viewport)
124	if !ok {
125		return nil
126	}
127	for y := visible.Y; y < visible.Y+visible.Height; y++ {
128		sy, ok := scaledCoordinate(y, destination.Y, destination.Height, sourceRect.Y, sourceRect.Height)
129		if !ok {
130			return fmt.Errorf("invalid vertical mapping")
131		}
132		for x := visible.X; x < visible.X+visible.Width; x++ {
133			sx, ok := scaledCoordinate(x, destination.X, destination.Width, sourceRect.X, sourceRect.Width)
134			if !ok || sx < 0 || sy < 0 || sx >= int64(source.Bounds().Dx()) || sy >= int64(source.Bounds().Dy()) {
135				return fmt.Errorf("invalid source mapping")
136			}
137			dx, dy := int(x-viewport.X), int(y-viewport.Y)
138			blend(frame, dx, dy, source.RGBAAt(int(sx), int(sy)))
139		}
140	}
141	return nil
142}
143
144func scaledCoordinate(value, destinationStart, destinationSize, sourceStart, sourceSize int64) (int64, bool) {
145	if destinationSize <= 0 || sourceSize <= 0 || value < destinationStart {
146		return 0, false
147	}
148	offset := value - destinationStart
149	if offset >= destinationSize {
150		return 0, false
151	}
152	hi, lo := bits.Mul64(uint64(offset), uint64(sourceSize))
153	if hi >= uint64(destinationSize) {
154		return 0, false
155	}
156	quotient, _ := bits.Div64(hi, lo, uint64(destinationSize))
157	if quotient > uint64(^uint64(0)>>1) || sourceStart > int64(^uint64(0)>>1)-int64(quotient) {
158		return 0, false
159	}
160	return sourceStart + int64(quotient), true
161}
162
163func premultiply(component, alpha byte) byte {
164	return byte((uint16(component)*uint16(alpha) + 127) / 255)
165}
166
167func blend(dst *image.RGBA, x, y int, src color.RGBA) {
168	offset := dst.PixOffset(x, y)
169	inverse := uint16(255 - src.A)
170	dst.Pix[offset] = src.R + byte((uint16(dst.Pix[offset])*inverse+127)/255)
171	dst.Pix[offset+1] = src.G + byte((uint16(dst.Pix[offset+1])*inverse+127)/255)
172	dst.Pix[offset+2] = src.B + byte((uint16(dst.Pix[offset+2])*inverse+127)/255)
173	dst.Pix[offset+3] = src.A + byte((uint16(dst.Pix[offset+3])*inverse+127)/255)
174}

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