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