detached 3fd4793

fix(graphics): scroll placements with terminal rows

commit
3fd479303da40eb0daefdbcda9218055e0a98e98
author
brice · · 2026-08-26 13:17 UTC
parent
3d6f103
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +58 -1
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 screen_graphics_test.go +14 -0
@@ -257,3 +257,17 @@ func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) {
257257 require.Equal(t, 2, screen.CursorRow())
258258 require.Equal(t, 2, screen.CursorCol())
259259 }
260+
261+func TestScreenKittyPlacementScrollsWithTallImageCursorAdvance(t *testing.T) {
262+ screen := NewScreen(10, 5)
263+ screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50})
264+ screen.Write([]byte("\x1b[2;1H"))
265+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*45*4))
266+
267+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=45;" + payload + "\x1b\\"))
268+
269+ placement := screen.GraphicsSnapshot().Placements()[0]
270+ require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 15, Height: 45}, placement.Destination(), "the placement must scroll with the text buffer")
271+ require.Equal(t, 4, screen.CursorRow())
272+ require.Equal(t, 2, screen.CursorCol())
273+}
@@ -257,3 +257,17 @@ func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) {
257 require.Equal(t, 2, screen.CursorRow())257 require.Equal(t, 2, screen.CursorRow())
258 require.Equal(t, 2, screen.CursorCol())258 require.Equal(t, 2, screen.CursorCol())
259 }259 }
260+
261+func TestScreenKittyPlacementScrollsWithTallImageCursorAdvance(t *testing.T) {
262+ screen := NewScreen(10, 5)
263+ screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50})
264+ screen.Write([]byte("\x1b[2;1H"))
265+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*45*4))
266+
267+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=45;" + payload + "\x1b\\"))
268+
269+ placement := screen.GraphicsSnapshot().Placements()[0]
270+ require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 15, Height: 45}, placement.Destination(), "the placement must scroll with the text buffer")
271+ require.Equal(t, 4, screen.CursorRow())
272+ require.Equal(t, 2, screen.CursorCol())
273+}
M screen_scroll.go +43 -0
@@ -2,8 +2,10 @@ package vt
22
33 import (
44 "errors"
5+ "math"
56
67 renderer "github.com/bnema/vev-vt/core"
8+ "github.com/bnema/vev-vt/graphics"
79 )
810
911 // index moves to the next physical row, scrolling the region when the cursor
@@ -80,6 +82,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
8082 if n > height {
8183 n = height
8284 }
85+ s.scrollGraphicsRegion(top, bottom, -n)
8386 // VT scroll regions always span the full frame width, so we rotate the
8487 // frame's line offsets (recycling and blanking the evicted rows in place)
8588 // instead of copying cells. See renderer.Frame.ScrollUp.
@@ -132,6 +135,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
132135 if n > height {
133136 n = height
134137 }
138+ s.scrollGraphicsRegion(top, bottom, n)
135139 // Full-width region: rotate line offsets instead of copying cells.
136140 s.Frame.ScrollDown(top, bottom, n)
137141 s.buffer.scrollDown(top, bottom, n)
@@ -139,6 +143,45 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
139143 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
140144 }
141145
146+func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) {
147+ if rows == 0 || s.graphics == nil || s.graphics.scene == nil {
148+ return
149+ }
150+ _, cellHeight, ok := s.kittyCellPixels()
151+ if !ok || cellHeight <= 0 {
152+ return
153+ }
154+ delta := int64(rows) * int64(cellHeight)
155+ fullScreen := top == 0 && bottom == s.Frame.Height-1
156+ snapshot := s.graphics.scene.Snapshot()
157+ placements := snapshot.Placements()
158+ operations := make([]graphics.Operation, 0, len(placements))
159+ for _, placement := range placements {
160+ destination := placement.Destination()
161+ if !fullScreen {
162+ end, valid := destination.Bottom()
163+ if !valid || destination.Y < 0 || end <= destination.Y {
164+ continue
165+ }
166+ startRow := int(destination.Y / int64(cellHeight))
167+ endRow := int((end - 1) / int64(cellHeight))
168+ if startRow < top || endRow > bottom {
169+ continue
170+ }
171+ }
172+ if delta > 0 && destination.Y > math.MaxInt64-delta || delta < 0 && destination.Y < math.MinInt64-delta {
173+ continue
174+ }
175+ destination.Y += delta
176+ spec := placement.Spec()
177+ spec.Destination = destination
178+ operations = append(operations, graphics.Operation{Kind: graphics.OperationUpdatePlacement, PlacementID: placement.ID(), Placement: spec})
179+ }
180+ if len(operations) != 0 {
181+ _ = s.graphics.scene.Apply(operations...)
182+ }
183+}
184+
142185 func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
143186 if s.Frame.Height == 0 || top > bottom {
144187 return 0, 0, false
@@ -2,8 +2,10 @@ package vt
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "math"
5 6
6 renderer "github.com/bnema/vev-vt/core"7 renderer "github.com/bnema/vev-vt/core"
8+ "github.com/bnema/vev-vt/graphics"
7 )9 )
8 10
9 // index moves to the next physical row, scrolling the region when the cursor11 // index moves to the next physical row, scrolling the region when the cursor
@@ -80,6 +82,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
80 if n > height {82 if n > height {
81 n = height83 n = height
82 }84 }
85+ s.scrollGraphicsRegion(top, bottom, -n)
83 // VT scroll regions always span the full frame width, so we rotate the86 // 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)87 // frame's line offsets (recycling and blanking the evicted rows in place)
85 // instead of copying cells. See renderer.Frame.ScrollUp.88 // instead of copying cells. See renderer.Frame.ScrollUp.
@@ -132,6 +135,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
132 if n > height {135 if n > height {
133 n = height136 n = height
134 }137 }
138+ s.scrollGraphicsRegion(top, bottom, n)
135 // Full-width region: rotate line offsets instead of copying cells.139 // Full-width region: rotate line offsets instead of copying cells.
136 s.Frame.ScrollDown(top, bottom, n)140 s.Frame.ScrollDown(top, bottom, n)
137 s.buffer.scrollDown(top, bottom, n)141 s.buffer.scrollDown(top, bottom, n)
@@ -139,6 +143,45 @@ 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})143 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
140 }144 }
141 145
146+func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) {
147+ if rows == 0 || s.graphics == nil || s.graphics.scene == nil {
148+ return
149+ }
150+ _, cellHeight, ok := s.kittyCellPixels()
151+ if !ok || cellHeight <= 0 {
152+ return
153+ }
154+ delta := int64(rows) * int64(cellHeight)
155+ fullScreen := top == 0 && bottom == s.Frame.Height-1
156+ snapshot := s.graphics.scene.Snapshot()
157+ placements := snapshot.Placements()
158+ operations := make([]graphics.Operation, 0, len(placements))
159+ for _, placement := range placements {
160+ destination := placement.Destination()
161+ if !fullScreen {
162+ end, valid := destination.Bottom()
163+ if !valid || destination.Y < 0 || end <= destination.Y {
164+ continue
165+ }
166+ startRow := int(destination.Y / int64(cellHeight))
167+ endRow := int((end - 1) / int64(cellHeight))
168+ if startRow < top || endRow > bottom {
169+ continue
170+ }
171+ }
172+ if delta > 0 && destination.Y > math.MaxInt64-delta || delta < 0 && destination.Y < math.MinInt64-delta {
173+ continue
174+ }
175+ destination.Y += delta
176+ spec := placement.Spec()
177+ spec.Destination = destination
178+ operations = append(operations, graphics.Operation{Kind: graphics.OperationUpdatePlacement, PlacementID: placement.ID(), Placement: spec})
179+ }
180+ if len(operations) != 0 {
181+ _ = s.graphics.scene.Apply(operations...)
182+ }
183+}
184+
142 func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {185 func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
143 if s.Frame.Height == 0 || top > bottom {186 if s.Frame.Height == 0 || top > bottom {
144 return 0, 0, false187 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