fix(graphics): address wrap-up review findings
- commit
- 47ca90350bd7fad66d2449bdec90b7f76dec9cf1
- author
- brice · · 2026-08-26 14:30 UTC
- parent
- 2519bf3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +78 -18
M README.md +7 -6
| @@ -50,15 +50,16 @@ evidence. | |||
| 50 | 50 | The VT accepts bounded static direct transmissions used by current | |
| 51 | 51 | `kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit, | |
| 52 | 52 | 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. | |
| 57 | 58 | ||
| 58 | 59 | `Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are | |
| 59 | 60 | 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. | |
| 62 | 63 | ||
| 63 | 64 | ### Headless graphics harness | |
| 64 | 65 | ||
| @@ -50,15 +50,16 @@ evidence. | |||||
| 50 | The VT accepts bounded static direct transmissions used by current | 50 | 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; chunked | 52 | transmit-and-display, place, query, and supported delete operations; chunked | ||
| 53 | - | Base64 uploads; source rectangles; cell extents; pixel offsets; z-index; and | 53 | + | Base64 uploads with optional zlib compression; source rectangles; cell extents; |
| 54 | - | cursor movement policy. Assets and decoded pixels are bounded by explicit scene | 54 | + | 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 are | 59 | `Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are | ||
| 59 | separate from cells and history bytes; VTH3 remains text/history-only. Static | 60 | separate from cells and history bytes; VTH3 remains text/history-only. Static | ||
| 60 | - | placements retain their configured pixel rectangles across text scrolling and | 61 | + | 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 harness | 64 | ### Headless graphics harness | ||
| 64 | 65 | ||||
M protocol/kittygraphics/kittygraphics_test.go +26 -0
| @@ -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 | 134 | func TestSessionBoundsZlibDecompression(t *testing.T) { | |
| 117 | 135 | raw := bytes.Repeat([]byte{1}, 64) | |
| 118 | 136 | 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 | 416 | func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) { | |
| 391 | 417 | data := pngBytes(t, 2, 2) | |
| 392 | 418 | 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.Buffer | 136 | 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 | |||
| 484 | 484 | return 0, 0, ErrPayloadTooLarge | |
| 485 | 485 | } | |
| 486 | 486 | expected, ok := checkedProduct(c.Width, rowBytes) | |
| 487 | - | if !ok { | |
| 487 | + | if !ok || expected > limits.MaxDecodedBytes { | |
| 488 | 488 | return 0, 0, ErrPayloadTooLarge | |
| 489 | 489 | } | |
| 490 | 490 | if uint64(len(data)) != expected { | |
| @@ -649,7 +649,7 @@ func (s *Session) query(command Command) ([][]byte, *Mutation, error) { | |||
| 649 | 649 | if len(command.Payload) == 0 { | |
| 650 | 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 | 653 | if err != nil { | |
| 654 | 654 | return s.failure(c, responseImageID(c), err) | |
| 655 | 655 | } | |
| @@ -484,7 +484,7 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64 | |||||
| 484 | return 0, 0, ErrPayloadTooLarge | 484 | 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, ErrPayloadTooLarge | 488 | 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) { | |||
| 434 | 434 | ||
| 435 | 435 | func (s *Screen) kittyCursorX() uint64 { | |
| 436 | 436 | if cellWidth, _, ok := s.kittyCellPixels(); ok { | |
| 437 | - | return uint64(s.Col * cellWidth) | |
| 437 | + | return uint64(s.kittyCursorColumn() * cellWidth) | |
| 438 | 438 | } | |
| 439 | 439 | 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 | 449 | func (s *Screen) kittyCursorY() uint64 { | |
| 443 | 450 | _, cellHeight, ok := s.kittyCellPixels() | |
| 444 | 451 | if ok { | |
| @@ -464,18 +471,13 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit | |||
| 464 | 471 | return | |
| 465 | 472 | } | |
| 466 | 473 | columns, rows := s.kittyPlacementGridSize(c, mutation) | |
| 467 | - | targetColumn := s.Col + columns | |
| 474 | + | targetColumn := s.kittyCursorColumn() + columns | |
| 468 | 475 | wraps := targetColumn >= s.Frame.Width | |
| 469 | 476 | rowMoves := max(rows-1, 0) | |
| 470 | 477 | if wraps { | |
| 471 | 478 | rowMoves++ | |
| 472 | 479 | } | |
| 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) | |
| 479 | 481 | if wraps { | |
| 480 | 482 | s.Col = 0 | |
| 481 | 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 | 505 | func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) { | |
| 487 | 506 | columns, rows = 1, 1 | |
| 488 | 507 | 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 0 | 439 | 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 | return | 471 | return | ||
| 465 | } | 472 | } | ||
| 466 | columns, rows := s.kittyPlacementGridSize(c, mutation) | 473 | columns, rows := s.kittyPlacementGridSize(c, mutation) | ||
| 467 | - | targetColumn := s.Col + columns | 474 | + | targetColumn := s.kittyCursorColumn() + columns |
| 468 | wraps := targetColumn >= s.Frame.Width | 475 | 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 calls | 480 | + | 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 = 0 | 482 | 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, 1 | 506 | 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) { | |||
| 245 | 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 | 260 | func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) { | |
| 249 | 261 | screen := NewScreen(10, 5) | |
| 250 | 262 | 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) { | |||
| 79 | 79 | } | |
| 80 | 80 | w := s.Frame.Width | |
| 81 | 81 | height := bottom - top + 1 | |
| 82 | + | graphicsRows := n | |
| 82 | 83 | if n > height { | |
| 83 | 84 | n = height | |
| 84 | 85 | } | |
| 85 | - | s.scrollGraphicsRegion(top, bottom, -n) | |
| 86 | + | s.scrollGraphicsRegion(top, bottom, -graphicsRows) | |
| 86 | 87 | // VT scroll regions always span the full frame width, so we rotate the | |
| 87 | 88 | // frame's line offsets (recycling and blanking the evicted rows in place) | |
| 88 | 89 | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| @@ -132,10 +133,11 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 132 | 133 | } | |
| 133 | 134 | w := s.Frame.Width | |
| 134 | 135 | height := bottom - top + 1 | |
| 136 | + | graphicsRows := n | |
| 135 | 137 | if n > height { | |
| 136 | 138 | n = height | |
| 137 | 139 | } | |
| 138 | - | s.scrollGraphicsRegion(top, bottom, n) | |
| 140 | + | s.scrollGraphicsRegion(top, bottom, graphicsRows) | |
| 139 | 141 | // Full-width region: rotate line offsets instead of copying cells. | |
| 140 | 142 | s.Frame.ScrollDown(top, bottom, n) | |
| 141 | 143 | 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.Width | 80 | w := s.Frame.Width | ||
| 81 | height := bottom - top + 1 | 81 | height := bottom - top + 1 | ||
| 82 | + | graphicsRows := n | |||
| 82 | if n > height { | 83 | if n > height { | ||
| 83 | n = height | 84 | 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 the | 87 | // 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.Width | 134 | w := s.Frame.Width | ||
| 134 | height := bottom - top + 1 | 135 | height := bottom - top + 1 | ||
| 136 | + | graphicsRows := n | |||
| 135 | if n > height { | 137 | if n > height { | ||
| 136 | n = height | 138 | 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) | ||