detached 47ca903

fix(graphics): address wrap-up review findings

commit
47ca90350bd7fad66d2449bdec90b7f76dec9cf1
author
brice · · 2026-08-26 14:30 UTC
parent
2519bf3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +78 -18
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 protocol/kittygraphics/kittygraphics_test.go +26 -0
@@ -113,6 +113,24 @@ func TestSessionAcceptsKittenZlibCompressedRGB(t *testing.T) {
113113 }
114114 }
115115
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+
116134 func TestSessionBoundsZlibDecompression(t *testing.T) {
117135 raw := bytes.Repeat([]byte{1}, 64)
118136 var compressed bytes.Buffer
@@ -387,6 +405,14 @@ func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) {
387405 }
388406 }
389407
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+
390416 func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) {
391417 data := pngBytes(t, 2, 2)
392418 if _, _, err := imageGeometry(data, FormatPNG, Controls{}, normalizeLimits(Limits{MaxDecodedBytes: 15})); !errors.Is(err, ErrPayloadTooLarge) {
@@ -113,6 +113,24 @@ func TestSessionAcceptsKittenZlibCompressedRGB(t *testing.T) {
113 }113 }
114 }114 }
115 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+
116 func TestSessionBoundsZlibDecompression(t *testing.T) {134 func TestSessionBoundsZlibDecompression(t *testing.T) {
117 raw := bytes.Repeat([]byte{1}, 64)135 raw := bytes.Repeat([]byte{1}, 64)
118 var compressed bytes.Buffer136 var compressed bytes.Buffer
@@ -387,6 +405,14 @@ func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) {
387 }405 }
388 }406 }
389 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+
390 func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) {416 func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) {
391 data := pngBytes(t, 2, 2)417 data := pngBytes(t, 2, 2)
392 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 +2 -2
@@ -484,7 +484,7 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
484484 return 0, 0, ErrPayloadTooLarge
485485 }
486486 expected, ok := checkedProduct(c.Width, rowBytes)
487- if !ok {
487+ if !ok || expected > limits.MaxDecodedBytes {
488488 return 0, 0, ErrPayloadTooLarge
489489 }
490490 if uint64(len(data)) != expected {
@@ -649,7 +649,7 @@ func (s *Session) query(command Command) ([][]byte, *Mutation, error) {
649649 if len(command.Payload) == 0 {
650650 return s.failure(c, responseImageID(c), ErrInvalidCommand)
651651 }
652- decoded, err := DecodeBase64(command.Payload)
652+ decoded, err := decodeImagePayload(command.Payload, c, s.limits)
653653 if err != nil {
654654 return s.failure(c, responseImageID(c), err)
655655 }
@@ -484,7 +484,7 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
484 return 0, 0, ErrPayloadTooLarge484 return 0, 0, ErrPayloadTooLarge
485 }485 }
486 expected, ok := checkedProduct(c.Width, rowBytes)486 expected, ok := checkedProduct(c.Width, rowBytes)
487- if !ok {487+ if !ok || expected > limits.MaxDecodedBytes {
488 return 0, 0, ErrPayloadTooLarge488 return 0, 0, ErrPayloadTooLarge
489 }489 }
490 if uint64(len(data)) != expected {490 if uint64(len(data)) != expected {
@@ -649,7 +649,7 @@ func (s *Session) query(command Command) ([][]byte, *Mutation, error) {
649 if len(command.Payload) == 0 {649 if len(command.Payload) == 0 {
650 return s.failure(c, responseImageID(c), ErrInvalidCommand)650 return s.failure(c, responseImageID(c), ErrInvalidCommand)
651 }651 }
652- decoded, err := DecodeBase64(command.Payload)652+ decoded, err := decodeImagePayload(command.Payload, c, s.limits)
653 if err != nil {653 if err != nil {
654 return s.failure(c, responseImageID(c), err)654 return s.failure(c, responseImageID(c), err)
655 }655 }
M screen.go +27 -8
@@ -434,11 +434,18 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
434434
435435 func (s *Screen) kittyCursorX() uint64 {
436436 if cellWidth, _, ok := s.kittyCellPixels(); ok {
437- return uint64(s.Col * cellWidth)
437+ return uint64(s.kittyCursorColumn() * cellWidth)
438438 }
439439 return 0
440440 }
441441
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+
442449 func (s *Screen) kittyCursorY() uint64 {
443450 _, cellHeight, ok := s.kittyCellPixels()
444451 if ok {
@@ -464,18 +471,13 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit
464471 return
465472 }
466473 columns, rows := s.kittyPlacementGridSize(c, mutation)
467- targetColumn := s.Col + columns
474+ targetColumn := s.kittyCursorColumn() + columns
468475 wraps := targetColumn >= s.Frame.Width
469476 rowMoves := max(rows-1, 0)
470477 if wraps {
471478 rowMoves++
472479 }
473- // Bound work for adversarial dimensions while retaining enough index calls
474- // to reach and then scroll one complete screen, matching terminal behavior.
475- rowMoves = min(rowMoves, s.Frame.Height*2)
476- for range rowMoves {
477- s.index()
478- }
480+ s.advanceKittyCursorRows(rowMoves)
479481 if wraps {
480482 s.Col = 0
481483 } else {
@@ -483,6 +485,23 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit
483485 }
484486 }
485487
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)
500+ return
501+ }
502+ s.Row = min(s.Row+rows, s.Frame.Height-1)
503+}
504+
486505 func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
487506 columns, rows = 1, 1
488507 if c.HasColumns {
@@ -434,11 +434,18 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
434 434
435 func (s *Screen) kittyCursorX() uint64 {435 func (s *Screen) kittyCursorX() uint64 {
436 if cellWidth, _, ok := s.kittyCellPixels(); ok {436 if cellWidth, _, ok := s.kittyCellPixels(); ok {
437- return uint64(s.Col * cellWidth)437+ return uint64(s.kittyCursorColumn() * cellWidth)
438 }438 }
439 return 0439 return 0
440 }440 }
441 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+
442 func (s *Screen) kittyCursorY() uint64 {449 func (s *Screen) kittyCursorY() uint64 {
443 _, cellHeight, ok := s.kittyCellPixels()450 _, cellHeight, ok := s.kittyCellPixels()
444 if ok {451 if ok {
@@ -464,18 +471,13 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit
464 return471 return
465 }472 }
466 columns, rows := s.kittyPlacementGridSize(c, mutation)473 columns, rows := s.kittyPlacementGridSize(c, mutation)
467- targetColumn := s.Col + columns474+ targetColumn := s.kittyCursorColumn() + columns
468 wraps := targetColumn >= s.Frame.Width475 wraps := targetColumn >= s.Frame.Width
469 rowMoves := max(rows-1, 0)476 rowMoves := max(rows-1, 0)
470 if wraps {477 if wraps {
471 rowMoves++478 rowMoves++
472 }479 }
473- // Bound work for adversarial dimensions while retaining enough index calls480+ s.advanceKittyCursorRows(rowMoves)
474- // to reach and then scroll one complete screen, matching terminal behavior.
475- rowMoves = min(rowMoves, s.Frame.Height*2)
476- for range rowMoves {
477- s.index()
478- }
479 if wraps {481 if wraps {
480 s.Col = 0482 s.Col = 0
481 } else {483 } else {
@@ -483,6 +485,23 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit
483 }485 }
484 }486 }
485 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)
500+ return
501+ }
502+ s.Row = min(s.Row+rows, s.Frame.Height-1)
503+}
504+
486 func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {505 func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
487 columns, rows = 1, 1506 columns, rows = 1, 1
488 if c.HasColumns {507 if c.HasColumns {
M screen_graphics_test.go +12 -0
@@ -245,6 +245,18 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) {
245245 require.Equal(t, 3, screen.CursorCol())
246246 }
247247
248+func TestScreenKittyPlacementNormalizesDeferredWrapCursor(t *testing.T) {
249+ screen := NewScreen(3, 3)
250+ screen.SetGeometry(Geometry{Cols: 3, Rows: 3, PixelWidth: 30, PixelHeight: 30})
251+ screen.Write([]byte("abc"))
252+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 4))
253+
254+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\"))
255+
256+ placement := screen.GraphicsSnapshot().Placements()[0]
257+ require.Equal(t, graphics.PixelRect{X: 20, Width: 1, Height: 1}, placement.Destination())
258+}
259+
248260 func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) {
249261 screen := NewScreen(10, 5)
250262 screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50})
@@ -245,6 +245,18 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) {
245 require.Equal(t, 3, screen.CursorCol())245 require.Equal(t, 3, screen.CursorCol())
246 }246 }
247 247
248+func TestScreenKittyPlacementNormalizesDeferredWrapCursor(t *testing.T) {
249+ screen := NewScreen(3, 3)
250+ screen.SetGeometry(Geometry{Cols: 3, Rows: 3, PixelWidth: 30, PixelHeight: 30})
251+ screen.Write([]byte("abc"))
252+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 4))
253+
254+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\"))
255+
256+ placement := screen.GraphicsSnapshot().Placements()[0]
257+ require.Equal(t, graphics.PixelRect{X: 20, Width: 1, Height: 1}, placement.Destination())
258+}
259+
248 func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) {260 func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) {
249 screen := NewScreen(10, 5)261 screen := NewScreen(10, 5)
250 screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50})262 screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50})
M screen_scroll.go +4 -2
@@ -79,10 +79,11 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
7979 }
8080 w := s.Frame.Width
8181 height := bottom - top + 1
82+ graphicsRows := n
8283 if n > height {
8384 n = height
8485 }
85- s.scrollGraphicsRegion(top, bottom, -n)
86+ s.scrollGraphicsRegion(top, bottom, -graphicsRows)
8687 // VT scroll regions always span the full frame width, so we rotate the
8788 // frame's line offsets (recycling and blanking the evicted rows in place)
8889 // instead of copying cells. See renderer.Frame.ScrollUp.
@@ -132,10 +133,11 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
132133 }
133134 w := s.Frame.Width
134135 height := bottom - top + 1
136+ graphicsRows := n
135137 if n > height {
136138 n = height
137139 }
138- s.scrollGraphicsRegion(top, bottom, n)
140+ s.scrollGraphicsRegion(top, bottom, graphicsRows)
139141 // Full-width region: rotate line offsets instead of copying cells.
140142 s.Frame.ScrollDown(top, bottom, n)
141143 s.buffer.scrollDown(top, bottom, n)
@@ -79,10 +79,11 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
79 }79 }
80 w := s.Frame.Width80 w := s.Frame.Width
81 height := bottom - top + 181 height := bottom - top + 1
82+ graphicsRows := n
82 if n > height {83 if n > height {
83 n = height84 n = height
84 }85 }
85- s.scrollGraphicsRegion(top, bottom, -n)86+ s.scrollGraphicsRegion(top, bottom, -graphicsRows)
86 // VT scroll regions always span the full frame width, so we rotate the87 // VT scroll regions always span the full frame width, so we rotate the
87 // frame's line offsets (recycling and blanking the evicted rows in place)88 // frame's line offsets (recycling and blanking the evicted rows in place)
88 // instead of copying cells. See renderer.Frame.ScrollUp.89 // instead of copying cells. See renderer.Frame.ScrollUp.
@@ -132,10 +133,11 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
132 }133 }
133 w := s.Frame.Width134 w := s.Frame.Width
134 height := bottom - top + 1135 height := bottom - top + 1
136+ graphicsRows := n
135 if n > height {137 if n > height {
136 n = height138 n = height
137 }139 }
138- s.scrollGraphicsRegion(top, bottom, n)140+ s.scrollGraphicsRegion(top, bottom, graphicsRows)
139 // Full-width region: rotate line offsets instead of copying cells.141 // Full-width region: rotate line offsets instead of copying cells.
140 s.Frame.ScrollDown(top, bottom, n)142 s.Frame.ScrollDown(top, bottom, n)
141 s.buffer.scrollDown(top, bottom, n)143 s.buffer.scrollDown(top, bottom, n)

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