Merge pull request #6 from bnema/fix/kitty-icat-detection
fix(graphics): support kitten icat detection and rendering
13 files · +374 -46
M README.md +7 -6
| @@ -50,15 +50,16 @@ evidence. | |||
| 50 | 50 | The VT accepts bounded static direct transmissions used by current | |
| 51 | 51 | `kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit, | |
| 52 | 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. | |
| 53 | + | Base64 uploads with optional zlib compression; source rectangles; cell extents; | |
| 54 | + | pixel offsets; z-index; and cursor movement policy. Assets and decoded pixels | |
| 55 | + | are bounded by explicit scene and parser limits. File, temporary-file, | |
| 56 | + | shared-memory, animation, composition, relative placement, and | |
| 57 | + | Unicode-placeholder commands remain unsupported. | |
| 57 | 58 | ||
| 58 | 59 | `Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are | |
| 59 | 60 | 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. | |
| 61 | + | placements move with terminal row scrolling and are clipped by the active | |
| 62 | + | viewport; reflow and relative-placement movement remain unsupported. | |
| 62 | 63 | ||
| 63 | 64 | ### Headless graphics harness | |
| 64 | 65 | ||
| @@ -50,15 +50,16 @@ evidence. | |||||
| 50 | The VT accepts bounded static direct transmissions used by current | 50 | The VT accepts bounded static direct transmissions used by current | ||
| 51 | `kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit, | 51 | `kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit, | ||
| 52 | transmit-and-display, place, query, and supported delete operations; chunked | 52 | transmit-and-display, place, query, and supported delete operations; chunked | ||
| 53 | - | Base64 uploads; source rectangles; cell extents; pixel offsets; z-index; and | 53 | + | Base64 uploads with optional zlib compression; source rectangles; cell extents; |
| 54 | - | cursor movement policy. Assets and decoded pixels are bounded by explicit scene | 54 | + | pixel offsets; z-index; and cursor movement policy. Assets and decoded pixels |
| 55 | - | and parser limits. File, temporary-file, shared-memory, animation, composition, | 55 | + | are bounded by explicit scene and parser limits. File, temporary-file, |
| 56 | - | relative placement, and Unicode-placeholder commands remain unsupported. | 56 | + | shared-memory, animation, composition, relative placement, and |
| 57 | + | Unicode-placeholder commands remain unsupported. | |||
| 57 | 58 | ||||
| 58 | `Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are | 59 | `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 | separate from cells and history bytes; VTH3 remains text/history-only. Static | ||
| 60 | - | placements retain their configured pixel rectangles across text scrolling and | 61 | + | placements move with terminal row scrolling and are clipped by the active |
| 61 | - | reflow; row-anchored and relative-placement movement is unsupported. | 62 | + | viewport; reflow and relative-placement movement remain unsupported. |
| 62 | 63 | ||||
| 63 | ### Headless graphics harness | 64 | ### Headless graphics harness | ||
| 64 | 65 | ||||
M internal/graphicsharness/render_test.go +1 -1
| @@ -39,7 +39,7 @@ func TestRenderTerminalDemo(t *testing.T) { | |||
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 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}) | |
| 42 | + | frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1}) | |
| 43 | 43 | require.NoError(t, err) | |
| 44 | 44 | require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0)) | |
| 45 | 45 | } | |
| @@ -39,7 +39,7 @@ func TestRenderTerminalDemo(t *testing.T) { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | func TestRenderTerminalAPC(t *testing.T) { | 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}) | 42 | + | frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1}) |
| 43 | require.NoError(t, err) | 43 | require.NoError(t, err) | ||
| 44 | require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0)) | 44 | require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0)) | ||
| 45 | } | 45 | } | ||
M protocol/kittygraphics/controls.go +10 -7
| @@ -65,11 +65,11 @@ func parseControl(c *Controls, key byte, value []byte) error { | |||
| 65 | 65 | } | |
| 66 | 66 | c.Action, c.HasAction = Action(value[0]), true | |
| 67 | 67 | case ControlCompression: | |
| 68 | - | n, err := parseUint(value) | |
| 69 | - | if err != nil { | |
| 70 | - | return fieldError(key, err) | |
| 68 | + | if len(value) != 1 { | |
| 69 | + | return fmt.Errorf("%w: o=%q", ErrInvalidCommand, value) | |
| 71 | 70 | } | |
| 72 | - | c.Compression, c.HasCompression = n, true | |
| 71 | + | // Preserve unknown one-byte algorithms so Session can return ENOTSUP. | |
| 72 | + | c.Compression, c.HasCompression = Compression(value[0]), true | |
| 73 | 73 | case ControlFormat: | |
| 74 | 74 | n, err := parseUint(value) | |
| 75 | 75 | if err != nil { | |
| @@ -127,10 +127,13 @@ func parseControl(c *Controls, key byte, value []byte) error { | |||
| 127 | 127 | return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value) | |
| 128 | 128 | } | |
| 129 | 129 | case ControlTransmission: | |
| 130 | - | if len(value) != 1 || Transmission(value[0]) != TransmissionDirect { | |
| 131 | - | return fmt.Errorf("%w: t=%q", ErrUnsupported, value) | |
| 130 | + | if len(value) != 1 { | |
| 131 | + | return fmt.Errorf("%w: t=%q", ErrInvalidCommand, value) | |
| 132 | 132 | } | |
| 133 | - | c.Transmission, c.HasTransmission = TransmissionDirect, true | |
| 133 | + | // Preserve unsupported transports long enough for Session to emit the | |
| 134 | + | // protocol ENOTSUP response. Rejecting them during header parsing leaves | |
| 135 | + | // capability probes such as kitten icat waiting until timeout. | |
| 136 | + | c.Transmission, c.HasTransmission = Transmission(value[0]), true | |
| 134 | 137 | case ControlCursor: | |
| 135 | 138 | return setUint(&c.Cursor, &c.HasCursor, key, value) | |
| 136 | 139 | case ControlSourceX: | |
| @@ -65,11 +65,11 @@ func parseControl(c *Controls, key byte, value []byte) error { | |||||
| 65 | } | 65 | } | ||
| 66 | c.Action, c.HasAction = Action(value[0]), true | 66 | c.Action, c.HasAction = Action(value[0]), true | ||
| 67 | case ControlCompression: | 67 | case ControlCompression: | ||
| 68 | - | n, err := parseUint(value) | 68 | + | if len(value) != 1 { |
| 69 | - | if err != nil { | 69 | + | return fmt.Errorf("%w: o=%q", ErrInvalidCommand, value) |
| 70 | - | return fieldError(key, err) | |||
| 71 | } | 70 | } | ||
| 72 | - | c.Compression, c.HasCompression = n, true | 71 | + | // Preserve unknown one-byte algorithms so Session can return ENOTSUP. |
| 72 | + | c.Compression, c.HasCompression = Compression(value[0]), true | |||
| 73 | case ControlFormat: | 73 | case ControlFormat: | ||
| 74 | n, err := parseUint(value) | 74 | n, err := parseUint(value) | ||
| 75 | if err != nil { | 75 | if err != nil { | ||
| @@ -127,10 +127,13 @@ func parseControl(c *Controls, key byte, value []byte) error { | |||||
| 127 | return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value) | 127 | return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value) | ||
| 128 | } | 128 | } | ||
| 129 | case ControlTransmission: | 129 | case ControlTransmission: | ||
| 130 | - | if len(value) != 1 || Transmission(value[0]) != TransmissionDirect { | 130 | + | if len(value) != 1 { |
| 131 | - | return fmt.Errorf("%w: t=%q", ErrUnsupported, value) | 131 | + | return fmt.Errorf("%w: t=%q", ErrInvalidCommand, value) |
| 132 | } | 132 | } | ||
| 133 | - | c.Transmission, c.HasTransmission = TransmissionDirect, true | 133 | + | // Preserve unsupported transports long enough for Session to emit the |
| 134 | + | // protocol ENOTSUP response. Rejecting them during header parsing leaves | |||
| 135 | + | // capability probes such as kitten icat waiting until timeout. | |||
| 136 | + | c.Transmission, c.HasTransmission = Transmission(value[0]), true | |||
| 134 | case ControlCursor: | 137 | case ControlCursor: | ||
| 135 | return setUint(&c.Cursor, &c.HasCursor, key, value) | 138 | return setUint(&c.Cursor, &c.HasCursor, key, value) | ||
| 136 | case ControlSourceX: | 139 | case ControlSourceX: | ||
M protocol/kittygraphics/kittygraphics.go +6 -1
| @@ -145,6 +145,11 @@ const ( | |||
| 145 | 145 | ||
| 146 | 146 | func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG } | |
| 147 | 147 | ||
| 148 | + | // Compression is the optional compression applied to raw image data. | |
| 149 | + | type Compression byte | |
| 150 | + | ||
| 151 | + | const CompressionZlib Compression = 'z' | |
| 152 | + | ||
| 148 | 153 | // Quiet controls protocol response emission. Kitty uses q=1 for successful | |
| 149 | 154 | // operations only and q=2 for all responses. | |
| 150 | 155 | type Quiet uint8 | |
| @@ -213,7 +218,7 @@ const ( | |||
| 213 | 218 | type Controls struct { | |
| 214 | 219 | Action Action | |
| 215 | 220 | HasAction bool | |
| 216 | - | Compression uint64 | |
| 221 | + | Compression Compression | |
| 217 | 222 | HasCompression bool | |
| 218 | 223 | Format Format | |
| 219 | 224 | HasFormat bool | |
| @@ -145,6 +145,11 @@ const ( | |||||
| 145 | 145 | ||||
| 146 | func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG } | 146 | func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG } | ||
| 147 | 147 | ||||
| 148 | + | // Compression is the optional compression applied to raw image data. | |||
| 149 | + | type Compression byte | |||
| 150 | + | ||||
| 151 | + | const CompressionZlib Compression = 'z' | |||
| 152 | + | ||||
| 148 | // Quiet controls protocol response emission. Kitty uses q=1 for successful | 153 | // Quiet controls protocol response emission. Kitty uses q=1 for successful | ||
| 149 | // operations only and q=2 for all responses. | 154 | // operations only and q=2 for all responses. | ||
| 150 | type Quiet uint8 | 155 | type Quiet uint8 | ||
| @@ -213,7 +218,7 @@ const ( | |||||
| 213 | type Controls struct { | 218 | type Controls struct { | ||
| 214 | Action Action | 219 | Action Action | ||
| 215 | HasAction bool | 220 | HasAction bool | ||
| 216 | - | Compression uint64 | 221 | + | Compression Compression |
| 217 | HasCompression bool | 222 | HasCompression bool | ||
| 218 | Format Format | 223 | Format Format | ||
| 219 | HasFormat bool | 224 | HasFormat bool | ||
M protocol/kittygraphics/kittygraphics_test.go +81 -0
| @@ -2,6 +2,7 @@ package kittygraphics | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "compress/zlib" | |
| 5 | 6 | "encoding/base64" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "image" | |
| @@ -73,6 +74,78 @@ func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) { | |||
| 73 | 74 | } | |
| 74 | 75 | } | |
| 75 | 76 | ||
| 77 | + | func TestSessionAcceptsKittenZlibCompressedRGB(t *testing.T) { | |
| 78 | + | raw := []byte{ | |
| 79 | + | 255, 0, 0, 0, 255, 0, | |
| 80 | + | 0, 0, 255, 255, 255, 255, | |
| 81 | + | } | |
| 82 | + | var compressed bytes.Buffer | |
| 83 | + | zw := zlib.NewWriter(&compressed) | |
| 84 | + | if _, err := zw.Write(raw); err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 87 | + | if err := zw.Close(); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | scene := graphics.NewScene(graphics.Limits{}) | |
| 92 | + | session := NewSession(scene) | |
| 93 | + | payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes()) | |
| 94 | + | split := len(payload) / 2 | |
| 95 | + | split -= split % 4 | |
| 96 | + | first, err := session.Feed(apc("a=T,q=2,f=24,o=z,m=1,s=2,v=2", payload[:split])) | |
| 97 | + | if err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | if len(first.Mutations) != 0 { | |
| 101 | + | t.Fatalf("first chunk mutations = %#v", first.Mutations) | |
| 102 | + | } | |
| 103 | + | result, err := session.Feed(apc("m=0", payload[split:])) | |
| 104 | + | if err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement { | |
| 108 | + | t.Fatalf("mutations = %#v", result.Mutations) | |
| 109 | + | } | |
| 110 | + | assets := scene.Snapshot().Assets() | |
| 111 | + | if len(assets) != 1 || assets[0].Format() != graphics.AssetFormatRGB || !bytes.Equal(assets[0].Encoded(), raw) { | |
| 112 | + | t.Fatalf("assets = %#v", assets) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestSessionAcceptsZlibCompressedCapabilityQuery(t *testing.T) { | |
| 117 | + | raw := []byte{1, 2, 3} | |
| 118 | + | var compressed bytes.Buffer | |
| 119 | + | zw := zlib.NewWriter(&compressed) | |
| 120 | + | _, _ = zw.Write(raw) | |
| 121 | + | _ = zw.Close() | |
| 122 | + | ||
| 123 | + | session := NewSession(graphics.NewScene(graphics.Limits{})) | |
| 124 | + | payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes()) | |
| 125 | + | result, err := session.Feed(apc("a=q,i=31,f=24,o=z,s=1,v=1", payload)) | |
| 126 | + | if err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 129 | + | if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" { | |
| 130 | + | t.Fatalf("response = %q", got) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestSessionBoundsZlibDecompression(t *testing.T) { | |
| 135 | + | raw := bytes.Repeat([]byte{1}, 64) | |
| 136 | + | var compressed bytes.Buffer | |
| 137 | + | zw := zlib.NewWriter(&compressed) | |
| 138 | + | _, _ = zw.Write(raw) | |
| 139 | + | _ = zw.Close() | |
| 140 | + | ||
| 141 | + | session := NewSession(graphics.NewScene(graphics.Limits{}), Limits{MaxDecodedBytes: 32}) | |
| 142 | + | payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes()) | |
| 143 | + | _, err := session.Feed(apc("a=T,f=24,o=z,s=1,v=1", payload)) | |
| 144 | + | if !errors.Is(err, ErrPayloadTooLarge) { | |
| 145 | + | t.Fatalf("error = %v, want ErrPayloadTooLarge", err) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 76 | 149 | func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) { | |
| 77 | 150 | data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin") | |
| 78 | 151 | if err != nil { | |
| @@ -332,6 +405,14 @@ func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) { | |||
| 332 | 405 | } | |
| 333 | 406 | } | |
| 334 | 407 | ||
| 408 | + | func TestImageGeometryRejectsRawDecodedByteLimit(t *testing.T) { | |
| 409 | + | controls := Controls{Width: 2, HasWidth: true, Height: 2, HasHeight: true} | |
| 410 | + | limits := normalizeLimits(Limits{MaxDecodedBytes: 11}) | |
| 411 | + | if _, _, err := imageGeometry(make([]byte, 12), FormatRGB, controls, limits); !errors.Is(err, ErrPayloadTooLarge) { | |
| 412 | + | t.Fatalf("error = %v, want ErrPayloadTooLarge", err) | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 335 | 416 | func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) { | |
| 336 | 417 | data := pngBytes(t, 2, 2) | |
| 337 | 418 | if _, _, err := imageGeometry(data, FormatPNG, Controls{}, normalizeLimits(Limits{MaxDecodedBytes: 15})); !errors.Is(err, ErrPayloadTooLarge) { | |
| @@ -2,6 +2,7 @@ package kittygraphics | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "compress/zlib" | |||
| 5 | "encoding/base64" | 6 | "encoding/base64" | ||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "image" | 8 | "image" | ||
| @@ -73,6 +74,78 @@ func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) { | |||||
| 73 | } | 74 | } | ||
| 74 | } | 75 | } | ||
| 75 | 76 | ||||
| 77 | + | func TestSessionAcceptsKittenZlibCompressedRGB(t *testing.T) { | |||
| 78 | + | raw := []byte{ | |||
| 79 | + | 255, 0, 0, 0, 255, 0, | |||
| 80 | + | 0, 0, 255, 255, 255, 255, | |||
| 81 | + | } | |||
| 82 | + | var compressed bytes.Buffer | |||
| 83 | + | zw := zlib.NewWriter(&compressed) | |||
| 84 | + | if _, err := zw.Write(raw); err != nil { | |||
| 85 | + | t.Fatal(err) | |||
| 86 | + | } | |||
| 87 | + | if err := zw.Close(); err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | scene := graphics.NewScene(graphics.Limits{}) | |||
| 92 | + | session := NewSession(scene) | |||
| 93 | + | payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes()) | |||
| 94 | + | split := len(payload) / 2 | |||
| 95 | + | split -= split % 4 | |||
| 96 | + | first, err := session.Feed(apc("a=T,q=2,f=24,o=z,m=1,s=2,v=2", payload[:split])) | |||
| 97 | + | if err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | if len(first.Mutations) != 0 { | |||
| 101 | + | t.Fatalf("first chunk mutations = %#v", first.Mutations) | |||
| 102 | + | } | |||
| 103 | + | result, err := session.Feed(apc("m=0", payload[split:])) | |||
| 104 | + | if err != nil { | |||
| 105 | + | t.Fatal(err) | |||
| 106 | + | } | |||
| 107 | + | if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement { | |||
| 108 | + | t.Fatalf("mutations = %#v", result.Mutations) | |||
| 109 | + | } | |||
| 110 | + | assets := scene.Snapshot().Assets() | |||
| 111 | + | if len(assets) != 1 || assets[0].Format() != graphics.AssetFormatRGB || !bytes.Equal(assets[0].Encoded(), raw) { | |||
| 112 | + | t.Fatalf("assets = %#v", assets) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestSessionAcceptsZlibCompressedCapabilityQuery(t *testing.T) { | |||
| 117 | + | raw := []byte{1, 2, 3} | |||
| 118 | + | var compressed bytes.Buffer | |||
| 119 | + | zw := zlib.NewWriter(&compressed) | |||
| 120 | + | _, _ = zw.Write(raw) | |||
| 121 | + | _ = zw.Close() | |||
| 122 | + | ||||
| 123 | + | session := NewSession(graphics.NewScene(graphics.Limits{})) | |||
| 124 | + | payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes()) | |||
| 125 | + | result, err := session.Feed(apc("a=q,i=31,f=24,o=z,s=1,v=1", payload)) | |||
| 126 | + | if err != nil { | |||
| 127 | + | t.Fatal(err) | |||
| 128 | + | } | |||
| 129 | + | if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" { | |||
| 130 | + | t.Fatalf("response = %q", got) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestSessionBoundsZlibDecompression(t *testing.T) { | |||
| 135 | + | raw := bytes.Repeat([]byte{1}, 64) | |||
| 136 | + | var compressed bytes.Buffer | |||
| 137 | + | zw := zlib.NewWriter(&compressed) | |||
| 138 | + | _, _ = zw.Write(raw) | |||
| 139 | + | _ = zw.Close() | |||
| 140 | + | ||||
| 141 | + | session := NewSession(graphics.NewScene(graphics.Limits{}), Limits{MaxDecodedBytes: 32}) | |||
| 142 | + | payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes()) | |||
| 143 | + | _, err := session.Feed(apc("a=T,f=24,o=z,s=1,v=1", payload)) | |||
| 144 | + | if !errors.Is(err, ErrPayloadTooLarge) { | |||
| 145 | + | t.Fatalf("error = %v, want ErrPayloadTooLarge", err) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 76 | func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) { | 149 | func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) { | ||
| 77 | data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin") | 150 | data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin") | ||
| 78 | if err != nil { | 151 | if err != nil { | ||
| @@ -332,6 +405,14 @@ func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) { | |||||
| 332 | } | 405 | } | ||
| 333 | } | 406 | } | ||
| 334 | 407 | ||||
| 408 | + | func TestImageGeometryRejectsRawDecodedByteLimit(t *testing.T) { | |||
| 409 | + | controls := Controls{Width: 2, HasWidth: true, Height: 2, HasHeight: true} | |||
| 410 | + | limits := normalizeLimits(Limits{MaxDecodedBytes: 11}) | |||
| 411 | + | if _, _, err := imageGeometry(make([]byte, 12), FormatRGB, controls, limits); !errors.Is(err, ErrPayloadTooLarge) { | |||
| 412 | + | t.Fatalf("error = %v, want ErrPayloadTooLarge", err) | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | ||||
| 335 | func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) { | 416 | func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) { | ||
| 336 | data := pngBytes(t, 2, 2) | 417 | data := pngBytes(t, 2, 2) | ||
| 337 | if _, _, err := imageGeometry(data, FormatPNG, Controls{}, normalizeLimits(Limits{MaxDecodedBytes: 15})); !errors.Is(err, ErrPayloadTooLarge) { | 418 | if _, _, err := imageGeometry(data, FormatPNG, Controls{}, normalizeLimits(Limits{MaxDecodedBytes: 15})); !errors.Is(err, ErrPayloadTooLarge) { | ||
M protocol/kittygraphics/session.go +36 -4
| @@ -2,8 +2,10 @@ package kittygraphics | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "compress/zlib" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "image/png" | |
| 8 | + | "io" | |
| 7 | 9 | "math" | |
| 8 | 10 | "strconv" | |
| 9 | 11 | ||
| @@ -207,7 +209,7 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) { | |||
| 207 | 209 | if !c.HasAction { | |
| 208 | 210 | action = ActionTransmit | |
| 209 | 211 | } | |
| 210 | - | if c.HasCompression && c.Compression != 0 { | |
| 212 | + | if c.HasCompression && c.Compression != CompressionZlib { | |
| 211 | 213 | return s.failure(c, controlsImageID(c), ErrUnsupported) | |
| 212 | 214 | } | |
| 213 | 215 | if c.HasTransmission && c.Transmission != TransmissionDirect { | |
| @@ -299,7 +301,7 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, | |||
| 299 | 301 | if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes { | |
| 300 | 302 | return s.failure(value.controls, value.imageID, ErrPayloadTooLarge) | |
| 301 | 303 | } | |
| 302 | - | decoded, err := DecodeBase64(value.payload) | |
| 304 | + | decoded, err := decodeImagePayload(value.payload, value.controls, s.limits) | |
| 303 | 305 | if err != nil { | |
| 304 | 306 | return s.failure(value.controls, value.imageID, err) | |
| 305 | 307 | } | |
| @@ -390,6 +392,36 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, | |||
| 390 | 392 | return s.success(value.controls, value.imageID), mutation, nil | |
| 391 | 393 | } | |
| 392 | 394 | ||
| 395 | + | func decodeImagePayload(payload []byte, controls Controls, limits Limits) ([]byte, error) { | |
| 396 | + | decoded, err := DecodeBase64(payload) | |
| 397 | + | if err != nil { | |
| 398 | + | return nil, err | |
| 399 | + | } | |
| 400 | + | if !controls.HasCompression { | |
| 401 | + | return decoded, nil | |
| 402 | + | } | |
| 403 | + | if controls.Compression != CompressionZlib { | |
| 404 | + | return nil, ErrUnsupported | |
| 405 | + | } | |
| 406 | + | zr, err := zlib.NewReader(bytes.NewReader(decoded)) | |
| 407 | + | if err != nil { | |
| 408 | + | return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err) | |
| 409 | + | } | |
| 410 | + | defer func() { _ = zr.Close() }() | |
| 411 | + | limit := limits.MaxDecodedBytes | |
| 412 | + | if limit >= math.MaxInt64 { | |
| 413 | + | limit = math.MaxInt64 - 1 | |
| 414 | + | } | |
| 415 | + | decompressed, err := io.ReadAll(io.LimitReader(zr, int64(limit)+1)) | |
| 416 | + | if err != nil { | |
| 417 | + | return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err) | |
| 418 | + | } | |
| 419 | + | if uint64(len(decompressed)) > limits.MaxDecodedBytes { | |
| 420 | + | return nil, ErrPayloadTooLarge | |
| 421 | + | } | |
| 422 | + | return decompressed, nil | |
| 423 | + | } | |
| 424 | + | ||
| 393 | 425 | func assetFormat(format Format) graphics.AssetFormat { | |
| 394 | 426 | switch format { | |
| 395 | 427 | case FormatRGB: | |
| @@ -452,7 +484,7 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64 | |||
| 452 | 484 | return 0, 0, ErrPayloadTooLarge | |
| 453 | 485 | } | |
| 454 | 486 | expected, ok := checkedProduct(c.Width, rowBytes) | |
| 455 | - | if !ok { | |
| 487 | + | if !ok || expected > limits.MaxDecodedBytes { | |
| 456 | 488 | return 0, 0, ErrPayloadTooLarge | |
| 457 | 489 | } | |
| 458 | 490 | if uint64(len(data)) != expected { | |
| @@ -617,7 +649,7 @@ func (s *Session) query(command Command) ([][]byte, *Mutation, error) { | |||
| 617 | 649 | if len(command.Payload) == 0 { | |
| 618 | 650 | return s.failure(c, responseImageID(c), ErrInvalidCommand) | |
| 619 | 651 | } | |
| 620 | - | decoded, err := DecodeBase64(command.Payload) | |
| 652 | + | decoded, err := decodeImagePayload(command.Payload, c, s.limits) | |
| 621 | 653 | if err != nil { | |
| 622 | 654 | return s.failure(c, responseImageID(c), err) | |
| 623 | 655 | } | |
| @@ -2,8 +2,10 @@ package kittygraphics | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "compress/zlib" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "image/png" | 7 | "image/png" | ||
| 8 | + | "io" | |||
| 7 | "math" | 9 | "math" | ||
| 8 | "strconv" | 10 | "strconv" | ||
| 9 | 11 | ||||
| @@ -207,7 +209,7 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) { | |||||
| 207 | if !c.HasAction { | 209 | if !c.HasAction { | ||
| 208 | action = ActionTransmit | 210 | action = ActionTransmit | ||
| 209 | } | 211 | } | ||
| 210 | - | if c.HasCompression && c.Compression != 0 { | 212 | + | if c.HasCompression && c.Compression != CompressionZlib { |
| 211 | return s.failure(c, controlsImageID(c), ErrUnsupported) | 213 | return s.failure(c, controlsImageID(c), ErrUnsupported) | ||
| 212 | } | 214 | } | ||
| 213 | if c.HasTransmission && c.Transmission != TransmissionDirect { | 215 | if c.HasTransmission && c.Transmission != TransmissionDirect { | ||
| @@ -299,7 +301,7 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, | |||||
| 299 | if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes { | 301 | if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes { | ||
| 300 | return s.failure(value.controls, value.imageID, ErrPayloadTooLarge) | 302 | return s.failure(value.controls, value.imageID, ErrPayloadTooLarge) | ||
| 301 | } | 303 | } | ||
| 302 | - | decoded, err := DecodeBase64(value.payload) | 304 | + | decoded, err := decodeImagePayload(value.payload, value.controls, s.limits) |
| 303 | if err != nil { | 305 | if err != nil { | ||
| 304 | return s.failure(value.controls, value.imageID, err) | 306 | return s.failure(value.controls, value.imageID, err) | ||
| 305 | } | 307 | } | ||
| @@ -390,6 +392,36 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, | |||||
| 390 | return s.success(value.controls, value.imageID), mutation, nil | 392 | return s.success(value.controls, value.imageID), mutation, nil | ||
| 391 | } | 393 | } | ||
| 392 | 394 | ||||
| 395 | + | func decodeImagePayload(payload []byte, controls Controls, limits Limits) ([]byte, error) { | |||
| 396 | + | decoded, err := DecodeBase64(payload) | |||
| 397 | + | if err != nil { | |||
| 398 | + | return nil, err | |||
| 399 | + | } | |||
| 400 | + | if !controls.HasCompression { | |||
| 401 | + | return decoded, nil | |||
| 402 | + | } | |||
| 403 | + | if controls.Compression != CompressionZlib { | |||
| 404 | + | return nil, ErrUnsupported | |||
| 405 | + | } | |||
| 406 | + | zr, err := zlib.NewReader(bytes.NewReader(decoded)) | |||
| 407 | + | if err != nil { | |||
| 408 | + | return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err) | |||
| 409 | + | } | |||
| 410 | + | defer func() { _ = zr.Close() }() | |||
| 411 | + | limit := limits.MaxDecodedBytes | |||
| 412 | + | if limit >= math.MaxInt64 { | |||
| 413 | + | limit = math.MaxInt64 - 1 | |||
| 414 | + | } | |||
| 415 | + | decompressed, err := io.ReadAll(io.LimitReader(zr, int64(limit)+1)) | |||
| 416 | + | if err != nil { | |||
| 417 | + | return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err) | |||
| 418 | + | } | |||
| 419 | + | if uint64(len(decompressed)) > limits.MaxDecodedBytes { | |||
| 420 | + | return nil, ErrPayloadTooLarge | |||
| 421 | + | } | |||
| 422 | + | return decompressed, nil | |||
| 423 | + | } | |||
| 424 | + | ||||
| 393 | func assetFormat(format Format) graphics.AssetFormat { | 425 | func assetFormat(format Format) graphics.AssetFormat { | ||
| 394 | switch format { | 426 | switch format { | ||
| 395 | case FormatRGB: | 427 | case FormatRGB: | ||
| @@ -452,7 +484,7 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64 | |||||
| 452 | return 0, 0, ErrPayloadTooLarge | 484 | return 0, 0, ErrPayloadTooLarge | ||
| 453 | } | 485 | } | ||
| 454 | expected, ok := checkedProduct(c.Width, rowBytes) | 486 | expected, ok := checkedProduct(c.Width, rowBytes) | ||
| 455 | - | if !ok { | 487 | + | if !ok || expected > limits.MaxDecodedBytes { |
| 456 | return 0, 0, ErrPayloadTooLarge | 488 | return 0, 0, ErrPayloadTooLarge | ||
| 457 | } | 489 | } | ||
| 458 | if uint64(len(data)) != expected { | 490 | if uint64(len(data)) != expected { | ||
| @@ -617,7 +649,7 @@ func (s *Session) query(command Command) ([][]byte, *Mutation, error) { | |||||
| 617 | if len(command.Payload) == 0 { | 649 | if len(command.Payload) == 0 { | ||
| 618 | return s.failure(c, responseImageID(c), ErrInvalidCommand) | 650 | return s.failure(c, responseImageID(c), ErrInvalidCommand) | ||
| 619 | } | 651 | } | ||
| 620 | - | decoded, err := DecodeBase64(command.Payload) | 652 | + | decoded, err := decodeImagePayload(command.Payload, c, s.limits) |
| 621 | if err != nil { | 653 | if err != nil { | ||
| 622 | return s.failure(c, responseImageID(c), err) | 654 | return s.failure(c, responseImageID(c), err) | ||
| 623 | } | 655 | } | ||
M protocol/terminalquery/query.go +4 -2
| @@ -176,7 +176,7 @@ func validDA1Response(data []byte) bool { | |||
| 176 | 176 | // not sufficient for a direct graphics declaration. The parameter body is | |
| 177 | 177 | // a non-empty semicolon-separated list of decimal numbers. | |
| 178 | 178 | body := data[3 : len(data)-1] | |
| 179 | - | if len(body) == 0 || body[0] == ';' || body[len(body)-1] == ';' { | |
| 179 | + | if len(body) == 0 || body[0] == ';' { | |
| 180 | 180 | return false | |
| 181 | 181 | } | |
| 182 | 182 | previousSeparator := false | |
| @@ -193,7 +193,9 @@ func validDA1Response(data []byte) bool { | |||
| 193 | 193 | return false | |
| 194 | 194 | } | |
| 195 | 195 | } | |
| 196 | - | return !previousSeparator | |
| 196 | + | // Kitty terminates its DA1 parameter list with a separator. DEC treats the | |
| 197 | + | // resulting empty final parameter as zero, so both forms are valid. | |
| 198 | + | return true | |
| 197 | 199 | } | |
| 198 | 200 | ||
| 199 | 201 | func suffixPrefixLen(data []byte) int { | |
| @@ -176,7 +176,7 @@ func validDA1Response(data []byte) bool { | |||||
| 176 | // not sufficient for a direct graphics declaration. The parameter body is | 176 | // not sufficient for a direct graphics declaration. The parameter body is | ||
| 177 | // a non-empty semicolon-separated list of decimal numbers. | 177 | // a non-empty semicolon-separated list of decimal numbers. | ||
| 178 | body := data[3 : len(data)-1] | 178 | body := data[3 : len(data)-1] | ||
| 179 | - | if len(body) == 0 || body[0] == ';' || body[len(body)-1] == ';' { | 179 | + | if len(body) == 0 || body[0] == ';' { |
| 180 | return false | 180 | return false | ||
| 181 | } | 181 | } | ||
| 182 | previousSeparator := false | 182 | previousSeparator := false | ||
| @@ -193,7 +193,9 @@ func validDA1Response(data []byte) bool { | |||||
| 193 | return false | 193 | return false | ||
| 194 | } | 194 | } | ||
| 195 | } | 195 | } | ||
| 196 | - | return !previousSeparator | 196 | + | // Kitty terminates its DA1 parameter list with a separator. DEC treats the |
| 197 | + | // resulting empty final parameter as zero, so both forms are valid. | |||
| 198 | + | return true | |||
| 197 | } | 199 | } | ||
| 198 | 200 | ||||
| 199 | func suffixPrefixLen(data []byte) int { | 201 | func suffixPrefixLen(data []byte) int { | ||
M protocol/terminalquery/query_test.go +1 -0
| @@ -15,6 +15,7 @@ func TestProbeRequiresKittyAndDA1(t *testing.T) { | |||
| 15 | 15 | unrelated string | |
| 16 | 16 | }{ | |
| 17 | 17 | {name: "both and input", data: "x\x1b[?1;2c" + "\x1b_Gi=31;OK\x1b\\y", kitty: true, da1: true, unrelated: "xy"}, | |
| 18 | + | {name: "kitty DA1 with trailing separator", data: "\x1b_Gi=31;OK\x1b\\\x1b[?62;52;c", kitty: true, da1: true}, | |
| 18 | 19 | {name: "secondary DA is insufficient", data: "\x1b[>1;2c\x1b_Gi=31;OK\x1b\\", kitty: true, unrelated: "\x1b[>1;2c"}, | |
| 19 | 20 | {name: "ordinary input", data: "hello", unrelated: "hello"}, | |
| 20 | 21 | } { | |
| @@ -15,6 +15,7 @@ func TestProbeRequiresKittyAndDA1(t *testing.T) { | |||||
| 15 | unrelated string | 15 | unrelated string | ||
| 16 | }{ | 16 | }{ | ||
| 17 | {name: "both and input", data: "x\x1b[?1;2c" + "\x1b_Gi=31;OK\x1b\\y", kitty: true, da1: true, unrelated: "xy"}, | 17 | {name: "both and input", data: "x\x1b[?1;2c" + "\x1b_Gi=31;OK\x1b\\y", kitty: true, da1: true, unrelated: "xy"}, | ||
| 18 | + | {name: "kitty DA1 with trailing separator", data: "\x1b_Gi=31;OK\x1b\\\x1b[?62;52;c", kitty: true, da1: true}, | |||
| 18 | {name: "secondary DA is insufficient", data: "\x1b[>1;2c\x1b_Gi=31;OK\x1b\\", kitty: true, unrelated: "\x1b[>1;2c"}, | 19 | {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 | {name: "ordinary input", data: "hello", unrelated: "hello"}, | ||
| 20 | } { | 21 | } { | ||
M query_test.go +3 -3
| @@ -13,8 +13,8 @@ func TestScreenQueryResponses(t *testing.T) { | |||
| 13 | 13 | input string | |
| 14 | 14 | want string | |
| 15 | 15 | }{ | |
| 16 | - | {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?6c"}, | |
| 17 | - | {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?6c"}, | |
| 16 | + | {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?62;22c"}, | |
| 17 | + | {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?62;22c"}, | |
| 18 | 18 | {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"}, | |
| 19 | 19 | {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"}, | |
| 20 | 20 | {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"}, | |
| @@ -35,7 +35,7 @@ func TestScreenQueryResponses(t *testing.T) { | |||
| 35 | 35 | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | |
| 36 | 36 | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | |
| 37 | 37 | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | |
| 38 | - | {name: "DA split across writes", input: "", want: "\x1b[?6c"}, | |
| 38 | + | {name: "DA split across writes", input: "", want: "\x1b[?62;22c"}, | |
| 39 | 39 | } | |
| 40 | 40 | for _, tc := range cases { | |
| 41 | 41 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -13,8 +13,8 @@ func TestScreenQueryResponses(t *testing.T) { | |||||
| 13 | input string | 13 | input string | ||
| 14 | want string | 14 | want string | ||
| 15 | }{ | 15 | }{ | ||
| 16 | - | {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?6c"}, | 16 | + | {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?62;22c"}, |
| 17 | - | {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?6c"}, | 17 | + | {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?62;22c"}, |
| 18 | {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"}, | 18 | {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"}, | ||
| 19 | {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"}, | 19 | {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"}, | ||
| 20 | {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"}, | 20 | {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"}, | ||
| @@ -35,7 +35,7 @@ func TestScreenQueryResponses(t *testing.T) { | |||||
| 35 | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | 35 | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | ||
| 36 | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | 36 | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | ||
| 37 | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | 37 | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | ||
| 38 | - | {name: "DA split across writes", input: "", want: "\x1b[?6c"}, | 38 | + | {name: "DA split across writes", input: "", want: "\x1b[?62;22c"}, |
| 39 | } | 39 | } | ||
| 40 | for _, tc := range cases { | 40 | for _, tc := range cases { | ||
| 41 | t.Run(tc.name, func(t *testing.T) { | 41 | t.Run(tc.name, func(t *testing.T) { | ||
M screen.go +78 -20
| @@ -422,7 +422,8 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) { | |||
| 422 | 422 | } | |
| 423 | 423 | if len(result.Mutations) != 0 { | |
| 424 | 424 | if display { | |
| 425 | - | s.applyKittyCursorMovement(movementControls) | |
| 425 | + | mutation := result.Mutations[len(result.Mutations)-1] | |
| 426 | + | s.applyKittyCursorMovement(movementControls, mutation) | |
| 426 | 427 | s.kittyPendingDisplay = nil | |
| 427 | 428 | } | |
| 428 | 429 | // Graphics are rendered independently from Frame. A graphics mutation | |
| @@ -433,11 +434,18 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) { | |||
| 433 | 434 | ||
| 434 | 435 | func (s *Screen) kittyCursorX() uint64 { | |
| 435 | 436 | if cellWidth, _, ok := s.kittyCellPixels(); ok { | |
| 436 | - | return uint64(s.Col * cellWidth) | |
| 437 | + | return uint64(s.kittyCursorColumn() * cellWidth) | |
| 437 | 438 | } | |
| 438 | 439 | return 0 | |
| 439 | 440 | } | |
| 440 | 441 | ||
| 442 | + | func (s *Screen) kittyCursorColumn() int { | |
| 443 | + | if s.Frame.Width <= 0 { | |
| 444 | + | return 0 | |
| 445 | + | } | |
| 446 | + | return clamp(s.Col, 0, s.Frame.Width-1) | |
| 447 | + | } | |
| 448 | + | ||
| 441 | 449 | func (s *Screen) kittyCursorY() uint64 { | |
| 442 | 450 | _, cellHeight, ok := s.kittyCellPixels() | |
| 443 | 451 | if ok { | |
| @@ -458,35 +466,85 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) { | |||
| 458 | 466 | return width, height, true | |
| 459 | 467 | } | |
| 460 | 468 | ||
| 461 | - | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) { | |
| 462 | - | if c.HasCursor && c.Cursor == 1 { | |
| 469 | + | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) { | |
| 470 | + | if c.HasCursor && c.Cursor == 1 || s.Frame.Width <= 0 || s.Frame.Height <= 0 { | |
| 471 | + | return | |
| 472 | + | } | |
| 473 | + | columns, rows := s.kittyPlacementGridSize(c, mutation) | |
| 474 | + | targetColumn := s.kittyCursorColumn() + columns | |
| 475 | + | wraps := targetColumn >= s.Frame.Width | |
| 476 | + | rowMoves := max(rows-1, 0) | |
| 477 | + | if wraps { | |
| 478 | + | rowMoves++ | |
| 479 | + | } | |
| 480 | + | s.advanceKittyCursorRows(rowMoves) | |
| 481 | + | if wraps { | |
| 482 | + | s.Col = 0 | |
| 483 | + | } else { | |
| 484 | + | s.Col = targetColumn | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | func (s *Screen) advanceKittyCursorRows(rows int) { | |
| 489 | + | if rows <= 0 || s.Frame.Height <= 0 { | |
| 490 | + | return | |
| 491 | + | } | |
| 492 | + | if s.Row >= s.scrollTop && s.Row <= s.scrollBottom { | |
| 493 | + | beforeScroll := s.scrollBottom - s.Row | |
| 494 | + | if rows <= beforeScroll { | |
| 495 | + | s.Row += rows | |
| 496 | + | return | |
| 497 | + | } | |
| 498 | + | s.Row = s.scrollBottom | |
| 499 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll) | |
| 463 | 500 | return | |
| 464 | 501 | } | |
| 465 | - | columns, rows := 1, 1 | |
| 502 | + | s.Row = min(s.Row+rows, s.Frame.Height-1) | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) { | |
| 506 | + | columns, rows = 1, 1 | |
| 466 | 507 | if c.HasColumns { | |
| 467 | - | columns = kittyCursorAdvance(c.Columns) | |
| 508 | + | columns = max(kittyCursorAdvance(c.Columns), 1) | |
| 468 | 509 | } | |
| 469 | 510 | if c.HasRows { | |
| 470 | - | rows = kittyCursorAdvance(c.Rows) | |
| 511 | + | rows = max(kittyCursorAdvance(c.Rows), 1) | |
| 512 | + | } | |
| 513 | + | if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil { | |
| 514 | + | return columns, rows | |
| 515 | + | } | |
| 516 | + | placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID) | |
| 517 | + | if !ok { | |
| 518 | + | return columns, rows | |
| 471 | 519 | } | |
| 472 | - | s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1) | |
| 473 | - | s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow()) | |
| 520 | + | cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels() | |
| 521 | + | if !pixelsKnown { | |
| 522 | + | return columns, rows | |
| 523 | + | } | |
| 524 | + | destination := placement.Destination() | |
| 525 | + | if !c.HasColumns { | |
| 526 | + | offset := destination.X % int64(cellWidth) | |
| 527 | + | columns = kittyPixelCellSpan(offset, destination.Width, cellWidth) | |
| 528 | + | } | |
| 529 | + | if !c.HasRows { | |
| 530 | + | offset := destination.Y % int64(cellHeight) | |
| 531 | + | rows = kittyPixelCellSpan(offset, destination.Height, cellHeight) | |
| 532 | + | } | |
| 533 | + | return columns, rows | |
| 474 | 534 | } | |
| 475 | 535 | ||
| 476 | - | func advanceKittyCursor(current, amount, minimum, maximum int) int { | |
| 477 | - | if maximum < minimum { | |
| 478 | - | return minimum | |
| 536 | + | func kittyPixelCellSpan(offset, size int64, cellSize int) int { | |
| 537 | + | if offset < 0 || size <= 0 || cellSize <= 0 { | |
| 538 | + | return 1 | |
| 479 | 539 | } | |
| 480 | - | if current < minimum { | |
| 481 | - | current = minimum | |
| 540 | + | span := (offset + size + int64(cellSize) - 1) / int64(cellSize) | |
| 541 | + | if span <= 0 { | |
| 542 | + | return 1 | |
| 482 | 543 | } | |
| 483 | - | if current > maximum { | |
| 484 | - | current = maximum | |
| 485 | - | } | |
| 486 | - | if amount > maximum-current { | |
| 487 | - | return maximum | |
| 544 | + | if span > int64(^uint(0)>>1) { | |
| 545 | + | return int(^uint(0) >> 1) | |
| 488 | 546 | } | |
| 489 | - | return current + amount | |
| 547 | + | return int(span) | |
| 490 | 548 | } | |
| 491 | 549 | ||
| 492 | 550 | func kittyCursorAdvance(value uint64) int { | |
| @@ -422,7 +422,8 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) { | |||||
| 422 | } | 422 | } | ||
| 423 | if len(result.Mutations) != 0 { | 423 | if len(result.Mutations) != 0 { | ||
| 424 | if display { | 424 | if display { | ||
| 425 | - | s.applyKittyCursorMovement(movementControls) | 425 | + | mutation := result.Mutations[len(result.Mutations)-1] |
| 426 | + | s.applyKittyCursorMovement(movementControls, mutation) | |||
| 426 | s.kittyPendingDisplay = nil | 427 | s.kittyPendingDisplay = nil | ||
| 427 | } | 428 | } | ||
| 428 | // Graphics are rendered independently from Frame. A graphics mutation | 429 | // Graphics are rendered independently from Frame. A graphics mutation | ||
| @@ -433,11 +434,18 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) { | |||||
| 433 | 434 | ||||
| 434 | func (s *Screen) kittyCursorX() uint64 { | 435 | func (s *Screen) kittyCursorX() uint64 { | ||
| 435 | if cellWidth, _, ok := s.kittyCellPixels(); ok { | 436 | if cellWidth, _, ok := s.kittyCellPixels(); ok { | ||
| 436 | - | return uint64(s.Col * cellWidth) | 437 | + | return uint64(s.kittyCursorColumn() * cellWidth) |
| 437 | } | 438 | } | ||
| 438 | return 0 | 439 | return 0 | ||
| 439 | } | 440 | } | ||
| 440 | 441 | ||||
| 442 | + | func (s *Screen) kittyCursorColumn() int { | |||
| 443 | + | if s.Frame.Width <= 0 { | |||
| 444 | + | return 0 | |||
| 445 | + | } | |||
| 446 | + | return clamp(s.Col, 0, s.Frame.Width-1) | |||
| 447 | + | } | |||
| 448 | + | ||||
| 441 | func (s *Screen) kittyCursorY() uint64 { | 449 | func (s *Screen) kittyCursorY() uint64 { | ||
| 442 | _, cellHeight, ok := s.kittyCellPixels() | 450 | _, cellHeight, ok := s.kittyCellPixels() | ||
| 443 | if ok { | 451 | if ok { | ||
| @@ -458,35 +466,85 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) { | |||||
| 458 | return width, height, true | 466 | return width, height, true | ||
| 459 | } | 467 | } | ||
| 460 | 468 | ||||
| 461 | - | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) { | 469 | + | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) { |
| 462 | - | if c.HasCursor && c.Cursor == 1 { | 470 | + | if c.HasCursor && c.Cursor == 1 || s.Frame.Width <= 0 || s.Frame.Height <= 0 { |
| 471 | + | return | |||
| 472 | + | } | |||
| 473 | + | columns, rows := s.kittyPlacementGridSize(c, mutation) | |||
| 474 | + | targetColumn := s.kittyCursorColumn() + columns | |||
| 475 | + | wraps := targetColumn >= s.Frame.Width | |||
| 476 | + | rowMoves := max(rows-1, 0) | |||
| 477 | + | if wraps { | |||
| 478 | + | rowMoves++ | |||
| 479 | + | } | |||
| 480 | + | s.advanceKittyCursorRows(rowMoves) | |||
| 481 | + | if wraps { | |||
| 482 | + | s.Col = 0 | |||
| 483 | + | } else { | |||
| 484 | + | s.Col = targetColumn | |||
| 485 | + | } | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | func (s *Screen) advanceKittyCursorRows(rows int) { | |||
| 489 | + | if rows <= 0 || s.Frame.Height <= 0 { | |||
| 490 | + | return | |||
| 491 | + | } | |||
| 492 | + | if s.Row >= s.scrollTop && s.Row <= s.scrollBottom { | |||
| 493 | + | beforeScroll := s.scrollBottom - s.Row | |||
| 494 | + | if rows <= beforeScroll { | |||
| 495 | + | s.Row += rows | |||
| 496 | + | return | |||
| 497 | + | } | |||
| 498 | + | s.Row = s.scrollBottom | |||
| 499 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll) | |||
| 463 | return | 500 | return | ||
| 464 | } | 501 | } | ||
| 465 | - | columns, rows := 1, 1 | 502 | + | s.Row = min(s.Row+rows, s.Frame.Height-1) |
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) { | |||
| 506 | + | columns, rows = 1, 1 | |||
| 466 | if c.HasColumns { | 507 | if c.HasColumns { | ||
| 467 | - | columns = kittyCursorAdvance(c.Columns) | 508 | + | columns = max(kittyCursorAdvance(c.Columns), 1) |
| 468 | } | 509 | } | ||
| 469 | if c.HasRows { | 510 | if c.HasRows { | ||
| 470 | - | rows = kittyCursorAdvance(c.Rows) | 511 | + | rows = max(kittyCursorAdvance(c.Rows), 1) |
| 512 | + | } | |||
| 513 | + | if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil { | |||
| 514 | + | return columns, rows | |||
| 515 | + | } | |||
| 516 | + | placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID) | |||
| 517 | + | if !ok { | |||
| 518 | + | return columns, rows | |||
| 471 | } | 519 | } | ||
| 472 | - | s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1) | 520 | + | cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels() |
| 473 | - | s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow()) | 521 | + | if !pixelsKnown { |
| 522 | + | return columns, rows | |||
| 523 | + | } | |||
| 524 | + | destination := placement.Destination() | |||
| 525 | + | if !c.HasColumns { | |||
| 526 | + | offset := destination.X % int64(cellWidth) | |||
| 527 | + | columns = kittyPixelCellSpan(offset, destination.Width, cellWidth) | |||
| 528 | + | } | |||
| 529 | + | if !c.HasRows { | |||
| 530 | + | offset := destination.Y % int64(cellHeight) | |||
| 531 | + | rows = kittyPixelCellSpan(offset, destination.Height, cellHeight) | |||
| 532 | + | } | |||
| 533 | + | return columns, rows | |||
| 474 | } | 534 | } | ||
| 475 | 535 | ||||
| 476 | - | func advanceKittyCursor(current, amount, minimum, maximum int) int { | 536 | + | func kittyPixelCellSpan(offset, size int64, cellSize int) int { |
| 477 | - | if maximum < minimum { | 537 | + | if offset < 0 || size <= 0 || cellSize <= 0 { |
| 478 | - | return minimum | 538 | + | return 1 |
| 479 | } | 539 | } | ||
| 480 | - | if current < minimum { | 540 | + | span := (offset + size + int64(cellSize) - 1) / int64(cellSize) |
| 481 | - | current = minimum | 541 | + | if span <= 0 { |
| 542 | + | return 1 | |||
| 482 | } | 543 | } | ||
| 483 | - | if current > maximum { | 544 | + | if span > int64(^uint(0)>>1) { |
| 484 | - | current = maximum | 545 | + | return int(^uint(0) >> 1) |
| 485 | - | } | |||
| 486 | - | if amount > maximum-current { | |||
| 487 | - | return maximum | |||
| 488 | } | 546 | } | ||
| 489 | - | return current + amount | 547 | + | return int(span) |
| 490 | } | 548 | } | ||
| 491 | 549 | ||||
| 492 | func kittyCursorAdvance(value uint64) int { | 550 | func kittyCursorAdvance(value uint64) int { | ||
M screen_csi.go +4 -1
| @@ -31,7 +31,10 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 31 | 31 | case 'c': | |
| 32 | 32 | switch params { | |
| 33 | 33 | case "", "0": | |
| 34 | - | s.respond([]byte("\x1b[?6c")) | |
| 34 | + | // Identify as a VT220-compatible terminal with ANSI color. Modern | |
| 35 | + | // capability probes require a DA1 response with a conformance level; | |
| 36 | + | // the shorter VT102 response is ignored by kitten icat. | |
| 37 | + | s.respond([]byte("\x1b[?62;22c")) | |
| 35 | 38 | case ">", ">0": | |
| 36 | 39 | s.respond([]byte("\x1b[>0;0;0c")) | |
| 37 | 40 | } | |
| @@ -31,7 +31,10 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 31 | case 'c': | 31 | case 'c': | ||
| 32 | switch params { | 32 | switch params { | ||
| 33 | case "", "0": | 33 | case "", "0": | ||
| 34 | - | s.respond([]byte("\x1b[?6c")) | 34 | + | // Identify as a VT220-compatible terminal with ANSI color. Modern |
| 35 | + | // capability probes require a DA1 response with a conformance level; | |||
| 36 | + | // the shorter VT102 response is ignored by kitten icat. | |||
| 37 | + | s.respond([]byte("\x1b[?62;22c")) | |||
| 35 | case ">", ">0": | 38 | case ">", ">0": | ||
| 36 | s.respond([]byte("\x1b[>0;0;0c")) | 39 | s.respond([]byte("\x1b[>0;0;0c")) | ||
| 37 | } | 40 | } | ||
M screen_graphics_test.go +92 -1
| @@ -2,6 +2,7 @@ package vt | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/base64" | |
| 5 | + | "math" | |
| 5 | 6 | "strconv" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| @@ -42,6 +43,24 @@ func TestScreenKittyGraphicsFragmentedAPCAndResponse(t *testing.T) { | |||
| 42 | 43 | require.Equal(t, uint64(1), snapshot.Usage().Placements) | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 46 | + | func TestScreenKittyIcatDetectionResponses(t *testing.T) { | |
| 47 | + | screen := NewScreen(80, 24) | |
| 48 | + | var responses []string | |
| 49 | + | screen.OnResponse = func(response []byte) { responses = append(responses, string(response)) } | |
| 50 | + | ||
| 51 | + | screen.Write([]byte("\x1b_Ga=q,f=24,s=1,v=1,S=3,i=1;MTIz\x1b\\")) | |
| 52 | + | screen.Write([]byte("\x1b_Ga=q,f=24,t=t,s=1,v=1,S=47,i=2;L2Rldi9zaG0va2l0dHktdHR5LWdyYXBoaWNzLXByb3RvY29sLTMzMTU3NTkxNjc\x1b\\")) | |
| 53 | + | screen.Write([]byte("\x1b_Ga=q,f=24,t=s,s=1,v=1,S=18,i=3;aWNhdC1aQlJCWFdNQ0lIQ0ZD\x1b\\")) | |
| 54 | + | screen.Write([]byte("\x1b[c")) | |
| 55 | + | ||
| 56 | + | require.Equal(t, []string{ | |
| 57 | + | "\x1b_Gi=1;OK\x1b\\", | |
| 58 | + | "\x1b_Gi=2;ENOTSUP\x1b\\", | |
| 59 | + | "\x1b_Gi=3;ENOTSUP\x1b\\", | |
| 60 | + | "\x1b[?62;22c", | |
| 61 | + | }, responses) | |
| 62 | + | } | |
| 63 | + | ||
| 45 | 64 | func TestScreenKittyGraphicsPrimaryAndAlternateState(t *testing.T) { | |
| 46 | 65 | screen := NewScreen(8, 3) | |
| 47 | 66 | screen.Write(screenKittyImageAPC("T")) | |
| @@ -215,7 +234,7 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) { | |||
| 215 | 234 | require.Equal(t, 2, screen.CursorCol()) | |
| 216 | 235 | ||
| 217 | 236 | 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()) | |
| 237 | + | require.Equal(t, 1, screen.CursorRow()) | |
| 219 | 238 | require.Equal(t, 3, screen.CursorCol()) | |
| 220 | 239 | ||
| 221 | 240 | screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\")) | |
| @@ -226,3 +245,75 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) { | |||
| 226 | 245 | require.Equal(t, 2, screen.CursorRow()) | |
| 227 | 246 | require.Equal(t, 3, screen.CursorCol()) | |
| 228 | 247 | } | |
| 248 | + | ||
| 249 | + | func TestScreenKittyPlacementNormalizesDeferredWrapCursor(t *testing.T) { | |
| 250 | + | screen := NewScreen(3, 3) | |
| 251 | + | screen.SetGeometry(Geometry{Cols: 3, Rows: 3, PixelWidth: 30, PixelHeight: 30}) | |
| 252 | + | screen.Write([]byte("abc")) | |
| 253 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 4)) | |
| 254 | + | ||
| 255 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\")) | |
| 256 | + | ||
| 257 | + | placement := screen.GraphicsSnapshot().Placements()[0] | |
| 258 | + | require.Equal(t, graphics.PixelRect{X: 20, Width: 1, Height: 1}, placement.Destination()) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) { | |
| 262 | + | screen := NewScreen(10, 5) | |
| 263 | + | screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50}) | |
| 264 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*25*4)) | |
| 265 | + | ||
| 266 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=25;" + payload + "\x1b\\")) | |
| 267 | + | ||
| 268 | + | // A 15x25 image occupies 2x3 cells. Kitty leaves the cursor immediately to | |
| 269 | + | // the right of the placement and on its final occupied row. | |
| 270 | + | require.Equal(t, 2, screen.CursorRow()) | |
| 271 | + | require.Equal(t, 2, screen.CursorCol()) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestScreenKittyPlacementScrollsWithTallImageCursorAdvance(t *testing.T) { | |
| 275 | + | screen := NewScreen(10, 5) | |
| 276 | + | screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50}) | |
| 277 | + | screen.Write([]byte("\x1b[2;1H")) | |
| 278 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*45*4)) | |
| 279 | + | ||
| 280 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=45;" + payload + "\x1b\\")) | |
| 281 | + | ||
| 282 | + | placement := screen.GraphicsSnapshot().Placements()[0] | |
| 283 | + | require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 15, Height: 45}, placement.Destination(), "the placement must scroll with the text buffer") | |
| 284 | + | require.Equal(t, 4, screen.CursorRow()) | |
| 285 | + | require.Equal(t, 2, screen.CursorCol()) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestScreenKittyPlacementScrollsOnlyWhenContainedInRegion(t *testing.T) { | |
| 289 | + | screen := NewScreen(10, 6) | |
| 290 | + | screen.SetGeometry(Geometry{Cols: 10, Rows: 6, PixelWidth: 100, PixelHeight: 60}) | |
| 291 | + | screen.Write([]byte("\x1b[2;5r")) | |
| 292 | + | ||
| 293 | + | insidePayload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*10*4)) | |
| 294 | + | screen.Write([]byte("\x1b[3;1H\x1b_Ga=T,i=1,f=32,s=1,v=10,C=1;" + insidePayload + "\x1b\\")) | |
| 295 | + | straddlingPayload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*20*4)) | |
| 296 | + | screen.Write([]byte("\x1b[1;1H\x1b_Ga=T,i=2,f=32,s=1,v=20,Y=5,C=1;" + straddlingPayload + "\x1b\\")) | |
| 297 | + | ||
| 298 | + | screen.Write([]byte("\x1b[S")) | |
| 299 | + | ||
| 300 | + | placements := screen.GraphicsSnapshot().Placements() | |
| 301 | + | require.Len(t, placements, 2) | |
| 302 | + | require.Equal(t, graphics.PixelRect{X: 0, Y: 10, Width: 1, Height: 10}, placements[0].Destination(), "a placement contained in the margin must scroll") | |
| 303 | + | require.Equal(t, graphics.PixelRect{X: 0, Y: 5, Width: 1, Height: 20}, placements[1].Destination(), "a placement crossing the margin must remain fixed") | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func TestScreenKittyPlacementScrollSkipsBottomEdgeOverflow(t *testing.T) { | |
| 307 | + | screen := NewScreen(1, 2) | |
| 308 | + | screen.SetGeometry(Geometry{Cols: 1, Rows: 2, PixelWidth: 10, PixelHeight: 20}) | |
| 309 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*10*4)) | |
| 310 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=10,C=1;" + payload + "\x1b\\")) | |
| 311 | + | placement := screen.GraphicsSnapshot().Placements()[0] | |
| 312 | + | nearLimit := graphics.PixelRect{X: 0, Y: math.MaxInt64 - 11, Width: 1, Height: 10} | |
| 313 | + | require.NoError(t, screen.graphics.scene.UpdatePlacement(placement.ID(), graphics.PlacementSpec{Destination: nearLimit})) | |
| 314 | + | ||
| 315 | + | require.NotPanics(t, func() { screen.Write([]byte("\x1b[T")) }) | |
| 316 | + | ||
| 317 | + | placement = screen.GraphicsSnapshot().Placements()[0] | |
| 318 | + | require.Equal(t, nearLimit, placement.Destination()) | |
| 319 | + | } | |
| @@ -2,6 +2,7 @@ package vt | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/base64" | 4 | "encoding/base64" | ||
| 5 | + | "math" | |||
| 5 | "strconv" | 6 | "strconv" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| @@ -42,6 +43,24 @@ func TestScreenKittyGraphicsFragmentedAPCAndResponse(t *testing.T) { | |||||
| 42 | require.Equal(t, uint64(1), snapshot.Usage().Placements) | 43 | require.Equal(t, uint64(1), snapshot.Usage().Placements) | ||
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 46 | + | func TestScreenKittyIcatDetectionResponses(t *testing.T) { | |||
| 47 | + | screen := NewScreen(80, 24) | |||
| 48 | + | var responses []string | |||
| 49 | + | screen.OnResponse = func(response []byte) { responses = append(responses, string(response)) } | |||
| 50 | + | ||||
| 51 | + | screen.Write([]byte("\x1b_Ga=q,f=24,s=1,v=1,S=3,i=1;MTIz\x1b\\")) | |||
| 52 | + | screen.Write([]byte("\x1b_Ga=q,f=24,t=t,s=1,v=1,S=47,i=2;L2Rldi9zaG0va2l0dHktdHR5LWdyYXBoaWNzLXByb3RvY29sLTMzMTU3NTkxNjc\x1b\\")) | |||
| 53 | + | screen.Write([]byte("\x1b_Ga=q,f=24,t=s,s=1,v=1,S=18,i=3;aWNhdC1aQlJCWFdNQ0lIQ0ZD\x1b\\")) | |||
| 54 | + | screen.Write([]byte("\x1b[c")) | |||
| 55 | + | ||||
| 56 | + | require.Equal(t, []string{ | |||
| 57 | + | "\x1b_Gi=1;OK\x1b\\", | |||
| 58 | + | "\x1b_Gi=2;ENOTSUP\x1b\\", | |||
| 59 | + | "\x1b_Gi=3;ENOTSUP\x1b\\", | |||
| 60 | + | "\x1b[?62;22c", | |||
| 61 | + | }, responses) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 45 | func TestScreenKittyGraphicsPrimaryAndAlternateState(t *testing.T) { | 64 | func TestScreenKittyGraphicsPrimaryAndAlternateState(t *testing.T) { | ||
| 46 | screen := NewScreen(8, 3) | 65 | screen := NewScreen(8, 3) | ||
| 47 | screen.Write(screenKittyImageAPC("T")) | 66 | screen.Write(screenKittyImageAPC("T")) | ||
| @@ -215,7 +234,7 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) { | |||||
| 215 | require.Equal(t, 2, screen.CursorCol()) | 234 | require.Equal(t, 2, screen.CursorCol()) | ||
| 216 | 235 | ||||
| 217 | screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=2,v=2,C=0;" + payload + "\x1b\\")) | 236 | 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()) | 237 | + | require.Equal(t, 1, screen.CursorRow()) |
| 219 | require.Equal(t, 3, screen.CursorCol()) | 238 | require.Equal(t, 3, screen.CursorCol()) | ||
| 220 | 239 | ||||
| 221 | screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\")) | 240 | screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\")) | ||
| @@ -226,3 +245,75 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) { | |||||
| 226 | require.Equal(t, 2, screen.CursorRow()) | 245 | require.Equal(t, 2, screen.CursorRow()) | ||
| 227 | require.Equal(t, 3, screen.CursorCol()) | 246 | require.Equal(t, 3, screen.CursorCol()) | ||
| 228 | } | 247 | } | ||
| 248 | + | ||||
| 249 | + | func TestScreenKittyPlacementNormalizesDeferredWrapCursor(t *testing.T) { | |||
| 250 | + | screen := NewScreen(3, 3) | |||
| 251 | + | screen.SetGeometry(Geometry{Cols: 3, Rows: 3, PixelWidth: 30, PixelHeight: 30}) | |||
| 252 | + | screen.Write([]byte("abc")) | |||
| 253 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 4)) | |||
| 254 | + | ||||
| 255 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\")) | |||
| 256 | + | ||||
| 257 | + | placement := screen.GraphicsSnapshot().Placements()[0] | |||
| 258 | + | require.Equal(t, graphics.PixelRect{X: 20, Width: 1, Height: 1}, placement.Destination()) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) { | |||
| 262 | + | screen := NewScreen(10, 5) | |||
| 263 | + | screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50}) | |||
| 264 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*25*4)) | |||
| 265 | + | ||||
| 266 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=25;" + payload + "\x1b\\")) | |||
| 267 | + | ||||
| 268 | + | // A 15x25 image occupies 2x3 cells. Kitty leaves the cursor immediately to | |||
| 269 | + | // the right of the placement and on its final occupied row. | |||
| 270 | + | require.Equal(t, 2, screen.CursorRow()) | |||
| 271 | + | require.Equal(t, 2, screen.CursorCol()) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func TestScreenKittyPlacementScrollsWithTallImageCursorAdvance(t *testing.T) { | |||
| 275 | + | screen := NewScreen(10, 5) | |||
| 276 | + | screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50}) | |||
| 277 | + | screen.Write([]byte("\x1b[2;1H")) | |||
| 278 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*45*4)) | |||
| 279 | + | ||||
| 280 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=45;" + payload + "\x1b\\")) | |||
| 281 | + | ||||
| 282 | + | placement := screen.GraphicsSnapshot().Placements()[0] | |||
| 283 | + | require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 15, Height: 45}, placement.Destination(), "the placement must scroll with the text buffer") | |||
| 284 | + | require.Equal(t, 4, screen.CursorRow()) | |||
| 285 | + | require.Equal(t, 2, screen.CursorCol()) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func TestScreenKittyPlacementScrollsOnlyWhenContainedInRegion(t *testing.T) { | |||
| 289 | + | screen := NewScreen(10, 6) | |||
| 290 | + | screen.SetGeometry(Geometry{Cols: 10, Rows: 6, PixelWidth: 100, PixelHeight: 60}) | |||
| 291 | + | screen.Write([]byte("\x1b[2;5r")) | |||
| 292 | + | ||||
| 293 | + | insidePayload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*10*4)) | |||
| 294 | + | screen.Write([]byte("\x1b[3;1H\x1b_Ga=T,i=1,f=32,s=1,v=10,C=1;" + insidePayload + "\x1b\\")) | |||
| 295 | + | straddlingPayload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*20*4)) | |||
| 296 | + | screen.Write([]byte("\x1b[1;1H\x1b_Ga=T,i=2,f=32,s=1,v=20,Y=5,C=1;" + straddlingPayload + "\x1b\\")) | |||
| 297 | + | ||||
| 298 | + | screen.Write([]byte("\x1b[S")) | |||
| 299 | + | ||||
| 300 | + | placements := screen.GraphicsSnapshot().Placements() | |||
| 301 | + | require.Len(t, placements, 2) | |||
| 302 | + | require.Equal(t, graphics.PixelRect{X: 0, Y: 10, Width: 1, Height: 10}, placements[0].Destination(), "a placement contained in the margin must scroll") | |||
| 303 | + | require.Equal(t, graphics.PixelRect{X: 0, Y: 5, Width: 1, Height: 20}, placements[1].Destination(), "a placement crossing the margin must remain fixed") | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func TestScreenKittyPlacementScrollSkipsBottomEdgeOverflow(t *testing.T) { | |||
| 307 | + | screen := NewScreen(1, 2) | |||
| 308 | + | screen.SetGeometry(Geometry{Cols: 1, Rows: 2, PixelWidth: 10, PixelHeight: 20}) | |||
| 309 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*10*4)) | |||
| 310 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=10,C=1;" + payload + "\x1b\\")) | |||
| 311 | + | placement := screen.GraphicsSnapshot().Placements()[0] | |||
| 312 | + | nearLimit := graphics.PixelRect{X: 0, Y: math.MaxInt64 - 11, Width: 1, Height: 10} | |||
| 313 | + | require.NoError(t, screen.graphics.scene.UpdatePlacement(placement.ID(), graphics.PlacementSpec{Destination: nearLimit})) | |||
| 314 | + | ||||
| 315 | + | require.NotPanics(t, func() { screen.Write([]byte("\x1b[T")) }) | |||
| 316 | + | ||||
| 317 | + | placement = screen.GraphicsSnapshot().Placements()[0] | |||
| 318 | + | require.Equal(t, nearLimit, placement.Destination()) | |||
| 319 | + | } | |||
M screen_scroll.go +51 -0
| @@ -2,8 +2,11 @@ package vt | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "math" | |
| 5 | 7 | ||
| 6 | 8 | renderer "github.com/bnema/vev-vt/core" | |
| 9 | + | "github.com/bnema/vev-vt/graphics" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| 9 | 12 | // index moves to the next physical row, scrolling the region when the cursor | |
| @@ -77,9 +80,11 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | |||
| 77 | 80 | } | |
| 78 | 81 | w := s.Frame.Width | |
| 79 | 82 | height := bottom - top + 1 | |
| 83 | + | graphicsRows := n | |
| 80 | 84 | if n > height { | |
| 81 | 85 | n = height | |
| 82 | 86 | } | |
| 87 | + | s.scrollGraphicsRegion(top, bottom, -graphicsRows) | |
| 83 | 88 | // VT scroll regions always span the full frame width, so we rotate the | |
| 84 | 89 | // frame's line offsets (recycling and blanking the evicted rows in place) | |
| 85 | 90 | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| @@ -129,9 +134,11 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 129 | 134 | } | |
| 130 | 135 | w := s.Frame.Width | |
| 131 | 136 | height := bottom - top + 1 | |
| 137 | + | graphicsRows := n | |
| 132 | 138 | if n > height { | |
| 133 | 139 | n = height | |
| 134 | 140 | } | |
| 141 | + | s.scrollGraphicsRegion(top, bottom, graphicsRows) | |
| 135 | 142 | // Full-width region: rotate line offsets instead of copying cells. | |
| 136 | 143 | s.Frame.ScrollDown(top, bottom, n) | |
| 137 | 144 | s.buffer.scrollDown(top, bottom, n) | |
| @@ -139,6 +146,50 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 139 | 146 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |
| 140 | 147 | } | |
| 141 | 148 | ||
| 149 | + | func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) { | |
| 150 | + | if rows == 0 || s.graphics == nil || s.graphics.scene == nil { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | _, cellHeight, ok := s.kittyCellPixels() | |
| 154 | + | if !ok || cellHeight <= 0 { | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | delta := int64(rows) * int64(cellHeight) | |
| 158 | + | fullScreen := top == 0 && bottom == s.Frame.Height-1 | |
| 159 | + | snapshot := s.graphics.scene.Snapshot() | |
| 160 | + | placements := snapshot.Placements() | |
| 161 | + | operations := make([]graphics.Operation, 0, len(placements)) | |
| 162 | + | for _, placement := range placements { | |
| 163 | + | destination := placement.Destination() | |
| 164 | + | if !fullScreen { | |
| 165 | + | end, valid := destination.Bottom() | |
| 166 | + | if !valid || destination.Y < 0 || end <= destination.Y { | |
| 167 | + | continue | |
| 168 | + | } | |
| 169 | + | startRow := int(destination.Y / int64(cellHeight)) | |
| 170 | + | endRow := int((end - 1) / int64(cellHeight)) | |
| 171 | + | if startRow < top || endRow > bottom { | |
| 172 | + | continue | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | if delta > 0 && destination.Y > math.MaxInt64-delta || delta < 0 && destination.Y < math.MinInt64-delta { | |
| 176 | + | continue | |
| 177 | + | } | |
| 178 | + | destination.Y += delta | |
| 179 | + | if _, valid := destination.Bottom(); !valid { | |
| 180 | + | continue | |
| 181 | + | } | |
| 182 | + | spec := placement.Spec() | |
| 183 | + | spec.Destination = destination | |
| 184 | + | operations = append(operations, graphics.Operation{Kind: graphics.OperationUpdatePlacement, PlacementID: placement.ID(), Placement: spec}) | |
| 185 | + | } | |
| 186 | + | if len(operations) != 0 { | |
| 187 | + | if err := s.graphics.scene.Apply(operations...); err != nil { | |
| 188 | + | panic(fmt.Errorf("scroll graphics placements: %w", err)) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 142 | 193 | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |
| 143 | 194 | if s.Frame.Height == 0 || top > bottom { | |
| 144 | 195 | return 0, 0, false | |
| @@ -2,8 +2,11 @@ package vt | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "fmt" | |||
| 6 | + | "math" | |||
| 5 | 7 | ||||
| 6 | renderer "github.com/bnema/vev-vt/core" | 8 | renderer "github.com/bnema/vev-vt/core" | ||
| 9 | + | "github.com/bnema/vev-vt/graphics" | |||
| 7 | ) | 10 | ) | ||
| 8 | 11 | ||||
| 9 | // index moves to the next physical row, scrolling the region when the cursor | 12 | // index moves to the next physical row, scrolling the region when the cursor | ||
| @@ -77,9 +80,11 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | |||||
| 77 | } | 80 | } | ||
| 78 | w := s.Frame.Width | 81 | w := s.Frame.Width | ||
| 79 | height := bottom - top + 1 | 82 | height := bottom - top + 1 | ||
| 83 | + | graphicsRows := n | |||
| 80 | if n > height { | 84 | if n > height { | ||
| 81 | n = height | 85 | n = height | ||
| 82 | } | 86 | } | ||
| 87 | + | s.scrollGraphicsRegion(top, bottom, -graphicsRows) | |||
| 83 | // VT scroll regions always span the full frame width, so we rotate the | 88 | // VT scroll regions always span the full frame width, so we rotate the | ||
| 84 | // frame's line offsets (recycling and blanking the evicted rows in place) | 89 | // frame's line offsets (recycling and blanking the evicted rows in place) | ||
| 85 | // instead of copying cells. See renderer.Frame.ScrollUp. | 90 | // instead of copying cells. See renderer.Frame.ScrollUp. | ||
| @@ -129,9 +134,11 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||||
| 129 | } | 134 | } | ||
| 130 | w := s.Frame.Width | 135 | w := s.Frame.Width | ||
| 131 | height := bottom - top + 1 | 136 | height := bottom - top + 1 | ||
| 137 | + | graphicsRows := n | |||
| 132 | if n > height { | 138 | if n > height { | ||
| 133 | n = height | 139 | n = height | ||
| 134 | } | 140 | } | ||
| 141 | + | s.scrollGraphicsRegion(top, bottom, graphicsRows) | |||
| 135 | // Full-width region: rotate line offsets instead of copying cells. | 142 | // Full-width region: rotate line offsets instead of copying cells. | ||
| 136 | s.Frame.ScrollDown(top, bottom, n) | 143 | s.Frame.ScrollDown(top, bottom, n) | ||
| 137 | s.buffer.scrollDown(top, bottom, n) | 144 | s.buffer.scrollDown(top, bottom, n) | ||
| @@ -139,6 +146,50 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||||
| 139 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | 146 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | ||
| 140 | } | 147 | } | ||
| 141 | 148 | ||||
| 149 | + | func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) { | |||
| 150 | + | if rows == 0 || s.graphics == nil || s.graphics.scene == nil { | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | _, cellHeight, ok := s.kittyCellPixels() | |||
| 154 | + | if !ok || cellHeight <= 0 { | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | delta := int64(rows) * int64(cellHeight) | |||
| 158 | + | fullScreen := top == 0 && bottom == s.Frame.Height-1 | |||
| 159 | + | snapshot := s.graphics.scene.Snapshot() | |||
| 160 | + | placements := snapshot.Placements() | |||
| 161 | + | operations := make([]graphics.Operation, 0, len(placements)) | |||
| 162 | + | for _, placement := range placements { | |||
| 163 | + | destination := placement.Destination() | |||
| 164 | + | if !fullScreen { | |||
| 165 | + | end, valid := destination.Bottom() | |||
| 166 | + | if !valid || destination.Y < 0 || end <= destination.Y { | |||
| 167 | + | continue | |||
| 168 | + | } | |||
| 169 | + | startRow := int(destination.Y / int64(cellHeight)) | |||
| 170 | + | endRow := int((end - 1) / int64(cellHeight)) | |||
| 171 | + | if startRow < top || endRow > bottom { | |||
| 172 | + | continue | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | if delta > 0 && destination.Y > math.MaxInt64-delta || delta < 0 && destination.Y < math.MinInt64-delta { | |||
| 176 | + | continue | |||
| 177 | + | } | |||
| 178 | + | destination.Y += delta | |||
| 179 | + | if _, valid := destination.Bottom(); !valid { | |||
| 180 | + | continue | |||
| 181 | + | } | |||
| 182 | + | spec := placement.Spec() | |||
| 183 | + | spec.Destination = destination | |||
| 184 | + | operations = append(operations, graphics.Operation{Kind: graphics.OperationUpdatePlacement, PlacementID: placement.ID(), Placement: spec}) | |||
| 185 | + | } | |||
| 186 | + | if len(operations) != 0 { | |||
| 187 | + | if err := s.graphics.scene.Apply(operations...); err != nil { | |||
| 188 | + | panic(fmt.Errorf("scroll graphics placements: %w", err)) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 142 | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | 193 | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | ||
| 143 | if s.Frame.Height == 0 || top > bottom { | 194 | if s.Frame.Height == 0 || top > bottom { | ||
| 144 | return 0, 0, false | 195 | return 0, 0, false | ||