detached 3d6f103

fix(graphics): advance cursor by image cell span

commit
3d6f10398dd32c9d88c31688c482817f06e411d2
author
brice · · 2026-08-26 12:57 UTC
parent
b0cc038
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
422422 }
423423 if len(result.Mutations) != 0 {
424424 if display {
425- s.applyKittyCursorMovement(movementControls)
425+ mutation := result.Mutations[len(result.Mutations)-1]
426+ s.applyKittyCursorMovement(movementControls, mutation)
426427 s.kittyPendingDisplay = nil
427428 }
428429 // Graphics are rendered independently from Frame. A graphics mutation
@@ -458,35 +459,73 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
458459 return width, height, true
459460 }
460461
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 {
463464 return
464465 }
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
466488 if c.HasColumns {
467- columns = kittyCursorAdvance(c.Columns)
489+ columns = max(kittyCursorAdvance(c.Columns), 1)
468490 }
469491 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
471496 }
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
474515 }
475516
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
482520 }
483- if current > maximum {
484- current = maximum
521+ span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
522+ if span <= 0 {
523+ return 1
485524 }
486- if amount > maximum-current {
487- return maximum
525+ if span > int64(^uint(0)>>1) {
526+ return int(^uint(0) >> 1)
488527 }
489- return current + amount
528+ return int(span)
490529 }
491530
492531 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 = nil427 s.kittyPendingDisplay = nil
427 }428 }
428 // Graphics are rendered independently from Frame. A graphics mutation429 // 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, true459 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 return464 return
464 }465 }
465- columns, rows := 1, 1466+ 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 minimum519+ 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 = maximum522+ if span <= 0 {
523+ return 1
485 }524 }
486- if amount > maximum-current {525+ if span > int64(^uint(0)>>1) {
487- return maximum526+ return int(^uint(0) >> 1)
488 }527 }
489- return current + amount528+ 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) {
233233 require.Equal(t, 2, screen.CursorCol())
234234
235235 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())
237237 require.Equal(t, 3, screen.CursorCol())
238238
239239 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) {
244244 require.Equal(t, 2, screen.CursorRow())
245245 require.Equal(t, 3, screen.CursorCol())
246246 }
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+}

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