detached 0b9cfda

Merge pull request #5 from bnema/feat/kitty-image-protocol-support

feat(graphics): support Kitty terminal graphics
commit
0b9cfda7f9dcbdbb863cdeba1d5837423bd5c225
author
bnema · · 2026-08-26 09:22 UTC
committer
GitHub · · 2026-08-26 09:22 UTC
merge of
52147d7 + df3b2f3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.4.0
36 files · +5849 -26

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

this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.

M README.md +36 -0
@@ -15,6 +15,11 @@ The `ansi` package turns core frames and damage into transactional ANSI output.
1515 or application dependencies.
1616 - `github.com/bnema/vev-vt/ansi` is the concrete ANSI output package. It consumes
1717 core frames and damage; it does not define a renderer-backend interface.
18+- `github.com/bnema/vev-vt/graphics` owns bounded renderer-neutral raster assets,
19+ sparse placements, clipping fragments, and immutable scene snapshots. It has no
20+ Kitty, ANSI, VT-policy, or transport dependency.
21+- `github.com/bnema/vev-vt/protocol/kittygraphics` parses bounded Kitty graphics
22+ APC commands and translates the supported static/direct subset into `graphics`.
1823
1924 ## Ownership contract
2025
@@ -40,6 +45,37 @@ used only by the test suite. Keep the public v0.x API and byte formats immutable
4045 once released; behavior changes require explicit versioning and compatibility
4146 evidence.
4247
48+## Kitty graphics subset
49+
50+The VT accepts bounded static direct transmissions used by current
51+`kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit,
52+transmit-and-display, place, query, and supported delete operations; chunked
53+Base64 uploads; source rectangles; cell extents; pixel offsets; z-index; and
54+cursor movement policy. Assets and decoded pixels are bounded by explicit scene
55+and parser limits. File, temporary-file, shared-memory, animation, composition,
56+relative placement, and Unicode-placeholder commands remain unsupported.
57+
58+`Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are
59+separate from cells and history bytes; VTH3 remains text/history-only. Static
60+placements retain their configured pixel rectangles across text scrolling and
61+reflow; row-anchored and relative-placement movement is unsupported.
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+
4379 ## Checks
4480
4581 ```sh
M benchmark_test.go +82 -0
@@ -2,6 +2,7 @@ package vt
22
33 import (
44 "bytes"
5+ "encoding/base64"
56 "testing"
67
78 renderer "github.com/bnema/vev-vt/core"
@@ -10,6 +11,7 @@ import (
1011 var (
1112 benchmarkHistoryViewSink HistoryView
1213 benchmarkHistorySnapshotSink HistorySnapshotView
14+ benchmarkDamageCaptureSink DamageCapture
1315 benchmarkHistoryBlobSink []byte
1416 benchmarkHistoryBlobsSink [][]byte
1517 )
@@ -200,6 +202,14 @@ func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
200202 for range historyRows {
201203 requireHistoryAppend(b, s.history, make([]renderer.Cell, 120))
202204 }
205+ // History normally receives rows evicted from this screen, whose IDs are
206+ // allocated after every existing live or historical row. This fixture
207+ // prepopulates history directly, so advance and refresh the live IDs to
208+ // preserve that invariant before exercising resize eviction.
209+ s.nextRowID = s.history.nextRowID - 1
210+ for y := range s.buffer.rowIDs {
211+ s.buffer.rowIDs[y] = s.nextRowIDValue()
212+ }
203213 }
204214 s.Write(bytes.Repeat([]byte("x"), 120*39))
205215 s.ClearDamage()
@@ -217,6 +227,78 @@ func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
217227 }
218228 }
219229
230+func BenchmarkScreenPrintableASCII(b *testing.B) {
231+ s := NewScreen(120, 40)
232+ chunk := append(bytes.Repeat([]byte("x"), 119), '\r')
233+ b.SetBytes(int64(len(chunk)))
234+ b.ReportAllocs()
235+ for b.Loop() {
236+ s.Write(chunk)
237+ s.ClearDamage()
238+ }
239+}
240+
241+func BenchmarkScreenKittyAPC(b *testing.B) {
242+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 256*256*4))
243+ apc := []byte("\x1b_Ga=t,i=1,f=32,s=256,v=256,q=2;" + payload + "\x1b\\")
244+ b.SetBytes(int64(len(apc)))
245+ for _, fragmented := range []bool{false, true} {
246+ b.Run(map[bool]string{false: "single-write", true: "byte-by-byte"}[fragmented], func(b *testing.B) {
247+ s := NewScreen(120, 40)
248+ b.ReportAllocs()
249+ for b.Loop() {
250+ if fragmented {
251+ for _, part := range apc {
252+ s.Write([]byte{part})
253+ }
254+ } else {
255+ s.Write(apc)
256+ }
257+ s.ClearDamage()
258+ }
259+ })
260+ }
261+}
262+
263+func BenchmarkScreenMixedUTF8(b *testing.B) {
264+ s := NewScreen(120, 40)
265+ chunk := append(bytes.Repeat([]byte("Aé界"), 20), '\r')
266+ b.SetBytes(int64(len(chunk)))
267+ b.ReportAllocs()
268+ for b.Loop() {
269+ s.Write(chunk)
270+ s.ClearDamage()
271+ }
272+}
273+
274+func BenchmarkScreenCSIHeavy(b *testing.B) {
275+ s := NewScreen(120, 40)
276+ chunk := []byte("\x1b[1;1H\x1b[2K\x1b[31;48;2;1;2;3mstatus\x1b[0m\x1b[10C\x1b[?25l\x1b[?25h\r")
277+ b.SetBytes(int64(len(chunk)))
278+ b.ReportAllocs()
279+ for b.Loop() {
280+ s.Write(chunk)
281+ s.ClearDamage()
282+ }
283+}
284+
285+func BenchmarkScreenCaptureDamage(b *testing.B) {
286+ s := NewScreen(120, 40)
287+ chunk := []byte("x")
288+ s.ClearDamage()
289+ b.ReportAllocs()
290+ for b.Loop() {
291+ s.Write(chunk)
292+ benchmarkDamageCaptureSink = s.CaptureDamage()
293+ if len(benchmarkDamageCaptureSink.Damage) == 0 {
294+ b.Fatal("expected captured damage")
295+ }
296+ if !s.AcknowledgeDamage(benchmarkDamageCaptureSink.Generation) {
297+ b.Fatal("expected current damage acknowledgement")
298+ }
299+ }
300+}
301+
220302 func BenchmarkScreenShellRedrawBurst(b *testing.B) {
221303 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
222304 b.ReportAllocs()
A cmd/graphics-harness/main.go +90 -0
@@ -0,0 +1,90 @@
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+ maxInt := int(^uint(0) >> 1)
62+ if width > 0 && scale > maxInt/width {
63+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
64+ }
65+ if height > 0 && scale > maxInt/height {
66+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
67+ }
68+ scaledWidth, scaledHeight := width*scale, height*scale
69+ const maxScaledPixels = uint64(64 << 20)
70+ if scaledWidth > 0 && uint64(scaledHeight) > maxScaledPixels/uint64(scaledWidth) {
71+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
72+ }
73+ result := image.NewRGBA(image.Rect(0, 0, scaledWidth, scaledHeight))
74+ for y := range height {
75+ for x := range width {
76+ pixel := source.RGBAAt(x, y)
77+ for dy := range scale {
78+ for dx := range scale {
79+ result.SetRGBA(x*scale+dx, y*scale+dy, pixel)
80+ }
81+ }
82+ }
83+ }
84+ return result, nil
85+}
86+
87+func fail(format string, args ...any) {
88+ fmt.Fprintf(os.Stderr, "graphics-harness: "+format+"\n", args...)
89+ os.Exit(1)
90+}
A cmd/graphics-harness/main_test.go +33 -0
@@ -0,0 +1,33 @@
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+}
29+
30+func TestScaleFrameRejectsMaximumIntScale(t *testing.T) {
31+ _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), int(^uint(0)>>1))
32+ require.EqualError(t, err, "scaled frame exceeds 67108864 pixels")
33+}
A geometry.go +19 -0
@@ -0,0 +1,19 @@
1+package vt
2+
3+// Geometry combines a terminal's required cell dimensions with optional pixel
4+// dimensions reported by its frontend. Zero pixel dimensions mean they are
5+// unknown and must not be inferred from the cell dimensions.
6+type Geometry struct {
7+ Cols, Rows int
8+ PixelWidth, PixelHeight int
9+}
10+
11+// Valid reports whether the cell dimensions can describe a terminal screen.
12+func (g Geometry) Valid() bool {
13+ return g.Cols > 0 && g.Rows > 0
14+}
15+
16+// PixelsKnown reports whether both pixel dimensions are available.
17+func (g Geometry) PixelsKnown() bool {
18+ return g.PixelWidth > 0 && g.PixelHeight > 0
19+}
A geometry_test.go +26 -0
@@ -0,0 +1,26 @@
1+package vt
2+
3+import "testing"
4+
5+func TestGeometryValidityAndPixelsKnown(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ geometry Geometry
9+ valid, hasPixel bool
10+ }{
11+ {name: "cells only", geometry: Geometry{Cols: 80, Rows: 24}, valid: true},
12+ {name: "full", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, valid: true, hasPixel: true},
13+ {name: "partial pixels are unknown", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640}, valid: true},
14+ {name: "zero columns", geometry: Geometry{Rows: 24, PixelWidth: 640, PixelHeight: 384}, hasPixel: true},
15+ }
16+ for _, tt := range tests {
17+ t.Run(tt.name, func(t *testing.T) {
18+ if got := tt.geometry.Valid(); got != tt.valid {
19+ t.Fatalf("Valid() = %t, want %t", got, tt.valid)
20+ }
21+ if got := tt.geometry.PixelsKnown(); got != tt.hasPixel {
22+ t.Fatalf("PixelsKnown() = %t, want %t", got, tt.hasPixel)
23+ }
24+ })
25+ }
26+}
A graphics/consumer_external_test.go +54 -0
@@ -0,0 +1,54 @@
1+package graphics_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/graphics"
8+)
9+
10+func TestExternalConsumerCanBuildAndReadImmutableScene(t *testing.T) {
11+ scene := graphics.NewScene(graphics.Config{
12+ MaxAssets: 4,
13+ MaxPlacements: 4,
14+ MaxEncodedBytes: 32,
15+ MaxDecodedPixels: 64,
16+ })
17+ data := []byte("encoded")
18+ assetID, err := scene.RegisterAsset(graphics.AssetInput{Encoded: data, Width: 8, Height: 4})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ data[0] = 'X'
23+
24+ placementID, err := scene.AddPlacement(graphics.PlacementSpec{
25+ Asset: assetID,
26+ Destination: graphics.PixelRect{X: -2, Y: 3, Width: 16, Height: 8},
27+ })
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ snapshot := scene.Snapshot()
32+ asset, ok := snapshot.GetAsset(assetID)
33+ if !ok || string(asset.Bytes()) != "encoded" {
34+ t.Fatalf("asset = %#v, ok=%v", asset, ok)
35+ }
36+ placement, ok := snapshot.GetPlacement(placementID)
37+ if !ok || placement.AssetID() != assetID {
38+ t.Fatalf("placement = %#v, ok=%v", placement, ok)
39+ }
40+ fragments := snapshot.VisiblePixelFragments(graphics.PixelRect{X: 0, Y: 3, Width: 4, Height: 8})
41+ if len(fragments) != 1 || fragments[0].AssetID != assetID || fragments[0].PlacementID != placementID {
42+ t.Fatalf("fragments = %#v", fragments)
43+ }
44+
45+ if err := scene.RemoveAsset(assetID); !errors.Is(err, graphics.ErrAssetInUse) {
46+ t.Fatalf("remove in-use asset error = %v", err)
47+ }
48+ if err := scene.RemoveAssetCascade(assetID); err != nil {
49+ t.Fatal(err)
50+ }
51+ if _, ok := snapshot.GetAsset(assetID); !ok {
52+ t.Fatal("immutable snapshot changed after scene mutation")
53+ }
54+}
A graphics/graphics.go +199 -0
@@ -0,0 +1,199 @@
1+// Package graphics provides a renderer-neutral, bounded graphics scene.
2+//
3+// The package stores immutable encoded asset blobs and sparse placements. A
4+// scene is mutated by its owner and publishes immutable snapshots for
5+// consumers. It deliberately knows nothing about terminal protocols,
6+// renderers, image formats, or text state.
7+package graphics
8+
9+import "errors"
10+
11+var (
12+ ErrInvalidRect = errors.New("invalid rectangle")
13+ ErrInvalidAsset = errors.New("invalid asset")
14+ ErrInvalidPlacement = errors.New("invalid placement")
15+ ErrAssetNotFound = errors.New("asset not found")
16+ ErrPlacementNotFound = errors.New("placement not found")
17+ ErrAssetInUse = errors.New("asset is still in use")
18+ ErrTooManyAssets = errors.New("asset quota exceeded")
19+ ErrTooManyPlacements = errors.New("placement quota exceeded")
20+ ErrEncodedBudget = errors.New("encoded byte budget exceeded")
21+ ErrDecodedPixelBudget = errors.New("decoded pixel budget exceeded")
22+ ErrGenerationOverflow = errors.New("generation overflow")
23+ ErrIdentifierOverflow = errors.New("identifier overflow")
24+ ErrInvalidOperation = errors.New("invalid graphics operation")
25+ ErrInvalidSceneLimits = errors.New("invalid scene limits")
26+)
27+
28+// AssetID identifies an asset in a scene. Its representation is deliberately
29+// private: IDs are only meaningful to the scene that issued them and are not
30+// interchangeable with placement IDs.
31+type AssetID struct{ value uint64 }
32+
33+// PlacementID identifies a sparse placement in a scene.
34+type PlacementID struct{ value uint64 }
35+
36+// Valid reports whether the ID was issued by a scene.
37+func (id AssetID) Valid() bool { return id.value != 0 }
38+
39+// Valid reports whether the ID was issued by a scene.
40+func (id PlacementID) Valid() bool { return id.value != 0 }
41+
42+// String returns a stable diagnostic form of the opaque ID.
43+func (id AssetID) String() string { return formatID('a', id.value) }
44+
45+// String returns a stable diagnostic form of the opaque ID.
46+func (id PlacementID) String() string { return formatID('p', id.value) }
47+
48+func formatID(prefix byte, value uint64) string {
49+ if value == 0 {
50+ return "<invalid>"
51+ }
52+ var buf [1 + 20]byte
53+ buf[0] = prefix
54+ i := len(buf)
55+ for value != 0 {
56+ i--
57+ buf[i] = byte('0' + value%10)
58+ value /= 10
59+ }
60+ return string(buf[0:1]) + string(buf[i:])
61+}
62+
63+// Limits bounds live scene resources. Zero-valued limits use the package
64+// defaults; this keeps a zero-value configuration bounded as well.
65+type Limits struct {
66+ MaxAssets uint64
67+ MaxPlacements uint64
68+ MaxEncodedBytes uint64
69+ MaxDecodedPixels uint64
70+ MaxEncodedBytesPerAsset uint64
71+ MaxDecodedPixelsPerAsset uint64
72+}
73+
74+// Config is an alternate name for Limits for callers that prefer constructor
75+// configuration terminology.
76+type Config = Limits
77+
78+// SceneConfig is a descriptive alias for Config.
79+type SceneConfig = Limits
80+
81+// Generation is the monotonically increasing scene generation number.
82+type Generation = uint64
83+
84+// SnapshotRef is an explicit name for an immutable snapshot reference.
85+type SnapshotRef = Snapshot
86+
87+// AssetInput is an alternate name for an asset registration description.
88+type AssetInput = AssetBlob
89+
90+const (
91+ DefaultMaxAssets = uint64(1024)
92+ DefaultMaxPlacements = uint64(65536)
93+ DefaultMaxEncodedBytes = uint64(64 << 20)
94+ DefaultMaxDecodedPixels = uint64(256 << 20)
95+ DefaultMaxEncodedBytesPerAsset = uint64(16 << 20)
96+ DefaultMaxDecodedPixelsPerAsset = uint64(128 << 20)
97+)
98+
99+// AssetFormat identifies the encoded pixel layout supplied by an asset.
100+type AssetFormat uint8
101+
102+const (
103+ AssetFormatUnknown AssetFormat = iota
104+ AssetFormatRGB
105+ AssetFormatRGBA
106+ AssetFormatPNG
107+)
108+
109+// AssetBlob is the caller-owned description of one encoded asset. AddAsset
110+// copies Encoded before returning, so later caller mutations cannot alter a
111+// scene or any snapshot. Format preserves the encoded pixel layout for the
112+// renderer. Width and Height describe the decoded pixel extent used for
113+// clipping and quota accounting. DecodedPixels, when non-zero, must equal
114+// Width*Height; accounting always uses the checked product.
115+type AssetBlob struct {
116+ Encoded []byte
117+ Format AssetFormat
118+ Width, Height int64
119+ DecodedPixels uint64
120+}
121+
122+// AssetSpec is the conventional name for an asset input description.
123+type AssetSpec = AssetBlob
124+
125+// PlacementSpec describes one sparse placement. Source defaults to the full
126+// asset. Destination is a pixel rectangle. Cells optionally records the
127+// cell-space extent associated with the same placement. HasCells and HasLayer
128+// preserve explicit zero values when updating an existing placement.
129+type PlacementSpec struct {
130+ Asset AssetID
131+ Source PixelRect
132+ Destination PixelRect
133+ Cells CellRect
134+ HasCells bool
135+ Layer int64
136+ HasLayer bool
137+}
138+
139+// Viewport can constrain visible fragments in pixel space, cell space, or
140+// both. A zero-valued rectangle means that coordinate space is not supplied.
141+type Viewport struct {
142+ Pixels PixelRect
143+ Cells CellRect
144+}
145+
146+// VisibleFragment is the visible part of a placement. Source and Destination
147+// are clipped together, preserving the placement's source-to-destination
148+// mapping even when the destination is scaled. Cells is populated when the
149+// placement and viewport carry cell geometry.
150+type VisibleFragment struct {
151+ PlacementID PlacementID
152+ AssetID AssetID
153+ Source PixelRect
154+ Destination PixelRect
155+ Cells CellRect
156+}
157+
158+// Fragment is a short alias for VisibleFragment.
159+type Fragment = VisibleFragment
160+
161+// Usage reports resources held by the current scene or an immutable snapshot.
162+type Usage struct {
163+ Assets uint64
164+ Placements uint64
165+ EncodedBytes uint64
166+ DecodedPixels uint64
167+}
168+
169+// OperationKind identifies one transactional scene operation.
170+type OperationKind uint8
171+
172+const (
173+ OperationAddAsset OperationKind = iota + 1
174+ OperationRemoveAsset
175+ OperationPlace
176+ OperationUpdatePlacement
177+ OperationRemovePlacement
178+)
179+
180+// Operation is a sparse scene operation. Add-asset operations allocate an
181+// opaque ID internally; use AddAsset directly when the ID is needed by the
182+// caller for a subsequent placement.
183+type Operation struct {
184+ Kind OperationKind
185+ Blob AssetBlob
186+ Asset AssetID
187+ Placement PlacementSpec
188+ PlacementID PlacementID
189+}
190+
191+// Operation aliases make operation construction readable without introducing
192+// protocol-specific terminology.
193+const (
194+ AddAssetOperation = OperationAddAsset
195+ RemoveAssetOperation = OperationRemoveAsset
196+ PlaceOperation = OperationPlace
197+ UpdatePlacementOperation = OperationUpdatePlacement
198+ RemovePlacementOperation = OperationRemovePlacement
199+)
A graphics/rect.go +153 -0
@@ -0,0 +1,153 @@
1+package graphics
2+
3+import "fmt"
4+
5+const (
6+ maxInt64 = int64(^uint64(0) >> 1)
7+ minInt64 = -maxInt64 - 1
8+)
9+
10+// PixelRect is a half-open rectangle in pixel coordinates. Width and Height
11+// must be non-negative, and the exclusive right and bottom edges must fit in
12+// int64. Coordinates may be negative so that scenes can be clipped without
13+// first translating them into a viewport.
14+type PixelRect struct {
15+ X, Y int64
16+ Width, Height int64
17+}
18+
19+// CellRect is a half-open rectangle in cell coordinates. It has the same
20+// overflow rules as PixelRect but is kept as a distinct type so that pixel
21+// and cell coordinates cannot be accidentally mixed.
22+type CellRect struct {
23+ X, Y int64
24+ Width, Height int64
25+}
26+
27+type rect struct {
28+ x, y int64
29+ width, height int64
30+}
31+
32+func pixelRect(r PixelRect) rect { return rect{x: r.X, y: r.Y, width: r.Width, height: r.Height} }
33+func cellRect(r CellRect) rect { return rect{x: r.X, y: r.Y, width: r.Width, height: r.Height} }
34+
35+// NewPixelRect constructs a checked pixel rectangle.
36+func NewPixelRect(x, y, width, height int64) (PixelRect, error) {
37+ r := PixelRect{X: x, Y: y, Width: width, Height: height}
38+ if !r.Valid() {
39+ return PixelRect{}, fmt.Errorf("pixel rectangle: %w", ErrInvalidRect)
40+ }
41+ return r, nil
42+}
43+
44+// NewCellRect constructs a checked cell rectangle.
45+func NewCellRect(x, y, width, height int64) (CellRect, error) {
46+ r := CellRect{X: x, Y: y, Width: width, Height: height}
47+ if !r.Valid() {
48+ return CellRect{}, fmt.Errorf("cell rectangle: %w", ErrInvalidRect)
49+ }
50+ return r, nil
51+}
52+
53+func (r rect) valid() bool {
54+ return r.width >= 0 && r.height >= 0 && edge(r.x, r.width) && edge(r.y, r.height)
55+}
56+
57+func (r rect) empty() bool { return r.width == 0 || r.height == 0 }
58+
59+func (r rect) right() (int64, bool) { return checkedEdge(r.x, r.width) }
60+
61+func (r rect) bottom() (int64, bool) { return checkedEdge(r.y, r.height) }
62+
63+func (r rect) contains(x, y int64) bool {
64+ right, okRight := r.right()
65+ bottom, okBottom := r.bottom()
66+ return okRight && okBottom && x >= r.x && x < right && y >= r.y && y < bottom
67+}
68+
69+func (r rect) intersect(other rect) (rect, bool) {
70+ if !r.valid() || !other.valid() || r.empty() || other.empty() {
71+ return rect{}, false
72+ }
73+ right, _ := r.right()
74+ otherRight, _ := other.right()
75+ bottom, _ := r.bottom()
76+ otherBottom, _ := other.bottom()
77+ left := max(r.x, other.x)
78+ top := max(r.y, other.y)
79+ right = min(right, otherRight)
80+ bottom = min(bottom, otherBottom)
81+ if right <= left || bottom <= top {
82+ return rect{}, false
83+ }
84+ return rect{x: left, y: top, width: right - left, height: bottom - top}, true
85+}
86+
87+// Valid reports whether the rectangle's dimensions and exclusive edges are
88+// representable without signed overflow.
89+func (r PixelRect) Valid() bool { return pixelRect(r).valid() }
90+
91+// Valid reports whether the rectangle's dimensions and exclusive edges are
92+// representable without signed overflow.
93+func (r CellRect) Valid() bool { return cellRect(r).valid() }
94+
95+// Empty reports whether the rectangle has no area.
96+func (r PixelRect) Empty() bool { return pixelRect(r).empty() }
97+
98+// Empty reports whether the rectangle has no area.
99+func (r CellRect) Empty() bool { return cellRect(r).empty() }
100+
101+// Right returns the exclusive right edge. The boolean is false for an
102+// invalid rectangle; it is never produced by arithmetic that wraps.
103+func (r PixelRect) Right() (int64, bool) { return pixelRect(r).right() }
104+
105+// Bottom returns the exclusive bottom edge. The boolean is false for an
106+// invalid rectangle; it is never produced by arithmetic that wraps.
107+func (r PixelRect) Bottom() (int64, bool) { return pixelRect(r).bottom() }
108+
109+// Right returns the exclusive right edge. The boolean is false for an
110+// invalid rectangle; it is never produced by arithmetic that wraps.
111+func (r CellRect) Right() (int64, bool) { return cellRect(r).right() }
112+
113+// Bottom returns the exclusive bottom edge. The boolean is false for an
114+// invalid rectangle; it is never produced by arithmetic that wraps.
115+func (r CellRect) Bottom() (int64, bool) { return cellRect(r).bottom() }
116+
117+// Contains reports whether a point is inside the half-open rectangle.
118+func (r PixelRect) Contains(x, y int64) bool { return pixelRect(r).contains(x, y) }
119+
120+// Contains reports whether a point is inside the half-open rectangle.
121+func (r CellRect) Contains(x, y int64) bool { return cellRect(r).contains(x, y) }
122+
123+// Intersect returns the non-empty intersection of two rectangles. Invalid or
124+// empty rectangles do not intersect. The result is safe even when the input
125+// edges are close to the int64 limits.
126+func (r PixelRect) Intersect(other PixelRect) (PixelRect, bool) {
127+ intersect, ok := pixelRect(r).intersect(pixelRect(other))
128+ return PixelRect{X: intersect.x, Y: intersect.y, Width: intersect.width, Height: intersect.height}, ok
129+}
130+
131+// Intersect returns the non-empty intersection of two rectangles.
132+func (r CellRect) Intersect(other CellRect) (CellRect, bool) {
133+ intersect, ok := cellRect(r).intersect(cellRect(other))
134+ return CellRect{X: intersect.x, Y: intersect.y, Width: intersect.width, Height: intersect.height}, ok
135+}
136+
137+func edge(start, length int64) bool {
138+ _, ok := checkedEdge(start, length)
139+ return ok
140+}
141+
142+func checkedEdge(start, length int64) (int64, bool) {
143+ if length < 0 {
144+ return 0, false
145+ }
146+ // For a non-negative length, a negative start cannot overflow the
147+ // positive edge: even MinInt64 + MaxInt64 is -1. Avoid computing
148+ // maxInt64-start in that case because the subtraction itself could wrap.
149+ if start >= 0 && length > maxInt64-start {
150+ return 0, false
151+ }
152+ return start + length, true
153+}
A graphics/scene.go +1009 -0
@@ -0,0 +1,1009 @@
1+package graphics
2+
3+import (
4+ "fmt"
5+ "math/bits"
6+ "sort"
7+ "sync"
8+)
9+
10+type assetRecord struct {
11+ id AssetID
12+ encoded []byte
13+ format AssetFormat
14+ width int64
15+ height int64
16+ pixels uint64
17+}
18+
19+type placementRecord struct {
20+ id PlacementID
21+ asset AssetID
22+ source PixelRect
23+ destination PixelRect
24+ cells CellRect
25+ layer int64
26+ order uint64
27+}
28+
29+type sceneState struct {
30+ generation uint64
31+ nextAsset uint64
32+ nextPlace uint64
33+ nextOrder uint64
34+ assets map[AssetID]assetRecord
35+ placements map[PlacementID]placementRecord
36+ usage Usage
37+}
38+
39+// Scene owns a bounded set of immutable assets and sparse placements. Scene
40+// methods are safe for concurrent callers; snapshots never share mutable maps
41+// with the scene. The scene itself contains no text or renderer state.
42+type Scene struct {
43+ mu sync.RWMutex
44+ limits Limits
45+ state *sceneState
46+}
47+
48+// NewScene creates an empty bounded graphics scene. Zero-valued limits use
49+// package defaults. Limits are copied and may be safely reused by the caller.
50+func NewScene(limits Limits) *Scene {
51+ return &Scene{limits: normalizeLimits(limits), state: emptyState()}
52+}
53+
54+// New is a concise constructor alias.
55+func New(limits Limits) *Scene { return NewScene(limits) }
56+
57+// NewSceneWithLimits is a descriptive constructor alias.
58+func NewSceneWithLimits(limits Limits) *Scene { return NewScene(limits) }
59+
60+func emptyState() *sceneState {
61+ return &sceneState{
62+ nextAsset: 1,
63+ nextPlace: 1,
64+ nextOrder: 1,
65+ assets: make(map[AssetID]assetRecord),
66+ placements: make(map[PlacementID]placementRecord),
67+ }
68+}
69+
70+func normalizeLimits(limits Limits) Limits {
71+ if limits.MaxAssets == 0 {
72+ limits.MaxAssets = DefaultMaxAssets
73+ }
74+ if limits.MaxPlacements == 0 {
75+ limits.MaxPlacements = DefaultMaxPlacements
76+ }
77+ if limits.MaxEncodedBytes == 0 {
78+ limits.MaxEncodedBytes = DefaultMaxEncodedBytes
79+ }
80+ if limits.MaxDecodedPixels == 0 {
81+ limits.MaxDecodedPixels = DefaultMaxDecodedPixels
82+ }
83+ if limits.MaxEncodedBytesPerAsset == 0 {
84+ limits.MaxEncodedBytesPerAsset = DefaultMaxEncodedBytesPerAsset
85+ }
86+ if limits.MaxDecodedPixelsPerAsset == 0 {
87+ limits.MaxDecodedPixelsPerAsset = DefaultMaxDecodedPixelsPerAsset
88+ }
89+ return limits
90+}
91+
92+// Limits returns the immutable limits configured for the scene.
93+func (s *Scene) Limits() Limits {
94+ if s == nil {
95+ return Limits{}
96+ }
97+ s.mu.RLock()
98+ defer s.mu.RUnlock()
99+ return s.limits
100+}
101+
102+// AddAsset copies and registers an encoded asset, returning an opaque ID.
103+func (s *Scene) AddAsset(blob AssetBlob) (AssetID, error) {
104+ if s == nil {
105+ return AssetID{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
106+ }
107+ s.mu.Lock()
108+ defer s.mu.Unlock()
109+ candidate := cloneState(s.state)
110+ record, err := s.prepareAsset(blob, candidate)
111+ if err != nil {
112+ return AssetID{}, err
113+ }
114+ id, ok := takeAssetID(candidate)
115+ if !ok {
116+ return AssetID{}, fmt.Errorf("add asset: %w", ErrIdentifierOverflow)
117+ }
118+ record.id = id
119+ candidate.assets[id] = record
120+ candidate.usage.Assets++
121+ candidate.usage.EncodedBytes += uint64(len(record.encoded))
122+ candidate.usage.DecodedPixels += record.pixels
123+ if err := advanceGeneration(candidate); err != nil {
124+ return AssetID{}, err
125+ }
126+ s.state = candidate
127+ return id, nil
128+}
129+
130+// RegisterAsset is an alias for AddAsset.
131+func (s *Scene) RegisterAsset(blob AssetBlob) (AssetID, error) { return s.AddAsset(blob) }
132+
133+// ReplaceAsset atomically replaces an asset while removing all placements that
134+// reference it. If validation or resource limits reject the new asset, the
135+// existing asset and placements remain unchanged.
136+func (s *Scene) ReplaceAsset(id AssetID, blob AssetBlob) (AssetID, error) {
137+ if s == nil {
138+ return AssetID{}, fmt.Errorf("replace asset: %w", ErrAssetNotFound)
139+ }
140+ s.mu.Lock()
141+ defer s.mu.Unlock()
142+ candidate := cloneState(s.state)
143+ old, ok := candidate.assets[id]
144+ if !ok {
145+ return AssetID{}, fmt.Errorf("replace asset: %w", ErrAssetNotFound)
146+ }
147+ for placementID, placement := range candidate.placements {
148+ if placement.asset != id {
149+ continue
150+ }
151+ delete(candidate.placements, placementID)
152+ candidate.usage.Placements--
153+ }
154+ delete(candidate.assets, id)
155+ candidate.usage.Assets--
156+ candidate.usage.EncodedBytes -= uint64(len(old.encoded))
157+ candidate.usage.DecodedPixels -= old.pixels
158+ replacement, err := s.prepareAsset(blob, candidate)
159+ if err != nil {
160+ return AssetID{}, err
161+ }
162+ replacementID, ok := takeAssetID(candidate)
163+ if !ok {
164+ return AssetID{}, fmt.Errorf("replace asset: %w", ErrIdentifierOverflow)
165+ }
166+ replacement.id = replacementID
167+ candidate.assets[replacementID] = replacement
168+ candidate.usage.Assets++
169+ candidate.usage.EncodedBytes += uint64(len(replacement.encoded))
170+ candidate.usage.DecodedPixels += replacement.pixels
171+ if err := advanceGeneration(candidate); err != nil {
172+ return AssetID{}, fmt.Errorf("replace asset: %w", err)
173+ }
174+ s.state = candidate
175+ return replacementID, nil
176+}
177+
178+// AddEncodedAsset registers an asset from its encoded bytes and decoded pixel
179+// dimensions. The input bytes are copied before this method returns.
180+func (s *Scene) AddEncodedAsset(encoded []byte, width, height int64) (AssetID, error) {
181+ return s.AddAsset(AssetBlob{Encoded: encoded, Width: width, Height: height})
182+}
183+
184+// RemoveAsset removes an unreferenced asset. Use RemoveAssetCascade when the
185+// asset's placements should be removed as one transaction.
186+func (s *Scene) RemoveAsset(id AssetID) error {
187+ if s == nil {
188+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
189+ }
190+ s.mu.Lock()
191+ defer s.mu.Unlock()
192+ candidate := cloneState(s.state)
193+ if err := removeAsset(candidate, id, false); err != nil {
194+ return err
195+ }
196+ if err := advanceGeneration(candidate); err != nil {
197+ return fmt.Errorf("remove asset: %w", err)
198+ }
199+ s.state = candidate
200+ return nil
201+}
202+
203+// RemoveAssetCascade removes an asset and all placements that reference it in
204+// one generation.
205+func (s *Scene) RemoveAssetCascade(id AssetID) error {
206+ if s == nil {
207+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
208+ }
209+ s.mu.Lock()
210+ defer s.mu.Unlock()
211+ candidate := cloneState(s.state)
212+ if err := removeAsset(candidate, id, true); err != nil {
213+ return err
214+ }
215+ if err := advanceGeneration(candidate); err != nil {
216+ return fmt.Errorf("remove asset: %w", err)
217+ }
218+ s.state = candidate
219+ return nil
220+}
221+
222+// Place adds one sparse placement and returns its opaque ID.
223+func (s *Scene) Place(spec PlacementSpec) (PlacementID, error) {
224+ if s == nil {
225+ return PlacementID{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
226+ }
227+ s.mu.Lock()
228+ defer s.mu.Unlock()
229+ candidate := cloneState(s.state)
230+ record, err := s.preparePlacement(candidate, spec, nil)
231+ if err != nil {
232+ return PlacementID{}, err
233+ }
234+ if uint64(len(candidate.placements)) >= s.limits.MaxPlacements {
235+ return PlacementID{}, fmt.Errorf("place: %w", ErrTooManyPlacements)
236+ }
237+ id, ok := takePlacementID(candidate)
238+ if !ok {
239+ return PlacementID{}, fmt.Errorf("place: %w", ErrIdentifierOverflow)
240+ }
241+ record.id = id
242+ record.order = takeOrder(candidate)
243+ candidate.placements[id] = record
244+ candidate.usage.Placements++
245+ if err := advanceGeneration(candidate); err != nil {
246+ return PlacementID{}, fmt.Errorf("place: %w", err)
247+ }
248+ s.state = candidate
249+ return id, nil
250+}
251+
252+// AddPlacement is an alias for Place.
253+func (s *Scene) AddPlacement(spec PlacementSpec) (PlacementID, error) { return s.Place(spec) }
254+
255+// PlaceAsset creates a full-source placement of an asset.
256+func (s *Scene) PlaceAsset(asset AssetID, destination PixelRect) (PlacementID, error) {
257+ return s.Place(PlacementSpec{Asset: asset, Destination: destination})
258+}
259+
260+// UpdatePlacement replaces the geometry and optional metadata of a placement.
261+// If spec.Asset and spec.AssetID are both invalid, the existing asset is kept.
262+func (s *Scene) UpdatePlacement(id PlacementID, spec PlacementSpec) error {
263+ if s == nil {
264+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
265+ }
266+ s.mu.Lock()
267+ defer s.mu.Unlock()
268+ candidate := cloneState(s.state)
269+ existing, ok := candidate.placements[id]
270+ if !ok {
271+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
272+ }
273+ record, err := s.preparePlacement(candidate, spec, &existing)
274+ if err != nil {
275+ return err
276+ }
277+ record.id = id
278+ record.order = existing.order
279+ candidate.placements[id] = record
280+ if err := advanceGeneration(candidate); err != nil {
281+ return fmt.Errorf("update placement: %w", err)
282+ }
283+ s.state = candidate
284+ return nil
285+}
286+
287+// MovePlacement changes only the destination rectangle of a placement.
288+func (s *Scene) MovePlacement(id PlacementID, destination PixelRect) error {
289+ return s.UpdatePlacement(id, PlacementSpec{Destination: destination})
290+}
291+
292+// RemovePlacement removes one sparse placement.
293+func (s *Scene) RemovePlacement(id PlacementID) error {
294+ if s == nil {
295+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
296+ }
297+ s.mu.Lock()
298+ defer s.mu.Unlock()
299+ candidate := cloneState(s.state)
300+ if _, ok := candidate.placements[id]; !ok {
301+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
302+ }
303+ delete(candidate.placements, id)
304+ candidate.usage.Placements--
305+ if err := advanceGeneration(candidate); err != nil {
306+ return fmt.Errorf("remove placement: %w", err)
307+ }
308+ s.state = candidate
309+ return nil
310+}
311+
312+// Clear removes every asset and placement while preserving the monotonic ID
313+// counters. It publishes a new generation only when the scene is non-empty.
314+func (s *Scene) Clear() error {
315+ if s == nil {
316+ return nil
317+ }
318+ s.mu.Lock()
319+ defer s.mu.Unlock()
320+ if len(s.state.assets) == 0 && len(s.state.placements) == 0 {
321+ return nil
322+ }
323+ candidate := cloneState(s.state)
324+ candidate.assets = make(map[AssetID]assetRecord)
325+ candidate.placements = make(map[PlacementID]placementRecord)
326+ candidate.usage = Usage{}
327+ if err := advanceGeneration(candidate); err != nil {
328+ return fmt.Errorf("clear scene: %w", err)
329+ }
330+ s.state = candidate
331+ return nil
332+}
333+
334+// Apply commits sparse operations transactionally. If any operation fails,
335+// neither the state nor the generation changes.
336+func (s *Scene) Apply(operations ...Operation) error {
337+ if s == nil {
338+ return fmt.Errorf("apply operations: %w", ErrInvalidOperation)
339+ }
340+ if len(operations) == 0 {
341+ return nil
342+ }
343+ s.mu.Lock()
344+ defer s.mu.Unlock()
345+ candidate := cloneState(s.state)
346+ for i, operation := range operations {
347+ if err := s.applyOperation(candidate, operation); err != nil {
348+ return fmt.Errorf("apply operation %d: %w", i, err)
349+ }
350+ }
351+ if err := advanceGeneration(candidate); err != nil {
352+ return fmt.Errorf("apply operations: %w", err)
353+ }
354+ s.state = candidate
355+ return nil
356+}
357+
358+// ApplyOperations is the slice-taking form of Apply.
359+func (s *Scene) ApplyOperations(operations []Operation) error { return s.Apply(operations...) }
360+
361+// Snapshot returns an immutable reference to the current scene state. Future
362+// scene mutations use copy-on-write maps and cannot alter this snapshot.
363+func (s *Scene) Snapshot() *Snapshot {
364+ if s == nil {
365+ return &Snapshot{state: emptyState()}
366+ }
367+ s.mu.RLock()
368+ defer s.mu.RUnlock()
369+ return &Snapshot{state: s.state}
370+}
371+
372+// Generation returns the current scene generation.
373+func (s *Scene) Generation() Generation {
374+ if s == nil {
375+ return 0
376+ }
377+ s.mu.RLock()
378+ defer s.mu.RUnlock()
379+ return s.state.generation
380+}
381+
382+// Usage returns the current live resource usage.
383+func (s *Scene) Usage() Usage {
384+ if s == nil {
385+ return Usage{}
386+ }
387+ s.mu.RLock()
388+ defer s.mu.RUnlock()
389+ return s.state.usage
390+}
391+
392+func (s *Scene) prepareAsset(blob AssetBlob, state *sceneState) (assetRecord, error) {
393+ data := blob.Encoded
394+ if blob.Width <= 0 || blob.Height <= 0 {
395+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
396+ }
397+ pixels, ok := decodedPixels(blob.Width, blob.Height, blob.DecodedPixels)
398+ if !ok {
399+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
400+ }
401+ encoded := uint64(len(data))
402+ if encoded > s.limits.MaxEncodedBytesPerAsset {
403+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrEncodedBudget)
404+ }
405+ if pixels > s.limits.MaxDecodedPixelsPerAsset {
406+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
407+ }
408+ if uint64(len(state.assets)) >= s.limits.MaxAssets {
409+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrTooManyAssets)
410+ }
411+ if !within(state.usage.EncodedBytes, encoded, s.limits.MaxEncodedBytes) {
412+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrEncodedBudget)
413+ }
414+ if !within(state.usage.DecodedPixels, pixels, s.limits.MaxDecodedPixels) {
415+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
416+ }
417+ return assetRecord{encoded: append([]byte(nil), data...), format: blob.Format, width: blob.Width, height: blob.Height, pixels: pixels}, nil
418+}
419+
420+func (s *Scene) preparePlacement(state *sceneState, spec PlacementSpec, existing *placementRecord) (placementRecord, error) {
421+ asset := spec.Asset
422+ if !asset.Valid() && existing != nil {
423+ asset = existing.asset
424+ }
425+ recordAsset, ok := state.assets[asset]
426+ if !ok {
427+ return placementRecord{}, fmt.Errorf("place: %w", ErrAssetNotFound)
428+ }
429+ destination := spec.Destination
430+ if existing != nil && destination == (PixelRect{}) {
431+ destination = existing.destination
432+ }
433+ if !destination.Valid() || destination.Empty() {
434+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
435+ }
436+ source := spec.Source
437+ if source == (PixelRect{}) {
438+ if existing != nil && spec.Source == (PixelRect{}) {
439+ source = existing.source
440+ } else {
441+ source = PixelRect{Width: recordAsset.width, Height: recordAsset.height}
442+ }
443+ }
444+ if !source.Valid() || source.Empty() {
445+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
446+ }
447+ assetBounds := PixelRect{Width: recordAsset.width, Height: recordAsset.height}
448+ if _, ok := assetBounds.Intersect(source); !ok || source != assetBounds && !containsRect(assetBounds, source) {
449+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
450+ }
451+ cells := spec.Cells
452+ if existing != nil && !spec.HasCells && cells == (CellRect{}) {
453+ cells = existing.cells
454+ }
455+ if cells != (CellRect{}) && (!cells.Valid() || cells.Empty()) {
456+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
457+ }
458+ layer := spec.Layer
459+ if existing != nil && !spec.HasLayer {
460+ layer = existing.layer
461+ }
462+ return placementRecord{asset: asset, source: source, destination: destination, cells: cells, layer: layer}, nil
463+}
464+
465+func containsRect(outer, inner PixelRect) bool {
466+ right, okRight := outer.Right()
467+ innerRight, okInnerRight := inner.Right()
468+ bottom, okBottom := outer.Bottom()
469+ innerBottom, okInnerBottom := inner.Bottom()
470+ return okRight && okInnerRight && okBottom && okInnerBottom && inner.X >= outer.X && innerRight <= right && inner.Y >= outer.Y && innerBottom <= bottom
471+}
472+
473+func removeAsset(state *sceneState, id AssetID, cascade bool) error {
474+ record, ok := state.assets[id]
475+ if !ok {
476+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
477+ }
478+ for placementID, placement := range state.placements {
479+ if placement.asset != id {
480+ continue
481+ }
482+ if !cascade {
483+ return fmt.Errorf("remove asset %s: %w", id, ErrAssetInUse)
484+ }
485+ delete(state.placements, placementID)
486+ state.usage.Placements--
487+ }
488+ delete(state.assets, id)
489+ state.usage.Assets--
490+ state.usage.EncodedBytes -= uint64(len(record.encoded))
491+ state.usage.DecodedPixels -= record.pixels
492+ return nil
493+}
494+
495+func (s *Scene) applyOperation(state *sceneState, operation Operation) error {
496+ switch operation.Kind {
497+ case OperationAddAsset:
498+ record, err := s.prepareAsset(operation.Blob, state)
499+ if err != nil {
500+ return err
501+ }
502+ id, ok := takeAssetID(state)
503+ if !ok {
504+ return fmt.Errorf("add asset: %w", ErrIdentifierOverflow)
505+ }
506+ record.id = id
507+ state.assets[id] = record
508+ state.usage.Assets++
509+ state.usage.EncodedBytes += uint64(len(record.encoded))
510+ state.usage.DecodedPixels += record.pixels
511+ case OperationRemoveAsset:
512+ if err := removeAsset(state, operation.Asset, false); err != nil {
513+ return err
514+ }
515+ case OperationPlace:
516+ record, err := s.preparePlacement(state, operation.Placement, nil)
517+ if err != nil {
518+ return err
519+ }
520+ if uint64(len(state.placements)) >= s.limits.MaxPlacements {
521+ return fmt.Errorf("place: %w", ErrTooManyPlacements)
522+ }
523+ id, ok := takePlacementID(state)
524+ if !ok {
525+ return fmt.Errorf("place: %w", ErrIdentifierOverflow)
526+ }
527+ record.id = id
528+ record.order = takeOrder(state)
529+ state.placements[id] = record
530+ state.usage.Placements++
531+ case OperationUpdatePlacement:
532+ existing, ok := state.placements[operation.PlacementID]
533+ if !ok {
534+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
535+ }
536+ record, err := s.preparePlacement(state, operation.Placement, &existing)
537+ if err != nil {
538+ return err
539+ }
540+ record.id = existing.id
541+ record.order = existing.order
542+ state.placements[existing.id] = record
543+ case OperationRemovePlacement:
544+ if _, ok := state.placements[operation.PlacementID]; !ok {
545+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
546+ }
547+ delete(state.placements, operation.PlacementID)
548+ state.usage.Placements--
549+ default:
550+ return fmt.Errorf("operation kind %d: %w", operation.Kind, ErrInvalidOperation)
551+ }
552+ return nil
553+}
554+
555+func cloneState(state *sceneState) *sceneState {
556+ if state == nil {
557+ return emptyState()
558+ }
559+ clone := *state
560+ clone.assets = make(map[AssetID]assetRecord, len(state.assets))
561+ for id, record := range state.assets {
562+ clone.assets[id] = record
563+ }
564+ clone.placements = make(map[PlacementID]placementRecord, len(state.placements))
565+ for id, record := range state.placements {
566+ clone.placements[id] = record
567+ }
568+ return &clone
569+}
570+
571+func advanceGeneration(state *sceneState) error {
572+ if state.generation == ^uint64(0) {
573+ return ErrGenerationOverflow
574+ }
575+ state.generation++
576+ return nil
577+}
578+
579+func takeAssetID(state *sceneState) (AssetID, bool) {
580+ if state.nextAsset == 0 {
581+ return AssetID{}, false
582+ }
583+ id := AssetID{value: state.nextAsset}
584+ if state.nextAsset == ^uint64(0) {
585+ state.nextAsset = 0
586+ } else {
587+ state.nextAsset++
588+ }
589+ return id, true
590+}
591+
592+func takePlacementID(state *sceneState) (PlacementID, bool) {
593+ if state.nextPlace == 0 {
594+ return PlacementID{}, false
595+ }
596+ id := PlacementID{value: state.nextPlace}
597+ if state.nextPlace == ^uint64(0) {
598+ state.nextPlace = 0
599+ } else {
600+ state.nextPlace++
601+ }
602+ return id, true
603+}
604+
605+func takeOrder(state *sceneState) uint64 {
606+ order := state.nextOrder
607+ if state.nextOrder != ^uint64(0) {
608+ state.nextOrder++
609+ } else {
610+ state.nextOrder = 0
611+ }
612+ return order
613+}
614+
615+func within(current, additional, limit uint64) bool {
616+ if current > limit {
617+ return false
618+ }
619+ return additional <= limit-current
620+}
621+
622+func decodedPixels(width, height int64, declared uint64) (uint64, bool) {
623+ if width <= 0 || height <= 0 {
624+ return 0, false
625+ }
626+ w, h := uint64(width), uint64(height)
627+ if h != 0 && w > ^uint64(0)/h {
628+ return 0, false
629+ }
630+ computed := w * h
631+ // DecodedPixels is retained as an optional compatibility field, but it is
632+ // only a valid declaration when it agrees with the dimensions. Resource
633+ // accounting must never trust a caller-supplied number that can undercount
634+ // the pixels represented by the asset.
635+ if declared != 0 && declared != computed {
636+ return 0, false
637+ }
638+ return computed, true
639+}
640+
641+// AssetView is an immutable view of a registered asset. Encoded returns a new
642+// byte slice on every call; no caller can mutate scene or snapshot storage.
643+type AssetView struct {
644+ id AssetID
645+ encoded []byte
646+ format AssetFormat
647+ width int64
648+ height int64
649+ pixels uint64
650+}
651+
652+// Asset is an alias for the immutable snapshot view.
653+type Asset = AssetView
654+
655+func (a AssetView) ID() AssetID { return a.id }
656+func (a AssetView) Format() AssetFormat { return a.format }
657+func (a AssetView) Width() int64 { return a.width }
658+func (a AssetView) Height() int64 { return a.height }
659+func (a AssetView) DecodedPixels() uint64 { return a.pixels }
660+func (a AssetView) EncodedSize() uint64 { return uint64(len(a.encoded)) }
661+func (a AssetView) Encoded() []byte { return append([]byte(nil), a.encoded...) }
662+func (a AssetView) Bytes() []byte { return a.Encoded() }
663+func (a AssetView) Blob() AssetBlob {
664+ return AssetBlob{Encoded: a.Encoded(), Format: a.format, Width: a.width, Height: a.height, DecodedPixels: a.pixels}
665+}
666+
667+// PlacementView is an immutable view of a sparse placement.
668+type PlacementView struct {
669+ id PlacementID
670+ asset AssetID
671+ source PixelRect
672+ destination PixelRect
673+ cells CellRect
674+ layer int64
675+}
676+
677+// Placement is an alias for the immutable snapshot view.
678+type Placement = PlacementView
679+
680+func (p PlacementView) ID() PlacementID { return p.id }
681+func (p PlacementView) AssetID() AssetID { return p.asset }
682+func (p PlacementView) Source() PixelRect { return p.source }
683+func (p PlacementView) Destination() PixelRect { return p.destination }
684+func (p PlacementView) Cells() CellRect { return p.cells }
685+func (p PlacementView) Layer() int64 { return p.layer }
686+func (p PlacementView) Spec() PlacementSpec {
687+ return PlacementSpec{
688+ Asset: p.asset,
689+ Source: p.source,
690+ Destination: p.destination,
691+ Cells: p.cells,
692+ HasCells: p.cells != (CellRect{}),
693+ Layer: p.layer,
694+ HasLayer: true,
695+ }
696+}
697+
698+// Snapshot is an immutable reference to a scene state.
699+type Snapshot struct{ state *sceneState }
700+
701+// Generation is a monotonically increasing scene generation.
702+func (s *Snapshot) Generation() uint64 {
703+ if s == nil || s.state == nil {
704+ return 0
705+ }
706+ return s.state.generation
707+}
708+
709+// Usage reports the resources retained by this snapshot's state.
710+func (s *Snapshot) Usage() Usage {
711+ if s == nil || s.state == nil {
712+ return Usage{}
713+ }
714+ return s.state.usage
715+}
716+
717+// Asset returns an immutable asset view by ID.
718+func (s *Snapshot) Asset(id AssetID) (AssetView, bool) {
719+ if s == nil || s.state == nil {
720+ return AssetView{}, false
721+ }
722+ record, ok := s.state.assets[id]
723+ if !ok {
724+ return AssetView{}, false
725+ }
726+ return AssetView(record), true
727+}
728+
729+// GetAsset is an alias for Asset.
730+func (s *Snapshot) GetAsset(id AssetID) (AssetView, bool) { return s.Asset(id) }
731+
732+// Assets returns immutable asset views in ID order.
733+func (s *Snapshot) Assets() []AssetView {
734+ if s == nil || s.state == nil || len(s.state.assets) == 0 {
735+ return nil
736+ }
737+ ids := make([]AssetID, 0, len(s.state.assets))
738+ for id := range s.state.assets {
739+ ids = append(ids, id)
740+ }
741+ sort.Slice(ids, func(i, j int) bool { return ids[i].value < ids[j].value })
742+ assets := make([]AssetView, 0, len(ids))
743+ for _, id := range ids {
744+ asset, _ := s.Asset(id)
745+ assets = append(assets, asset)
746+ }
747+ return assets
748+}
749+
750+// Placement returns an immutable placement view by ID.
751+func (s *Snapshot) Placement(id PlacementID) (PlacementView, bool) {
752+ if s == nil || s.state == nil {
753+ return PlacementView{}, false
754+ }
755+ record, ok := s.state.placements[id]
756+ if !ok {
757+ return PlacementView{}, false
758+ }
759+ return PlacementView{id: record.id, asset: record.asset, source: record.source, destination: record.destination, cells: record.cells, layer: record.layer}, true
760+}
761+
762+// GetPlacement is an alias for Placement.
763+func (s *Snapshot) GetPlacement(id PlacementID) (PlacementView, bool) { return s.Placement(id) }
764+
765+// Placements returns immutable placement views in layer and insertion order.
766+func (s *Snapshot) Placements() []PlacementView {
767+ if s == nil || s.state == nil || len(s.state.placements) == 0 {
768+ return nil
769+ }
770+ placements := make([]placementRecord, 0, len(s.state.placements))
771+ for _, placement := range s.state.placements {
772+ placements = append(placements, placement)
773+ }
774+ sort.SliceStable(placements, func(i, j int) bool {
775+ if placements[i].layer != placements[j].layer {
776+ return placements[i].layer < placements[j].layer
777+ }
778+ return placements[i].order < placements[j].order
779+ })
780+ views := make([]PlacementView, 0, len(placements))
781+ for _, placement := range placements {
782+ views = append(views, PlacementView{id: placement.id, asset: placement.asset, source: placement.source, destination: placement.destination, cells: placement.cells, layer: placement.layer})
783+ }
784+ return views
785+}
786+
787+// VisibleFragments returns clipped placement fragments. viewport may be a
788+// PixelRect, CellRect, or Viewport. Unsupported or invalid viewport values
789+// produce no fragments rather than wrapping coordinates.
790+func (s *Snapshot) VisibleFragments(viewport any) []VisibleFragment {
791+ if s == nil || s.state == nil {
792+ return nil
793+ }
794+ pixels, cells, hasPixels, hasCells := viewportParts(viewport)
795+ if !hasPixels && !hasCells {
796+ return nil
797+ }
798+ placements := s.Placements()
799+ fragments := make([]VisibleFragment, 0, len(placements))
800+ for _, placement := range placements {
801+ visible, ok := visiblePlacement(placement, pixels, cells, hasPixels, hasCells)
802+ if ok {
803+ fragments = append(fragments, visible)
804+ }
805+ }
806+ return fragments
807+}
808+
809+// VisiblePixelFragments is the typed pixel-space form of VisibleFragments.
810+func (s *Snapshot) VisiblePixelFragments(viewport PixelRect) []VisibleFragment {
811+ return s.VisibleFragments(viewport)
812+}
813+
814+// VisibleCellFragments is the typed cell-space form of VisibleFragments.
815+func (s *Snapshot) VisibleCellFragments(viewport CellRect) []VisibleFragment {
816+ return s.VisibleFragments(viewport)
817+}
818+
819+func viewportParts(viewport any) (PixelRect, CellRect, bool, bool) {
820+ switch value := viewport.(type) {
821+ case PixelRect:
822+ return value, CellRect{}, value.Valid() && !value.Empty(), false
823+ case CellRect:
824+ return PixelRect{}, value, false, value.Valid() && !value.Empty()
825+ case Viewport:
826+ hasPixels := value.Pixels != (PixelRect{})
827+ hasCells := value.Cells != (CellRect{})
828+ if hasPixels && (!value.Pixels.Valid() || value.Pixels.Empty()) {
829+ return PixelRect{}, CellRect{}, false, false
830+ }
831+ if hasCells && (!value.Cells.Valid() || value.Cells.Empty()) {
832+ return PixelRect{}, CellRect{}, false, false
833+ }
834+ return value.Pixels, value.Cells, hasPixels, hasCells
835+ case *Viewport:
836+ if value == nil {
837+ return PixelRect{}, CellRect{}, false, false
838+ }
839+ return viewportParts(*value)
840+ default:
841+ return PixelRect{}, CellRect{}, false, false
842+ }
843+}
844+
845+func visiblePlacement(placement PlacementView, pixels PixelRect, cells CellRect, hasPixels, hasCells bool) (VisibleFragment, bool) {
846+ destination := placement.destination
847+ if hasPixels {
848+ var ok bool
849+ destination, ok = destination.Intersect(pixels)
850+ if !ok {
851+ return VisibleFragment{}, false
852+ }
853+ }
854+ var visibleCells CellRect
855+ if placement.cells.Valid() && !placement.cells.Empty() {
856+ visibleCells = placement.cells
857+ if hasCells {
858+ var ok bool
859+ visibleCells, ok = visibleCells.Intersect(cells)
860+ if !ok {
861+ return VisibleFragment{}, false
862+ }
863+ cellPixels, ok := mapSubRectCellToPixel(placement.cells, placement.destination, visibleCells)
864+ if !ok {
865+ return VisibleFragment{}, false
866+ }
867+ var pixelOK bool
868+ destination, pixelOK = destination.Intersect(cellPixels)
869+ if !pixelOK {
870+ return VisibleFragment{}, false
871+ }
872+ }
873+ if hasPixels {
874+ var ok bool
875+ visibleCells, ok = mapSubRectPixelToCell(placement.destination, placement.cells, destination)
876+ if !ok {
877+ return VisibleFragment{}, false
878+ }
879+ if hasCells {
880+ visibleCells, ok = visibleCells.Intersect(cells)
881+ if !ok {
882+ return VisibleFragment{}, false
883+ }
884+ }
885+ }
886+ } else if hasCells {
887+ return VisibleFragment{}, false
888+ }
889+ source, ok := mapSubRect(placement.destination, placement.source, destination)
890+ if !ok {
891+ return VisibleFragment{}, false
892+ }
893+ return VisibleFragment{PlacementID: placement.id, AssetID: placement.asset, Source: source, Destination: destination, Cells: visibleCells}, true
894+}
895+
896+// mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate
897+// space into another rectangle's coordinate space. Its range mapping uses
898+// exact 128-bit arithmetic so products cannot wrap before division.
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) {
901+ return PixelRect{}, false
902+ }
903+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
904+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
905+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
906+ return PixelRect{}, false
907+ }
908+ return PixelRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
909+}
910+
911+func mapSubRectCellToPixel(from CellRect, to PixelRect, sub CellRect) (PixelRect, bool) {
912+ if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsCellRect(from, sub) {
913+ return PixelRect{}, false
914+ }
915+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
916+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
917+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
918+ return PixelRect{}, false
919+ }
920+ return PixelRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
921+}
922+
923+func mapSubRectPixelToCell(from PixelRect, to CellRect, sub PixelRect) (CellRect, bool) {
924+ if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
925+ return CellRect{}, false
926+ }
927+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
928+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
929+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
930+ return CellRect{}, false
931+ }
932+ return CellRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
933+}
934+
935+func containsCellRect(outer, inner CellRect) bool {
936+ right, okRight := outer.Right()
937+ innerRight, okInnerRight := inner.Right()
938+ bottom, okBottom := outer.Bottom()
939+ innerBottom, okInnerBottom := inner.Bottom()
940+ return okRight && okInnerRight && okBottom && okInnerBottom && inner.X >= outer.X && innerRight <= right && inner.Y >= outer.Y && innerBottom <= bottom
941+}
942+
943+func mustRight(start, width int64) int64 {
944+ right, _ := checkedEdge(start, width)
945+ return right
946+}
947+
948+func mapRange(fromStart, fromSize, toStart, toSize, clipStart, clipEnd int64) ([2]int64, bool) {
949+ if fromSize <= 0 || toSize <= 0 || clipStart < fromStart || clipEnd < clipStart {
950+ return [2]int64{}, false
951+ }
952+ fromEnd, ok := checkedEdge(fromStart, fromSize)
953+ if !ok || clipEnd > fromEnd {
954+ return [2]int64{}, false
955+ }
956+ startOffset := clipStart - fromStart
957+ endOffset := clipEnd - fromStart
958+ startDelta, ok := ratio(startOffset, toSize, fromSize, false)
959+ if !ok {
960+ return [2]int64{}, false
961+ }
962+ endDelta, ok := ratio(endOffset, toSize, fromSize, true)
963+ if !ok {
964+ return [2]int64{}, false
965+ }
966+ start, ok := checkedAdd(toStart, startDelta)
967+ if !ok {
968+ return [2]int64{}, false
969+ }
970+ end, ok := checkedAdd(toStart, endDelta)
971+ if !ok || end <= start {
972+ return [2]int64{}, false
973+ }
974+ return [2]int64{start, end}, true
975+}
976+
977+func ratio(offset, numerator, denominator int64, ceil bool) (int64, bool) {
978+ if offset < 0 || numerator <= 0 || denominator <= 0 {
979+ return 0, false
980+ }
981+ hi, lo := bits.Mul64(uint64(offset), uint64(numerator))
982+ divisor := uint64(denominator)
983+ // bits.Div64 requires hi < divisor. Otherwise the quotient cannot fit in
984+ // uint64 and consequently cannot fit in the int64 result type.
985+ if hi >= divisor {
986+ return 0, false
987+ }
988+ quotient, remainder := bits.Div64(hi, lo, divisor)
989+ if ceil && remainder != 0 {
990+ if quotient == ^uint64(0) {
991+ return 0, false
992+ }
993+ quotient++
994+ }
995+ if quotient > uint64(maxInt64) {
996+ return 0, false
997+ }
998+ return int64(quotient), true
999+}
1000+
1001+func checkedAdd(a, b int64) (int64, bool) {
1002+ if b > 0 && a > maxInt64-b {
1003+ return 0, false
1004+ }
1005+ if b < 0 && a < minInt64-b {
1006+ return 0, false
1007+ }
1008+ return a + b, true
1009+}
A graphics/scene_test.go +238 -0
@@ -0,0 +1,238 @@
1+package graphics
2+
3+import (
4+ "errors"
5+ "math"
6+ "testing"
7+)
8+
9+func testAsset(t *testing.T, data []byte, width, height int64) AssetBlob {
10+ t.Helper()
11+ return AssetBlob{Encoded: data, Width: width, Height: height}
12+}
13+
14+func TestRectOperationsRejectOverflow(t *testing.T) {
15+ if _, err := NewPixelRect(math.MaxInt64, 0, 1, 1); !errors.Is(err, ErrInvalidRect) {
16+ t.Fatalf("overflowing pixel rectangle error = %v", err)
17+ }
18+ if _, err := NewCellRect(0, math.MaxInt64, 1, 1); !errors.Is(err, ErrInvalidRect) {
19+ t.Fatalf("overflowing cell rectangle error = %v", err)
20+ }
21+ valid := PixelRect{X: math.MinInt64, Y: math.MinInt64, Width: math.MaxInt64, Height: math.MaxInt64}
22+ if !valid.Valid() {
23+ t.Fatal("maximum representable negative-origin rectangle should be valid")
24+ }
25+ clipped, ok := valid.Intersect(PixelRect{X: -2, Y: -2, Width: 1, Height: 1})
26+ if !ok || clipped != (PixelRect{X: -2, Y: -2, Width: 1, Height: 1}) {
27+ t.Fatalf("intersection = %#v, %v", clipped, ok)
28+ }
29+}
30+
31+func TestAssetBlobOwnershipAndSnapshotLifecycle(t *testing.T) {
32+ scene := NewScene(Limits{MaxEncodedBytes: 32, MaxDecodedPixels: 64})
33+ encoded := []byte{1, 2, 3}
34+ assetID, err := scene.AddAsset(testAsset(t, encoded, 4, 4))
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ encoded[0] = 99
39+
40+ before := scene.Snapshot()
41+ asset, ok := before.Asset(assetID)
42+ if !ok {
43+ t.Fatal("asset missing from snapshot")
44+ }
45+ got := asset.Encoded()
46+ got[1] = 88
47+ if got := asset.Encoded(); got[0] != 1 || got[1] != 2 || asset.EncodedSize() != 3 {
48+ t.Fatalf("asset bytes were not immutable copies: %v", got)
49+ }
50+
51+ placementID, err := scene.Place(PlacementSpec{Asset: assetID, Destination: PixelRect{Width: 4, Height: 4}})
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if err := scene.RemoveAsset(assetID); !errors.Is(err, ErrAssetInUse) {
56+ t.Fatalf("remove in-use asset error = %v", err)
57+ }
58+ if err := scene.RemoveAssetCascade(assetID); err != nil {
59+ t.Fatal(err)
60+ }
61+ if _, ok := scene.Snapshot().Asset(assetID); ok {
62+ t.Fatal("removed asset remained in current snapshot")
63+ }
64+ if _, ok := scene.Snapshot().Placement(placementID); ok {
65+ t.Fatal("cascade did not remove placement")
66+ }
67+ if _, ok := before.Asset(assetID); !ok {
68+ t.Fatal("old snapshot lost its asset after scene mutation")
69+ }
70+}
71+
72+func TestDecodedPixelAccountingRejectsForgedDeclarations(t *testing.T) {
73+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 4})
74+ _, err := scene.AddAsset(AssetBlob{
75+ Encoded: []byte{1},
76+ Width: 2,
77+ Height: 2,
78+ DecodedPixels: 1,
79+ })
80+ if !errors.Is(err, ErrInvalidAsset) {
81+ t.Fatalf("forged decoded-pixel declaration error = %v", err)
82+ }
83+ if got := scene.Usage(); got != (Usage{}) {
84+ t.Fatalf("rejected asset changed usage: %#v", got)
85+ }
86+}
87+
88+func TestAssetAndPlacementQuotasAreTransactional(t *testing.T) {
89+ scene := NewScene(Limits{
90+ MaxAssets: 1,
91+ MaxPlacements: 1,
92+ MaxEncodedBytes: 4,
93+ MaxDecodedPixels: 4,
94+ })
95+ id, err := scene.AddAsset(testAsset(t, []byte{1, 2, 3, 4}, 2, 2))
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ generation := scene.Snapshot().Generation()
100+ if _, err := scene.AddAsset(testAsset(t, []byte{5}, 1, 1)); !errors.Is(err, ErrTooManyAssets) {
101+ t.Fatalf("asset quota error = %v", err)
102+ }
103+ if scene.Snapshot().Generation() != generation {
104+ t.Fatal("failed asset addition changed generation")
105+ }
106+ firstPlacement, err := scene.Place(PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}})
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ if _, err := scene.Place(PlacementSpec{Asset: id, Destination: PixelRect{X: 2, Width: 2, Height: 2}}); !errors.Is(err, ErrTooManyPlacements) {
111+ t.Fatalf("placement quota error = %v", err)
112+ }
113+ if err := scene.Apply(
114+ Operation{Kind: OperationRemovePlacement, PlacementID: firstPlacement},
115+ Operation{Kind: OperationPlace, Placement: PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}},
116+ ); err != nil {
117+ t.Fatal(err)
118+ }
119+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.EncodedBytes != 4 || got.DecodedPixels != 4 {
120+ t.Fatalf("usage = %#v", got)
121+ }
122+}
123+
124+func TestSnapshotGenerationAndTransactionalApply(t *testing.T) {
125+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 64})
126+ initial := scene.Snapshot()
127+ id, err := scene.AddAsset(testAsset(t, []byte{1}, 2, 2))
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if got := scene.Snapshot().Generation(); got <= initial.Generation() {
132+ t.Fatalf("generation did not advance: initial=%d current=%d", initial.Generation(), got)
133+ }
134+ before := scene.Snapshot()
135+ err = scene.Apply(
136+ Operation{Kind: OperationPlace, Placement: PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}},
137+ Operation{Kind: OperationRemovePlacement, PlacementID: PlacementID{value: 999}},
138+ )
139+ if !errors.Is(err, ErrPlacementNotFound) {
140+ t.Fatalf("transaction error = %v", err)
141+ }
142+ if scene.Snapshot().Generation() != before.Generation() || len(scene.Snapshot().Placements()) != 0 {
143+ t.Fatal("failed transaction partially committed")
144+ }
145+}
146+
147+func TestVisibleFragmentsPreserveScaledSourceMapping(t *testing.T) {
148+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 10000})
149+ assetID, err := scene.AddAsset(testAsset(t, []byte{1}, 100, 50))
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ placementID, err := scene.Place(PlacementSpec{
154+ Asset: assetID,
155+ Destination: PixelRect{X: 10, Y: 20, Width: 200, Height: 100},
156+ Cells: CellRect{X: 2, Y: 3, Width: 20, Height: 10},
157+ })
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ fragments := scene.Snapshot().VisibleFragments(PixelRect{X: 110, Y: 20, Width: 100, Height: 100})
162+ if len(fragments) != 1 {
163+ t.Fatalf("fragments = %#v", fragments)
164+ }
165+ fragment := fragments[0]
166+ if fragment.PlacementID != placementID || fragment.AssetID != assetID {
167+ t.Fatalf("fragment IDs = %#v", fragment)
168+ }
169+ if fragment.Destination != (PixelRect{X: 110, Y: 20, Width: 100, Height: 100}) {
170+ t.Fatalf("destination = %#v", fragment.Destination)
171+ }
172+ if fragment.Source != (PixelRect{X: 50, Y: 0, Width: 50, Height: 50}) {
173+ t.Fatalf("source mapping = %#v", fragment.Source)
174+ }
175+ if fragment.Cells != (CellRect{X: 12, Y: 3, Width: 10, Height: 10}) {
176+ t.Fatalf("cell mapping = %#v", fragment.Cells)
177+ }
178+
179+ cellFragments := scene.Snapshot().VisibleFragments(CellRect{X: 7, Y: 5, Width: 5, Height: 5})
180+ if len(cellFragments) != 1 || cellFragments[0].Destination != (PixelRect{X: 60, Y: 40, Width: 50, Height: 50}) {
181+ t.Fatalf("cell clipping = %#v", cellFragments)
182+ }
183+}
184+
185+func TestVisibleFragmentsRejectInvalidMixedViewport(t *testing.T) {
186+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 64})
187+ assetID, err := scene.AddAsset(testAsset(t, []byte{1}, 2, 2))
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ if _, err := scene.Place(PlacementSpec{
192+ Asset: assetID,
193+ Destination: PixelRect{Width: 2, Height: 2},
194+ Cells: CellRect{Width: 2, Height: 2},
195+ }); err != nil {
196+ t.Fatal(err)
197+ }
198+ for _, viewport := range []Viewport{
199+ {Pixels: PixelRect{Width: 2, Height: 2}, Cells: CellRect{Width: -1, Height: 1}},
200+ {Pixels: PixelRect{Width: 2, Height: 2}, Cells: CellRect{X: 1}},
201+ {Pixels: PixelRect{Width: -1, Height: 1}, Cells: CellRect{Width: 2, Height: 2}},
202+ } {
203+ if fragments := scene.Snapshot().VisibleFragments(viewport); len(fragments) != 0 {
204+ t.Fatalf("invalid mixed viewport %#v produced fragments %#v", viewport, fragments)
205+ }
206+ }
207+}
208+
209+func TestUpdatePlacementCanResetLayerAndClearCells(t *testing.T) {
210+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 64})
211+ assetID, err := scene.AddAsset(testAsset(t, []byte{1}, 4, 4))
212+ if err != nil {
213+ t.Fatal(err)
214+ }
215+ placementID, err := scene.Place(PlacementSpec{
216+ Asset: assetID,
217+ Destination: PixelRect{Width: 4, Height: 4},
218+ Cells: CellRect{X: 1, Y: 2, Width: 3, Height: 2},
219+ Layer: 7,
220+ HasLayer: true,
221+ })
222+ if err != nil {
223+ t.Fatal(err)
224+ }
225+ if err := scene.UpdatePlacement(placementID, PlacementSpec{HasCells: true, HasLayer: true}); err != nil {
226+ t.Fatal(err)
227+ }
228+ placement, ok := scene.Snapshot().Placement(placementID)
229+ if !ok {
230+ t.Fatal("updated placement missing")
231+ }
232+ if placement.Layer() != 0 {
233+ t.Fatalf("layer = %d, want 0", placement.Layer())
234+ }
235+ if placement.Cells() != (CellRect{}) {
236+ t.Fatalf("cells = %#v, want cleared", placement.Cells())
237+ }
238+}
A graphics_state.go +91 -0
@@ -0,0 +1,91 @@
1+package vt
2+
3+import (
4+ "github.com/bnema/vev-vt/graphics"
5+ "github.com/bnema/vev-vt/protocol/kittygraphics"
6+)
7+
8+// screenGraphicsState owns the protocol adapter and scene for one VT screen
9+// buffer. It is allocated only after the first Kitty graphics APC reaches the
10+// screen, so ordinary text writes do not pay for graphics state.
11+type screenGraphicsState struct {
12+ scene *graphics.Scene
13+ kitty *kittygraphics.Session
14+}
15+
16+func newScreenGraphicsState() *screenGraphicsState {
17+ scene := graphics.NewScene(graphics.Limits{})
18+ return &screenGraphicsState{
19+ scene: scene,
20+ kitty: kittygraphics.NewSession(scene),
21+ }
22+}
23+
24+func (g *screenGraphicsState) snapshot() *graphics.Snapshot {
25+ if g == nil || g.scene == nil {
26+ return nil
27+ }
28+ return g.scene.Snapshot()
29+}
30+
31+// clearPlacements applies the ordinary full-screen erase policy without
32+// discarding uploaded image mappings. The protocol session owns those
33+// mappings, so the operation goes through Kitty's delete-all-visible command
34+// rather than mutating the scene directly.
35+func (g *screenGraphicsState) clearPlacements() {
36+ if g == nil || g.kitty == nil {
37+ return
38+ }
39+ g.kitty.AbortPendingUpload()
40+ _, _ = g.kitty.Process(kittygraphics.Command{Controls: kittygraphics.Controls{
41+ Action: kittygraphics.ActionDelete,
42+ HasAction: true,
43+ Delete: kittygraphics.DeleteAll,
44+ HasDelete: true,
45+ Quiet: kittygraphics.QuietAll,
46+ HasQuiet: true,
47+ }})
48+}
49+
50+func (s *Screen) graphicsClearPlacements() {
51+ if s == nil {
52+ return
53+ }
54+ if s.graphics != nil {
55+ s.graphics.clearPlacements()
56+ }
57+ s.kittyPendingDisplay = nil
58+}
59+
60+func (s *Screen) abortKittyPendingDisplay() {
61+ if s == nil {
62+ return
63+ }
64+ s.kittyPendingDisplay = nil
65+ if s.graphics != nil && s.graphics.kitty != nil {
66+ s.graphics.kitty.AbortPendingUpload()
67+ }
68+}
69+
70+func (s *Screen) abortAllKittyPending() {
71+ if s == nil {
72+ return
73+ }
74+ s.abortKittyPendingDisplay()
75+ if state := s.alternate; state != nil {
76+ state.kittyPendingDisplay = nil
77+ if state.graphics != nil && state.graphics.kitty != nil {
78+ state.graphics.kitty.AbortPendingUpload()
79+ }
80+ }
81+}
82+
83+// GraphicsSnapshot returns an immutable snapshot of the active screen
84+// buffer's graphics scene, or nil before graphics has been used. The returned
85+// reference remains valid after subsequent Screen mutations.
86+func (s *Screen) GraphicsSnapshot() *graphics.Snapshot {
87+ if s == nil || s.graphics == nil {
88+ return nil
89+ }
90+ return s.graphics.snapshot()
91+}
A internal/graphicsharness/render.go +174 -0
@@ -0,0 +1,174 @@
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+ 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+
122+func 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+
144+func 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+
163+func premultiply(component, alpha byte) byte {
164+ return byte((uint16(component)*uint16(alpha) + 127) / 255)
165+}
166+
167+func 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+}
A internal/graphicsharness/render_test.go +117 -0
@@ -0,0 +1,117 @@
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 TestRenderRejectsPNGDimensionsDifferentFromSnapshot(t *testing.T) {
60+ encoded, err := base64.StdEncoding.DecodeString(pngSuiteEXIFBase64)
61+ require.NoError(t, err)
62+ scene := graphics.NewScene(graphics.Limits{})
63+ assetID, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatPNG, Width: 31, Height: 32, Encoded: encoded})
64+ require.NoError(t, err)
65+ _, err = scene.Place(graphics.PlacementSpec{Asset: assetID, Destination: graphics.PixelRect{Width: 31, Height: 32}})
66+ require.NoError(t, err)
67+
68+ _, err = Render(scene.Snapshot(), graphics.PixelRect{Width: 31, Height: 32})
69+ require.EqualError(t, err, "decode asset a1: PNG dimensions do not match snapshot")
70+}
71+
72+func TestRenderScalesCropsAndClips(t *testing.T) {
73+ scene := graphics.NewScene(graphics.Limits{})
74+ assetID, err := scene.AddAsset(graphics.AssetBlob{
75+ Format: graphics.AssetFormatRGBA,
76+ Width: 3,
77+ Height: 2,
78+ Encoded: []byte{
79+ 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255,
80+ 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 0, 255,
81+ },
82+ })
83+ require.NoError(t, err)
84+ _, err = scene.Place(graphics.PlacementSpec{
85+ Asset: assetID,
86+ Source: graphics.PixelRect{X: 1, Width: 2, Height: 2},
87+ Destination: graphics.PixelRect{X: -1, Y: 1, Width: 5, Height: 2},
88+ })
89+ require.NoError(t, err)
90+
91+ frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 4, Height: 4})
92+ require.NoError(t, err)
93+
94+ for x, want := range []color.RGBA{{0, 255, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {0, 0, 255, 255}} {
95+ require.Equal(t, want, frame.RGBAAt(x, 1))
96+ }
97+ for x, want := range []color.RGBA{{255, 0, 255, 255}, {255, 0, 255, 255}, {255, 255, 0, 255}, {255, 255, 0, 255}} {
98+ require.Equal(t, want, frame.RGBAAt(x, 2))
99+ }
100+}
101+
102+func TestRenderCompositesPlacementsInLayerOrder(t *testing.T) {
103+ scene := graphics.NewScene(graphics.Limits{})
104+ background, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 2, Height: 1, Encoded: []byte{0, 0, 255, 255, 0, 0, 255, 255}})
105+ require.NoError(t, err)
106+ foreground, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 1, Height: 1, Encoded: []byte{255, 0, 0, 128}})
107+ require.NoError(t, err)
108+ _, err = scene.Place(graphics.PlacementSpec{Asset: background, Destination: graphics.PixelRect{Width: 2, Height: 1}, HasLayer: true})
109+ require.NoError(t, err)
110+ _, err = scene.Place(graphics.PlacementSpec{Asset: foreground, Destination: graphics.PixelRect{X: 1, Width: 1, Height: 1}, Layer: 1, HasLayer: true})
111+ require.NoError(t, err)
112+
113+ frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 2, Height: 1})
114+ require.NoError(t, err)
115+ require.Equal(t, color.RGBA{0, 0, 255, 255}, frame.RGBAAt(0, 0))
116+ require.Equal(t, color.RGBA{128, 0, 127, 255}, frame.RGBAAt(1, 0))
117+}
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+}
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+```
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>\⏎̸
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
A protocol/kittygraphics/base64.go +24 -0
@@ -0,0 +1,24 @@
1+package kittygraphics
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+)
7+
8+// DecodeBase64 accepts both padded standard base64 and unpadded raw standard
9+// base64. It deliberately does not accept URL-safe alphabets or non-base64
10+// whitespace other than the CR/LF ignored by the standard encoding.
11+func DecodeBase64(encoded []byte) ([]byte, error) {
12+ decoded, err := base64.StdEncoding.DecodeString(string(encoded))
13+ if err == nil {
14+ return decoded, nil
15+ }
16+ decoded, rawErr := base64.RawStdEncoding.DecodeString(string(encoded))
17+ if rawErr == nil {
18+ return decoded, nil
19+ }
20+ return nil, fmt.Errorf("%w: %v", ErrInvalidBase64, err)
21+}
22+
23+// DecodePayload is a descriptive alias for DecodeBase64.
24+func DecodePayload(encoded []byte) ([]byte, error) { return DecodeBase64(encoded) }
A protocol/kittygraphics/controls.go +245 -0
@@ -0,0 +1,245 @@
1+package kittygraphics
2+
3+import (
4+ "fmt"
5+)
6+
7+// ParseControls parses the comma-separated control header of an APC.
8+func ParseControls(header []byte) (Controls, error) { return parseControls(header) }
9+
10+// ParseHeader is an alias for ParseControls.
11+func ParseHeader(header []byte) (Controls, error) { return ParseControls(header) }
12+
13+func parseControls(header []byte) (Controls, error) {
14+ var c Controls
15+ if len(header) == 0 {
16+ return c, nil
17+ }
18+ var seen [256]bool
19+ start := 0
20+ for i := 0; i <= len(header); i++ {
21+ if i != len(header) && header[i] != ',' {
22+ continue
23+ }
24+ field := header[start:i]
25+ if len(field) < 3 || field[1] != '=' {
26+ return Controls{}, fmt.Errorf("%w: control %q", ErrInvalidCommand, field)
27+ }
28+ key := field[0]
29+ if seen[key] {
30+ return Controls{}, fmt.Errorf("%w: %c", ErrDuplicateControl, key)
31+ }
32+ seen[key] = true
33+ value := field[2:]
34+ if err := parseControl(&c, key, value); err != nil {
35+ return Controls{}, err
36+ }
37+ start = i + 1
38+ }
39+ if c.HasAction && !c.Action.Valid() {
40+ return Controls{}, fmt.Errorf("%w: %q", ErrUnknownAction, c.Action)
41+ }
42+ if c.HasImageID && c.HasImageNumber {
43+ return Controls{}, fmt.Errorf("%w: image id and image number are mutually exclusive", ErrInvalidCommand)
44+ }
45+ if c.HasQuiet && c.Quiet > QuietAll {
46+ return Controls{}, fmt.Errorf("%w: quiet=%d", ErrInvalidCommand, c.Quiet)
47+ }
48+ if c.HasMore && c.More > 1 {
49+ return Controls{}, fmt.Errorf("%w: m=%d", ErrInvalidCommand, c.More)
50+ }
51+ if c.HasCursor && c.Cursor > 1 {
52+ return Controls{}, fmt.Errorf("%w: C=%d", ErrInvalidCommand, c.Cursor)
53+ }
54+ return c, nil
55+}
56+
57+func parseControl(c *Controls, key byte, value []byte) error {
58+ if len(value) == 0 {
59+ return fmt.Errorf("%w: empty %c value", ErrInvalidInteger, key)
60+ }
61+ switch ControlKey(key) {
62+ case ControlAction:
63+ if len(value) != 1 {
64+ return fmt.Errorf("%w: action %q", ErrUnknownAction, value)
65+ }
66+ c.Action, c.HasAction = Action(value[0]), true
67+ case ControlCompression:
68+ n, err := parseUint(value)
69+ if err != nil {
70+ return fieldError(key, err)
71+ }
72+ c.Compression, c.HasCompression = n, true
73+ case ControlFormat:
74+ n, err := parseUint(value)
75+ if err != nil {
76+ return fieldError(key, err)
77+ }
78+ f := Format(n)
79+ if !f.Valid() {
80+ return fmt.Errorf("%w: f=%d", ErrInvalidCommand, n)
81+ }
82+ c.Format, c.HasFormat = f, true
83+ case ControlImageID:
84+ return setID(&c.ImageID, &c.HasImageID, key, value)
85+ case ControlImageNumber:
86+ return setID(&c.ImageNumber, &c.HasImageNumber, key, value)
87+ case ControlMore:
88+ return setUint(&c.More, &c.HasMore, key, value)
89+ case ControlQuiet:
90+ n, err := parseUint(value)
91+ if err != nil {
92+ return fieldError(key, err)
93+ }
94+ if n > uint64(QuietAll) {
95+ return fmt.Errorf("%w: quiet=%d", ErrInvalidCommand, n)
96+ }
97+ c.Quiet, c.HasQuiet = Quiet(n), true
98+ case ControlWidth:
99+ return setUint(&c.Width, &c.HasWidth, key, value)
100+ case ControlHeight:
101+ return setUint(&c.Height, &c.HasHeight, key, value)
102+ case ControlColumns:
103+ return setUint(&c.Columns, &c.HasColumns, key, value)
104+ case ControlRows:
105+ return setUint(&c.Rows, &c.HasRows, key, value)
106+ case ControlX:
107+ return setUint(&c.X, &c.HasX, key, value)
108+ case ControlY:
109+ return setUint(&c.Y, &c.HasY, key, value)
110+ case ControlSourceWidth:
111+ return setUint(&c.SourceWidth, &c.HasSourceWidth, key, value)
112+ case ControlSourceHeight:
113+ return setUint(&c.SourceHeight, &c.HasSourceHeight, key, value)
114+ case ControlLayer:
115+ return setInt(&c.Layer, &c.HasLayer, key, value)
116+ case ControlPlacementID:
117+ return setID(&c.PlacementID, &c.HasPlacementID, key, value)
118+ case ControlDelete:
119+ if len(value) != 1 {
120+ return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value)
121+ }
122+ d := DeleteTarget(value[0])
123+ switch d {
124+ case DeleteImage, DeleteImageNumber, DeleteCell, DeleteAll, DeleteAllImages, DeleteCellAll:
125+ c.Delete, c.HasDelete = d, true
126+ default:
127+ return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value)
128+ }
129+ case ControlTransmission:
130+ if len(value) != 1 || Transmission(value[0]) != TransmissionDirect {
131+ return fmt.Errorf("%w: t=%q", ErrUnsupported, value)
132+ }
133+ c.Transmission, c.HasTransmission = TransmissionDirect, true
134+ case ControlCursor:
135+ return setUint(&c.Cursor, &c.HasCursor, key, value)
136+ case ControlSourceX:
137+ return setUint(&c.SourceX, &c.HasSourceX, key, value)
138+ case ControlSourceY:
139+ return setUint(&c.SourceY, &c.HasSourceY, key, value)
140+ case ControlCellOffsetX:
141+ return setInt(&c.CellOffsetX, &c.HasCellOffsetX, key, value)
142+ case ControlCellOffsetY:
143+ return setInt(&c.CellOffsetY, &c.HasCellOffsetY, key, value)
144+ case ControlParent:
145+ return setUint(&c.Parent, &c.HasParent, key, value)
146+ default:
147+ // Kitty reserves unknown controls for forward-compatible extensions.
148+ // Commands that depend on an unsupported control are rejected later by
149+ // their action-specific validation.
150+ return nil
151+ }
152+ return nil
153+}
154+
155+func fieldError(key byte, err error) error {
156+ return fmt.Errorf("%w: %c: %w", ErrInvalidCommand, key, err)
157+}
158+
159+func setID(dst *uint64, present *bool, key byte, value []byte) error {
160+ if err := setUint(dst, present, key, value); err != nil {
161+ return err
162+ }
163+ if *dst > uint64(^uint32(0)) {
164+ return fieldError(key, ErrIntegerOverflow)
165+ }
166+ return nil
167+}
168+
169+func setUint(dst *uint64, present *bool, key byte, value []byte) error {
170+ n, err := parseUint(value)
171+ if err != nil {
172+ return fieldError(key, err)
173+ }
174+ if n > MaxKittyID {
175+ return fieldError(key, ErrIntegerOverflow)
176+ }
177+ *dst = n
178+ if present != nil {
179+ *present = true
180+ }
181+ return nil
182+}
183+
184+func setInt(dst *int64, present *bool, key byte, value []byte) error {
185+ n, err := parseInt(value)
186+ if err != nil {
187+ return fieldError(key, err)
188+ }
189+ if n < -1<<31 || n > 1<<31-1 {
190+ return fieldError(key, ErrIntegerOverflow)
191+ }
192+ *dst = n
193+ if present != nil {
194+ *present = true
195+ }
196+ return nil
197+}
198+
199+func parseUint(value []byte) (uint64, error) {
200+ if len(value) == 0 {
201+ return 0, ErrInvalidInteger
202+ }
203+ var n uint64
204+ for _, b := range value {
205+ if b < '0' || b > '9' {
206+ return 0, ErrInvalidInteger
207+ }
208+ digit := uint64(b - '0')
209+ if n > (^uint64(0)-digit)/10 {
210+ return 0, ErrIntegerOverflow
211+ }
212+ n = n*10 + digit
213+ }
214+ return n, nil
215+}
216+
217+func parseInt(value []byte) (int64, error) {
218+ if len(value) == 0 {
219+ return 0, ErrInvalidInteger
220+ }
221+ negative := value[0] == '-'
222+ if negative {
223+ value = value[1:]
224+ if len(value) == 0 {
225+ return 0, ErrInvalidInteger
226+ }
227+ }
228+ n, err := parseUint(value)
229+ if err != nil {
230+ return 0, err
231+ }
232+ if negative {
233+ if n > uint64(1)<<63 {
234+ return 0, ErrIntegerOverflow
235+ }
236+ if n == uint64(1)<<63 {
237+ return -1 << 63, nil
238+ }
239+ return -int64(n), nil
240+ }
241+ if n > uint64(^uint64(0)>>1) {
242+ return 0, ErrIntegerOverflow
243+ }
244+ return int64(n), nil
245+}
A protocol/kittygraphics/doc.go +5 -0
@@ -0,0 +1,5 @@
1+// Package kittygraphics implements the bounded Kitty graphics protocol
2+// adapter. It parses APC graphics commands independently of terminal text
3+// state and applies accepted image and placement operations to a graphics
4+// scene.
5+package kittygraphics
A protocol/kittygraphics/kittygraphics.go +471 -0
@@ -0,0 +1,471 @@
1+package kittygraphics
2+
3+import (
4+ "errors"
5+ "math"
6+ "sort"
7+ "sync"
8+
9+ "github.com/bnema/vev-vt/graphics"
10+)
11+
12+var (
13+ ErrInvalidAPC = errors.New("invalid kitty graphics APC")
14+ ErrAPCTooLarge = errors.New("kitty graphics APC is too large")
15+ ErrAPCTruncated = errors.New("truncated kitty graphics APC")
16+ ErrInvalidCommand = errors.New("invalid kitty graphics command")
17+ ErrUnknownAction = errors.New("unknown kitty graphics action")
18+ ErrUnknownControl = errors.New("unknown kitty graphics control")
19+ ErrDuplicateControl = errors.New("duplicate kitty graphics control")
20+ ErrInvalidInteger = errors.New("invalid kitty graphics integer")
21+ ErrIntegerOverflow = errors.New("kitty graphics integer overflow")
22+ ErrInvalidBase64 = errors.New("invalid kitty graphics base64")
23+ ErrInvalidPNG = errors.New("invalid kitty graphics PNG")
24+ ErrPayloadTooLarge = errors.New("kitty graphics payload is too large")
25+ ErrTooManyChunks = errors.New("too many kitty graphics continuation chunks")
26+ ErrInterleavedUpload = errors.New("interleaved kitty graphics upload")
27+ ErrUnsupported = errors.New("unsupported kitty graphics command")
28+ ErrImageNotFound = errors.New("kitty graphics image not found")
29+ ErrPlacementNotFound = errors.New("kitty graphics placement not found")
30+ ErrNoScene = errors.New("kitty graphics scene is nil")
31+)
32+
33+const (
34+ // DefaultMaxAPCBytes bounds the complete framed APC, including its
35+ // controls and encoded payload.
36+ DefaultMaxAPCBytes = uint64(32 << 20)
37+ // DefaultMaxPayloadBytes bounds encoded bytes in one image, before base64
38+ // decoding. The graphics scene applies its own encoded-byte budget too.
39+ DefaultMaxPayloadBytes = uint64(24 << 20)
40+ DefaultMaxUploadBytes = uint64(16 << 20)
41+ DefaultMaxChunks = uint64(1024)
42+ DefaultMaxPixels = uint64(128 << 20)
43+ DefaultMaxDecodedBytes = uint64(256 << 20)
44+ DefaultMaxDimension = uint64(1 << 20)
45+)
46+
47+// Limits bounds parser and adapter resource use. Zero values select bounded
48+// defaults. The limits are independent from the graphics scene limits: both
49+// layers enforce their budgets.
50+type Limits struct {
51+ MaxAPCBytes uint64
52+ MaxPayloadBytes uint64
53+ MaxUploadBytes uint64
54+ MaxChunks uint64
55+ MaxDecodedPixels uint64
56+ MaxDecodedBytes uint64
57+ MaxDimension uint64
58+ MaxResponseBytes uint64
59+ MaxImages uint64
60+ MaxPlacements uint64
61+}
62+
63+// Config is an alias retained for callers that use constructor terminology.
64+type Config = Limits
65+
66+func normalizeLimits(l Limits) Limits {
67+ if l.MaxAPCBytes == 0 {
68+ l.MaxAPCBytes = DefaultMaxAPCBytes
69+ }
70+ if l.MaxPayloadBytes == 0 {
71+ l.MaxPayloadBytes = DefaultMaxPayloadBytes
72+ }
73+ if l.MaxUploadBytes == 0 {
74+ l.MaxUploadBytes = DefaultMaxUploadBytes
75+ }
76+ if l.MaxChunks == 0 {
77+ l.MaxChunks = DefaultMaxChunks
78+ }
79+ if l.MaxDecodedPixels == 0 {
80+ l.MaxDecodedPixels = DefaultMaxPixels
81+ }
82+ if l.MaxDecodedBytes == 0 {
83+ l.MaxDecodedBytes = DefaultMaxDecodedBytes
84+ }
85+ if l.MaxDimension == 0 {
86+ l.MaxDimension = DefaultMaxDimension
87+ }
88+ if l.MaxResponseBytes == 0 {
89+ l.MaxResponseBytes = 4096
90+ }
91+ if l.MaxImages == 0 {
92+ l.MaxImages = 1024
93+ }
94+ if l.MaxPlacements == 0 {
95+ l.MaxPlacements = 65536
96+ }
97+ return l
98+}
99+
100+// Action is the closed set of Kitty graphics actions understood by this
101+// adapter. The underlying byte values are the protocol values.
102+type Action byte
103+
104+const (
105+ ActionTransmit Action = 't'
106+ ActionTransmitDisplay Action = 'T'
107+ ActionPut Action = 'p'
108+ ActionQuery Action = 'q'
109+ ActionDelete Action = 'd'
110+ ActionFrame Action = 'f'
111+ ActionCompose Action = 'a'
112+
113+ // Descriptive aliases used by callers that prefer the protocol names.
114+ ActionTransmitAndDisplay = ActionTransmitDisplay
115+ ActionDisplay = ActionPut
116+)
117+
118+func (a Action) Valid() bool {
119+ switch a {
120+ case ActionTransmit, ActionTransmitDisplay, ActionPut, ActionQuery, ActionDelete, ActionFrame, ActionCompose:
121+ return true
122+ default:
123+ return false
124+ }
125+}
126+
127+func (a Action) String() string {
128+ if !a.Valid() {
129+ return "unknown"
130+ }
131+ return string([]byte{byte(a)})
132+}
133+
134+// Format is the closed set of Kitty image formats supported by this adapter.
135+type Format uint64
136+
137+const (
138+ FormatRGB Format = 24
139+ FormatRGBA Format = 32
140+ FormatPNG Format = 100
141+
142+ FormatRGB24 = FormatRGB
143+ FormatRGBA32 = FormatRGBA
144+)
145+
146+func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG }
147+
148+// Quiet controls protocol response emission. Kitty uses q=1 for successful
149+// operations only and q=2 for all responses.
150+type Quiet uint8
151+
152+const (
153+ QuietNever Quiet = 0
154+ QuietSuccess Quiet = 1
155+ QuietAll Quiet = 2
156+
157+ QuietNone = QuietNever
158+ QuietErrorsOnly = QuietSuccess
159+ QuietEverything = QuietAll
160+)
161+
162+// DeleteTarget is the closed set of targets accepted by a delete action.
163+type DeleteTarget byte
164+
165+const (
166+ DeleteImage DeleteTarget = 'i'
167+ DeleteImageNumber DeleteTarget = 'I'
168+ DeleteCell DeleteTarget = 'p'
169+ DeleteAll DeleteTarget = 'a'
170+ DeleteAllImages DeleteTarget = 'A'
171+ DeleteCellAll DeleteTarget = 'P'
172+)
173+
174+// Transmission is the direct transmission mode. File, temporary-file and
175+// shared-memory transmissions are intentionally not accepted by this adapter.
176+type Transmission byte
177+
178+const TransmissionDirect Transmission = 'd'
179+
180+// ControlKey identifies a recognized Kitty control field.
181+type ControlKey byte
182+
183+const (
184+ ControlAction ControlKey = 'a'
185+ ControlCompression ControlKey = 'o'
186+ ControlFormat ControlKey = 'f'
187+ ControlImageID ControlKey = 'i'
188+ ControlImageNumber ControlKey = 'I'
189+ ControlMore ControlKey = 'm'
190+ ControlQuiet ControlKey = 'q'
191+ ControlWidth ControlKey = 's'
192+ ControlHeight ControlKey = 'v'
193+ ControlColumns ControlKey = 'c'
194+ ControlRows ControlKey = 'r'
195+ ControlX ControlKey = 'x'
196+ ControlY ControlKey = 'y'
197+ ControlSourceWidth ControlKey = 'w'
198+ ControlSourceHeight ControlKey = 'h'
199+ ControlLayer ControlKey = 'z'
200+ ControlPlacementID ControlKey = 'p'
201+ ControlDelete ControlKey = 'd'
202+ ControlTransmission ControlKey = 't'
203+ ControlCursor ControlKey = 'C'
204+ ControlSourceX ControlKey = 'X'
205+ ControlSourceY ControlKey = 'Y'
206+ ControlCellOffsetX ControlKey = 'H'
207+ ControlCellOffsetY ControlKey = 'V'
208+ ControlParent ControlKey = 'P'
209+)
210+
211+// Controls is the typed, closed representation of a Kitty control header.
212+// Has* fields preserve the distinction between an omitted field and zero.
213+type Controls struct {
214+ Action Action
215+ HasAction bool
216+ Compression uint64
217+ HasCompression bool
218+ Format Format
219+ HasFormat bool
220+ ImageID uint64
221+ HasImageID bool
222+ ImageNumber uint64
223+ HasImageNumber bool
224+ More uint64
225+ HasMore bool
226+ Quiet Quiet
227+ HasQuiet bool
228+ Width uint64
229+ HasWidth bool
230+ Height uint64
231+ HasHeight bool
232+ Columns uint64
233+ HasColumns bool
234+ Rows uint64
235+ HasRows bool
236+ X uint64
237+ HasX bool
238+ Y uint64
239+ HasY bool
240+ SourceWidth uint64
241+ HasSourceWidth bool
242+ SourceHeight uint64
243+ HasSourceHeight bool
244+ Layer int64
245+ HasLayer bool
246+ PlacementID uint64
247+ HasPlacementID bool
248+ Delete DeleteTarget
249+ HasDelete bool
250+ Transmission Transmission
251+ HasTransmission bool
252+ Cursor uint64
253+ HasCursor bool
254+ SourceX uint64
255+ HasSourceX bool
256+ SourceY uint64
257+ HasSourceY bool
258+ CellOffsetX int64
259+ HasCellOffsetX bool
260+ CellOffsetY int64
261+ HasCellOffsetY bool
262+ Parent uint64
263+ HasParent bool
264+}
265+
266+// Command is one complete APC graphics command. Payload is the encoded
267+// command payload and is copied from the parser input.
268+type Command struct {
269+ Controls Controls
270+ Payload []byte
271+}
272+
273+// EventKind identifies parser output. Text events contain bytes which are not
274+// Kitty graphics APCs; command events contain complete commands.
275+type EventKind uint8
276+
277+const (
278+ EventText EventKind = iota + 1
279+ EventCommand
280+ EventError
281+)
282+
283+// Event is emitted by Parser. Text and command payloads are owned by the
284+// event and remain valid after the next Feed call.
285+type Event struct {
286+ Kind EventKind
287+ Text []byte
288+ Command Command
289+ Err error
290+}
291+
292+// ParserConfig is a descriptive alias for Limits.
293+type ParserConfig = Limits
294+
295+// Parser incrementally recognizes ESC _ G APCs. It never interprets ordinary
296+// text or other escape sequences and can be fed arbitrary byte-sized chunks.
297+type Parser struct {
298+ limits Limits
299+ candidate []byte
300+ apc []byte
301+ inAPC bool
302+ escaped bool
303+ oversize bool
304+}
305+
306+// NewParser creates an incremental bounded parser.
307+func NewParser(config ...Limits) *Parser {
308+ var l Limits
309+ if len(config) != 0 {
310+ l = config[0]
311+ }
312+ return &Parser{limits: normalizeLimits(l)}
313+}
314+
315+// Limits reports the parser limits.
316+func (p *Parser) Limits() Limits {
317+ if p == nil {
318+ return Limits{}
319+ }
320+ return p.limits
321+}
322+
323+// MaxInt64 is exported for callers constructing checked protocol geometry.
324+const MaxInt64 = int64(math.MaxInt64)
325+
326+// MaxKittyID is the largest protocol image or placement identifier.
327+const MaxKittyID = uint64(^uint32(0))
328+
329+// Session owns protocol-to-scene mappings. It is safe for one owner to call
330+// Feed and query methods serially; the mutex also makes read-only mapping
331+// access safe while a caller publishes a snapshot.
332+type Session struct {
333+ mu sync.Mutex
334+ scene *graphics.Scene
335+ limits Limits
336+ parser *Parser
337+ images map[uint64]graphics.AssetID
338+ imageNumbers map[uint64]uint64
339+ placements map[placementKey]graphics.PlacementID
340+ children map[uint64]Child
341+ nextChild uint64
342+ nextPlacement uint64
343+ upload *upload
344+ origin placementOrigin
345+}
346+
347+// Adapter is an alias for the stateful Kitty graphics session.
348+type Adapter = Session
349+
350+// Child describes a session-local image child and its graphics asset. Child
351+// IDs are never sent to the terminal; they make the protocol mapping explicit
352+// and stable for consumers.
353+type Child struct {
354+ ID uint64
355+ ImageID uint64
356+ AssetID graphics.AssetID
357+}
358+
359+// NewSession constructs an adapter over scene. A nil scene is allowed so the
360+// parser can still be used for validation, but commands which mutate a scene
361+// return ErrNoScene.
362+func NewSession(scene *graphics.Scene, config ...Limits) *Session {
363+ var l Limits
364+ if len(config) != 0 {
365+ l = config[0]
366+ }
367+ l = normalizeLimits(l)
368+ return &Session{
369+ scene: scene,
370+ limits: l,
371+ parser: NewParser(l),
372+ images: make(map[uint64]graphics.AssetID),
373+ imageNumbers: make(map[uint64]uint64),
374+ placements: make(map[placementKey]graphics.PlacementID),
375+ children: make(map[uint64]Child),
376+ nextChild: 1,
377+ nextPlacement: 1,
378+ }
379+}
380+
381+// NewAdapter is an adapter-named constructor alias.
382+func NewAdapter(scene *graphics.Scene, config ...Limits) *Session {
383+ return NewSession(scene, config...)
384+}
385+
386+// New is a concise constructor alias for NewSession.
387+func New(scene *graphics.Scene, config ...Limits) *Session { return NewSession(scene, config...) }
388+
389+// Image returns the graphics asset mapped from a Kitty image ID.
390+func (s *Session) Image(imageID uint64) (graphics.AssetID, bool) {
391+ if s == nil {
392+ return graphics.AssetID{}, false
393+ }
394+ s.mu.Lock()
395+ defer s.mu.Unlock()
396+ id, ok := s.images[imageID]
397+ return id, ok
398+}
399+
400+// Asset is an alias for Image.
401+func (s *Session) Asset(imageID uint64) (graphics.AssetID, bool) { return s.Image(imageID) }
402+
403+// Placement returns the graphics placement mapped from one Kitty image and
404+// placement-ID pair. Kitty placement IDs are scoped to their image ID.
405+func (s *Session) Placement(imageID, placementID uint64) (graphics.PlacementID, bool) {
406+ if s == nil {
407+ return graphics.PlacementID{}, false
408+ }
409+ s.mu.Lock()
410+ defer s.mu.Unlock()
411+ id, ok := s.placements[placementKey{imageID: imageID, placementID: placementID}]
412+ return id, ok
413+}
414+
415+// PlacementID is an alias for Placement.
416+func (s *Session) PlacementID(imageID, placementID uint64) (graphics.PlacementID, bool) {
417+ return s.Placement(imageID, placementID)
418+}
419+
420+// Child returns the oldest session child associated with an image ID.
421+func (s *Session) Child(imageID uint64) (Child, bool) {
422+ if s == nil {
423+ return Child{}, false
424+ }
425+ s.mu.Lock()
426+ defer s.mu.Unlock()
427+ var selected Child
428+ found := false
429+ for _, child := range s.children {
430+ if child.ImageID == imageID && (!found || child.ID < selected.ID) {
431+ selected, found = child, true
432+ }
433+ }
434+ return selected, found
435+}
436+
437+// ChildByID returns a session child by its adapter-local ID.
438+func (s *Session) ChildByID(childID uint64) (Child, bool) {
439+ if s == nil {
440+ return Child{}, false
441+ }
442+ s.mu.Lock()
443+ defer s.mu.Unlock()
444+ child, ok := s.children[childID]
445+ return child, ok
446+}
447+
448+// ChildID returns the adapter-local child ID associated with an image ID.
449+func (s *Session) ChildID(imageID uint64) (uint64, bool) {
450+ child, ok := s.Child(imageID)
451+ if !ok {
452+ return 0, false
453+ }
454+ return child.ID, true
455+}
456+
457+// Children returns a stable copy of all current session children in child-ID
458+// order.
459+func (s *Session) Children() []Child {
460+ if s == nil {
461+ return nil
462+ }
463+ s.mu.Lock()
464+ defer s.mu.Unlock()
465+ children := make([]Child, 0, len(s.children))
466+ for _, child := range s.children {
467+ children = append(children, child)
468+ }
469+ sort.Slice(children, func(i, j int) bool { return children[i].ID < children[j].ID })
470+ return children
471+}
A protocol/kittygraphics/kittygraphics_test.go +505 -0
@@ -0,0 +1,505 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "encoding/base64"
6+ "errors"
7+ "image"
8+ "image/color"
9+ "image/png"
10+ "math/rand"
11+ "os"
12+ "strconv"
13+ "testing"
14+
15+ "github.com/bnema/vev-vt/graphics"
16+)
17+
18+func apc(header, payload string) []byte {
19+ return append([]byte("\x1b_G"+header+";"+payload+"\x1b\\"), []byte{}...)
20+}
21+
22+func pngBytes(t *testing.T, width, height int) []byte {
23+ t.Helper()
24+ img := image.NewRGBA(image.Rect(0, 0, width, height))
25+ img.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
26+ var out bytes.Buffer
27+ if err := png.Encode(&out, img); err != nil {
28+ t.Fatal(err)
29+ }
30+ return out.Bytes()
31+}
32+
33+func TestParserRecognizesSplitPrefixAndPreservesText(t *testing.T) {
34+ p := NewParser()
35+ var events []Event
36+ for _, b := range []byte("before\x1b_Ga=q,i=7;\x1b\\after") {
37+ events = append(events, p.Feed([]byte{b})...)
38+ }
39+ var textBefore, textAfter []byte
40+ var command Event
41+ for _, event := range events {
42+ if event.Kind == EventText && command.Kind == 0 {
43+ textBefore = append(textBefore, event.Text...)
44+ }
45+ if event.Kind == EventCommand {
46+ command = event
47+ }
48+ if event.Kind == EventText && command.Kind != 0 {
49+ textAfter = append(textAfter, event.Text...)
50+ }
51+ }
52+ if string(textBefore) != "before" || command.Kind != EventCommand || string(textAfter) != "after" {
53+ t.Fatalf("events = %#v", events)
54+ }
55+ if command.Command.Controls.Action != ActionQuery || command.Command.Controls.ImageID != 7 {
56+ t.Fatalf("command = %#v", command.Command)
57+ }
58+}
59+
60+func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
61+ scene := graphics.NewScene(graphics.Limits{})
62+ session := NewSession(scene)
63+
64+ result, err := session.Feed(apc("a=q,i=31,t=d,f=24,s=1,v=1", "AAAA"))
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" {
69+ t.Fatalf("response = %q", got)
70+ }
71+ if got := scene.Usage(); got != (graphics.Usage{}) {
72+ t.Fatalf("query persisted graphics state: %#v", got)
73+ }
74+}
75+
76+func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
77+ data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ scene := graphics.NewScene(graphics.Limits{})
82+ session := NewSession(scene)
83+ // Feed split boundaries deliberately unrelated to APC boundaries. This
84+ // models the short writes observed from the current kitten/icat fixture.
85+ rng := rand.New(rand.NewSource(7))
86+ var result Result
87+ for len(data) != 0 {
88+ n := 1 + rng.Intn(4096)
89+ if n > len(data) {
90+ n = len(data)
91+ }
92+ part, err := session.Feed(data[:n])
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ result.Events = append(result.Events, part.Events...)
97+ result.Responses = append(result.Responses, part.Responses...)
98+ result.Mutations = append(result.Mutations, part.Mutations...)
99+ data = data[n:]
100+ }
101+ if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
102+ t.Fatalf("mutations = %#v", result.Mutations)
103+ }
104+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.DecodedPixels != 40000 {
105+ t.Fatalf("usage = %#v", got)
106+ }
107+ if len(result.Responses) != 0 {
108+ t.Fatalf("quiet fixture response = %q", result.Bytes())
109+ }
110+ if _, ok := session.Image(0); ok {
111+ t.Fatal("unidentified fixture image was retained as a reusable mapping")
112+ }
113+}
114+
115+func TestDecodeBase64AcceptsStandardAndRaw(t *testing.T) {
116+ want := []byte{0, 1, 2, 253, 254, 255}
117+ standard := base64.StdEncoding.EncodeToString(want)
118+ raw := base64.RawStdEncoding.EncodeToString(want)
119+ for _, encoded := range []string{standard, raw} {
120+ got, err := DecodeBase64([]byte(encoded))
121+ if err != nil || !bytes.Equal(got, want) {
122+ t.Fatalf("DecodeBase64(%q) = %x, %v", encoded, got, err)
123+ }
124+ }
125+ if _, err := DecodeBase64([]byte("not+base64?")); !errors.Is(err, ErrInvalidBase64) {
126+ t.Fatalf("invalid base64 error = %v", err)
127+ }
128+}
129+
130+func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
131+ scene := graphics.NewScene(graphics.Limits{})
132+ session := NewSession(scene)
133+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
134+ result, err := session.Feed(apc("a=t,i=9,f=32,s=1,v=1,q=2", payload))
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if len(result.Responses) != 0 || len(result.Mutations) != 1 {
139+ t.Fatalf("result = %#v", result)
140+ }
141+ asset, ok := session.Image(9)
142+ if !ok {
143+ t.Fatal("image mapping missing")
144+ }
145+ if _, err := session.Feed(apc("a=p,i=9,p=4,X=5,Y=6,c=1,r=1", "")); err != nil {
146+ t.Fatal(err)
147+ }
148+ placement, ok := session.Placement(9, 4)
149+ if !ok {
150+ t.Fatal("placement mapping missing")
151+ }
152+ view, ok := scene.Snapshot().Placement(placement)
153+ if !ok || view.AssetID() != asset || view.Destination() != (graphics.PixelRect{X: 5, Y: 6, Width: 1, Height: 1}) {
154+ t.Fatalf("placement = %#v, ok=%v", view, ok)
155+ }
156+}
157+
158+func TestPlacementIDsAreScopedToTheirImage(t *testing.T) {
159+ scene := graphics.NewScene(graphics.Limits{})
160+ session := NewSession(scene)
161+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
162+ for _, imageID := range []uint64{1, 2} {
163+ if _, err := session.Feed(apc("a=t,i="+strconv.FormatUint(imageID, 10)+",f=32,s=1,v=1,q=2", payload)); err != nil {
164+ t.Fatal(err)
165+ }
166+ if _, err := session.Feed(apc("a=p,i="+strconv.FormatUint(imageID, 10)+",p=7,q=2", "")); err != nil {
167+ t.Fatal(err)
168+ }
169+ }
170+ first, ok := session.Placement(1, 7)
171+ if !ok {
172+ t.Fatal("first placement missing")
173+ }
174+ second, ok := session.Placement(2, 7)
175+ if !ok || first == second {
176+ t.Fatalf("placement mapping collision: first=%v second=%v ok=%v", first, second, ok)
177+ }
178+ if got := scene.Usage().Placements; got != 2 {
179+ t.Fatalf("placements = %d, want 2", got)
180+ }
181+}
182+
183+func TestParseAPCAcceptsPayloadFreeCommands(t *testing.T) {
184+ for _, raw := range []string{"\x1b_Ga=d,d=a\x1b\\", "\x1b_Ga=p,i=1\x1b\\"} {
185+ command, err := ParseAPC([]byte(raw))
186+ if err != nil {
187+ t.Fatalf("ParseAPC(%q) error = %v", raw, err)
188+ }
189+ if len(command.Payload) != 0 {
190+ t.Fatalf("ParseAPC(%q) payload = %q", raw, command.Payload)
191+ }
192+ }
193+}
194+
195+func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
196+ if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {
197+ t.Fatalf("duplicate control error = %v", err)
198+ }
199+ if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {
200+ t.Fatalf("integer overflow error = %v", err)
201+ }
202+ if _, err := ParseCommand([]byte("a=q,q=256;")); !errors.Is(err, ErrInvalidCommand) {
203+ t.Fatalf("out-of-range quiet error = %v", err)
204+ }
205+ if _, err := ParseCommand([]byte("a=q,i=1,I=2;")); !errors.Is(err, ErrInvalidCommand) {
206+ t.Fatalf("mixed image selector error = %v", err)
207+ }
208+
209+ p := NewParser(Limits{MaxAPCBytes: 8})
210+ events := p.Feed(apc("a=q", "0123456789"))
211+ if len(events) != 1 || !errors.Is(events[0].Err, ErrAPCTooLarge) {
212+ t.Fatalf("oversize events = %#v", events)
213+ }
214+ p = NewParser()
215+ if got := p.Feed([]byte("\x1b_Ga=q;payload")); len(got) != 0 {
216+ t.Fatalf("truncated feed events = %#v", got)
217+ }
218+ got := p.Finish()
219+ if len(got) != 1 || !errors.Is(got[0].Err, ErrAPCTruncated) {
220+ t.Fatalf("truncated finish events = %#v", got)
221+ }
222+
223+ session := NewSession(graphics.NewScene(graphics.Limits{}))
224+ first := apc("a=t,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
225+ if _, err := session.Feed(first); err != nil {
226+ t.Fatal(err)
227+ }
228+ if _, err := session.Feed(apc("a=t,i=2,m=0,q=2", "ID")); !errors.Is(err, ErrInterleavedUpload) {
229+ t.Fatalf("interleaved error = %v", err)
230+ }
231+ if _, err := session.Feed(apc("a=t,m=0,q=2", "IDBA")); err != nil {
232+ t.Fatal(err)
233+ }
234+}
235+
236+func TestSessionTruncationAndDeleteAbortPendingUpload(t *testing.T) {
237+ for _, action := range []string{"finish", "delete"} {
238+ t.Run(action, func(t *testing.T) {
239+ scene := graphics.NewScene(graphics.Limits{})
240+ session := NewSession(scene)
241+ if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")); err != nil {
242+ t.Fatal(err)
243+ }
244+ if action == "finish" {
245+ if _, err := session.Finish(); err != nil {
246+ t.Fatalf("Finish error = %v", err)
247+ }
248+ } else if _, err := session.Feed(apc("a=d,q=2", "")); err != nil {
249+ t.Fatal(err)
250+ }
251+ if _, err := session.Feed(apc("m=0,q=2", "IDBA")); err == nil {
252+ t.Fatal("stale continuation unexpectedly succeeded")
253+ }
254+ if got := scene.Usage(); got != (graphics.Usage{}) {
255+ t.Fatalf("stale continuation mutated scene: %#v", got)
256+ }
257+ })
258+ }
259+}
260+
261+func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
262+ good := pngBytes(t, 1, 1)
263+ scene := graphics.NewScene(graphics.Limits{})
264+ session := NewSession(scene)
265+ if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", base64.RawStdEncoding.EncodeToString(good[:len(good)-2]))); !errors.Is(err, ErrInvalidPNG) {
266+ t.Fatalf("malformed PNG error = %v", err)
267+ }
268+ if got := scene.Usage(); got.Assets != 0 || got.Placements != 0 {
269+ t.Fatalf("malformed PNG mutated scene: %#v", got)
270+ }
271+}
272+
273+func TestImageGeometryRejectsOverflowBeforeByteArithmetic(t *testing.T) {
274+ controls := Controls{Width: ^uint64(0), HasWidth: true, Height: ^uint64(0), HasHeight: true}
275+ limits := Limits{MaxDimension: ^uint64(0), MaxDecodedPixels: ^uint64(0)}
276+ if _, _, err := imageGeometry(nil, FormatRGBA, controls, limits); !errors.Is(err, ErrPayloadTooLarge) {
277+ t.Fatalf("overflowing geometry error = %v", err)
278+ }
279+}
280+
281+func TestChunkedTransmitAndDisplayRetainsDisplayAction(t *testing.T) {
282+ scene := graphics.NewScene(graphics.Limits{})
283+ session := NewSession(scene)
284+ first := apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
285+ if _, err := session.Feed(first); err != nil {
286+ t.Fatal(err)
287+ }
288+ last := apc("m=0,q=2", "IDBA")
289+ result, err := session.Feed(last)
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
294+ t.Fatalf("mutations = %#v", result.Mutations)
295+ }
296+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
297+ t.Fatalf("usage = %#v", got)
298+ }
299+}
300+
301+func TestControlsIgnoreExtensionsAndBoundIntegerFields(t *testing.T) {
302+ controls, err := ParseControls([]byte("a=p,i=1,N=1,S=2,O=3"))
303+ if err != nil || controls.ImageID != 1 {
304+ t.Fatalf("extension controls = %#v, %v", controls, err)
305+ }
306+ if _, err := ParseControls([]byte("a=p,s=4294967296")); !errors.Is(err, ErrIntegerOverflow) {
307+ t.Fatalf("out-of-range uint32 control error = %v", err)
308+ }
309+ if _, err := ParseControls([]byte("a=p,z=2147483648")); !errors.Is(err, ErrIntegerOverflow) {
310+ t.Fatalf("out-of-range int32 control error = %v", err)
311+ }
312+}
313+
314+func TestImplicitPlacementIDsStopAtKittyLimit(t *testing.T) {
315+ session := NewSession(nil)
316+ session.nextPlacement = MaxKittyID + 1
317+ if _, ok := session.takePlacementID(1); ok {
318+ t.Fatal("implicit placement ID exceeded Kitty's uint32 namespace")
319+ }
320+}
321+
322+func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) {
323+ session := NewSession(graphics.NewScene(graphics.Limits{}))
324+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
325+ if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", payload)); err != nil {
326+ t.Fatal(err)
327+ }
328+ for _, header := range []string{"a=p,i=1,P=2", "a=p,i=1,H=1", "a=d,d=p,x=1,y=1", "a=d,d=P,x=1,y=1"} {
329+ if _, err := session.Feed(apc(header, "")); !errors.Is(err, ErrUnsupported) {
330+ t.Fatalf("%q error = %v, want ErrUnsupported", header, err)
331+ }
332+ }
333+}
334+
335+func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) {
336+ data := pngBytes(t, 2, 2)
337+ if _, _, err := imageGeometry(data, FormatPNG, Controls{}, normalizeLimits(Limits{MaxDecodedBytes: 15})); !errors.Is(err, ErrPayloadTooLarge) {
338+ t.Fatalf("PNG byte-budget error = %v", err)
339+ }
340+ scene := graphics.NewScene(graphics.Limits{})
341+ session := NewSession(scene)
342+ payload := base64.RawStdEncoding.EncodeToString(data)
343+ if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", payload)); err != nil {
344+ t.Fatal(err)
345+ }
346+ assetID, ok := session.Image(1)
347+ if !ok {
348+ t.Fatal("image mapping missing")
349+ }
350+ asset, ok := scene.Snapshot().Asset(assetID)
351+ if !ok || asset.Format() != graphics.AssetFormatPNG || asset.Blob().Format != graphics.AssetFormatPNG {
352+ t.Fatalf("asset format = %#v, ok=%v", asset, ok)
353+ }
354+}
355+
356+func TestPlacementFailureEmitsStableProtocolError(t *testing.T) {
357+ scene := graphics.NewScene(graphics.Limits{})
358+ session := NewSession(scene)
359+ if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
360+ t.Fatal(err)
361+ }
362+ result, err := session.Feed(apc("a=p,i=1,p=7,w=2,q=0", ""))
363+ if !errors.Is(err, graphics.ErrInvalidPlacement) {
364+ t.Fatalf("placement error = %v", err)
365+ }
366+ if got := result.Bytes(); string(got) != "\x1b_Gi=1;EINVAL\x1b\\" {
367+ t.Fatalf("placement response = %q", got)
368+ }
369+ if len(result.Mutations) != 0 || scene.Usage().Placements != 0 {
370+ t.Fatalf("failed placement mutated state: result=%#v usage=%#v", result, scene.Usage())
371+ }
372+}
373+
374+func TestFailedImageReplacementPreservesAssetAndPlacements(t *testing.T) {
375+ scene := graphics.NewScene(graphics.Limits{MaxEncodedBytes: 4, MaxDecodedPixels: 4})
376+ session := NewSession(scene)
377+ if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
378+ t.Fatal(err)
379+ }
380+ if _, err := session.Feed(apc("a=p,i=1,p=7,q=2", "")); err != nil {
381+ t.Fatal(err)
382+ }
383+ assetID, ok := session.Image(1)
384+ if !ok {
385+ t.Fatal("original image mapping missing")
386+ }
387+ placementID, ok := session.Placement(1, 7)
388+ if !ok {
389+ t.Fatal("original placement mapping missing")
390+ }
391+ before := scene.Snapshot()
392+ result, err := session.Feed(apc("a=t,i=1,f=32,s=2,v=1,q=0", "AQIDBAUGBwg"))
393+ if !errors.Is(err, graphics.ErrEncodedBudget) {
394+ t.Fatalf("replacement error = %v", err)
395+ }
396+ if got := result.Bytes(); string(got) != "\x1b_Gi=1;E2BIG\x1b\\" {
397+ t.Fatalf("replacement response = %q", got)
398+ }
399+ if got, ok := session.Image(1); !ok || got != assetID {
400+ t.Fatalf("replacement changed image mapping: id=%v ok=%v want=%v", got, ok, assetID)
401+ }
402+ if got, ok := session.Placement(1, 7); !ok || got != placementID {
403+ t.Fatalf("replacement changed placement mapping: id=%v ok=%v want=%v", got, ok, placementID)
404+ }
405+ if scene.Snapshot().Generation() != before.Generation() || scene.Usage() != before.Usage() {
406+ t.Fatalf("failed replacement changed scene: before=%#v after=%#v", before.Usage(), scene.Usage())
407+ }
408+ if _, ok := scene.Snapshot().Placement(placementID); !ok {
409+ t.Fatal("failed replacement removed original placement")
410+ }
411+}
412+
413+func TestFailedDisplayedImageReplacementPreservesOldPlacement(t *testing.T) {
414+ scene := graphics.NewScene(graphics.Limits{})
415+ session := NewSession(scene)
416+ if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
417+ t.Fatal(err)
418+ }
419+ assetID, ok := session.Image(1)
420+ if !ok {
421+ t.Fatal("original image mapping missing")
422+ }
423+ placementID, ok := session.Placement(1, 1)
424+ if !ok {
425+ t.Fatal("original placement mapping missing")
426+ }
427+
428+ result, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,w=2,q=0", "AQIDBA"))
429+ if !errors.Is(err, graphics.ErrInvalidPlacement) {
430+ t.Fatalf("replacement placement error = %v", err)
431+ }
432+ if got := result.Bytes(); string(got) != "\x1b_Gi=1;EINVAL\x1b\\" {
433+ t.Fatalf("replacement response = %q", got)
434+ }
435+ if got, ok := session.Image(1); !ok || got != assetID {
436+ t.Fatalf("failed displayed replacement changed image: id=%v ok=%v want=%v", got, ok, assetID)
437+ }
438+ if got, ok := session.Placement(1, 1); !ok || got != placementID {
439+ t.Fatalf("failed displayed replacement changed placement: id=%v ok=%v want=%v", got, ok, placementID)
440+ }
441+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
442+ t.Fatalf("failed displayed replacement usage = %#v", got)
443+ }
444+}
445+
446+func TestDeleteSelectedImagePlacementsAndImageNumberNamespace(t *testing.T) {
447+ scene := graphics.NewScene(graphics.Limits{})
448+ session := NewSession(scene)
449+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
450+ for range 2 {
451+ if _, err := session.Feed(apc("a=t,I=7,f=32,s=1,v=1,q=2", payload)); err != nil {
452+ t.Fatal(err)
453+ }
454+ }
455+ if got := scene.Usage(); got.Assets != 2 || got.Placements != 0 {
456+ t.Fatalf("numbered uploads collapsed namespaces: %#v", got)
457+ }
458+ if _, err := session.Feed(apc("a=p,I=7,p=2,q=2", "")); err != nil {
459+ t.Fatal(err)
460+ }
461+ if _, err := session.Feed(apc("a=p,i=1,p=1,q=2", "")); err != nil {
462+ t.Fatal(err)
463+ }
464+ if got := scene.Usage(); got.Placements != 2 {
465+ t.Fatalf("placements = %#v", got)
466+ }
467+ if _, err := session.Feed(apc("a=d,d=i,i=1,q=2", "")); err != nil {
468+ t.Fatal(err)
469+ }
470+ if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {
471+ t.Fatalf("selected delete affected unrelated scene state: %#v", got)
472+ }
473+ if _, err := session.Feed(apc("a=d,d=I,I=7,q=2", "")); err != nil {
474+ t.Fatal(err)
475+ }
476+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 0 {
477+ t.Fatalf("image-number delete = %#v", got)
478+ }
479+ if _, ok := session.Image(1); !ok {
480+ t.Fatal("image ID namespace was removed by image-number delete")
481+ }
482+}
483+
484+func FuzzParserNeverPanics(f *testing.F) {
485+ f.Add([]byte("text\x1b_Ga=q;\x1b\\"))
486+ f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))
487+ f.Fuzz(func(t *testing.T, data []byte) {
488+ p := NewParser(Limits{MaxAPCBytes: 1024, MaxPayloadBytes: 512, MaxUploadBytes: 512})
489+ _ = p.Feed(data)
490+ _ = p.Finish()
491+ })
492+}
493+
494+func FuzzSessionNeverPanics(f *testing.F) {
495+ f.Add([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;AQIDBA==\x1b\\"))
496+ f.Add([]byte("\x1b_Ga=T,f=100,m=1;AAAA\x1b\\\x1b_Gm=0;AAAA\x1b\\"))
497+ f.Fuzz(func(t *testing.T, data []byte) {
498+ s := NewSession(graphics.NewScene(graphics.Limits{}), Limits{
499+ MaxAPCBytes: 4096, MaxPayloadBytes: 2048, MaxUploadBytes: 2048,
500+ MaxDimension: 128, MaxDecodedPixels: 4096,
501+ })
502+ _, _ = s.Feed(data)
503+ _, _ = s.Finish()
504+ })
505+}
A protocol/kittygraphics/parser.go +199 -0
@@ -0,0 +1,199 @@
1+package kittygraphics
2+
3+import "bytes"
4+
5+var apcPrefix = [...]byte{0x1b, '_', 'G'}
6+
7+// Feed consumes data and returns text and complete graphics events. The
8+// parser retains only an incomplete APC or a possible split prefix between
9+// calls. A malformed APC is reported as an EventError and discarded through
10+// its string terminator, allowing following text and APCs to be processed.
11+func (p *Parser) Feed(data []byte) []Event {
12+ if p == nil {
13+ if len(data) == 0 {
14+ return nil
15+ }
16+ return []Event{{Kind: EventText, Text: append([]byte(nil), data...)}}
17+ }
18+ var events []Event
19+ text := make([]byte, 0, len(data))
20+ flushText := func() {
21+ if len(text) == 0 {
22+ return
23+ }
24+ events = append(events, Event{Kind: EventText, Text: append([]byte(nil), text...)})
25+ text = text[:0]
26+ }
27+
28+ for _, b := range data {
29+ if !p.inAPC {
30+ p.candidate = append(p.candidate, b)
31+ for len(p.candidate) != 0 {
32+ if len(p.candidate) <= len(apcPrefix) && bytes.Equal(p.candidate, apcPrefix[:len(p.candidate)]) {
33+ break
34+ }
35+ text = append(text, p.candidate[0])
36+ p.candidate = p.candidate[1:]
37+ }
38+ if len(p.candidate) == len(apcPrefix) {
39+ flushText()
40+ p.candidate = p.candidate[:0]
41+ p.inAPC = true
42+ p.escaped = false
43+ p.oversize = false
44+ p.apc = p.apc[:0]
45+ }
46+ continue
47+ }
48+
49+ if p.escaped {
50+ if b == '\\' {
51+ if p.oversize {
52+ events = append(events, p.errorEvent())
53+ } else {
54+ events = append(events, p.commandEvent())
55+ }
56+ p.resetAPC()
57+ continue
58+ }
59+ // An ESC that is not followed by backslash is payload. Keep the
60+ // byte and continue looking for a terminator. This is necessary
61+ // for raw payloads and is harmless for base64 payloads.
62+ p.appendAPC(0x1b)
63+ p.escaped = false
64+ }
65+ if b == 0x9c { // C1 ST is also a valid APC terminator.
66+ if p.oversize {
67+ events = append(events, p.errorEvent())
68+ } else {
69+ events = append(events, p.commandEvent())
70+ }
71+ p.resetAPC()
72+ continue
73+ }
74+ if b == 0x1b {
75+ p.escaped = true
76+ continue
77+ }
78+ p.appendAPC(b)
79+ }
80+ flushText()
81+ return events
82+}
83+
84+// Parse is an alias for Feed.
85+func (p *Parser) Parse(data []byte) []Event { return p.Feed(data) }
86+
87+// FeedWithErrors is a conventional error-returning facade over Feed. Parser
88+// errors are also retained in the returned Event values.
89+func (p *Parser) FeedWithErrors(data []byte) ([]Event, error) {
90+ events := p.Feed(data)
91+ for _, event := range events {
92+ if event.Kind == EventError && event.Err != nil {
93+ return events, event.Err
94+ }
95+ }
96+ return events, nil
97+}
98+
99+// Finish reports an unterminated APC and resets the parser. Text which is a
100+// partial ordinary escape sequence is returned as text. Finish is useful at a
101+// stream boundary; a caller that will feed more data must not call it.
102+func (p *Parser) Finish() []Event {
103+ if p == nil {
104+ return nil
105+ }
106+ var events []Event
107+ if p.inAPC {
108+ err := ErrAPCTruncated
109+ if p.oversize {
110+ err = ErrAPCTooLarge
111+ }
112+ events = append(events, Event{Kind: EventError, Err: err})
113+ p.resetAPC()
114+ }
115+ if len(p.candidate) != 0 {
116+ events = append(events, Event{Kind: EventText, Text: append([]byte(nil), p.candidate...)})
117+ p.candidate = p.candidate[:0]
118+ }
119+ return events
120+}
121+
122+// Flush is an alias for Finish.
123+func (p *Parser) Flush() []Event { return p.Finish() }
124+
125+func (p *Parser) appendAPC(b byte) {
126+ if p.oversize {
127+ return
128+ }
129+ if uint64(len(p.apc))+1 > p.limits.MaxAPCBytes {
130+ p.oversize = true
131+ return
132+ }
133+ p.apc = append(p.apc, b)
134+}
135+
136+func (p *Parser) resetAPC() {
137+ p.inAPC = false
138+ p.escaped = false
139+ p.oversize = false
140+ p.apc = p.apc[:0]
141+}
142+
143+func (p *Parser) errorEvent() Event {
144+ if p.oversize {
145+ return Event{Kind: EventError, Err: ErrAPCTooLarge}
146+ }
147+ return Event{Kind: EventError, Err: ErrInvalidAPC}
148+}
149+
150+func (p *Parser) commandEvent() Event {
151+ command, err := ParseCommand(p.apc, p.limits)
152+ if err != nil {
153+ return Event{Kind: EventError, Err: err}
154+ }
155+ return Event{Kind: EventCommand, Command: command}
156+}
157+
158+// ParseCommand parses the bytes between APC's G and its ST terminator. It is
159+// exported separately so adapters can validate commands without maintaining a
160+// stream parser.
161+func ParseCommand(body []byte, config ...Limits) (Command, error) {
162+ var l Limits
163+ if len(config) != 0 {
164+ l = config[0]
165+ }
166+ l = normalizeLimits(l)
167+ if uint64(len(body)) > l.MaxAPCBytes {
168+ return Command{}, ErrAPCTooLarge
169+ }
170+ controlEnd, payloadStart := len(body), len(body)
171+ if sep := bytes.IndexByte(body, ';'); sep >= 0 {
172+ controlEnd, payloadStart = sep, sep+1
173+ }
174+ controls, err := parseControls(body[:controlEnd])
175+ if err != nil {
176+ return Command{}, err
177+ }
178+ payload := body[payloadStart:]
179+ if uint64(len(payload)) > l.MaxPayloadBytes {
180+ return Command{}, ErrPayloadTooLarge
181+ }
182+ return Command{Controls: controls, Payload: append([]byte(nil), payload...)}, nil
183+}
184+
185+// ParseAPC parses a complete ESC _ G ... ST sequence.
186+func ParseAPC(apc []byte, config ...Limits) (Command, error) {
187+ if len(apc) < 4 || apc[0] != 0x1b || apc[1] != '_' || apc[2] != 'G' {
188+ return Command{}, ErrInvalidAPC
189+ }
190+ body := apc[3:]
191+ if body[len(body)-1] == 0x9c {
192+ body = body[:len(body)-1]
193+ } else if len(body) >= 2 && body[len(body)-2] == 0x1b && body[len(body)-1] == '\\' {
194+ body = body[:len(body)-2]
195+ } else {
196+ return Command{}, ErrAPCTruncated
197+ }
198+ return ParseCommand(body, config...)
199+}
A protocol/kittygraphics/session.go +1020 -0
@@ -0,0 +1,1020 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "image/png"
7+ "math"
8+ "strconv"
9+
10+ "github.com/bnema/vev-vt/graphics"
11+)
12+
13+type placementKey struct {
14+ imageID uint64
15+ placementID uint64
16+}
17+
18+type placementOrigin struct {
19+ x, y uint64
20+ set bool
21+}
22+
23+type upload struct {
24+ controls Controls
25+ imageID uint64
26+ payload []byte
27+ chunks uint64
28+ display bool
29+ origin placementOrigin
30+}
31+
32+// MutationKind describes a scene mutation performed by a session.
33+type MutationKind uint8
34+
35+const (
36+ MutationUpload MutationKind = iota + 1
37+ MutationPlacement
38+ MutationDeleteImage
39+ MutationDeletePlacement
40+ MutationClear
41+ MutationDeletePlacements
42+)
43+
44+// Mutation records the stable protocol and opaque scene IDs involved in one
45+// accepted command.
46+type Mutation struct {
47+ Kind MutationKind
48+ ImageID uint64
49+ ChildID uint64
50+ AssetID graphics.AssetID
51+ PlacementID uint64
52+ ScenePlacementID graphics.PlacementID
53+}
54+
55+// Result contains all output from one or more commands in a Feed call.
56+type Result struct {
57+ Events []Event
58+ Responses [][]byte
59+ Mutations []Mutation
60+}
61+
62+// Bytes returns response bytes in stable command order. The returned slice is
63+// newly allocated and may be modified by the caller.
64+func (r Result) Bytes() []byte {
65+ var n int
66+ for _, response := range r.Responses {
67+ n += len(response)
68+ }
69+ out := make([]byte, 0, n)
70+ for _, response := range r.Responses {
71+ out = append(out, response...)
72+ }
73+ return out
74+}
75+
76+// Response is the wire representation of a stable Kitty response.
77+type Response struct {
78+ ImageID uint64
79+ OK bool
80+ Code string
81+ Bytes []byte
82+}
83+
84+const responsePrefix = "\x1b_G"
85+const responseSuffix = "\x1b\\"
86+
87+// MakeResponse constructs the canonical Kitty response used by Session.
88+func MakeResponse(imageID uint64, code string) []byte {
89+ if code == "" {
90+ code = "OK"
91+ }
92+ return []byte(responsePrefix + "i=" + strconv.FormatUint(imageID, 10) + ";" + code + responseSuffix)
93+}
94+
95+// Feed parses and applies arbitrary bytes. Ordinary text is returned as
96+// EventText; it is never interpreted, discarded, or passed through graphics
97+// scene mutation. A malformed command is reported after all complete events
98+// in the same input have been handled.
99+func (s *Session) Feed(data []byte) (Result, error) {
100+ if s == nil {
101+ return Result{}, ErrNoScene
102+ }
103+ s.mu.Lock()
104+ defer s.mu.Unlock()
105+ var result Result
106+ for _, event := range s.parser.Feed(data) {
107+ result.Events = append(result.Events, event)
108+ if event.Kind == EventError {
109+ s.upload = nil
110+ continue
111+ }
112+ if event.Kind != EventCommand {
113+ continue
114+ }
115+ responses, mutation, err := s.apply(event.Command)
116+ if len(responses) != 0 {
117+ result.Responses = append(result.Responses, responses...)
118+ }
119+ if mutation != nil {
120+ result.Mutations = append(result.Mutations, *mutation)
121+ }
122+ if err != nil {
123+ // Keep the error attached to the event as well as returning it
124+ // below. This makes mixed text/graphics feeds inspectable without
125+ // requiring callers to reconstruct command order.
126+ result.Events[len(result.Events)-1].Kind = EventError
127+ result.Events[len(result.Events)-1].Err = err
128+ }
129+ }
130+ var first error
131+ for _, event := range result.Events {
132+ if event.Kind == EventError && event.Err != nil {
133+ first = event.Err
134+ break
135+ }
136+ }
137+ return result, first
138+}
139+
140+// Write is an adapter-oriented alias for Feed.
141+func (s *Session) Write(data []byte) (Result, error) { return s.Feed(data) }
142+
143+// Handle is an adapter-oriented alias for Feed.
144+func (s *Session) Handle(data []byte) (Result, error) { return s.Feed(data) }
145+
146+// Finish flushes an incomplete parser sequence. It never mutates the scene.
147+func (s *Session) Finish() (Result, error) {
148+ if s == nil {
149+ return Result{}, ErrNoScene
150+ }
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ result := Result{Events: s.parser.Finish()}
154+ s.upload = nil
155+ if len(result.Events) != 0 {
156+ return result, result.Events[0].Err
157+ }
158+ return result, nil
159+}
160+
161+// Process applies one already parsed command. It is useful for callers that
162+// have their own stream framing but want this package's strict adapter.
163+func (s *Session) Process(command Command) (Result, error) {
164+ if s == nil {
165+ return Result{}, ErrNoScene
166+ }
167+ s.mu.Lock()
168+ defer s.mu.Unlock()
169+ responses, mutation, err := s.apply(command)
170+ result := Result{Responses: responses}
171+ if mutation != nil {
172+ result.Mutations = []Mutation{*mutation}
173+ }
174+ return result, err
175+}
176+
177+// SetPendingPlacement supplies the cursor-relative pixel origin for the next
178+// placement. The origin is adapter context, not a Kitty control field: Kitty
179+// X/Y remain pixel offsets within the cursor cell.
180+func (s *Session) SetPendingPlacement(x, y uint64) {
181+ if s == nil {
182+ return
183+ }
184+ s.mu.Lock()
185+ defer s.mu.Unlock()
186+ s.origin = placementOrigin{x: x, y: y, set: true}
187+ if s.upload != nil {
188+ s.upload.origin = s.origin
189+ }
190+}
191+
192+// AbortPendingUpload discards an in-flight chunked upload without changing
193+// committed images or placements. Screen-level full clears use it to ensure a
194+// continuation arriving after the clear cannot publish stale graphics.
195+func (s *Session) AbortPendingUpload() {
196+ if s == nil {
197+ return
198+ }
199+ s.mu.Lock()
200+ defer s.mu.Unlock()
201+ s.upload = nil
202+}
203+
204+func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
205+ c := command.Controls
206+ action := c.Action
207+ if !c.HasAction {
208+ action = ActionTransmit
209+ }
210+ if c.HasCompression && c.Compression != 0 {
211+ return s.failure(c, controlsImageID(c), ErrUnsupported)
212+ }
213+ if c.HasTransmission && c.Transmission != TransmissionDirect {
214+ return s.failure(c, controlsImageID(c), ErrUnsupported)
215+ }
216+ if action == ActionDelete {
217+ s.upload = nil
218+ } else if s.upload != nil && action != ActionTransmit && action != ActionTransmitDisplay {
219+ return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
220+ }
221+ switch action {
222+ case ActionTransmit, ActionTransmitDisplay:
223+ return s.transmit(command, action == ActionTransmitDisplay)
224+ case ActionPut:
225+ if len(command.Payload) != 0 {
226+ return s.failure(c, controlsImageID(c), ErrInvalidCommand)
227+ }
228+ return s.put(c)
229+ case ActionQuery:
230+ return s.query(command)
231+ case ActionDelete:
232+ if len(command.Payload) != 0 {
233+ return s.failure(c, controlsImageID(c), ErrInvalidCommand)
234+ }
235+ return s.delete(c)
236+ case ActionFrame, ActionCompose:
237+ return s.failure(c, controlsImageID(c), ErrUnsupported)
238+ default:
239+ return s.failure(c, controlsImageID(c), ErrUnknownAction)
240+ }
241+}
242+
243+func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {
244+ c := command.Controls
245+ imageID := c.ImageID
246+ if !c.HasImageID || imageID == 0 {
247+ imageID = 0
248+ if c.HasImageNumber {
249+ imageID = s.nextImageID()
250+ }
251+ }
252+ if s.upload != nil {
253+ if !continuationControlsAllowed(c, s.upload.controls) {
254+ return s.failure(c, responseImageID(c), ErrInterleavedUpload)
255+ }
256+ if c.HasImageID && c.ImageID != s.upload.imageID {
257+ return s.failure(c, c.ImageID, ErrInterleavedUpload)
258+ }
259+ if c.HasImageNumber && (!s.upload.controls.HasImageNumber || c.ImageNumber != s.upload.controls.ImageNumber) {
260+ return s.failure(c, responseImageID(c), ErrInterleavedUpload)
261+ }
262+ imageID = s.upload.imageID
263+ if s.upload.chunks >= s.limits.MaxChunks {
264+ return s.abortUpload(c, imageID, ErrTooManyChunks)
265+ }
266+ if uint64(len(s.upload.payload))+uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
267+ return s.abortUpload(c, imageID, ErrPayloadTooLarge)
268+ }
269+ s.upload.payload = append(s.upload.payload, command.Payload...)
270+ s.upload.chunks++
271+ s.upload.controls = mergeControls(s.upload.controls, c)
272+ if c.HasMore && c.More == 1 {
273+ return nil, nil, nil
274+ }
275+ complete := *s.upload
276+ s.upload = nil
277+ complete.controls = mergeControls(complete.controls, c)
278+ return s.commitUpload(complete, complete.display)
279+ }
280+
281+ if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
282+ return s.failure(c, imageID, ErrPayloadTooLarge)
283+ }
284+ if c.HasMore && c.More == 1 {
285+ s.upload = &upload{
286+ controls: c,
287+ imageID: imageID,
288+ payload: append([]byte(nil), command.Payload...),
289+ chunks: 1,
290+ display: display,
291+ origin: s.origin,
292+ }
293+ return nil, nil, nil
294+ }
295+ return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1, display: display, origin: s.origin}, display)
296+}
297+
298+func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
299+ if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes {
300+ return s.failure(value.controls, value.imageID, ErrPayloadTooLarge)
301+ }
302+ decoded, err := DecodeBase64(value.payload)
303+ if err != nil {
304+ return s.failure(value.controls, value.imageID, err)
305+ }
306+ format := value.controls.Format
307+ if !value.controls.HasFormat {
308+ format = FormatRGBA
309+ }
310+ width, height, err := imageGeometry(decoded, format, value.controls, s.limits)
311+ if err != nil {
312+ return s.failure(value.controls, value.imageID, err)
313+ }
314+ if s.scene == nil {
315+ return s.failure(value.controls, value.imageID, ErrNoScene)
316+ }
317+ persistent := value.controls.HasImageID && value.controls.ImageID != 0 || value.controls.HasImageNumber
318+ old, replacing := s.images[value.imageID]
319+ if !persistent {
320+ replacing = false
321+ }
322+ if !replacing && s.scene.Usage().Assets >= s.limits.MaxImages {
323+ return s.failure(value.controls, value.imageID, graphics.ErrTooManyAssets)
324+ }
325+ if replacing && display {
326+ // Validate the prospective placement before replacing the asset. A
327+ // rejected T command must leave both the old asset and its placements
328+ // available for subsequent use.
329+ if err := s.validateReplacementPlacement(value.controls, old, width, height); err != nil {
330+ return s.failure(value.controls, value.imageID, err)
331+ }
332+ }
333+ var assetID graphics.AssetID
334+ if replacing {
335+ // Scene replacement is copy-on-write: a rejected new asset leaves the
336+ // old asset and every placement that references it untouched.
337+ assetID, err = s.scene.ReplaceAsset(old, graphics.AssetBlob{
338+ Encoded: decoded,
339+ Format: assetFormat(format),
340+ Width: width,
341+ Height: height,
342+ })
343+ } else {
344+ assetID, err = s.scene.AddAsset(graphics.AssetBlob{
345+ Encoded: decoded,
346+ Format: assetFormat(format),
347+ Width: width,
348+ Height: height,
349+ })
350+ }
351+ if err != nil {
352+ return s.failure(value.controls, value.imageID, err)
353+ }
354+ if replacing {
355+ s.removeMappingsForAsset(old)
356+ }
357+ if persistent {
358+ s.images[value.imageID] = assetID
359+ }
360+ if value.controls.HasImageNumber {
361+ s.imageNumbers[value.controls.ImageNumber] = value.imageID
362+ }
363+ childID := s.nextChild
364+ s.nextChild++
365+ s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}
366+ mutation := &Mutation{Kind: MutationUpload, ImageID: value.imageID, ChildID: childID, AssetID: assetID}
367+ if display {
368+ responses, placementMutation, placeErr := s.place(value.controls, value.imageID, assetID, value.origin)
369+ if placementMutation != nil {
370+ mutation.Kind = MutationPlacement
371+ mutation.PlacementID = placementMutation.PlacementID
372+ mutation.ScenePlacementID = placementMutation.ScenePlacementID
373+ }
374+ if placeErr != nil {
375+ // T (transmit and display) is one protocol operation. Do not
376+ // leave an uploaded asset behind when its placement is rejected.
377+ _ = s.scene.RemoveAssetCascade(assetID)
378+ if persistent {
379+ delete(s.images, value.imageID)
380+ }
381+ if value.controls.HasImageNumber && s.imageNumbers[value.controls.ImageNumber] == value.imageID {
382+ delete(s.imageNumbers, value.controls.ImageNumber)
383+ }
384+ delete(s.children, childID)
385+ failureResponses, _, failureErr := s.failure(value.controls, value.imageID, placeErr)
386+ return failureResponses, nil, failureErr
387+ }
388+ return s.success(value.controls, value.imageID, responses...), mutation, nil
389+ }
390+ return s.success(value.controls, value.imageID), mutation, nil
391+}
392+
393+func assetFormat(format Format) graphics.AssetFormat {
394+ switch format {
395+ case FormatRGB:
396+ return graphics.AssetFormatRGB
397+ case FormatRGBA:
398+ return graphics.AssetFormatRGBA
399+ case FormatPNG:
400+ return graphics.AssetFormatPNG
401+ default:
402+ return graphics.AssetFormatUnknown
403+ }
404+}
405+
406+func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64, int64, error) {
407+ if format == FormatPNG {
408+ config, err := png.DecodeConfig(bytes.NewReader(data))
409+ if err != nil {
410+ return 0, 0, fmt.Errorf("%w: %v", ErrInvalidPNG, err)
411+ }
412+ if config.Width <= 0 || config.Height <= 0 {
413+ return 0, 0, ErrInvalidPNG
414+ }
415+ if uint64(config.Width) > limits.MaxDimension || uint64(config.Height) > limits.MaxDimension {
416+ return 0, 0, ErrPayloadTooLarge
417+ }
418+ pixels, ok := checkedProduct(uint64(config.Width), uint64(config.Height))
419+ decodedBytes, bytesOK := checkedProduct(pixels, 4)
420+ if !ok || !bytesOK || pixels > limits.MaxDecodedPixels || decodedBytes > limits.MaxDecodedBytes {
421+ return 0, 0, ErrPayloadTooLarge
422+ }
423+ if c.HasWidth && c.Width != uint64(config.Width) || c.HasHeight && c.Height != uint64(config.Height) {
424+ return 0, 0, fmt.Errorf("%w: PNG dimensions do not match controls", ErrInvalidPNG)
425+ }
426+ // Decode, rather than only DecodeConfig, so malformed/truncated IDAT
427+ // data and CRC failures are rejected by the standard library.
428+ if _, err := png.Decode(bytes.NewReader(data)); err != nil {
429+ return 0, 0, fmt.Errorf("%w: %v", ErrInvalidPNG, err)
430+ }
431+ return int64(config.Width), int64(config.Height), nil
432+ }
433+ if !format.Valid() {
434+ return 0, 0, ErrUnsupported
435+ }
436+ if !c.HasWidth || !c.HasHeight || c.Width == 0 || c.Height == 0 {
437+ return 0, 0, fmt.Errorf("%w: raw format needs s and v", ErrInvalidCommand)
438+ }
439+ if c.Width > limits.MaxDimension || c.Height > limits.MaxDimension {
440+ return 0, 0, ErrPayloadTooLarge
441+ }
442+ pixels, ok := checkedProduct(c.Width, c.Height)
443+ if !ok || pixels > limits.MaxDecodedPixels {
444+ return 0, 0, ErrPayloadTooLarge
445+ }
446+ channels := uint64(3)
447+ if format == FormatRGBA {
448+ channels = 4
449+ }
450+ rowBytes, ok := checkedProduct(c.Height, channels)
451+ if !ok {
452+ return 0, 0, ErrPayloadTooLarge
453+ }
454+ expected, ok := checkedProduct(c.Width, rowBytes)
455+ if !ok {
456+ return 0, 0, ErrPayloadTooLarge
457+ }
458+ if uint64(len(data)) != expected {
459+ return 0, 0, fmt.Errorf("%w: raw payload has %d bytes, want %d", ErrInvalidCommand, len(data), expected)
460+ }
461+ return int64(c.Width), int64(c.Height), nil
462+}
463+
464+func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
465+ imageID, assetID, ok := s.resolveImage(c)
466+ if !ok {
467+ return s.failure(c, responseImageID(c), ErrImageNotFound)
468+ }
469+ responses, mutation, err := s.place(c, imageID, assetID, s.origin)
470+ if err != nil {
471+ failureResponses, _, failureErr := s.failure(c, imageID, err)
472+ return failureResponses, nil, failureErr
473+ }
474+ return s.success(c, imageID, responses...), mutation, nil
475+}
476+
477+func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID, width, height int64) error {
478+ if _, err := placementSpec(c, old, width, height, s.origin); err != nil {
479+ return err
480+ }
481+ snapshot := s.scene.Snapshot()
482+ removedScene := uint64(0)
483+ for _, placement := range snapshot.Placements() {
484+ if placement.AssetID() == old {
485+ removedScene++
486+ }
487+ }
488+ removedSession := uint64(0)
489+ for _, mapped := range s.placements {
490+ placement, ok := snapshot.Placement(mapped)
491+ if !ok || placement.AssetID() == old {
492+ removedSession++
493+ }
494+ }
495+ projectedSession := uint64(len(s.placements)) - removedSession
496+ projectedScene := snapshot.Usage().Placements - removedScene
497+ needsSlot := true
498+ if c.HasPlacementID && c.PlacementID != 0 {
499+ if mapped, ok := s.placements[placementKey{imageID: c.ImageID, placementID: c.PlacementID}]; ok {
500+ if placement, ok := snapshot.Placement(mapped); ok && placement.AssetID() != old {
501+ needsSlot = false
502+ }
503+ }
504+ }
505+ if needsSlot && projectedSession >= s.limits.MaxPlacements {
506+ return graphics.ErrTooManyPlacements
507+ }
508+ if needsSlot && projectedScene >= s.scene.Limits().MaxPlacements {
509+ return graphics.ErrTooManyPlacements
510+ }
511+ if s.scene.Generation() >= ^uint64(0)-1 {
512+ return graphics.ErrGenerationOverflow
513+ }
514+ return nil
515+}
516+
517+func placementSpec(c Controls, assetID graphics.AssetID, width, height int64, origin placementOrigin) (graphics.PlacementSpec, error) {
518+ source := graphics.PixelRect{Width: width, Height: height}
519+ var err error
520+ if source.X, err = checkedCoordinate(c.X, c.HasX); err != nil {
521+ return graphics.PlacementSpec{}, err
522+ }
523+ if source.Y, err = checkedCoordinate(c.Y, c.HasY); err != nil {
524+ return graphics.PlacementSpec{}, err
525+ }
526+ if source.Width, err = checkedDimension(c.SourceWidth, c.HasSourceWidth, width); err != nil {
527+ return graphics.PlacementSpec{}, err
528+ }
529+ if source.Height, err = checkedDimension(c.SourceHeight, c.HasSourceHeight, height); err != nil {
530+ return graphics.PlacementSpec{}, err
531+ }
532+ offsetX, err := checkedCoordinate(c.SourceX, c.HasSourceX)
533+ if err != nil {
534+ return graphics.PlacementSpec{}, err
535+ }
536+ offsetY, err := checkedCoordinate(c.SourceY, c.HasSourceY)
537+ if err != nil {
538+ return graphics.PlacementSpec{}, err
539+ }
540+ destination := graphics.PixelRect{X: offsetX, Y: offsetY, Width: source.Width, Height: source.Height}
541+ if origin.set {
542+ if origin.x > math.MaxInt64 || origin.y > math.MaxInt64 || destination.X > math.MaxInt64-int64(origin.x) || destination.Y > math.MaxInt64-int64(origin.y) {
543+ return graphics.PlacementSpec{}, ErrIntegerOverflow
544+ }
545+ destination.X += int64(origin.x)
546+ destination.Y += int64(origin.y)
547+ }
548+ if !source.Valid() || !destination.Valid() {
549+ return graphics.PlacementSpec{}, graphics.ErrInvalidRect
550+ }
551+ assetBounds := graphics.PixelRect{Width: width, Height: height}
552+ clipped, ok := assetBounds.Intersect(source)
553+ if !ok || clipped != source {
554+ return graphics.PlacementSpec{}, graphics.ErrInvalidPlacement
555+ }
556+ var cells graphics.CellRect
557+ if c.HasColumns || c.HasRows {
558+ if !c.HasColumns || !c.HasRows || c.Columns == 0 || c.Rows == 0 || c.Columns > math.MaxInt64 || c.Rows > math.MaxInt64 {
559+ return graphics.PlacementSpec{}, fmt.Errorf("%w: c/r", ErrInvalidCommand)
560+ }
561+ cells = graphics.CellRect{X: signedCoordinateInt(c.CellOffsetX, c.HasCellOffsetX), Y: signedCoordinateInt(c.CellOffsetY, c.HasCellOffsetY), Width: int64(c.Columns), Height: int64(c.Rows)}
562+ if !cells.Valid() {
563+ return graphics.PlacementSpec{}, graphics.ErrInvalidRect
564+ }
565+ }
566+ spec := graphics.PlacementSpec{Asset: assetID, Source: source, Destination: destination, Cells: cells, HasCells: c.HasColumns || c.HasRows}
567+ if c.HasLayer {
568+ spec.Layer = c.Layer
569+ spec.HasLayer = true
570+ }
571+ return spec, nil
572+}
573+
574+func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {
575+ if c.HasParent || c.HasCellOffsetX || c.HasCellOffsetY {
576+ return nil, nil, ErrUnsupported
577+ }
578+ if s.scene == nil {
579+ return nil, nil, ErrNoScene
580+ }
581+ asset, ok := s.scene.Snapshot().Asset(assetID)
582+ if !ok {
583+ return nil, nil, ErrImageNotFound
584+ }
585+ spec, err := placementSpec(c, assetID, asset.Width(), asset.Height(), origin)
586+ if err != nil {
587+ return nil, nil, err
588+ }
589+ placementID := c.PlacementID
590+ if imageID == 0 || placementID == 0 {
591+ var ok bool
592+ placementID, ok = s.takePlacementID(imageID)
593+ if !ok {
594+ return nil, nil, graphics.ErrIdentifierOverflow
595+ }
596+ }
597+ key := placementKey{imageID: imageID, placementID: placementID}
598+ if existing, ok := s.placements[key]; ok {
599+ if err := s.scene.UpdatePlacement(existing, spec); err != nil {
600+ return nil, nil, err
601+ }
602+ return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: existing}, nil
603+ }
604+ if uint64(len(s.placements)) >= s.limits.MaxPlacements {
605+ return nil, nil, graphics.ErrTooManyPlacements
606+ }
607+ sceneID, err := s.scene.Place(spec)
608+ if err != nil {
609+ return nil, nil, err
610+ }
611+ s.placements[key] = sceneID
612+ return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: sceneID}, nil
613+}
614+
615+func (s *Session) query(command Command) ([][]byte, *Mutation, error) {
616+ c := command.Controls
617+ if len(command.Payload) == 0 {
618+ return s.failure(c, responseImageID(c), ErrInvalidCommand)
619+ }
620+ decoded, err := DecodeBase64(command.Payload)
621+ if err != nil {
622+ return s.failure(c, responseImageID(c), err)
623+ }
624+ format := c.Format
625+ if !c.HasFormat {
626+ format = FormatRGBA
627+ }
628+ if _, _, err := imageGeometry(decoded, format, c, s.limits); err != nil {
629+ return s.failure(c, responseImageID(c), err)
630+ }
631+ return s.success(c, responseImageID(c)), nil, nil
632+}
633+
634+func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
635+ target := c.Delete
636+ if !c.HasDelete {
637+ target = DeleteAll
638+ }
639+ id, asset, selected := s.resolveImage(c)
640+ responseID := responseImageID(c)
641+ switch target {
642+ case DeleteImage:
643+ if !selected || s.scene == nil {
644+ return s.failure(c, responseID, ErrImageNotFound)
645+ }
646+ keys := s.placementKeysForAsset(asset)
647+ if c.HasPlacementID && c.PlacementID != 0 {
648+ keys = []placementKey{{imageID: id, placementID: c.PlacementID}}
649+ }
650+ if err := s.removePlacements(keys); err != nil {
651+ return s.failure(c, id, err)
652+ }
653+ return s.success(c, id), &Mutation{Kind: MutationDeletePlacements, ImageID: id, AssetID: asset}, nil
654+ case DeleteImageNumber:
655+ if !selected {
656+ return s.failure(c, responseID, ErrImageNotFound)
657+ }
658+ if s.scene == nil {
659+ return s.failure(c, id, ErrNoScene)
660+ }
661+ s.removeMappingsForAsset(asset)
662+ if err := s.scene.RemoveAssetCascade(asset); err != nil {
663+ return s.failure(c, id, err)
664+ }
665+ return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil
666+ case DeleteAll:
667+ if s.scene == nil {
668+ return s.failure(c, responseID, ErrNoScene)
669+ }
670+ placementIDs := make([]placementKey, 0, len(s.placements))
671+ for placementID := range s.placements {
672+ placementIDs = append(placementIDs, placementID)
673+ }
674+ if err := s.removePlacements(placementIDs); err != nil {
675+ return s.failure(c, responseID, err)
676+ }
677+ return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacements}, nil
678+ case DeleteCell, DeleteCellAll:
679+ // Kitty d=p/P deletes by cell intersection, not by placement ID or
680+ // all placements. Those selectors remain unsupported until the scene
681+ // tracks terminal cell anchors.
682+ return s.failure(c, responseID, ErrUnsupported)
683+ case DeleteAllImages:
684+ if s.scene == nil {
685+ return s.failure(c, responseID, ErrNoScene)
686+ }
687+ if err := s.scene.Clear(); err != nil {
688+ return s.failure(c, responseID, err)
689+ }
690+ s.images = make(map[uint64]graphics.AssetID)
691+ s.imageNumbers = make(map[uint64]uint64)
692+ s.placements = make(map[placementKey]graphics.PlacementID)
693+ s.children = make(map[uint64]Child)
694+ return s.success(c, responseID), &Mutation{Kind: MutationClear}, nil
695+ default:
696+ return s.failure(c, responseID, ErrUnsupported)
697+ }
698+}
699+
700+func (s *Session) placementKeysForAsset(asset graphics.AssetID) []placementKey {
701+ if s.scene == nil {
702+ return nil
703+ }
704+ snapshot := s.scene.Snapshot()
705+ keys := make([]placementKey, 0)
706+ for key, mapped := range s.placements {
707+ placement, ok := snapshot.Placement(mapped)
708+ if ok && placement.AssetID() == asset {
709+ keys = append(keys, key)
710+ }
711+ }
712+ return keys
713+}
714+
715+func (s *Session) removePlacements(ids []placementKey) error {
716+ if len(ids) == 0 {
717+ return nil
718+ }
719+ operations := make([]graphics.Operation, 0, len(ids))
720+ for _, id := range ids {
721+ mapped, ok := s.placements[id]
722+ if !ok {
723+ continue
724+ }
725+ operations = append(operations, graphics.Operation{
726+ Kind: graphics.OperationRemovePlacement,
727+ PlacementID: mapped,
728+ })
729+ }
730+ if len(operations) == 0 {
731+ return nil
732+ }
733+ if err := s.scene.ApplyOperations(operations); err != nil {
734+ return err
735+ }
736+ for _, id := range ids {
737+ delete(s.placements, id)
738+ }
739+ return nil
740+}
741+
742+func (s *Session) removeMappingsForAsset(asset graphics.AssetID) {
743+ for imageID, mapped := range s.images {
744+ if mapped == asset {
745+ delete(s.images, imageID)
746+ }
747+ }
748+ for imageNumber, imageID := range s.imageNumbers {
749+ if mapped, ok := s.images[imageID]; !ok || mapped == asset {
750+ delete(s.imageNumbers, imageNumber)
751+ }
752+ }
753+ if s.scene != nil {
754+ snapshot := s.scene.Snapshot()
755+ for placementID, mapped := range s.placements {
756+ placement, ok := snapshot.Placement(mapped)
757+ if !ok || placement.AssetID() == asset {
758+ delete(s.placements, placementID)
759+ }
760+ }
761+ }
762+ for childID, child := range s.children {
763+ if child.AssetID == asset {
764+ delete(s.children, childID)
765+ }
766+ }
767+}
768+
769+func continuationControlsAllowed(current, initial Controls) bool {
770+ if current.HasAction {
771+ initialAction := initial.Action
772+ if !initial.HasAction {
773+ initialAction = ActionTransmit
774+ }
775+ if current.Action != initialAction {
776+ return false
777+ }
778+ }
779+ return !current.HasCompression && !current.HasFormat && !current.HasImageID &&
780+ !current.HasImageNumber && !current.HasWidth && !current.HasHeight &&
781+ !current.HasColumns && !current.HasRows && !current.HasX && !current.HasY &&
782+ !current.HasSourceWidth && !current.HasSourceHeight && !current.HasLayer &&
783+ !current.HasPlacementID && !current.HasDelete && !current.HasTransmission &&
784+ !current.HasCursor && !current.HasSourceX && !current.HasSourceY &&
785+ !current.HasCellOffsetX && !current.HasCellOffsetY && !current.HasParent
786+}
787+
788+func (s *Session) resolveImage(c Controls) (uint64, graphics.AssetID, bool) {
789+ if c.HasImageID {
790+ asset, ok := s.images[c.ImageID]
791+ return c.ImageID, asset, ok
792+ }
793+ if c.HasImageNumber {
794+ imageID, ok := s.imageNumbers[c.ImageNumber]
795+ if !ok {
796+ return 0, graphics.AssetID{}, false
797+ }
798+ asset, ok := s.images[imageID]
799+ return imageID, asset, ok
800+ }
801+ return 0, graphics.AssetID{}, false
802+}
803+
804+func responseImageID(c Controls) uint64 {
805+ if c.HasImageID {
806+ return c.ImageID
807+ }
808+ return 0
809+}
810+
811+func controlsImageID(c Controls) uint64 { return responseImageID(c) }
812+
813+func (s *Session) nextImageID() uint64 {
814+ for id := uint64(1); ; id++ {
815+ if _, ok := s.images[id]; !ok {
816+ return id
817+ }
818+ }
819+}
820+
821+func (s *Session) takePlacementID(imageID uint64) (uint64, bool) {
822+ for s.nextPlacement != 0 && s.nextPlacement <= MaxKittyID {
823+ id := s.nextPlacement
824+ if s.nextPlacement == MaxKittyID {
825+ s.nextPlacement = 0
826+ } else {
827+ s.nextPlacement++
828+ }
829+ if _, exists := s.placements[placementKey{imageID: imageID, placementID: id}]; !exists {
830+ return id, true
831+ }
832+ }
833+ return 0, false
834+}
835+
836+func checkedProduct(left, right uint64) (uint64, bool) {
837+ if left != 0 && right > math.MaxUint64/left {
838+ return 0, false
839+ }
840+ return left * right, true
841+}
842+
843+func checkedCoordinate(n uint64, present bool) (int64, error) {
844+ if !present {
845+ return 0, nil
846+ }
847+ if n > math.MaxInt64 {
848+ return 0, ErrIntegerOverflow
849+ }
850+ return int64(n), nil
851+}
852+
853+func checkedDimension(n uint64, present bool, fallback int64) (int64, error) {
854+ if !present {
855+ return fallback, nil
856+ }
857+ if n > math.MaxInt64 {
858+ return 0, ErrIntegerOverflow
859+ }
860+ return int64(n), nil
861+}
862+
863+func signedCoordinateInt(n int64, present bool) int64 {
864+ if !present {
865+ return 0
866+ }
867+ return n
868+}
869+
870+func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte {
871+ responses := prior
872+ if c.Quiet != QuietSuccess && c.Quiet != QuietAll {
873+ response := makeControlResponse(c, imageID, "OK")
874+ if uint64(len(response)) <= s.limits.MaxResponseBytes {
875+ responses = append(responses, response)
876+ }
877+ }
878+ return responses
879+}
880+
881+func makeControlResponse(c Controls, imageID uint64, code string) []byte {
882+ if !c.HasImageNumber {
883+ return MakeResponse(imageID, code)
884+ }
885+ if code == "" {
886+ code = "OK"
887+ }
888+ return []byte(responsePrefix + "i=" + strconv.FormatUint(imageID, 10) + ",I=" + strconv.FormatUint(c.ImageNumber, 10) + ";" + code + responseSuffix)
889+}
890+
891+func (s *Session) failure(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
892+ if err == nil {
893+ err = ErrInvalidCommand
894+ }
895+ if c.Quiet != QuietAll {
896+ code := errorCode(err)
897+ response := makeControlResponse(c, imageID, code)
898+ if uint64(len(response)) <= s.limits.MaxResponseBytes {
899+ return [][]byte{response}, nil, err
900+ }
901+ }
902+ return nil, nil, err
903+}
904+
905+func (s *Session) abortUpload(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
906+ s.upload = nil
907+ return s.failure(c, imageID, err)
908+}
909+
910+func errorCode(err error) string {
911+ switch {
912+ case err == nil:
913+ return "OK"
914+ case errorsIs(err, ErrImageNotFound), errorsIs(err, ErrPlacementNotFound), errorsIs(err, graphics.ErrAssetNotFound), errorsIs(err, graphics.ErrPlacementNotFound):
915+ return "ENOENT"
916+ case errorsIs(err, ErrPayloadTooLarge), errorsIs(err, ErrAPCTooLarge), errorsIs(err, graphics.ErrEncodedBudget), errorsIs(err, graphics.ErrDecodedPixelBudget):
917+ return "E2BIG"
918+ case errorsIs(err, ErrUnsupported), errorsIs(err, ErrUnknownAction):
919+ return "ENOTSUP"
920+ default:
921+ return "EINVAL"
922+ }
923+}
924+
925+// errorsIs is kept local to make error-code selection explicit and avoid
926+// leaking any protocol error implementation details in the public API.
927+func errorsIs(err, target error) bool {
928+ for err != nil {
929+ if err == target {
930+ return true
931+ }
932+ type unwrapper interface{ Unwrap() error }
933+ u, ok := err.(unwrapper)
934+ if !ok {
935+ return false
936+ }
937+ err = u.Unwrap()
938+ }
939+ return false
940+}
941+
942+func mergeControls(base, override Controls) Controls {
943+ out := base
944+ if override.HasAction {
945+ out.Action, out.HasAction = override.Action, true
946+ }
947+ if override.HasCompression {
948+ out.Compression, out.HasCompression = override.Compression, true
949+ }
950+ if override.HasFormat {
951+ out.Format, out.HasFormat = override.Format, true
952+ }
953+ if override.HasImageID {
954+ out.ImageID, out.HasImageID = override.ImageID, true
955+ }
956+ if override.HasImageNumber {
957+ out.ImageNumber, out.HasImageNumber = override.ImageNumber, true
958+ }
959+ if override.HasMore {
960+ out.More, out.HasMore = override.More, true
961+ }
962+ if override.HasQuiet {
963+ out.Quiet, out.HasQuiet = override.Quiet, true
964+ }
965+ if override.HasWidth {
966+ out.Width, out.HasWidth = override.Width, true
967+ }
968+ if override.HasHeight {
969+ out.Height, out.HasHeight = override.Height, true
970+ }
971+ if override.HasColumns {
972+ out.Columns, out.HasColumns = override.Columns, true
973+ }
974+ if override.HasRows {
975+ out.Rows, out.HasRows = override.Rows, true
976+ }
977+ if override.HasX {
978+ out.X, out.HasX = override.X, true
979+ }
980+ if override.HasY {
981+ out.Y, out.HasY = override.Y, true
982+ }
983+ if override.HasSourceWidth {
984+ out.SourceWidth, out.HasSourceWidth = override.SourceWidth, true
985+ }
986+ if override.HasSourceHeight {
987+ out.SourceHeight, out.HasSourceHeight = override.SourceHeight, true
988+ }
989+ if override.HasLayer {
990+ out.Layer, out.HasLayer = override.Layer, true
991+ }
992+ if override.HasPlacementID {
993+ out.PlacementID, out.HasPlacementID = override.PlacementID, true
994+ }
995+ if override.HasDelete {
996+ out.Delete, out.HasDelete = override.Delete, true
997+ }
998+ if override.HasTransmission {
999+ out.Transmission, out.HasTransmission = override.Transmission, true
1000+ }
1001+ if override.HasCursor {
1002+ out.Cursor, out.HasCursor = override.Cursor, true
1003+ }
1004+ if override.HasSourceX {
1005+ out.SourceX, out.HasSourceX = override.SourceX, true
1006+ }
1007+ if override.HasSourceY {
1008+ out.SourceY, out.HasSourceY = override.SourceY, true
1009+ }
1010+ if override.HasCellOffsetX {
1011+ out.CellOffsetX, out.HasCellOffsetX = override.CellOffsetX, true
1012+ }
1013+ if override.HasCellOffsetY {
1014+ out.CellOffsetY, out.HasCellOffsetY = override.CellOffsetY, true
1015+ }
1016+ if override.HasParent {
1017+ out.Parent, out.HasParent = override.Parent, true
1018+ }
1019+ return out
1020+}
A protocol/kittygraphics/testdata/kitten-icat-stream-chunk.bin +1 -0 too large to show in the browser

too large to show in the browser view raw

A protocol/terminalquery/query.go +213 -0
@@ -0,0 +1,213 @@
1+// Package terminalquery provides bounded parsers for terminal capability
2+// probes. Probe responses are terminal input, not application input: callers
3+// can remove only the two exact responses while replaying every unrelated byte.
4+package terminalquery
5+
6+import "bytes"
7+
8+const (
9+ // KittyGraphicsQuery asks a terminal whether it understands the Kitty
10+ // graphics protocol. The image id is deliberately reserved for probing and
11+ // the 1×1 RGB payload is validated without being retained by a=q.
12+ KittyGraphicsQuery = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\"
13+ // DeviceAttributesQuery is the primary DA1 query. It is paired with the
14+ // Kitty query so a graphics declaration requires both terminal identity and
15+ // protocol support.
16+ DeviceAttributesQuery = "\x1b[c"
17+
18+ maxProbeResponseBytes = 256
19+)
20+
21+// Probe incrementally recognizes the bounded responses to KittyGraphicsQuery
22+// and DeviceAttributesQuery. Feed returns bytes that are not recognized probe
23+// responses in their original order. A response may be split across any
24+// number of calls.
25+type Probe struct {
26+ pending []byte
27+ kitty bool
28+ da1 bool
29+}
30+
31+// Feed consumes one terminal input fragment and returns unrelated input. The
32+// returned slice is owned by the Probe and is invalidated by the next call.
33+func (p *Probe) Feed(data []byte) []byte {
34+ if p == nil || len(data) == 0 {
35+ return append([]byte(nil), data...)
36+ }
37+ // Never retain or copy an unbounded caller fragment into pending. scan
38+ // leaves at most a bounded incomplete probe prefix, so feeding bounded
39+ // chunks also prevents a small suffix from retaining a large backing array.
40+ var unrelated []byte
41+ for len(data) != 0 {
42+ n := min(len(data), maxProbeResponseBytes+2)
43+ p.pending = append(p.pending, data[:n]...)
44+ unrelated = append(unrelated, p.scan(false)...)
45+ data = data[n:]
46+ }
47+ return unrelated
48+}
49+
50+// Finish flushes an incomplete or unrelated prefix without recognizing any
51+// further response. It is used when the bounded probe deadline expires.
52+func (p *Probe) Finish() []byte {
53+ if p == nil {
54+ return nil
55+ }
56+ return p.scan(true)
57+}
58+
59+// Ready reports whether both independent capability responses were observed.
60+func (p *Probe) Ready() bool { return p != nil && p.kitty && p.da1 }
61+
62+// KittyGraphics reports whether the Kitty graphics response was observed.
63+func (p *Probe) KittyGraphics() bool { return p != nil && p.kitty }
64+
65+// DA1 reports whether a primary device-attributes response was observed.
66+func (p *Probe) DA1() bool { return p != nil && p.da1 }
67+
68+func (p *Probe) scan(flush bool) []byte {
69+ var unrelated []byte
70+ for len(p.pending) != 0 {
71+ kittyStart := bytes.Index(p.pending, []byte("\x1b_G"))
72+ da1Start := bytes.Index(p.pending, []byte("\x1b["))
73+ start := -1
74+ if kittyStart >= 0 {
75+ start = kittyStart
76+ }
77+ if da1Start >= 0 && (start < 0 || da1Start < start) {
78+ start = da1Start
79+ }
80+ if start < 0 {
81+ if flush || len(p.pending) > 2 {
82+ keep := 0
83+ if !flush {
84+ keep = suffixPrefixLen(p.pending)
85+ }
86+ if len(p.pending) > keep {
87+ unrelated = append(unrelated, p.pending[:len(p.pending)-keep]...)
88+ p.pending = p.pending[len(p.pending)-keep:]
89+ }
90+ }
91+ p.retainPending()
92+ break
93+ }
94+ if start > 0 {
95+ unrelated = append(unrelated, p.pending[:start]...)
96+ p.pending = p.pending[start:]
97+ }
98+ if p.pending[1] == '_' {
99+ end, complete := probeTerminator(p.pending)
100+ if !complete {
101+ if flush || len(p.pending) > maxProbeResponseBytes {
102+ unrelated = append(unrelated, p.pending[0])
103+ p.pending = p.pending[1:]
104+ continue
105+ }
106+ p.retainPending()
107+ break
108+ }
109+ response := p.pending[:end]
110+ if validKittyResponse(response) {
111+ p.kitty = true
112+ p.pending = p.pending[end:]
113+ continue
114+ }
115+ unrelated = append(unrelated, p.pending[0])
116+ p.pending = p.pending[1:]
117+ continue
118+ }
119+ end, complete := da1Terminator(p.pending)
120+ if !complete {
121+ if flush || len(p.pending) > maxProbeResponseBytes {
122+ unrelated = append(unrelated, p.pending[0])
123+ p.pending = p.pending[1:]
124+ continue
125+ }
126+ p.retainPending()
127+ break
128+ }
129+ response := p.pending[:end]
130+ if validDA1Response(response) {
131+ p.da1 = true
132+ p.pending = p.pending[end:]
133+ continue
134+ }
135+ unrelated = append(unrelated, p.pending[0])
136+ p.pending = p.pending[1:]
137+ }
138+ return unrelated
139+}
140+
141+func (p *Probe) retainPending() {
142+ if len(p.pending) == 0 {
143+ p.pending = nil
144+ return
145+ }
146+ p.pending = append([]byte(nil), p.pending...)
147+}
148+
149+func probeTerminator(data []byte) (int, bool) {
150+ for i := 3; i+1 < len(data) && i <= maxProbeResponseBytes; i++ {
151+ if data[i] == '\x1b' && data[i+1] == '\\' {
152+ return i + 2, true
153+ }
154+ }
155+ return 0, false
156+}
157+
158+func da1Terminator(data []byte) (int, bool) {
159+ for i := 2; i < len(data) && i <= maxProbeResponseBytes; i++ {
160+ if data[i] >= 0x40 && data[i] <= 0x7e {
161+ return i + 1, true
162+ }
163+ }
164+ return 0, false
165+}
166+
167+func validKittyResponse(data []byte) bool {
168+ return bytes.Equal(data, []byte("\x1b_Gi=31;OK\x1b\\"))
169+}
170+
171+func validDA1Response(data []byte) bool {
172+ if len(data) < 5 || !bytes.HasPrefix(data, []byte("\x1b[")) || data[len(data)-1] != 'c' || data[2] != '?' || len(data) > maxProbeResponseBytes {
173+ return false
174+ }
175+ // DA1 is a primary response. Secondary DA (ESC[>...c) is deliberately
176+ // not sufficient for a direct graphics declaration. The parameter body is
177+ // a non-empty semicolon-separated list of decimal numbers.
178+ body := data[3 : len(data)-1]
179+ if len(body) == 0 || body[0] == ';' || body[len(body)-1] == ';' {
180+ return false
181+ }
182+ previousSeparator := false
183+ for _, b := range body {
184+ switch {
185+ case b >= '0' && b <= '9':
186+ previousSeparator = false
187+ case b == ';':
188+ if previousSeparator {
189+ return false
190+ }
191+ previousSeparator = true
192+ default:
193+ return false
194+ }
195+ }
196+ return !previousSeparator
197+}
198+
199+func suffixPrefixLen(data []byte) int {
200+ for n := min(len(data), 2); n > 0; n-- {
201+ if bytes.HasPrefix([]byte("\x1b_G"), data[len(data)-n:]) || bytes.HasPrefix([]byte("\x1b["), data[len(data)-n:]) {
202+ return n
203+ }
204+ }
205+ return 0
206+}
207+
208+func min(a, b int) int {
209+ if a < b {
210+ return a
211+ }
212+ return b
213+}
A protocol/terminalquery/query_test.go +82 -0
@@ -0,0 +1,82 @@
1+package terminalquery
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestProbeRequiresKittyAndDA1(t *testing.T) {
10+ for _, tc := range []struct {
11+ name string
12+ data string
13+ kitty bool
14+ da1 bool
15+ unrelated string
16+ }{
17+ {name: "both and input", data: "x\x1b[?1;2c" + "\x1b_Gi=31;OK\x1b\\y", kitty: true, da1: true, unrelated: "xy"},
18+ {name: "secondary DA is insufficient", data: "\x1b[>1;2c\x1b_Gi=31;OK\x1b\\", kitty: true, unrelated: "\x1b[>1;2c"},
19+ {name: "ordinary input", data: "hello", unrelated: "hello"},
20+ } {
21+ t.Run(tc.name, func(t *testing.T) {
22+ var p Probe
23+ got := append(p.Feed([]byte(tc.data)), p.Finish()...)
24+ require.Equal(t, tc.kitty, p.KittyGraphics())
25+ require.Equal(t, tc.da1, p.DA1())
26+ require.Equal(t, tc.unrelated, string(got))
27+ })
28+ }
29+}
30+
31+func TestProbeRecognizesFragmentedResponsesAndReplaysInput(t *testing.T) {
32+ var p Probe
33+ var unrelated []byte
34+ for _, fragment := range []string{"a\x1b", "[?1;2", "c", "b\x1b_Gi=31;", "OK\x1b", "\\z"} {
35+ unrelated = append(unrelated, p.Feed([]byte(fragment))...)
36+ }
37+ unrelated = append(unrelated, p.Finish()...)
38+ require.True(t, p.Ready())
39+ require.Equal(t, "abz", string(unrelated))
40+}
41+
42+func TestProbeRequiresExactCapabilityResponses(t *testing.T) {
43+ for _, data := range []string{
44+ "\x1b_Gi=31;OK,extra\x1b\\",
45+ "\x1b_Gi=310;OK\x1b\\",
46+ "\x1b_Gi=31;OKAY\x1b\\",
47+ "\x1b[?1;;2c",
48+ "\x1b[?x;2c",
49+ } {
50+ var p Probe
51+ got := append(p.Feed([]byte(data)), p.Finish()...)
52+ require.False(t, p.Ready(), "response %q was accepted", data)
53+ require.Equal(t, data, string(got))
54+ }
55+}
56+
57+func FuzzProbeNeverPanics(f *testing.F) {
58+ f.Add([]byte("text\x1b[?1;2c\x1b_Gi=31;OK\x1b\\"))
59+ f.Add([]byte("\x1b_G"))
60+ f.Fuzz(func(t *testing.T, data []byte) {
61+ var p Probe
62+ _ = p.Feed(data)
63+ _ = p.Finish()
64+ })
65+}
66+
67+func TestProbeDoesNotRetainLargeInputFragments(t *testing.T) {
68+ var p Probe
69+ input := append(make([]byte, 1<<20), []byte("\x1b_G")...)
70+ got := append(p.Feed(input), p.Finish()...)
71+ require.Equal(t, input, got)
72+ require.LessOrEqual(t, cap(p.pending), maxProbeResponseBytes+2)
73+}
74+
75+func TestProbeBoundsUnterminatedResponse(t *testing.T) {
76+ var p Probe
77+ input := append([]byte("prefix"), []byte("\x1b_G")...)
78+ input = append(input, make([]byte, maxProbeResponseBytes+1)...)
79+ got := append(p.Feed(input), p.Finish()...)
80+ require.False(t, p.Ready())
81+ require.Equal(t, input, got)
82+}
M screen.go +285 -3
@@ -4,6 +4,7 @@ import (
44 "unicode/utf8"
55
66 renderer "github.com/bnema/vev-vt/core"
7+ "github.com/bnema/vev-vt/protocol/kittygraphics"
78 )
89
910 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
@@ -12,6 +13,11 @@ import (
1213 // so keep this value in sync if that payload cap changes.
1314 const maxEscapeBufferLen = 128 * 1024
1415
16+// Kitty graphics APCs have their own protocol limit. Keep this separate from
17+// the ordinary escape-string limit so a fragmented image does not get fed
18+// through the text path or truncated at the OSC clipboard bound.
19+const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5
20+
1521 const (
1622 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
1723 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -54,6 +60,7 @@ type Screen struct {
5460 buffer *buffer
5561
5662 nextRowID RowID
63+ geometry Geometry
5764
5865 defaultFG renderer.RGB
5966 defaultBG renderer.RGB
@@ -65,6 +72,9 @@ type Screen struct {
6572 damageSaturated bool
6673 damageFullRedrawSticky bool
6774 escapeBuf []byte
75+ kittyDiscard bool
76+ kittyDiscardEscaped bool
77+ kittyPendingDisplay *kittygraphics.Controls
6878 csiScratch []int
6979 sgrScratch []int
7080
@@ -72,6 +82,7 @@ type Screen struct {
7282 scrollBottom int
7383 savedCursor cursorState
7484 alternate *screenState
85+ graphics *screenGraphicsState
7586 syncUpdateActive bool
7687 progressState int
7788 cursorVisible bool
@@ -93,6 +104,7 @@ func NewScreen(width, height int) *Screen {
93104 damage: []renderer.Damage{renderer.FullRedraw()},
94105 damageGeneration: 1,
95106 cursorVisible: true,
107+ geometry: Geometry{Cols: width, Rows: height},
96108 }
97109 s.buffer = s.newBuffer(width, height)
98110 s.Frame = s.buffer.frame
@@ -122,12 +134,27 @@ func (s *Screen) LineBounds() []LineBound {
122134 }
123135
124136 func (s *Screen) Write(data []byte) {
137+ if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {
138+ prefix := s.escapeBuf
139+ s.escapeBuf = nil
140+ data = s.continueKittyEscape(prefix, data)
141+ if len(data) == 0 {
142+ return
143+ }
144+ }
125145 if len(s.escapeBuf) > 0 {
126146 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
127147 combined = append(combined, s.escapeBuf...)
128148 combined = append(combined, data...)
129149 data = combined
130- s.escapeBuf = s.escapeBuf[:0]
150+ s.escapeBuf = nil
151+ }
152+ if s.kittyDiscard {
153+ consumed := s.consumeKittyDiscard(data)
154+ data = data[consumed:]
155+ if s.kittyDiscard {
156+ return
157+ }
131158 }
132159
133160 for len(data) > 0 {
@@ -138,8 +165,16 @@ func (s *Screen) Write(data []byte) {
138165 continue
139166 }
140167 if partial {
141- if len(data) <= maxEscapeBufferLen {
168+ limit := maxEscapeBufferLen
169+ if isKittyEscapePrefix(data) {
170+ limit = maxKittyEscapeBufferLen
171+ }
172+ if len(data) <= limit {
142173 s.escapeBuf = append(s.escapeBuf[:0], data...)
174+ } else if isKittyEscapePrefix(data) {
175+ s.abortKittyPendingDisplay()
176+ s.kittyDiscard = true
177+ s.kittyDiscardEscaped = false
143178 }
144179 return
145180 }
@@ -154,6 +189,105 @@ func (s *Screen) Write(data []byte) {
154189 }
155190 }
156191
192+func isKittyEscapePrefix(data []byte) bool {
193+ if len(data) == 0 || data[0] != 0x1b {
194+ return false
195+ }
196+ if len(data) > 1 && data[1] != '_' {
197+ return false
198+ }
199+ return len(data) < 3 || data[2] == 'G'
200+}
201+
202+func isKittyEscapeContinuation(prefix, data []byte) bool {
203+ if !isKittyEscapePrefix(prefix) {
204+ return false
205+ }
206+ if len(data) == 0 {
207+ return true
208+ }
209+ if len(prefix) == 1 {
210+ return data[0] == '_'
211+ }
212+ if len(prefix) == 2 {
213+ return data[0] == 'G'
214+ }
215+ return true
216+}
217+
218+func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
219+ if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
220+ if len(prefix)+1 <= maxKittyEscapeBufferLen {
221+ apc := append(prefix, data[:1]...)
222+ s.dispatchKittyGraphics(apc)
223+ } else {
224+ s.abortKittyPendingDisplay()
225+ }
226+ return data[1:]
227+ }
228+ for i := 0; i < len(data); i++ {
229+ if data[i] == 0x9c {
230+ end := i + 1
231+ if len(prefix)+end <= maxKittyEscapeBufferLen {
232+ apc := append(prefix, data[:end]...)
233+ s.dispatchKittyGraphics(apc)
234+ } else {
235+ s.abortKittyPendingDisplay()
236+ }
237+ return data[end:]
238+ }
239+ if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
240+ end := i + 2
241+ if len(prefix)+end <= maxKittyEscapeBufferLen {
242+ apc := append(prefix, data[:end]...)
243+ s.dispatchKittyGraphics(apc)
244+ } else {
245+ s.abortKittyPendingDisplay()
246+ }
247+ return data[end:]
248+ }
249+ }
250+ remaining := maxKittyEscapeBufferLen - len(prefix)
251+ if len(data) <= remaining {
252+ returnData := append(prefix, data...)
253+ s.escapeBuf = returnData
254+ return nil
255+ }
256+ s.abortKittyPendingDisplay()
257+ s.kittyDiscard = true
258+ escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
259+ if remaining > 0 {
260+ escaped = data[remaining-1] == 0x1b
261+ }
262+ s.kittyDiscardEscaped = escaped
263+ tail := data[remaining:]
264+ return tail[s.consumeKittyDiscard(tail):]
265+}
266+
267+func (s *Screen) consumeKittyDiscard(data []byte) int {
268+ for i, b := range data {
269+ if s.kittyDiscardEscaped {
270+ s.kittyDiscardEscaped = false
271+ if b == '\\' {
272+ s.kittyDiscard = false
273+ return i + 1
274+ }
275+ }
276+ if b == 0x9c {
277+ s.kittyDiscard = false
278+ return i + 1
279+ }
280+ if b == 0x1b {
281+ if i+1 < len(data) && data[i+1] == '\\' {
282+ s.kittyDiscard = false
283+ return i + 2
284+ }
285+ s.kittyDiscardEscaped = true
286+ }
287+ }
288+ return len(data)
289+}
290+
157291 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
158292 if len(data) < 2 {
159293 return 0, true
@@ -163,7 +297,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
163297 return s.consumeOSC(data)
164298 case 'P':
165299 return consumeSTString(data)
166- case '_', '^', 'X':
300+ case '_':
301+ if len(data) >= 3 && data[2] == 'G' {
302+ return s.consumeKittyGraphics(data)
303+ }
304+ return consumeSTString(data)
305+ case '^', 'X':
167306 return consumeSTString(data)
168307 case '[':
169308 return s.consumeCSI(data)
@@ -213,3 +352,146 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
213352 }
214353 return 0, true
215354 }
355+
356+// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
357+// complete bytes to the protocol adapter. The ordinary parser never sees the
358+// APC body as text, and the adapter is allocated only on the first complete
359+// graphics command.
360+func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
361+ for i := 3; i < len(data); i++ {
362+ switch data[i] {
363+ case 0x9c: // C1 string terminator.
364+ end := i + 1
365+ if end > maxKittyEscapeBufferLen {
366+ s.abortKittyPendingDisplay()
367+ return end, false
368+ }
369+ s.dispatchKittyGraphics(data[:end])
370+ return end, false
371+ case 0x1b:
372+ if i+1 < len(data) && data[i+1] == '\\' {
373+ end := i + 2
374+ if end > maxKittyEscapeBufferLen {
375+ s.abortKittyPendingDisplay()
376+ return end, false
377+ }
378+ s.dispatchKittyGraphics(data[:end])
379+ return end, false
380+ }
381+ }
382+ }
383+ return 0, true
384+}
385+
386+func (s *Screen) dispatchKittyGraphics(apc []byte) {
387+ command, err := kittygraphics.ParseAPC(apc)
388+ if err != nil {
389+ s.abortKittyPendingDisplay()
390+ return
391+ }
392+ if s.graphics == nil {
393+ s.graphics = newScreenGraphicsState()
394+ }
395+ controls := command.Controls
396+ pending := s.kittyPendingDisplay
397+ action := controls.Action
398+ if !controls.HasAction {
399+ action = kittygraphics.ActionTransmit
400+ if pending != nil {
401+ action = pending.Action
402+ }
403+ }
404+ display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
405+ movementControls := controls
406+ if pending != nil {
407+ movementControls = *pending
408+ }
409+ if display {
410+ s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
411+ }
412+ if display && controls.HasMore && controls.More == 1 {
413+ copy := controls
414+ s.kittyPendingDisplay = &copy
415+ }
416+ result, processErr := s.graphics.kitty.Process(command)
417+ if processErr != nil {
418+ s.abortKittyPendingDisplay()
419+ }
420+ for _, response := range result.Responses {
421+ s.respond(response)
422+ }
423+ if len(result.Mutations) != 0 {
424+ if display {
425+ s.applyKittyCursorMovement(movementControls)
426+ s.kittyPendingDisplay = nil
427+ }
428+ // Graphics are rendered independently from Frame. A graphics mutation
429+ // still needs to wake consumers which only redraw on screen damage.
430+ s.fullRedraw()
431+ }
432+}
433+
434+func (s *Screen) kittyCursorX() uint64 {
435+ if cellWidth, _, ok := s.kittyCellPixels(); ok {
436+ return uint64(s.Col * cellWidth)
437+ }
438+ return 0
439+}
440+
441+func (s *Screen) kittyCursorY() uint64 {
442+ _, cellHeight, ok := s.kittyCellPixels()
443+ if ok {
444+ return uint64(s.Row * cellHeight)
445+ }
446+ return 0
447+}
448+
449+func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
450+ if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
451+ return 0, 0, false
452+ }
453+ width = s.geometry.PixelWidth / s.geometry.Cols
454+ height = s.geometry.PixelHeight / s.geometry.Rows
455+ if width <= 0 || height <= 0 {
456+ return 0, 0, false
457+ }
458+ return width, height, true
459+}
460+
461+func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) {
462+ if c.HasCursor && c.Cursor == 1 {
463+ return
464+ }
465+ columns, rows := 1, 1
466+ if c.HasColumns {
467+ columns = kittyCursorAdvance(c.Columns)
468+ }
469+ if c.HasRows {
470+ rows = kittyCursorAdvance(c.Rows)
471+ }
472+ s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1)
473+ s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow())
474+}
475+
476+func advanceKittyCursor(current, amount, minimum, maximum int) int {
477+ if maximum < minimum {
478+ return minimum
479+ }
480+ if current < minimum {
481+ current = minimum
482+ }
483+ if current > maximum {
484+ current = maximum
485+ }
486+ if amount > maximum-current {
487+ return maximum
488+ }
489+ return current + amount
490+}
491+
492+func kittyCursorAdvance(value uint64) int {
493+ if value > uint64(^uint(0)>>1) {
494+ return int(^uint(0) >> 1)
495+ }
496+ return int(value)
497+}
M screen_csi.go +5 -0
@@ -144,6 +144,10 @@ func (s *Screen) applyCSI(params string, cmd byte) {
144144 s.deleteLines(firstPositive(parts, 1))
145145 case 'r':
146146 s.setScrollRegion(parts)
147+ case 't':
148+ if !private {
149+ s.reportWindowSize(parts)
150+ }
147151 case 's':
148152 s.saveCursor()
149153 case 'u':
@@ -414,6 +418,7 @@ func (s *Screen) clearScreenMode(mode int) {
414418 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
415419 s.buffer.rowIDs[y] = s.nextRowIDValue()
416420 }
421+ s.graphicsClearPlacements()
417422 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
418423 default:
419424 for y := s.Row; y < s.Frame.Height; y++ {
A screen_geometry.go +62 -0
@@ -0,0 +1,62 @@
1+package vt
2+
3+// Geometry returns the screen's last frontend-supplied geometry.
4+func (s *Screen) Geometry() Geometry {
5+ if s == nil {
6+ return Geometry{}
7+ }
8+ return s.geometry
9+}
10+
11+// SetGeometry updates the frontend-supplied cell and optional pixel geometry.
12+// Cell changes retain the existing Resize behavior; pixel-only changes do not
13+// touch the text frame or damage state.
14+func (s *Screen) SetGeometry(geometry Geometry) {
15+ if s == nil || !geometry.Valid() {
16+ return
17+ }
18+ cellsChanged := s.geometry.Cols != geometry.Cols || s.geometry.Rows != geometry.Rows
19+ geometryChanged := s.geometry != geometry
20+ s.geometry = geometry
21+ if cellsChanged {
22+ s.Resize(geometry.Cols, geometry.Rows)
23+ } else if geometryChanged {
24+ s.escapeBuf = s.escapeBuf[:0]
25+ s.kittyDiscard = false
26+ s.kittyDiscardEscaped = false
27+ s.abortAllKittyPending()
28+ }
29+}
30+
31+// reportWindowSize answers the CSI 14 t and CSI 16 t pixel-geometry queries.
32+// Unknown pixel geometry is intentionally not fabricated.
33+func (s *Screen) reportWindowSize(parts []int) {
34+ if s == nil || len(parts) == 0 || !s.geometry.PixelsKnown() || s.geometry.Rows <= 0 || s.geometry.Cols <= 0 {
35+ return
36+ }
37+ kind := parts[0]
38+ var rows, cols int
39+ switch kind {
40+ case 14:
41+ rows, cols = s.geometry.PixelHeight, s.geometry.PixelWidth
42+ case 16:
43+ rows, cols = s.geometry.PixelHeight/s.geometry.Rows, s.geometry.PixelWidth/s.geometry.Cols
44+ default:
45+ return
46+ }
47+ s.respond([]byte("\x1b[" + itoa(kind-10) + ";" + itoa(rows) + ";" + itoa(cols) + "t"))
48+}
49+
50+func itoa(value int) string {
51+ if value == 0 {
52+ return "0"
53+ }
54+ var digits [20]byte
55+ i := len(digits)
56+ for value > 0 {
57+ i--
58+ digits[i] = byte('0' + value%10)
59+ value /= 10
60+ }
61+ return string(digits[i:])
62+}
A screen_geometry_test.go +44 -0
@@ -0,0 +1,44 @@
1+package vt
2+
3+import "testing"
4+
5+func TestScreenWindowSizeReports(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ geometry Geometry
9+ query string
10+ want string
11+ }{
12+ {name: "window pixels", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, query: "\x1b[14t", want: "\x1b[4;384;640t"},
13+ {name: "cell pixels", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, query: "\x1b[16t", want: "\x1b[6;16;8t"},
14+ {name: "cell pixels truncate", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 803, PixelHeight: 487}, query: "\x1b[16t", want: "\x1b[6;20;10t"},
15+ {name: "unknown pixels", geometry: Geometry{Cols: 80, Rows: 24}, query: "\x1b[14t"},
16+ {name: "partial pixels are unknown", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640}, query: "\x1b[16t"},
17+ {name: "unrelated window query", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, query: "\x1b[18t"},
18+ {name: "private parameter is ignored", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, query: "\x1b[?14t"},
19+ }
20+ for _, tt := range tests {
21+ t.Run(tt.name, func(t *testing.T) {
22+ s := NewScreen(tt.geometry.Cols, tt.geometry.Rows)
23+ s.SetGeometry(tt.geometry)
24+ var got string
25+ s.OnResponse = func(response []byte) { got += string(response) }
26+ s.Write([]byte(tt.query))
27+ if got != tt.want {
28+ t.Fatalf("response = %q, want %q", got, tt.want)
29+ }
30+ })
31+ }
32+}
33+
34+func TestScreenWindowSizeReportsRequireUsableGeometry(t *testing.T) {
35+ s := NewScreen(80, 24)
36+ s.SetGeometry(Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384})
37+ s.geometry.Cols = 0
38+ var got string
39+ s.OnResponse = func(response []byte) { got += string(response) }
40+ s.Write([]byte("\x1b[14t"))
41+ if got != "" {
42+ t.Fatalf("response = %q", got)
43+ }
44+}
A screen_graphics_test.go +228 -0
@@ -0,0 +1,228 @@
1+package vt
2+
3+import (
4+ "encoding/base64"
5+ "strconv"
6+ "testing"
7+
8+ "github.com/bnema/vev-vt/graphics"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func screenKittyImageAPC(action string) []byte {
13+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
14+ return []byte("\x1b_Ga=" + action + ",i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\")
15+}
16+
17+func screenKittyImageAPCWithID(action string, id uint64) []byte {
18+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
19+ return []byte("\x1b_Ga=" + action + ",i=" + strconv.FormatUint(id, 10) + ",f=32,s=1,v=1,C=1;" + payload + "\x1b\\")
20+}
21+
22+func screenKittyPutAPC(id uint64) []byte {
23+ return []byte("\x1b_Ga=p,i=" + strconv.FormatUint(id, 10) + ";\x1b\\")
24+}
25+
26+func TestScreenKittyGraphicsFragmentedAPCAndResponse(t *testing.T) {
27+ screen := NewScreen(16, 3)
28+ var responses []string
29+ screen.OnResponse = func(response []byte) { responses = append(responses, string(response)) }
30+
31+ input := append([]byte("before"), screenKittyImageAPC("T")...)
32+ input = append(input, []byte("after")...)
33+ for _, b := range input {
34+ screen.Write([]byte{b})
35+ }
36+
37+ require.Equal(t, "beforeafter ", rowText(screen.Snapshot().Row(0)))
38+ require.Equal(t, []string{"\x1b_Gi=1;OK\x1b\\"}, responses)
39+ snapshot := screen.GraphicsSnapshot()
40+ require.NotNil(t, snapshot)
41+ require.Equal(t, uint64(1), snapshot.Usage().Assets)
42+ require.Equal(t, uint64(1), snapshot.Usage().Placements)
43+}
44+
45+func TestScreenKittyGraphicsPrimaryAndAlternateState(t *testing.T) {
46+ screen := NewScreen(8, 3)
47+ screen.Write(screenKittyImageAPC("T"))
48+ primary := screen.GraphicsSnapshot()
49+ require.NotNil(t, primary)
50+ require.Equal(t, uint64(1), primary.Usage().Placements)
51+
52+ screen.Write([]byte("\x1b[?1049halt"))
53+ require.True(t, screen.AltScreenActive())
54+ require.Nil(t, screen.GraphicsSnapshot())
55+ screen.Write(screenKittyImageAPCWithID("T", 2))
56+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
57+
58+ screen.Write([]byte("\x1b[?1049l"))
59+ require.False(t, screen.AltScreenActive())
60+ require.Equal(t, primary.Usage(), screen.GraphicsSnapshot().Usage())
61+}
62+
63+func TestScreenReenteringActiveAlternateClearsGraphicsState(t *testing.T) {
64+ screen := NewScreen(8, 3)
65+ screen.Write([]byte("\x1b[?1049h"))
66+ screen.Write(screenKittyImageAPC("T"))
67+ before := screen.GraphicsSnapshot()
68+ require.NotNil(t, before)
69+ require.Equal(t, uint64(1), before.Usage().Placements)
70+
71+ screen.Write([]byte("\x1b[?1049h"))
72+ after := screen.GraphicsSnapshot()
73+ require.Nil(t, after)
74+ require.Equal(t, uint64(1), before.Usage().Placements)
75+}
76+
77+func TestScreenFullClearAbortsPendingUploadAndClearsPlacements(t *testing.T) {
78+ screen := NewScreen(8, 3)
79+ screen.Write(screenKittyImageAPC("T"))
80+ screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\"))
81+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
82+
83+ screen.Write([]byte("\x1b[2J"))
84+ require.Equal(t, uint64(0), screen.GraphicsSnapshot().Usage().Placements)
85+ screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
86+ graphicsSnapshot := screen.GraphicsSnapshot()
87+ require.NotNil(t, graphicsSnapshot)
88+ require.Equal(t, uint64(1), graphicsSnapshot.Usage().Assets)
89+ require.Equal(t, uint64(0), graphicsSnapshot.Usage().Placements)
90+}
91+
92+func TestScreenPendingTransmitDisplayFailsClosedAcrossErrorAndResize(t *testing.T) {
93+ pending := []byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\")
94+ continuation := []byte("\x1b_Gm=0;IDBA\x1b\\")
95+
96+ screen := NewScreen(4, 2)
97+ screen.Write(pending)
98+ screen.Write([]byte("\x1b_Ga=p,i=1;\x1b\\"))
99+ screen.Write(continuation)
100+ require.NotNil(t, screen.GraphicsSnapshot())
101+ require.Equal(t, uint64(0), screen.GraphicsSnapshot().Usage().Placements)
102+
103+ screen.Write(pending)
104+ screen.Resize(5, 2)
105+ screen.Write(continuation)
106+ require.NotNil(t, screen.GraphicsSnapshot())
107+ require.Equal(t, uint64(0), screen.GraphicsSnapshot().Usage().Placements)
108+}
109+
110+func TestScreenPendingTransmitDisplayIsScopedToScreenBuffer(t *testing.T) {
111+ screen := NewScreen(4, 2)
112+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\"))
113+ screen.Write([]byte("\x1b[?1049h"))
114+ screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
115+ require.NotNil(t, screen.GraphicsSnapshot())
116+ require.Equal(t, uint64(0), screen.GraphicsSnapshot().Usage().Placements)
117+ screen.Write([]byte("\x1b[?1049l"))
118+ screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
119+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
120+}
121+
122+func TestScreenPartialPixelGeometryDoesNotInventPixelCoordinates(t *testing.T) {
123+ screen := NewScreen(10, 4)
124+ screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100})
125+ screen.Write([]byte("\x1b[2;3H"))
126+ screen.Write(screenKittyImageAPC("T"))
127+ placement := screen.GraphicsSnapshot().Placements()[0]
128+ require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 1, Height: 1}, placement.Destination())
129+}
130+
131+func TestScreenKittyPlacementSeparatesCursorOriginCropAndPixelOffset(t *testing.T) {
132+ screen := NewScreen(10, 4)
133+ screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100, PixelHeight: 40})
134+ screen.Write([]byte("\x1b[2;3H"))
135+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 64))
136+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=4,v=4,x=1,y=1,w=2,h=2,X=3,Y=4,C=1;" + payload + "\x1b\\"))
137+
138+ placement := screen.GraphicsSnapshot().Placements()[0]
139+ require.Equal(t, graphics.PixelRect{X: 1, Y: 1, Width: 2, Height: 2}, placement.Source())
140+ require.Equal(t, graphics.PixelRect{X: 23, Y: 14, Width: 2, Height: 2}, placement.Destination())
141+}
142+
143+func TestScreenKittyGraphicsOrdinaryResetClearScrollAndResize(t *testing.T) {
144+ screen := NewScreen(3, 2)
145+ screen.Write(screenKittyImageAPC("T"))
146+
147+ // Printable text and a scroll do not guess at image anchoring. Existing
148+ // placements remain available until a graphics-aware policy is supplied.
149+ screen.Write([]byte("abcdefg"))
150+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
151+ screen.Resize(4, 3)
152+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
153+
154+ // A full ordinary erase removes visible placements while retaining the
155+ // protocol image mapping for a later explicit put.
156+ screen.Write([]byte("\x1b[2J"))
157+ graphicsSnapshot := screen.GraphicsSnapshot()
158+ require.NotNil(t, graphicsSnapshot)
159+ require.Equal(t, uint64(1), graphicsSnapshot.Usage().Assets)
160+ require.Equal(t, uint64(0), graphicsSnapshot.Usage().Placements)
161+
162+ screen.Write(screenKittyPutAPC(1))
163+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
164+ screen.Write([]byte("\x1bc"))
165+ require.Nil(t, screen.GraphicsSnapshot())
166+}
167+
168+func TestScreenSnapshotCapturesOwnedActiveGraphicsSnapshot(t *testing.T) {
169+ screen := NewScreen(4, 2)
170+ screen.Write(screenKittyImageAPC("T"))
171+ captured := screen.Snapshot()
172+ require.NotNil(t, captured.Graphics())
173+ require.Equal(t, uint64(1), captured.Graphics().Usage().Placements)
174+ require.Equal(t, captured.Graphics().Usage(), screen.GraphicsSnapshot().Usage(), "screen graphics snapshot returns the immutable active-scene reference")
175+
176+ screen.Write([]byte("\x1b[2J"))
177+ require.Equal(t, uint64(1), captured.Graphics().Usage().Placements)
178+ require.Equal(t, uint64(0), screen.Snapshot().Graphics().Usage().Placements)
179+
180+ // Graphics snapshots are independent of the text frame and have no Cell or
181+ // VTH3 representation to invalidate.
182+ require.Equal(t, graphics.Usage{Assets: 1, Placements: 1, EncodedBytes: 4, DecodedPixels: 1}, captured.Graphics().Usage())
183+}
184+
185+func TestScreenOrdinaryTextPathDoesNotAllocateGraphicsState(t *testing.T) {
186+ screen := NewScreen(4, 2)
187+ screen.Write([]byte("text"))
188+ require.Nil(t, screen.GraphicsSnapshot())
189+ require.Nil(t, screen.Snapshot().Graphics())
190+ require.Equal(t, "text", rowText(screen.Snapshot().Row(0)))
191+}
192+
193+func TestScreenKittyDiscardRetainsTerminatorFraming(t *testing.T) {
194+ screen := NewScreen(16, 2)
195+ screen.kittyDiscard = true
196+ screen.Write([]byte("discarded body"))
197+ screen.Write([]byte("\x1b"))
198+ screen.Write([]byte("\\after"))
199+ require.Equal(t, "after ", rowText(screen.Snapshot().Row(0)))
200+}
201+
202+func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) {
203+ screen := NewScreen(10, 4)
204+ screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100, PixelHeight: 40})
205+ screen.Write([]byte("\x1b[2;3H"))
206+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 16))
207+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=2,v=2,c=2,r=1,C=1;" + payload + "\x1b\\"))
208+
209+ snapshot := screen.GraphicsSnapshot()
210+ require.NotNil(t, snapshot)
211+ placements := snapshot.Placements()
212+ require.Len(t, placements, 1)
213+ require.Equal(t, graphics.PixelRect{X: 20, Y: 10, Width: 2, Height: 2}, placements[0].Destination())
214+ require.Equal(t, 1, screen.CursorRow())
215+ require.Equal(t, 2, screen.CursorCol())
216+
217+ screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=2,v=2,C=0;" + payload + "\x1b\\"))
218+ require.Equal(t, 2, screen.CursorRow())
219+ require.Equal(t, 3, screen.CursorCol())
220+
221+ screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\"))
222+ screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
223+ placements = screen.GraphicsSnapshot().Placements()
224+ require.Len(t, placements, 3)
225+ require.Equal(t, graphics.PixelRect{X: 30, Y: 20, Width: 1, Height: 1}, placements[2].Destination())
226+ require.Equal(t, 2, screen.CursorRow())
227+ require.Equal(t, 3, screen.CursorCol())
228+}
M screen_modes.go +36 -21
@@ -1,18 +1,23 @@
11 package vt
22
3-import renderer "github.com/bnema/vev-vt/core"
3+import (
4+ renderer "github.com/bnema/vev-vt/core"
5+ "github.com/bnema/vev-vt/protocol/kittygraphics"
6+)
47
58 type screenState struct {
6- frame renderer.Frame
7- buffer *buffer
8- row int
9- col int
10- style renderer.Style
11- scrollTop int
12- scrollBottom int
13- savedCursor cursorState
14- originMode bool
15- insertMode bool
9+ frame renderer.Frame
10+ buffer *buffer
11+ row int
12+ col int
13+ style renderer.Style
14+ scrollTop int
15+ scrollBottom int
16+ savedCursor cursorState
17+ graphics *screenGraphicsState
18+ kittyPendingDisplay *kittygraphics.Controls
19+ originMode bool
20+ insertMode bool
1621 }
1722
1823 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
@@ -92,8 +97,12 @@ func (s *Screen) reset() {
9297 s.Row, s.Col = 0, 0
9398 s.Style = renderer.DefaultStyle()
9499 s.escapeBuf = s.escapeBuf[:0]
100+ s.kittyDiscard = false
101+ s.kittyDiscardEscaped = false
102+ s.kittyPendingDisplay = nil
95103 s.savedCursor = cursorState{}
96104 s.alternate = nil
105+ s.graphics = nil
97106 s.originMode = false
98107 s.insertMode = false
99108 s.cursorVisible = true
@@ -191,18 +200,22 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
191200 func (s *Screen) enterAlternateScreen() {
192201 if s.alternate == nil {
193202 s.alternate = &screenState{
194- frame: cloneFrame(s.Frame),
195- buffer: s.buffer.clone(),
196- row: s.Row,
197- col: s.Col,
198- style: s.Style,
199- scrollTop: s.scrollTop,
200- scrollBottom: s.scrollBottom,
201- savedCursor: s.savedCursor,
202- originMode: s.originMode,
203- insertMode: s.insertMode,
203+ frame: cloneFrame(s.Frame),
204+ buffer: s.buffer.clone(),
205+ row: s.Row,
206+ col: s.Col,
207+ style: s.Style,
208+ scrollTop: s.scrollTop,
209+ scrollBottom: s.scrollBottom,
210+ savedCursor: s.savedCursor,
211+ graphics: s.graphics,
212+ kittyPendingDisplay: s.kittyPendingDisplay,
213+ originMode: s.originMode,
214+ insertMode: s.insertMode,
204215 }
205216 }
217+ s.graphics = nil
218+ s.kittyPendingDisplay = nil
206219 s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
207220 s.Frame = s.buffer.frame
208221 s.Row, s.Col = 0, 0
@@ -229,6 +242,8 @@ func (s *Screen) exitAlternateScreen() {
229242 s.scrollTop = state.scrollTop
230243 s.scrollBottom = state.scrollBottom
231244 s.savedCursor = state.savedCursor
245+ s.graphics = state.graphics
246+ s.kittyPendingDisplay = state.kittyPendingDisplay
232247 s.originMode = state.originMode
233248 s.insertMode = state.insertMode
234249 s.alternate = nil
M screen_resize.go +5 -0
@@ -4,6 +4,7 @@ import renderer "github.com/bnema/vev-vt/core"
44
55 func (s *Screen) Resize(width, height int) {
66 if width == s.Frame.Width && height == s.Frame.Height {
7+ s.geometry.Cols, s.geometry.Rows = width, height
78 return
89 }
910 if width <= 0 || height <= 0 {
@@ -60,9 +61,13 @@ func (s *Screen) Resize(width, height int) {
6061 }
6162 }
6263 s.Frame = s.buffer.frame
64+ s.geometry.Cols, s.geometry.Rows = width, height
6365 // A resize can split an in-flight escape sequence from the terminal state it
6466 // was meant to mutate; keep the durable child state but discard partial bytes.
6567 s.escapeBuf = s.escapeBuf[:0]
68+ s.kittyDiscard = false
69+ s.kittyDiscardEscaped = false
70+ s.abortAllKittyPending()
6671 s.resetScrollRegion()
6772 s.fullRedraw()
6873 }
M screen_snapshot.go +11 -2
@@ -1,6 +1,9 @@
11 package vt
22
3-import renderer "github.com/bnema/vev-vt/core"
3+import (
4+ renderer "github.com/bnema/vev-vt/core"
5+ "github.com/bnema/vev-vt/graphics"
6+)
47
58 // CursorSnapshot is the cursor state captured with a visible screen snapshot.
69 type CursorSnapshot struct {
@@ -31,6 +34,7 @@ type ScreenSnapshot struct {
3134 cursor CursorSnapshot
3235 modes ModeSnapshot
3336 title string
37+ graphics *graphics.Snapshot
3438 nextRowID RowID
3539 }
3640
@@ -67,7 +71,8 @@ func (s *Screen) Snapshot() ScreenSnapshot {
6771 MouseTracking: mouseTracking,
6872 MouseSGR: mouseSGR,
6973 },
70- title: s.TerminalTitle(),
74+ title: s.TerminalTitle(),
75+ graphics: s.GraphicsSnapshot(),
7176 }
7277 }
7378
@@ -114,3 +119,7 @@ func (s ScreenSnapshot) NextRowID() RowID {
114119 func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
115120 func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
116121 func (s ScreenSnapshot) Title() string { return s.title }
122+
123+// Graphics returns the immutable graphics scene snapshot for the active
124+// screen buffer, or nil when that buffer has not used Kitty graphics.
125+func (s ScreenSnapshot) Graphics() *graphics.Snapshot { return s.graphics }

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