fix(graphics): scroll placements with terminal rows
- commit
- 3fd479303da40eb0daefdbcda9218055e0a98e98
- author
- brice · · 2026-08-26 13:17 UTC
- parent
- 3d6f103
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | func TestRenderTerminalAPC(t *testing.T) { | |
| 42 | - | frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1}) | |
| 42 | + | frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1}) | |
| 43 | 43 | require.NoError(t, err) | |
| 44 | 44 | require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0)) | |
| 45 | 45 | } | |
| @@ -39,7 +39,7 @@ func TestRenderTerminalDemo(t *testing.T) { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | func TestRenderTerminalAPC(t *testing.T) { | 41 | func TestRenderTerminalAPC(t *testing.T) { | ||
| 42 | - | frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1}) | 42 | + | frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1}) |
| 43 | require.NoError(t, err) | 43 | require.NoError(t, err) | ||
| 44 | require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0)) | 44 | require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0)) | ||
| 45 | } | 45 | } | ||
M screen_graphics_test.go +14 -0
| @@ -257,3 +257,17 @@ func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) { | |||
| 257 | 257 | require.Equal(t, 2, screen.CursorRow()) | |
| 258 | 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 | + | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "math" | |
| 5 | 6 | ||
| 6 | 7 | renderer "github.com/bnema/vev-vt/core" | |
| 8 | + | "github.com/bnema/vev-vt/graphics" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // 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 | 82 | if n > height { | |
| 81 | 83 | n = height | |
| 82 | 84 | } | |
| 85 | + | s.scrollGraphicsRegion(top, bottom, -n) | |
| 83 | 86 | // VT scroll regions always span the full frame width, so we rotate the | |
| 84 | 87 | // frame's line offsets (recycling and blanking the evicted rows in place) | |
| 85 | 88 | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| @@ -132,6 +135,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 132 | 135 | if n > height { | |
| 133 | 136 | n = height | |
| 134 | 137 | } | |
| 138 | + | s.scrollGraphicsRegion(top, bottom, n) | |
| 135 | 139 | // Full-width region: rotate line offsets instead of copying cells. | |
| 136 | 140 | s.Frame.ScrollDown(top, bottom, n) | |
| 137 | 141 | s.buffer.scrollDown(top, bottom, n) | |
| @@ -139,6 +143,45 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 139 | 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 | 185 | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |
| 143 | 186 | if s.Frame.Height == 0 || top > bottom { | |
| 144 | 187 | 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 cursor | 11 | // 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 = height | 83 | n = height | ||
| 82 | } | 84 | } | ||
| 85 | + | s.scrollGraphicsRegion(top, bottom, -n) | |||
| 83 | // VT scroll regions always span the full frame width, so we rotate the | 86 | // 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 = height | 136 | 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, false | 187 | return 0, 0, false | ||