Merge pull request #5 from bnema/feat/kitty-image-protocol-support
feat(graphics): support Kitty terminal graphics
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. | |||||
| 15 | or application dependencies. | 15 | or application dependencies. | ||
| 16 | - `github.com/bnema/vev-vt/ansi` is the concrete ANSI output package. It consumes | 16 | - `github.com/bnema/vev-vt/ansi` is the concrete ANSI output package. It consumes | ||
| 17 | core frames and damage; it does not define a renderer-backend interface. | 17 | 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`. | |||
| 18 | 23 | ||||
| 19 | ## Ownership contract | 24 | ## Ownership contract | ||
| 20 | 25 | ||||
| @@ -40,6 +45,37 @@ used only by the test suite. Keep the public v0.x API and byte formats immutable | |||||
| 40 | once released; behavior changes require explicit versioning and compatibility | 45 | once released; behavior changes require explicit versioning and compatibility | ||
| 41 | evidence. | 46 | evidence. | ||
| 42 | 47 | ||||
| 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 | + | ||||
| 43 | ## Checks | 79 | ## Checks | ||
| 44 | 80 | ||||
| 45 | ```sh | 81 | ```sh | ||
M benchmark_test.go +82 -0
| @@ -2,6 +2,7 @@ package vt | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "encoding/base64" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | renderer "github.com/bnema/vev-vt/core" | 8 | renderer "github.com/bnema/vev-vt/core" | ||
| @@ -10,6 +11,7 @@ import ( | |||||
| 10 | var ( | 11 | var ( | ||
| 11 | benchmarkHistoryViewSink HistoryView | 12 | benchmarkHistoryViewSink HistoryView | ||
| 12 | benchmarkHistorySnapshotSink HistorySnapshotView | 13 | benchmarkHistorySnapshotSink HistorySnapshotView | ||
| 14 | + | benchmarkDamageCaptureSink DamageCapture | |||
| 13 | benchmarkHistoryBlobSink []byte | 15 | benchmarkHistoryBlobSink []byte | ||
| 14 | benchmarkHistoryBlobsSink [][]byte | 16 | benchmarkHistoryBlobsSink [][]byte | ||
| 15 | ) | 17 | ) | ||
| @@ -200,6 +202,14 @@ func benchmarkScreenResizeReflow(b *testing.B, historyRows int) { | |||||
| 200 | for range historyRows { | 202 | for range historyRows { | ||
| 201 | requireHistoryAppend(b, s.history, make([]renderer.Cell, 120)) | 203 | requireHistoryAppend(b, s.history, make([]renderer.Cell, 120)) | ||
| 202 | } | 204 | } | ||
| 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 | + | } | |||
| 203 | } | 213 | } | ||
| 204 | s.Write(bytes.Repeat([]byte("x"), 120*39)) | 214 | s.Write(bytes.Repeat([]byte("x"), 120*39)) | ||
| 205 | s.ClearDamage() | 215 | s.ClearDamage() | ||
| @@ -217,6 +227,78 @@ func benchmarkScreenResizeReflow(b *testing.B, historyRows int) { | |||||
| 217 | } | 227 | } | ||
| 218 | } | 228 | } | ||
| 219 | 229 | ||||
| 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 | + | ||||
| 220 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | 302 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | ||
| 221 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | 303 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | ||
| 222 | b.ReportAllocs() | 304 | 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 ( | |||||
| 4 | "unicode/utf8" | 4 | "unicode/utf8" | ||
| 5 | 5 | ||||
| 6 | renderer "github.com/bnema/vev-vt/core" | 6 | renderer "github.com/bnema/vev-vt/core" | ||
| 7 | + | "github.com/bnema/vev-vt/protocol/kittygraphics" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | 10 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | ||
| @@ -12,6 +13,11 @@ import ( | |||||
| 12 | // so keep this value in sync if that payload cap changes. | 13 | // so keep this value in sync if that payload cap changes. | ||
| 13 | const maxEscapeBufferLen = 128 * 1024 | 14 | const maxEscapeBufferLen = 128 * 1024 | ||
| 14 | 15 | ||||
| 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 | + | ||||
| 15 | const ( | 21 | const ( | ||
| 16 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | 22 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | ||
| 17 | ColorSchemeReportDark = "\x1b[?997;1n" | 23 | ColorSchemeReportDark = "\x1b[?997;1n" | ||
| @@ -54,6 +60,7 @@ type Screen struct { | |||||
| 54 | buffer *buffer | 60 | buffer *buffer | ||
| 55 | 61 | ||||
| 56 | nextRowID RowID | 62 | nextRowID RowID | ||
| 63 | + | geometry Geometry | |||
| 57 | 64 | ||||
| 58 | defaultFG renderer.RGB | 65 | defaultFG renderer.RGB | ||
| 59 | defaultBG renderer.RGB | 66 | defaultBG renderer.RGB | ||
| @@ -65,6 +72,9 @@ type Screen struct { | |||||
| 65 | damageSaturated bool | 72 | damageSaturated bool | ||
| 66 | damageFullRedrawSticky bool | 73 | damageFullRedrawSticky bool | ||
| 67 | escapeBuf []byte | 74 | escapeBuf []byte | ||
| 75 | + | kittyDiscard bool | |||
| 76 | + | kittyDiscardEscaped bool | |||
| 77 | + | kittyPendingDisplay *kittygraphics.Controls | |||
| 68 | csiScratch []int | 78 | csiScratch []int | ||
| 69 | sgrScratch []int | 79 | sgrScratch []int | ||
| 70 | 80 | ||||
| @@ -72,6 +82,7 @@ type Screen struct { | |||||
| 72 | scrollBottom int | 82 | scrollBottom int | ||
| 73 | savedCursor cursorState | 83 | savedCursor cursorState | ||
| 74 | alternate *screenState | 84 | alternate *screenState | ||
| 85 | + | graphics *screenGraphicsState | |||
| 75 | syncUpdateActive bool | 86 | syncUpdateActive bool | ||
| 76 | progressState int | 87 | progressState int | ||
| 77 | cursorVisible bool | 88 | cursorVisible bool | ||
| @@ -93,6 +104,7 @@ func NewScreen(width, height int) *Screen { | |||||
| 93 | damage: []renderer.Damage{renderer.FullRedraw()}, | 104 | damage: []renderer.Damage{renderer.FullRedraw()}, | ||
| 94 | damageGeneration: 1, | 105 | damageGeneration: 1, | ||
| 95 | cursorVisible: true, | 106 | cursorVisible: true, | ||
| 107 | + | geometry: Geometry{Cols: width, Rows: height}, | |||
| 96 | } | 108 | } | ||
| 97 | s.buffer = s.newBuffer(width, height) | 109 | s.buffer = s.newBuffer(width, height) | ||
| 98 | s.Frame = s.buffer.frame | 110 | s.Frame = s.buffer.frame | ||
| @@ -122,12 +134,27 @@ func (s *Screen) LineBounds() []LineBound { | |||||
| 122 | } | 134 | } | ||
| 123 | 135 | ||||
| 124 | func (s *Screen) Write(data []byte) { | 136 | 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 | + | } | |||
| 125 | if len(s.escapeBuf) > 0 { | 145 | if len(s.escapeBuf) > 0 { | ||
| 126 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | 146 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | ||
| 127 | combined = append(combined, s.escapeBuf...) | 147 | combined = append(combined, s.escapeBuf...) | ||
| 128 | combined = append(combined, data...) | 148 | combined = append(combined, data...) | ||
| 129 | data = combined | 149 | 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 | + | } | |||
| 131 | } | 158 | } | ||
| 132 | 159 | ||||
| 133 | for len(data) > 0 { | 160 | for len(data) > 0 { | ||
| @@ -138,8 +165,16 @@ func (s *Screen) Write(data []byte) { | |||||
| 138 | continue | 165 | continue | ||
| 139 | } | 166 | } | ||
| 140 | if partial { | 167 | if partial { | ||
| 141 | - | if len(data) <= maxEscapeBufferLen { | 168 | + | limit := maxEscapeBufferLen |
| 169 | + | if isKittyEscapePrefix(data) { | |||
| 170 | + | limit = maxKittyEscapeBufferLen | |||
| 171 | + | } | |||
| 172 | + | if len(data) <= limit { | |||
| 142 | s.escapeBuf = append(s.escapeBuf[:0], data...) | 173 | s.escapeBuf = append(s.escapeBuf[:0], data...) | ||
| 174 | + | } else if isKittyEscapePrefix(data) { | |||
| 175 | + | s.abortKittyPendingDisplay() | |||
| 176 | + | s.kittyDiscard = true | |||
| 177 | + | s.kittyDiscardEscaped = false | |||
| 143 | } | 178 | } | ||
| 144 | return | 179 | return | ||
| 145 | } | 180 | } | ||
| @@ -154,6 +189,105 @@ func (s *Screen) Write(data []byte) { | |||||
| 154 | } | 189 | } | ||
| 155 | } | 190 | } | ||
| 156 | 191 | ||||
| 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 | + | ||||
| 157 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | 291 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | ||
| 158 | if len(data) < 2 { | 292 | if len(data) < 2 { | ||
| 159 | return 0, true | 293 | return 0, true | ||
| @@ -163,7 +297,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |||||
| 163 | return s.consumeOSC(data) | 297 | return s.consumeOSC(data) | ||
| 164 | case 'P': | 298 | case 'P': | ||
| 165 | return consumeSTString(data) | 299 | 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': | |||
| 167 | return consumeSTString(data) | 306 | return consumeSTString(data) | ||
| 168 | case '[': | 307 | case '[': | ||
| 169 | return s.consumeCSI(data) | 308 | return s.consumeCSI(data) | ||
| @@ -213,3 +352,146 @@ func consumeSTString(data []byte) (consumed int, partial bool) { | |||||
| 213 | } | 352 | } | ||
| 214 | return 0, true | 353 | return 0, true | ||
| 215 | } | 354 | } | ||
| 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 = © | |||
| 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) { | |||||
| 144 | s.deleteLines(firstPositive(parts, 1)) | 144 | s.deleteLines(firstPositive(parts, 1)) | ||
| 145 | case 'r': | 145 | case 'r': | ||
| 146 | s.setScrollRegion(parts) | 146 | s.setScrollRegion(parts) | ||
| 147 | + | case 't': | |||
| 148 | + | if !private { | |||
| 149 | + | s.reportWindowSize(parts) | |||
| 150 | + | } | |||
| 147 | case 's': | 151 | case 's': | ||
| 148 | s.saveCursor() | 152 | s.saveCursor() | ||
| 149 | case 'u': | 153 | case 'u': | ||
| @@ -414,6 +418,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||||
| 414 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | 418 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | ||
| 415 | s.buffer.rowIDs[y] = s.nextRowIDValue() | 419 | s.buffer.rowIDs[y] = s.nextRowIDValue() | ||
| 416 | } | 420 | } | ||
| 421 | + | s.graphicsClearPlacements() | |||
| 417 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | 422 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | ||
| 418 | default: | 423 | default: | ||
| 419 | for y := s.Row; y < s.Frame.Height; y++ { | 424 | 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 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 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 | + | ) | |||
| 4 | 7 | ||||
| 5 | type screenState struct { | 8 | type screenState struct { | ||
| 6 | - | frame renderer.Frame | 9 | + | frame renderer.Frame |
| 7 | - | buffer *buffer | 10 | + | buffer *buffer |
| 8 | - | row int | 11 | + | row int |
| 9 | - | col int | 12 | + | col int |
| 10 | - | style renderer.Style | 13 | + | style renderer.Style |
| 11 | - | scrollTop int | 14 | + | scrollTop int |
| 12 | - | scrollBottom int | 15 | + | scrollBottom int |
| 13 | - | savedCursor cursorState | 16 | + | savedCursor cursorState |
| 14 | - | originMode bool | 17 | + | graphics *screenGraphicsState |
| 15 | - | insertMode bool | 18 | + | kittyPendingDisplay *kittygraphics.Controls |
| 19 | + | originMode bool | |||
| 20 | + | insertMode bool | |||
| 16 | } | 21 | } | ||
| 17 | 22 | ||||
| 18 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | 23 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | ||
| @@ -92,8 +97,12 @@ func (s *Screen) reset() { | |||||
| 92 | s.Row, s.Col = 0, 0 | 97 | s.Row, s.Col = 0, 0 | ||
| 93 | s.Style = renderer.DefaultStyle() | 98 | s.Style = renderer.DefaultStyle() | ||
| 94 | s.escapeBuf = s.escapeBuf[:0] | 99 | s.escapeBuf = s.escapeBuf[:0] | ||
| 100 | + | s.kittyDiscard = false | |||
| 101 | + | s.kittyDiscardEscaped = false | |||
| 102 | + | s.kittyPendingDisplay = nil | |||
| 95 | s.savedCursor = cursorState{} | 103 | s.savedCursor = cursorState{} | ||
| 96 | s.alternate = nil | 104 | s.alternate = nil | ||
| 105 | + | s.graphics = nil | |||
| 97 | s.originMode = false | 106 | s.originMode = false | ||
| 98 | s.insertMode = false | 107 | s.insertMode = false | ||
| 99 | s.cursorVisible = true | 108 | s.cursorVisible = true | ||
| @@ -191,18 +200,22 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||||
| 191 | func (s *Screen) enterAlternateScreen() { | 200 | func (s *Screen) enterAlternateScreen() { | ||
| 192 | if s.alternate == nil { | 201 | if s.alternate == nil { | ||
| 193 | s.alternate = &screenState{ | 202 | s.alternate = &screenState{ | ||
| 194 | - | frame: cloneFrame(s.Frame), | 203 | + | frame: cloneFrame(s.Frame), |
| 195 | - | buffer: s.buffer.clone(), | 204 | + | buffer: s.buffer.clone(), |
| 196 | - | row: s.Row, | 205 | + | row: s.Row, |
| 197 | - | col: s.Col, | 206 | + | col: s.Col, |
| 198 | - | style: s.Style, | 207 | + | style: s.Style, |
| 199 | - | scrollTop: s.scrollTop, | 208 | + | scrollTop: s.scrollTop, |
| 200 | - | scrollBottom: s.scrollBottom, | 209 | + | scrollBottom: s.scrollBottom, |
| 201 | - | savedCursor: s.savedCursor, | 210 | + | savedCursor: s.savedCursor, |
| 202 | - | originMode: s.originMode, | 211 | + | graphics: s.graphics, |
| 203 | - | insertMode: s.insertMode, | 212 | + | kittyPendingDisplay: s.kittyPendingDisplay, |
| 213 | + | originMode: s.originMode, | |||
| 214 | + | insertMode: s.insertMode, | |||
| 204 | } | 215 | } | ||
| 205 | } | 216 | } | ||
| 217 | + | s.graphics = nil | |||
| 218 | + | s.kittyPendingDisplay = nil | |||
| 206 | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | 219 | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | ||
| 207 | s.Frame = s.buffer.frame | 220 | s.Frame = s.buffer.frame | ||
| 208 | s.Row, s.Col = 0, 0 | 221 | s.Row, s.Col = 0, 0 | ||
| @@ -229,6 +242,8 @@ func (s *Screen) exitAlternateScreen() { | |||||
| 229 | s.scrollTop = state.scrollTop | 242 | s.scrollTop = state.scrollTop | ||
| 230 | s.scrollBottom = state.scrollBottom | 243 | s.scrollBottom = state.scrollBottom | ||
| 231 | s.savedCursor = state.savedCursor | 244 | s.savedCursor = state.savedCursor | ||
| 245 | + | s.graphics = state.graphics | |||
| 246 | + | s.kittyPendingDisplay = state.kittyPendingDisplay | |||
| 232 | s.originMode = state.originMode | 247 | s.originMode = state.originMode | ||
| 233 | s.insertMode = state.insertMode | 248 | s.insertMode = state.insertMode | ||
| 234 | s.alternate = nil | 249 | s.alternate = nil | ||
M screen_resize.go +5 -0
| @@ -4,6 +4,7 @@ import renderer "github.com/bnema/vev-vt/core" | |||||
| 4 | 4 | ||||
| 5 | func (s *Screen) Resize(width, height int) { | 5 | func (s *Screen) Resize(width, height int) { | ||
| 6 | if width == s.Frame.Width && height == s.Frame.Height { | 6 | if width == s.Frame.Width && height == s.Frame.Height { | ||
| 7 | + | s.geometry.Cols, s.geometry.Rows = width, height | |||
| 7 | return | 8 | return | ||
| 8 | } | 9 | } | ||
| 9 | if width <= 0 || height <= 0 { | 10 | if width <= 0 || height <= 0 { | ||
| @@ -60,9 +61,13 @@ func (s *Screen) Resize(width, height int) { | |||||
| 60 | } | 61 | } | ||
| 61 | } | 62 | } | ||
| 62 | s.Frame = s.buffer.frame | 63 | s.Frame = s.buffer.frame | ||
| 64 | + | s.geometry.Cols, s.geometry.Rows = width, height | |||
| 63 | // A resize can split an in-flight escape sequence from the terminal state it | 65 | // A resize can split an in-flight escape sequence from the terminal state it | ||
| 64 | // was meant to mutate; keep the durable child state but discard partial bytes. | 66 | // was meant to mutate; keep the durable child state but discard partial bytes. | ||
| 65 | s.escapeBuf = s.escapeBuf[:0] | 67 | s.escapeBuf = s.escapeBuf[:0] | ||
| 68 | + | s.kittyDiscard = false | |||
| 69 | + | s.kittyDiscardEscaped = false | |||
| 70 | + | s.abortAllKittyPending() | |||
| 66 | s.resetScrollRegion() | 71 | s.resetScrollRegion() | ||
| 67 | s.fullRedraw() | 72 | s.fullRedraw() | ||
| 68 | } | 73 | } | ||
M screen_snapshot.go +11 -2
| @@ -1,6 +1,9 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 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 | + | ) | |||
| 4 | 7 | ||||
| 5 | // CursorSnapshot is the cursor state captured with a visible screen snapshot. | 8 | // CursorSnapshot is the cursor state captured with a visible screen snapshot. | ||
| 6 | type CursorSnapshot struct { | 9 | type CursorSnapshot struct { | ||
| @@ -31,6 +34,7 @@ type ScreenSnapshot struct { | |||||
| 31 | cursor CursorSnapshot | 34 | cursor CursorSnapshot | ||
| 32 | modes ModeSnapshot | 35 | modes ModeSnapshot | ||
| 33 | title string | 36 | title string | ||
| 37 | + | graphics *graphics.Snapshot | |||
| 34 | nextRowID RowID | 38 | nextRowID RowID | ||
| 35 | } | 39 | } | ||
| 36 | 40 | ||||
| @@ -67,7 +71,8 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||||
| 67 | MouseTracking: mouseTracking, | 71 | MouseTracking: mouseTracking, | ||
| 68 | MouseSGR: mouseSGR, | 72 | MouseSGR: mouseSGR, | ||
| 69 | }, | 73 | }, | ||
| 70 | - | title: s.TerminalTitle(), | 74 | + | title: s.TerminalTitle(), |
| 75 | + | graphics: s.GraphicsSnapshot(), | |||
| 71 | } | 76 | } | ||
| 72 | } | 77 | } | ||
| 73 | 78 | ||||
| @@ -114,3 +119,7 @@ func (s ScreenSnapshot) NextRowID() RowID { | |||||
| 114 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | 119 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | ||
| 115 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | 120 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | ||
| 116 | func (s ScreenSnapshot) Title() string { return s.title } | 121 | 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 } | |||