detached 03674e0

Merge pull request #6 from bnema/fix/kitty-icat-detection

fix(graphics): support kitten icat detection and rendering
commit
03674e01037ac456226d33b272af7fceea7f1357
author
bnema · · 2026-08-26 15:35 UTC
committer
GitHub · · 2026-08-26 15:35 UTC
merge of
0b9cfda + a67301c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.4.1
13 files · +374 -46
M README.md +7 -6
@@ -50,15 +50,16 @@ evidence.
5050 The VT accepts bounded static direct transmissions used by current
5151 `kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit,
5252 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.
5758
5859 `Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are
5960 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.
6263
6364 ### Headless graphics harness
6465
@@ -50,15 +50,16 @@ evidence.
50 The VT accepts bounded static direct transmissions used by current50 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; chunked52 transmit-and-display, place, query, and supported delete operations; chunked
53-Base64 uploads; source rectangles; cell extents; pixel offsets; z-index; and53+Base64 uploads with optional zlib compression; source rectangles; cell extents;
54-cursor movement policy. Assets and decoded pixels are bounded by explicit scene54+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 are59 `Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are
59 separate from cells and history bytes; VTH3 remains text/history-only. Static60 separate from cells and history bytes; VTH3 remains text/history-only. Static
60-placements retain their configured pixel rectangles across text scrolling and61+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 harness64 ### Headless graphics harness
64 65
M internal/graphicsharness/render_test.go +1 -1
@@ -39,7 +39,7 @@ func TestRenderTerminalDemo(t *testing.T) {
3939 }
4040
4141 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})
4343 require.NoError(t, err)
4444 require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0))
4545 }
@@ -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 {
6565 }
6666 c.Action, c.HasAction = Action(value[0]), true
6767 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)
7170 }
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
7373 case ControlFormat:
7474 n, err := parseUint(value)
7575 if err != nil {
@@ -127,10 +127,13 @@ func parseControl(c *Controls, key byte, value []byte) error {
127127 return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value)
128128 }
129129 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)
132132 }
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
134137 case ControlCursor:
135138 return setUint(&c.Cursor, &c.HasCursor, key, value)
136139 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]), true66 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, true71+ // 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, true133+ // 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 (
145145
146146 func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG }
147147
148+// Compression is the optional compression applied to raw image data.
149+type Compression byte
150+
151+const CompressionZlib Compression = 'z'
152+
148153 // Quiet controls protocol response emission. Kitty uses q=1 for successful
149154 // operations only and q=2 for all responses.
150155 type Quiet uint8
@@ -213,7 +218,7 @@ const (
213218 type Controls struct {
214219 Action Action
215220 HasAction bool
216- Compression uint64
221+ Compression Compression
217222 HasCompression bool
218223 Format Format
219224 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 successful153 // 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 uint8155 type Quiet uint8
@@ -213,7 +218,7 @@ const (
213 type Controls struct {218 type Controls struct {
214 Action Action219 Action Action
215 HasAction bool220 HasAction bool
216- Compression uint64221+ Compression Compression
217 HasCompression bool222 HasCompression bool
218 Format Format223 Format Format
219 HasFormat bool224 HasFormat bool
M protocol/kittygraphics/kittygraphics_test.go +81 -0
@@ -2,6 +2,7 @@ package kittygraphics
22
33 import (
44 "bytes"
5+ "compress/zlib"
56 "encoding/base64"
67 "errors"
78 "image"
@@ -73,6 +74,78 @@ func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
7374 }
7475 }
7576
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+
76149 func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
77150 data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
78151 if err != nil {
@@ -332,6 +405,14 @@ func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) {
332405 }
333406 }
334407
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+
335416 func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) {
336417 data := pngBytes(t, 2, 2)
337418 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
22
33 import (
44 "bytes"
5+ "compress/zlib"
56 "fmt"
67 "image/png"
8+ "io"
79 "math"
810 "strconv"
911
@@ -207,7 +209,7 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
207209 if !c.HasAction {
208210 action = ActionTransmit
209211 }
210- if c.HasCompression && c.Compression != 0 {
212+ if c.HasCompression && c.Compression != CompressionZlib {
211213 return s.failure(c, controlsImageID(c), ErrUnsupported)
212214 }
213215 if c.HasTransmission && c.Transmission != TransmissionDirect {
@@ -299,7 +301,7 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
299301 if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes {
300302 return s.failure(value.controls, value.imageID, ErrPayloadTooLarge)
301303 }
302- decoded, err := DecodeBase64(value.payload)
304+ decoded, err := decodeImagePayload(value.payload, value.controls, s.limits)
303305 if err != nil {
304306 return s.failure(value.controls, value.imageID, err)
305307 }
@@ -390,6 +392,36 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
390392 return s.success(value.controls, value.imageID), mutation, nil
391393 }
392394
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+
393425 func assetFormat(format Format) graphics.AssetFormat {
394426 switch format {
395427 case FormatRGB:
@@ -452,7 +484,7 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
452484 return 0, 0, ErrPayloadTooLarge
453485 }
454486 expected, ok := checkedProduct(c.Width, rowBytes)
455- if !ok {
487+ if !ok || expected > limits.MaxDecodedBytes {
456488 return 0, 0, ErrPayloadTooLarge
457489 }
458490 if uint64(len(data)) != expected {
@@ -617,7 +649,7 @@ func (s *Session) query(command Command) ([][]byte, *Mutation, error) {
617649 if len(command.Payload) == 0 {
618650 return s.failure(c, responseImageID(c), ErrInvalidCommand)
619651 }
620- decoded, err := DecodeBase64(command.Payload)
652+ decoded, err := decodeImagePayload(command.Payload, c, s.limits)
621653 if err != nil {
622654 return s.failure(c, responseImageID(c), err)
623655 }
@@ -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 = ActionTransmit210 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, nil392 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, ErrPayloadTooLarge484 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, ErrPayloadTooLarge488 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 {
176176 // not sufficient for a direct graphics declaration. The parameter body is
177177 // a non-empty semicolon-separated list of decimal numbers.
178178 body := data[3 : len(data)-1]
179- if len(body) == 0 || body[0] == ';' || body[len(body)-1] == ';' {
179+ if len(body) == 0 || body[0] == ';' {
180180 return false
181181 }
182182 previousSeparator := false
@@ -193,7 +193,9 @@ func validDA1Response(data []byte) bool {
193193 return false
194194 }
195195 }
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
197199 }
198200
199201 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 is176 // 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 false180 return false
181 }181 }
182 previousSeparator := false182 previousSeparator := false
@@ -193,7 +193,9 @@ func validDA1Response(data []byte) bool {
193 return false193 return false
194 }194 }
195 }195 }
196- return !previousSeparator196+ // 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) {
1515 unrelated string
1616 }{
1717 {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},
1819 {name: "secondary DA is insufficient", data: "\x1b[>1;2c\x1b_Gi=31;OK\x1b\\", kitty: true, unrelated: "\x1b[>1;2c"},
1920 {name: "ordinary input", data: "hello", unrelated: "hello"},
2021 } {
@@ -15,6 +15,7 @@ func TestProbeRequiresKittyAndDA1(t *testing.T) {
15 unrelated string15 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) {
1313 input string
1414 want string
1515 }{
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"},
1818 {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"},
1919 {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"},
2020 {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"},
@@ -35,7 +35,7 @@ func TestScreenQueryResponses(t *testing.T) {
3535 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
3636 {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
3737 {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"},
3939 }
4040 for _, tc := range cases {
4141 t.Run(tc.name, func(t *testing.T) {
@@ -13,8 +13,8 @@ func TestScreenQueryResponses(t *testing.T) {
13 input string13 input string
14 want string14 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) {
422422 }
423423 if len(result.Mutations) != 0 {
424424 if display {
425- s.applyKittyCursorMovement(movementControls)
425+ mutation := result.Mutations[len(result.Mutations)-1]
426+ s.applyKittyCursorMovement(movementControls, mutation)
426427 s.kittyPendingDisplay = nil
427428 }
428429 // Graphics are rendered independently from Frame. A graphics mutation
@@ -433,11 +434,18 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
433434
434435 func (s *Screen) kittyCursorX() uint64 {
435436 if cellWidth, _, ok := s.kittyCellPixels(); ok {
436- return uint64(s.Col * cellWidth)
437+ return uint64(s.kittyCursorColumn() * cellWidth)
437438 }
438439 return 0
439440 }
440441
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+
441449 func (s *Screen) kittyCursorY() uint64 {
442450 _, cellHeight, ok := s.kittyCellPixels()
443451 if ok {
@@ -458,35 +466,85 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
458466 return width, height, true
459467 }
460468
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)
463500 return
464501 }
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
466507 if c.HasColumns {
467- columns = kittyCursorAdvance(c.Columns)
508+ columns = max(kittyCursorAdvance(c.Columns), 1)
468509 }
469510 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
471519 }
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
474534 }
475535
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
479539 }
480- if current < minimum {
481- current = minimum
540+ span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
541+ if span <= 0 {
542+ return 1
482543 }
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)
488546 }
489- return current + amount
547+ return int(span)
490548 }
491549
492550 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 = nil427 s.kittyPendingDisplay = nil
427 }428 }
428 // Graphics are rendered independently from Frame. A graphics mutation429 // 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 0439 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, true466 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 return500 return
464 }501 }
465- columns, rows := 1, 1502+ 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 minimum538+ return 1
479 }539 }
480- if current < minimum {540+ span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
481- current = minimum541+ if span <= 0 {
542+ return 1
482 }543 }
483- if current > maximum {544+ if span > int64(^uint(0)>>1) {
484- current = maximum545+ return int(^uint(0) >> 1)
485- }
486- if amount > maximum-current {
487- return maximum
488 }546 }
489- return current + amount547+ 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) {
3131 case 'c':
3232 switch params {
3333 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"))
3538 case ">", ">0":
3639 s.respond([]byte("\x1b[>0;0;0c"))
3740 }
@@ -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
22
33 import (
44 "encoding/base64"
5+ "math"
56 "strconv"
67 "testing"
78
@@ -42,6 +43,24 @@ func TestScreenKittyGraphicsFragmentedAPCAndResponse(t *testing.T) {
4243 require.Equal(t, uint64(1), snapshot.Usage().Placements)
4344 }
4445
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+
4564 func TestScreenKittyGraphicsPrimaryAndAlternateState(t *testing.T) {
4665 screen := NewScreen(8, 3)
4766 screen.Write(screenKittyImageAPC("T"))
@@ -215,7 +234,7 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) {
215234 require.Equal(t, 2, screen.CursorCol())
216235
217236 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())
219238 require.Equal(t, 3, screen.CursorCol())
220239
221240 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) {
226245 require.Equal(t, 2, screen.CursorRow())
227246 require.Equal(t, 3, screen.CursorCol())
228247 }
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
22
33 import (
44 "errors"
5+ "fmt"
6+ "math"
57
68 renderer "github.com/bnema/vev-vt/core"
9+ "github.com/bnema/vev-vt/graphics"
710 )
811
912 // 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) {
7780 }
7881 w := s.Frame.Width
7982 height := bottom - top + 1
83+ graphicsRows := n
8084 if n > height {
8185 n = height
8286 }
87+ s.scrollGraphicsRegion(top, bottom, -graphicsRows)
8388 // VT scroll regions always span the full frame width, so we rotate the
8489 // frame's line offsets (recycling and blanking the evicted rows in place)
8590 // instead of copying cells. See renderer.Frame.ScrollUp.
@@ -129,9 +134,11 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
129134 }
130135 w := s.Frame.Width
131136 height := bottom - top + 1
137+ graphicsRows := n
132138 if n > height {
133139 n = height
134140 }
141+ s.scrollGraphicsRegion(top, bottom, graphicsRows)
135142 // Full-width region: rotate line offsets instead of copying cells.
136143 s.Frame.ScrollDown(top, bottom, n)
137144 s.buffer.scrollDown(top, bottom, n)
@@ -139,6 +146,50 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
139146 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
140147 }
141148
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+
142193 func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
143194 if s.Frame.Height == 0 || top > bottom {
144195 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 cursor12 // 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.Width81 w := s.Frame.Width
79 height := bottom - top + 182 height := bottom - top + 1
83+ graphicsRows := n
80 if n > height {84 if n > height {
81 n = height85 n = height
82 }86 }
87+ s.scrollGraphicsRegion(top, bottom, -graphicsRows)
83 // VT scroll regions always span the full frame width, so we rotate the88 // 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.Width135 w := s.Frame.Width
131 height := bottom - top + 1136 height := bottom - top + 1
137+ graphicsRows := n
132 if n > height {138 if n > height {
133 n = height139 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, false195 return 0, 0, false

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close