fix(graphics): advance cursor by image cell span
- commit
- 3d6f10398dd32c9d88c31688c482817f06e411d2
- author
- brice · · 2026-08-26 12:57 UTC
- parent
- b0cc038
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +72 -20
M screen.go +58 -19
| @@ -422,7 +422,8 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) { | |||
| 422 | 422 | } | |
| 423 | 423 | if len(result.Mutations) != 0 { | |
| 424 | 424 | if display { | |
| 425 | - | s.applyKittyCursorMovement(movementControls) | |
| 425 | + | mutation := result.Mutations[len(result.Mutations)-1] | |
| 426 | + | s.applyKittyCursorMovement(movementControls, mutation) | |
| 426 | 427 | s.kittyPendingDisplay = nil | |
| 427 | 428 | } | |
| 428 | 429 | // Graphics are rendered independently from Frame. A graphics mutation | |
| @@ -458,35 +459,73 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) { | |||
| 458 | 459 | return width, height, true | |
| 459 | 460 | } | |
| 460 | 461 | ||
| 461 | - | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) { | |
| 462 | - | if c.HasCursor && c.Cursor == 1 { | |
| 462 | + | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) { | |
| 463 | + | if c.HasCursor && c.Cursor == 1 || s.Frame.Width <= 0 || s.Frame.Height <= 0 { | |
| 463 | 464 | return | |
| 464 | 465 | } | |
| 465 | - | columns, rows := 1, 1 | |
| 466 | + | columns, rows := s.kittyPlacementGridSize(c, mutation) | |
| 467 | + | targetColumn := s.Col + columns | |
| 468 | + | wraps := targetColumn >= s.Frame.Width | |
| 469 | + | rowMoves := max(rows-1, 0) | |
| 470 | + | if wraps { | |
| 471 | + | rowMoves++ | |
| 472 | + | } | |
| 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 | + | } | |
| 479 | + | if wraps { | |
| 480 | + | s.Col = 0 | |
| 481 | + | } else { | |
| 482 | + | s.Col = targetColumn | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) { | |
| 487 | + | columns, rows = 1, 1 | |
| 466 | 488 | if c.HasColumns { | |
| 467 | - | columns = kittyCursorAdvance(c.Columns) | |
| 489 | + | columns = max(kittyCursorAdvance(c.Columns), 1) | |
| 468 | 490 | } | |
| 469 | 491 | if c.HasRows { | |
| 470 | - | rows = kittyCursorAdvance(c.Rows) | |
| 492 | + | rows = max(kittyCursorAdvance(c.Rows), 1) | |
| 493 | + | } | |
| 494 | + | if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil { | |
| 495 | + | return columns, rows | |
| 471 | 496 | } | |
| 472 | - | s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1) | |
| 473 | - | s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow()) | |
| 497 | + | placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID) | |
| 498 | + | if !ok { | |
| 499 | + | return columns, rows | |
| 500 | + | } | |
| 501 | + | cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels() | |
| 502 | + | if !pixelsKnown { | |
| 503 | + | return columns, rows | |
| 504 | + | } | |
| 505 | + | destination := placement.Destination() | |
| 506 | + | if !c.HasColumns { | |
| 507 | + | offset := destination.X % int64(cellWidth) | |
| 508 | + | columns = kittyPixelCellSpan(offset, destination.Width, cellWidth) | |
| 509 | + | } | |
| 510 | + | if !c.HasRows { | |
| 511 | + | offset := destination.Y % int64(cellHeight) | |
| 512 | + | rows = kittyPixelCellSpan(offset, destination.Height, cellHeight) | |
| 513 | + | } | |
| 514 | + | return columns, rows | |
| 474 | 515 | } | |
| 475 | 516 | ||
| 476 | - | func advanceKittyCursor(current, amount, minimum, maximum int) int { | |
| 477 | - | if maximum < minimum { | |
| 478 | - | return minimum | |
| 479 | - | } | |
| 480 | - | if current < minimum { | |
| 481 | - | current = minimum | |
| 517 | + | func kittyPixelCellSpan(offset, size int64, cellSize int) int { | |
| 518 | + | if offset < 0 || size <= 0 || cellSize <= 0 { | |
| 519 | + | return 1 | |
| 482 | 520 | } | |
| 483 | - | if current > maximum { | |
| 484 | - | current = maximum | |
| 521 | + | span := (offset + size + int64(cellSize) - 1) / int64(cellSize) | |
| 522 | + | if span <= 0 { | |
| 523 | + | return 1 | |
| 485 | 524 | } | |
| 486 | - | if amount > maximum-current { | |
| 487 | - | return maximum | |
| 525 | + | if span > int64(^uint(0)>>1) { | |
| 526 | + | return int(^uint(0) >> 1) | |
| 488 | 527 | } | |
| 489 | - | return current + amount | |
| 528 | + | return int(span) | |
| 490 | 529 | } | |
| 491 | 530 | ||
| 492 | 531 | func kittyCursorAdvance(value uint64) int { | |
| @@ -422,7 +422,8 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) { | |||||
| 422 | } | 422 | } | ||
| 423 | if len(result.Mutations) != 0 { | 423 | if len(result.Mutations) != 0 { | ||
| 424 | if display { | 424 | if display { | ||
| 425 | - | s.applyKittyCursorMovement(movementControls) | 425 | + | mutation := result.Mutations[len(result.Mutations)-1] |
| 426 | + | s.applyKittyCursorMovement(movementControls, mutation) | |||
| 426 | s.kittyPendingDisplay = nil | 427 | s.kittyPendingDisplay = nil | ||
| 427 | } | 428 | } | ||
| 428 | // Graphics are rendered independently from Frame. A graphics mutation | 429 | // Graphics are rendered independently from Frame. A graphics mutation | ||
| @@ -458,35 +459,73 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) { | |||||
| 458 | return width, height, true | 459 | return width, height, true | ||
| 459 | } | 460 | } | ||
| 460 | 461 | ||||
| 461 | - | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) { | 462 | + | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) { |
| 462 | - | if c.HasCursor && c.Cursor == 1 { | 463 | + | if c.HasCursor && c.Cursor == 1 || s.Frame.Width <= 0 || s.Frame.Height <= 0 { |
| 463 | return | 464 | return | ||
| 464 | } | 465 | } | ||
| 465 | - | columns, rows := 1, 1 | 466 | + | columns, rows := s.kittyPlacementGridSize(c, mutation) |
| 467 | + | targetColumn := s.Col + columns | |||
| 468 | + | wraps := targetColumn >= s.Frame.Width | |||
| 469 | + | rowMoves := max(rows-1, 0) | |||
| 470 | + | if wraps { | |||
| 471 | + | rowMoves++ | |||
| 472 | + | } | |||
| 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 | + | } | |||
| 479 | + | if wraps { | |||
| 480 | + | s.Col = 0 | |||
| 481 | + | } else { | |||
| 482 | + | s.Col = targetColumn | |||
| 483 | + | } | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) { | |||
| 487 | + | columns, rows = 1, 1 | |||
| 466 | if c.HasColumns { | 488 | if c.HasColumns { | ||
| 467 | - | columns = kittyCursorAdvance(c.Columns) | 489 | + | columns = max(kittyCursorAdvance(c.Columns), 1) |
| 468 | } | 490 | } | ||
| 469 | if c.HasRows { | 491 | if c.HasRows { | ||
| 470 | - | rows = kittyCursorAdvance(c.Rows) | 492 | + | rows = max(kittyCursorAdvance(c.Rows), 1) |
| 493 | + | } | |||
| 494 | + | if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil { | |||
| 495 | + | return columns, rows | |||
| 471 | } | 496 | } | ||
| 472 | - | s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1) | 497 | + | placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID) |
| 473 | - | s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow()) | 498 | + | if !ok { |
| 499 | + | return columns, rows | |||
| 500 | + | } | |||
| 501 | + | cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels() | |||
| 502 | + | if !pixelsKnown { | |||
| 503 | + | return columns, rows | |||
| 504 | + | } | |||
| 505 | + | destination := placement.Destination() | |||
| 506 | + | if !c.HasColumns { | |||
| 507 | + | offset := destination.X % int64(cellWidth) | |||
| 508 | + | columns = kittyPixelCellSpan(offset, destination.Width, cellWidth) | |||
| 509 | + | } | |||
| 510 | + | if !c.HasRows { | |||
| 511 | + | offset := destination.Y % int64(cellHeight) | |||
| 512 | + | rows = kittyPixelCellSpan(offset, destination.Height, cellHeight) | |||
| 513 | + | } | |||
| 514 | + | return columns, rows | |||
| 474 | } | 515 | } | ||
| 475 | 516 | ||||
| 476 | - | func advanceKittyCursor(current, amount, minimum, maximum int) int { | 517 | + | func kittyPixelCellSpan(offset, size int64, cellSize int) int { |
| 477 | - | if maximum < minimum { | 518 | + | if offset < 0 || size <= 0 || cellSize <= 0 { |
| 478 | - | return minimum | 519 | + | return 1 |
| 479 | - | } | |||
| 480 | - | if current < minimum { | |||
| 481 | - | current = minimum | |||
| 482 | } | 520 | } | ||
| 483 | - | if current > maximum { | 521 | + | span := (offset + size + int64(cellSize) - 1) / int64(cellSize) |
| 484 | - | current = maximum | 522 | + | if span <= 0 { |
| 523 | + | return 1 | |||
| 485 | } | 524 | } | ||
| 486 | - | if amount > maximum-current { | 525 | + | if span > int64(^uint(0)>>1) { |
| 487 | - | return maximum | 526 | + | return int(^uint(0) >> 1) |
| 488 | } | 527 | } | ||
| 489 | - | return current + amount | 528 | + | return int(span) |
| 490 | } | 529 | } | ||
| 491 | 530 | ||||
| 492 | func kittyCursorAdvance(value uint64) int { | 531 | func kittyCursorAdvance(value uint64) int { | ||
M screen_graphics_test.go +14 -1
| @@ -233,7 +233,7 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) { | |||
| 233 | 233 | require.Equal(t, 2, screen.CursorCol()) | |
| 234 | 234 | ||
| 235 | 235 | screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=2,v=2,C=0;" + payload + "\x1b\\")) | |
| 236 | - | require.Equal(t, 2, screen.CursorRow()) | |
| 236 | + | require.Equal(t, 1, screen.CursorRow()) | |
| 237 | 237 | require.Equal(t, 3, screen.CursorCol()) | |
| 238 | 238 | ||
| 239 | 239 | screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\")) | |
| @@ -244,3 +244,16 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) { | |||
| 244 | 244 | require.Equal(t, 2, screen.CursorRow()) | |
| 245 | 245 | require.Equal(t, 3, screen.CursorCol()) | |
| 246 | 246 | } | |
| 247 | + | ||
| 248 | + | func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) { | |
| 249 | + | screen := NewScreen(10, 5) | |
| 250 | + | screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50}) | |
| 251 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*25*4)) | |
| 252 | + | ||
| 253 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=25;" + payload + "\x1b\\")) | |
| 254 | + | ||
| 255 | + | // A 15x25 image occupies 2x3 cells. Kitty leaves the cursor immediately to | |
| 256 | + | // the right of the placement and on its final occupied row. | |
| 257 | + | require.Equal(t, 2, screen.CursorRow()) | |
| 258 | + | require.Equal(t, 2, screen.CursorCol()) | |
| 259 | + | } | |
| @@ -233,7 +233,7 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) { | |||||
| 233 | require.Equal(t, 2, screen.CursorCol()) | 233 | require.Equal(t, 2, screen.CursorCol()) | ||
| 234 | 234 | ||||
| 235 | screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=2,v=2,C=0;" + payload + "\x1b\\")) | 235 | screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=2,v=2,C=0;" + payload + "\x1b\\")) | ||
| 236 | - | require.Equal(t, 2, screen.CursorRow()) | 236 | + | require.Equal(t, 1, screen.CursorRow()) |
| 237 | require.Equal(t, 3, screen.CursorCol()) | 237 | require.Equal(t, 3, screen.CursorCol()) | ||
| 238 | 238 | ||||
| 239 | screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\")) | 239 | screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\")) | ||
| @@ -244,3 +244,16 @@ func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) { | |||||
| 244 | require.Equal(t, 2, screen.CursorRow()) | 244 | require.Equal(t, 2, screen.CursorRow()) | ||
| 245 | require.Equal(t, 3, screen.CursorCol()) | 245 | require.Equal(t, 3, screen.CursorCol()) | ||
| 246 | } | 246 | } | ||
| 247 | + | ||||
| 248 | + | func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) { | |||
| 249 | + | screen := NewScreen(10, 5) | |||
| 250 | + | screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50}) | |||
| 251 | + | payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*25*4)) | |||
| 252 | + | ||||
| 253 | + | screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=25;" + payload + "\x1b\\")) | |||
| 254 | + | ||||
| 255 | + | // A 15x25 image occupies 2x3 cells. Kitty leaves the cursor immediately to | |||
| 256 | + | // the right of the placement and on its final occupied row. | |||
| 257 | + | require.Equal(t, 2, screen.CursorRow()) | |||
| 258 | + | require.Equal(t, 2, screen.CursorCol()) | |||
| 259 | + | } | |||