detached a67301c

fix(graphics): validate placement scrolling

commit
a67301cbb68aa950cc595694fd11e246c88eff68
author
brice · · 2026-08-26 15:30 UTC
parent
47ca903
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +42 -2
M protocol/kittygraphics/session.go +1 -1
@@ -407,7 +407,7 @@ func decodeImagePayload(payload []byte, controls Controls, limits Limits) ([]byt
407407 if err != nil {
408408 return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)
409409 }
410- defer zr.Close()
410+ defer func() { _ = zr.Close() }()
411411 limit := limits.MaxDecodedBytes
412412 if limit >= math.MaxInt64 {
413413 limit = math.MaxInt64 - 1
@@ -407,7 +407,7 @@ func decodeImagePayload(payload []byte, controls Controls, limits Limits) ([]byt
407 if err != nil {407 if err != nil {
408 return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)408 return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)
409 }409 }
410- defer zr.Close()410+ defer func() { _ = zr.Close() }()
411 limit := limits.MaxDecodedBytes411 limit := limits.MaxDecodedBytes
412 if limit >= math.MaxInt64 {412 if limit >= math.MaxInt64 {
413 limit = math.MaxInt64 - 1413 limit = math.MaxInt64 - 1
M screen_graphics_test.go +34 -0
@@ -2,6 +2,7 @@ package vt
22
33 import (
44 "encoding/base64"
5+ "math"
56 "strconv"
67 "testing"
78
@@ -283,3 +284,36 @@ func TestScreenKittyPlacementScrollsWithTallImageCursorAdvance(t *testing.T) {
283284 require.Equal(t, 4, screen.CursorRow())
284285 require.Equal(t, 2, screen.CursorCol())
285286 }
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
@@ -283,3 +284,36 @@ func TestScreenKittyPlacementScrollsWithTallImageCursorAdvance(t *testing.T) {
283 require.Equal(t, 4, screen.CursorRow())284 require.Equal(t, 4, screen.CursorRow())
284 require.Equal(t, 2, screen.CursorCol())285 require.Equal(t, 2, screen.CursorCol())
285 }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 +7 -1
@@ -2,6 +2,7 @@ package vt
22
33 import (
44 "errors"
5+ "fmt"
56 "math"
67
78 renderer "github.com/bnema/vev-vt/core"
@@ -175,12 +176,17 @@ func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) {
175176 continue
176177 }
177178 destination.Y += delta
179+ if _, valid := destination.Bottom(); !valid {
180+ continue
181+ }
178182 spec := placement.Spec()
179183 spec.Destination = destination
180184 operations = append(operations, graphics.Operation{Kind: graphics.OperationUpdatePlacement, PlacementID: placement.ID(), Placement: spec})
181185 }
182186 if len(operations) != 0 {
183- _ = s.graphics.scene.Apply(operations...)
187+ if err := s.graphics.scene.Apply(operations...); err != nil {
188+ panic(fmt.Errorf("scroll graphics placements: %w", err))
189+ }
184190 }
185191 }
186192
@@ -2,6 +2,7 @@ package vt
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "fmt"
5 "math"6 "math"
6 7
7 renderer "github.com/bnema/vev-vt/core"8 renderer "github.com/bnema/vev-vt/core"
@@ -175,12 +176,17 @@ func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) {
175 continue176 continue
176 }177 }
177 destination.Y += delta178 destination.Y += delta
179+ if _, valid := destination.Bottom(); !valid {
180+ continue
181+ }
178 spec := placement.Spec()182 spec := placement.Spec()
179 spec.Destination = destination183 spec.Destination = destination
180 operations = append(operations, graphics.Operation{Kind: graphics.OperationUpdatePlacement, PlacementID: placement.ID(), Placement: spec})184 operations = append(operations, graphics.Operation{Kind: graphics.OperationUpdatePlacement, PlacementID: placement.ID(), Placement: spec})
181 }185 }
182 if len(operations) != 0 {186 if len(operations) != 0 {
183- _ = s.graphics.scene.Apply(operations...)187+ if err := s.graphics.scene.Apply(operations...); err != nil {
188+ panic(fmt.Errorf("scroll graphics placements: %w", err))
189+ }
184 }190 }
185 }191 }
186 192

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