detached 0c10420

fix(kittygraphics): derive missing c/r from aspect ratio

Placements with only c= or only r= were rejected and dropped. Derive the
missing value from the displayed source rect and the cell pixel size (1:2
cell when unknown), treat c=0/r=0 as unset, and advance the cursor by the
stored cell size.
commit
0c10420df64487e5b0c86280a2d203fd13a53db9
author
brice · · 2026-10-21 07:15 UTC
parent
ff50ee4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +179 -5
M docs/graphics.md +4 -0
@@ -18,6 +18,10 @@ how to display it. This library does not open a window.
1818 - Direct image transmission, display, placement, queries and supported deletion.
1919 - Chunked Base64 data, with optional zlib compression.
2020 - Cropping, cell-based placement, pixel offsets and z-index.
21+- Placements with only `c=` or only `r=` keep the displayed region's aspect
22+ ratio: the other value is the rounded-up size derived from the cell pixel
23+ size (1:2 width:height when the screen pixel size is unknown), at least 1.
24+ `c=0` and `r=0` mean unset.
2125 - Movement and clipping as terminal rows scroll.
2226
2327 Images are stored separately from text history. The `graphics` package contains
@@ -18,6 +18,10 @@ how to display it. This library does not open a window.
18 - Direct image transmission, display, placement, queries and supported deletion.18 - Direct image transmission, display, placement, queries and supported deletion.
19 - Chunked Base64 data, with optional zlib compression.19 - Chunked Base64 data, with optional zlib compression.
20 - Cropping, cell-based placement, pixel offsets and z-index.20 - Cropping, cell-based placement, pixel offsets and z-index.
21+- Placements with only `c=` or only `r=` keep the displayed region's aspect
22+ ratio: the other value is the rounded-up size derived from the cell pixel
23+ size (1:2 width:height when the screen pixel size is unknown), at least 1.
24+ `c=0` and `r=0` mean unset.
21 - Movement and clipping as terminal rows scroll.25 - Movement and clipping as terminal rows scroll.
22 26
23 Images are stored separately from text history. The `graphics` package contains27 Images are stored separately from text history. The `graphics` package contains
M protocol/kittygraphics/kittygraphics_test.go +63 -0
@@ -584,3 +584,66 @@ func FuzzSessionNeverPanics(f *testing.F) {
584584 _, _ = s.Finish()
585585 })
586586 }
587+
588+func placeWithControls(t *testing.T, cellW, cellH int, imgW, imgH int, header string, chunked bool) graphics.PlacementView {
589+ t.Helper()
590+ scene := graphics.NewScene(graphics.Limits{})
591+ session := NewSession(scene)
592+ session.SetCellPixels(cellW, cellH)
593+ payload := base64.StdEncoding.EncodeToString(pngBytes(t, imgW, imgH))
594+ if chunked {
595+ half := len(payload) / 2 / 4 * 4
596+ if _, err := session.Feed(apc(header+",m=1", payload[:half])); err != nil {
597+ t.Fatal(err)
598+ }
599+ if _, err := session.Feed(apc("m=0", payload[half:])); err != nil {
600+ t.Fatal(err)
601+ }
602+ } else if _, err := session.Feed(apc(header, payload)); err != nil {
603+ t.Fatal(err)
604+ }
605+ placements := scene.Snapshot().Placements()
606+ if len(placements) != 1 || scene.Snapshot().Usage().Assets != 1 {
607+ t.Fatalf("usage = %#v", scene.Snapshot().Usage())
608+ }
609+ return placements[0]
610+}
611+
612+func TestPlacementSingleColumnOrRowDerivesOther(t *testing.T) {
613+ tests := []struct {
614+ name string
615+ cellW, cellH int
616+ imgW, imgH int
617+ header string
618+ chunked bool
619+ cols, rows int64
620+ }{
621+ {"columns only", 10, 20, 4, 4, "a=T,f=100,C=1,c=5", false, 5, 3},
622+ {"rows only", 10, 20, 4, 4, "a=T,f=100,C=1,r=3", false, 6, 3},
623+ {"both", 10, 20, 4, 4, "a=T,f=100,C=1,c=5,r=3", false, 5, 3},
624+ {"wide image columns only", 10, 20, 40, 10, "a=T,f=100,C=1,c=8", false, 8, 1},
625+ {"crop columns only", 10, 20, 8, 8, "a=T,f=100,C=1,c=4,w=8,h=4", false, 4, 1},
626+ {"crop rows only", 10, 20, 8, 8, "a=T,f=100,C=1,r=2,w=8,h=4", false, 8, 2},
627+ {"unknown cell size assumes 1:2", 0, 0, 4, 4, "a=T,f=100,C=1,c=4", false, 4, 2},
628+ {"unknown cell size rows only", 0, 0, 4, 4, "a=T,f=100,C=1,r=2", false, 4, 2},
629+ {"minimum one", 10, 20, 100, 1, "a=T,f=100,C=1,c=2", false, 2, 1},
630+ {"zero means unset", 10, 20, 4, 4, "a=T,f=100,C=1,c=5,r=0", false, 5, 3},
631+ {"chunked rows only", 10, 20, 4, 4, "a=T,f=100,C=1,r=3", true, 6, 3},
632+ }
633+ for _, tt := range tests {
634+ t.Run(tt.name, func(t *testing.T) {
635+ p := placeWithControls(t, tt.cellW, tt.cellH, tt.imgW, tt.imgH, tt.header, tt.chunked)
636+ cells := p.Cells()
637+ if cells.Width != tt.cols || cells.Height != tt.rows {
638+ t.Fatalf("cells = %dx%d, want %dx%d", cells.Width, cells.Height, tt.cols, tt.rows)
639+ }
640+ })
641+ }
642+}
643+
644+func TestPlacementZeroColumnsAndRowsMeansAuto(t *testing.T) {
645+ p := placeWithControls(t, 10, 20, 4, 4, "a=T,f=100,C=1,c=0,r=0", false)
646+ if !p.Cells().Empty() {
647+ t.Fatalf("cells = %#v, want none", p.Cells())
648+ }
649+}
@@ -584,3 +584,66 @@ func FuzzSessionNeverPanics(f *testing.F) {
584 _, _ = s.Finish()584 _, _ = s.Finish()
585 })585 })
586 }586 }
587+
588+func placeWithControls(t *testing.T, cellW, cellH int, imgW, imgH int, header string, chunked bool) graphics.PlacementView {
589+ t.Helper()
590+ scene := graphics.NewScene(graphics.Limits{})
591+ session := NewSession(scene)
592+ session.SetCellPixels(cellW, cellH)
593+ payload := base64.StdEncoding.EncodeToString(pngBytes(t, imgW, imgH))
594+ if chunked {
595+ half := len(payload) / 2 / 4 * 4
596+ if _, err := session.Feed(apc(header+",m=1", payload[:half])); err != nil {
597+ t.Fatal(err)
598+ }
599+ if _, err := session.Feed(apc("m=0", payload[half:])); err != nil {
600+ t.Fatal(err)
601+ }
602+ } else if _, err := session.Feed(apc(header, payload)); err != nil {
603+ t.Fatal(err)
604+ }
605+ placements := scene.Snapshot().Placements()
606+ if len(placements) != 1 || scene.Snapshot().Usage().Assets != 1 {
607+ t.Fatalf("usage = %#v", scene.Snapshot().Usage())
608+ }
609+ return placements[0]
610+}
611+
612+func TestPlacementSingleColumnOrRowDerivesOther(t *testing.T) {
613+ tests := []struct {
614+ name string
615+ cellW, cellH int
616+ imgW, imgH int
617+ header string
618+ chunked bool
619+ cols, rows int64
620+ }{
621+ {"columns only", 10, 20, 4, 4, "a=T,f=100,C=1,c=5", false, 5, 3},
622+ {"rows only", 10, 20, 4, 4, "a=T,f=100,C=1,r=3", false, 6, 3},
623+ {"both", 10, 20, 4, 4, "a=T,f=100,C=1,c=5,r=3", false, 5, 3},
624+ {"wide image columns only", 10, 20, 40, 10, "a=T,f=100,C=1,c=8", false, 8, 1},
625+ {"crop columns only", 10, 20, 8, 8, "a=T,f=100,C=1,c=4,w=8,h=4", false, 4, 1},
626+ {"crop rows only", 10, 20, 8, 8, "a=T,f=100,C=1,r=2,w=8,h=4", false, 8, 2},
627+ {"unknown cell size assumes 1:2", 0, 0, 4, 4, "a=T,f=100,C=1,c=4", false, 4, 2},
628+ {"unknown cell size rows only", 0, 0, 4, 4, "a=T,f=100,C=1,r=2", false, 4, 2},
629+ {"minimum one", 10, 20, 100, 1, "a=T,f=100,C=1,c=2", false, 2, 1},
630+ {"zero means unset", 10, 20, 4, 4, "a=T,f=100,C=1,c=5,r=0", false, 5, 3},
631+ {"chunked rows only", 10, 20, 4, 4, "a=T,f=100,C=1,r=3", true, 6, 3},
632+ }
633+ for _, tt := range tests {
634+ t.Run(tt.name, func(t *testing.T) {
635+ p := placeWithControls(t, tt.cellW, tt.cellH, tt.imgW, tt.imgH, tt.header, tt.chunked)
636+ cells := p.Cells()
637+ if cells.Width != tt.cols || cells.Height != tt.rows {
638+ t.Fatalf("cells = %dx%d, want %dx%d", cells.Width, cells.Height, tt.cols, tt.rows)
639+ }
640+ })
641+ }
642+}
643+
644+func TestPlacementZeroColumnsAndRowsMeansAuto(t *testing.T) {
645+ p := placeWithControls(t, 10, 20, 4, 4, "a=T,f=100,C=1,c=0,r=0", false)
646+ if !p.Cells().Empty() {
647+ t.Fatalf("cells = %#v, want none", p.Cells())
648+ }
649+}
M protocol/kittygraphics/session.go +76 -5
@@ -7,6 +7,7 @@ import (
77 "image/png"
88 "io"
99 "math"
10+ "math/big"
1011 "strconv"
1112
1213 "github.com/bnema/vev-vt/graphics"
@@ -20,6 +21,8 @@ type placementKey struct {
2021 type placementOrigin struct {
2122 x, y uint64
2223 set bool
24+ // cellW and cellH are the cell size in pixels, or zero when unknown.
25+ cellW, cellH int64
2326 }
2427
2528 type upload struct {
@@ -191,7 +194,25 @@ func (s *Session) SetPendingPlacement(x, y uint64) {
191194 }
192195 s.mu.Lock()
193196 defer s.mu.Unlock()
194- s.origin = placementOrigin{x: x, y: y, set: true}
197+ s.origin.x, s.origin.y, s.origin.set = x, y, true
198+ if s.upload != nil {
199+ s.upload.origin = s.origin
200+ }
201+}
202+
203+// SetCellPixels supplies the terminal cell size in pixels. It is used to keep
204+// the aspect ratio when a placement gives only one of c/r. Non-positive values
205+// mean the size is unknown and a default 1:2 (width:height) cell is assumed.
206+func (s *Session) SetCellPixels(width, height int) {
207+ if s == nil {
208+ return
209+ }
210+ s.mu.Lock()
211+ defer s.mu.Unlock()
212+ if width <= 0 || height <= 0 {
213+ width, height = 0, 0
214+ }
215+ s.origin.cellW, s.origin.cellH = int64(width), int64(height)
195216 if s.upload != nil {
196217 s.upload.origin = s.origin
197218 }
@@ -593,17 +614,44 @@ func placementSpec(c Controls, assetID graphics.AssetID, width, height int64, or
593614 if !ok || clipped != source {
594615 return graphics.PlacementSpec{}, graphics.ErrInvalidPlacement
595616 }
617+ // As in Kitty, c=0 and r=0 mean "unset". When only one is set, the other
618+ // is derived to keep the aspect ratio of the displayed source rectangle.
596619 var cells graphics.CellRect
597- if c.HasColumns || c.HasRows {
598- if !c.HasColumns || !c.HasRows || c.Columns == 0 || c.Rows == 0 || c.Columns > math.MaxInt64 || c.Rows > math.MaxInt64 {
620+ hasCells := (c.HasColumns && c.Columns != 0) || (c.HasRows && c.Rows != 0)
621+ if hasCells {
622+ if c.Columns > math.MaxInt64 || c.Rows > math.MaxInt64 {
599623 return graphics.PlacementSpec{}, fmt.Errorf("%w: c/r", ErrInvalidCommand)
600624 }
601- cells = graphics.CellRect{X: signedCoordinateInt(c.CellOffsetX, c.HasCellOffsetX), Y: signedCoordinateInt(c.CellOffsetY, c.HasCellOffsetY), Width: int64(c.Columns), Height: int64(c.Rows)}
625+ columns, rows := int64(c.Columns), int64(c.Rows)
626+ if !c.HasColumns {
627+ columns = 0
628+ }
629+ if !c.HasRows {
630+ rows = 0
631+ }
632+ // Unknown cell size: assume a 1:2 (width:height) cell, which is only
633+ // used for its ratio.
634+ cellW, cellH := origin.cellW, origin.cellH
635+ if cellW <= 0 || cellH <= 0 {
636+ cellW, cellH = 1, 2
637+ }
638+ var ok bool
639+ if columns == 0 {
640+ columns, ok = deriveCells(rows, source.Height, source.Width, cellH, cellW)
641+ } else if rows == 0 {
642+ rows, ok = deriveCells(columns, source.Width, source.Height, cellW, cellH)
643+ } else {
644+ ok = true
645+ }
646+ if !ok {
647+ return graphics.PlacementSpec{}, ErrIntegerOverflow
648+ }
649+ cells = graphics.CellRect{X: signedCoordinateInt(c.CellOffsetX, c.HasCellOffsetX), Y: signedCoordinateInt(c.CellOffsetY, c.HasCellOffsetY), Width: columns, Height: rows}
602650 if !cells.Valid() {
603651 return graphics.PlacementSpec{}, graphics.ErrInvalidRect
604652 }
605653 }
606- spec := graphics.PlacementSpec{Asset: assetID, Source: source, Destination: destination, Cells: cells, HasCells: c.HasColumns || c.HasRows}
654+ spec := graphics.PlacementSpec{Asset: assetID, Source: source, Destination: destination, Cells: cells, HasCells: hasCells}
607655 if c.HasLayer {
608656 spec.Layer = c.Layer
609657 spec.HasLayer = true
@@ -611,6 +659,29 @@ func placementSpec(c Controls, assetID graphics.AssetID, width, height int64, or
611659 return spec, nil
612660 }
613661
662+// deriveCells returns the cell count along the unspecified axis that preserves
663+// the source aspect ratio: ceil(given*givenCell*otherSrc / (givenSrc*otherCell)),
664+// at least 1. The boolean is false if the result does not fit in int64.
665+func deriveCells(given, givenSrc, otherSrc, givenCell, otherCell int64) (int64, bool) {
666+ num := new(big.Int).Mul(big.NewInt(given), big.NewInt(givenCell))
667+ num.Mul(num, big.NewInt(otherSrc))
668+ den := new(big.Int).Mul(big.NewInt(givenSrc), big.NewInt(otherCell))
669+ if den.Sign() <= 0 {
670+ return 0, false
671+ }
672+ q, r := new(big.Int).QuoRem(num, den, new(big.Int))
673+ if r.Sign() != 0 {
674+ q.Add(q, big.NewInt(1))
675+ }
676+ if q.Sign() == 0 {
677+ return 1, true
678+ }
679+ if !q.IsInt64() {
680+ return 0, false
681+ }
682+ return q.Int64(), true
683+}
684+
614685 func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {
615686 if c.HasParent || c.HasCellOffsetX || c.HasCellOffsetY {
616687 return nil, nil, ErrUnsupported
@@ -7,6 +7,7 @@ import (
7 "image/png"7 "image/png"
8 "io"8 "io"
9 "math"9 "math"
10+ "math/big"
10 "strconv"11 "strconv"
11 12
12 "github.com/bnema/vev-vt/graphics"13 "github.com/bnema/vev-vt/graphics"
@@ -20,6 +21,8 @@ type placementKey struct {
20 type placementOrigin struct {21 type placementOrigin struct {
21 x, y uint6422 x, y uint64
22 set bool23 set bool
24+ // cellW and cellH are the cell size in pixels, or zero when unknown.
25+ cellW, cellH int64
23 }26 }
24 27
25 type upload struct {28 type upload struct {
@@ -191,7 +194,25 @@ func (s *Session) SetPendingPlacement(x, y uint64) {
191 }194 }
192 s.mu.Lock()195 s.mu.Lock()
193 defer s.mu.Unlock()196 defer s.mu.Unlock()
194- s.origin = placementOrigin{x: x, y: y, set: true}197+ s.origin.x, s.origin.y, s.origin.set = x, y, true
198+ if s.upload != nil {
199+ s.upload.origin = s.origin
200+ }
201+}
202+
203+// SetCellPixels supplies the terminal cell size in pixels. It is used to keep
204+// the aspect ratio when a placement gives only one of c/r. Non-positive values
205+// mean the size is unknown and a default 1:2 (width:height) cell is assumed.
206+func (s *Session) SetCellPixels(width, height int) {
207+ if s == nil {
208+ return
209+ }
210+ s.mu.Lock()
211+ defer s.mu.Unlock()
212+ if width <= 0 || height <= 0 {
213+ width, height = 0, 0
214+ }
215+ s.origin.cellW, s.origin.cellH = int64(width), int64(height)
195 if s.upload != nil {216 if s.upload != nil {
196 s.upload.origin = s.origin217 s.upload.origin = s.origin
197 }218 }
@@ -593,17 +614,44 @@ func placementSpec(c Controls, assetID graphics.AssetID, width, height int64, or
593 if !ok || clipped != source {614 if !ok || clipped != source {
594 return graphics.PlacementSpec{}, graphics.ErrInvalidPlacement615 return graphics.PlacementSpec{}, graphics.ErrInvalidPlacement
595 }616 }
617+ // As in Kitty, c=0 and r=0 mean "unset". When only one is set, the other
618+ // is derived to keep the aspect ratio of the displayed source rectangle.
596 var cells graphics.CellRect619 var cells graphics.CellRect
597- if c.HasColumns || c.HasRows {620+ hasCells := (c.HasColumns && c.Columns != 0) || (c.HasRows && c.Rows != 0)
598- if !c.HasColumns || !c.HasRows || c.Columns == 0 || c.Rows == 0 || c.Columns > math.MaxInt64 || c.Rows > math.MaxInt64 {621+ if hasCells {
622+ if c.Columns > math.MaxInt64 || c.Rows > math.MaxInt64 {
599 return graphics.PlacementSpec{}, fmt.Errorf("%w: c/r", ErrInvalidCommand)623 return graphics.PlacementSpec{}, fmt.Errorf("%w: c/r", ErrInvalidCommand)
600 }624 }
601- cells = graphics.CellRect{X: signedCoordinateInt(c.CellOffsetX, c.HasCellOffsetX), Y: signedCoordinateInt(c.CellOffsetY, c.HasCellOffsetY), Width: int64(c.Columns), Height: int64(c.Rows)}625+ columns, rows := int64(c.Columns), int64(c.Rows)
626+ if !c.HasColumns {
627+ columns = 0
628+ }
629+ if !c.HasRows {
630+ rows = 0
631+ }
632+ // Unknown cell size: assume a 1:2 (width:height) cell, which is only
633+ // used for its ratio.
634+ cellW, cellH := origin.cellW, origin.cellH
635+ if cellW <= 0 || cellH <= 0 {
636+ cellW, cellH = 1, 2
637+ }
638+ var ok bool
639+ if columns == 0 {
640+ columns, ok = deriveCells(rows, source.Height, source.Width, cellH, cellW)
641+ } else if rows == 0 {
642+ rows, ok = deriveCells(columns, source.Width, source.Height, cellW, cellH)
643+ } else {
644+ ok = true
645+ }
646+ if !ok {
647+ return graphics.PlacementSpec{}, ErrIntegerOverflow
648+ }
649+ cells = graphics.CellRect{X: signedCoordinateInt(c.CellOffsetX, c.HasCellOffsetX), Y: signedCoordinateInt(c.CellOffsetY, c.HasCellOffsetY), Width: columns, Height: rows}
602 if !cells.Valid() {650 if !cells.Valid() {
603 return graphics.PlacementSpec{}, graphics.ErrInvalidRect651 return graphics.PlacementSpec{}, graphics.ErrInvalidRect
604 }652 }
605 }653 }
606- spec := graphics.PlacementSpec{Asset: assetID, Source: source, Destination: destination, Cells: cells, HasCells: c.HasColumns || c.HasRows}654+ spec := graphics.PlacementSpec{Asset: assetID, Source: source, Destination: destination, Cells: cells, HasCells: hasCells}
607 if c.HasLayer {655 if c.HasLayer {
608 spec.Layer = c.Layer656 spec.Layer = c.Layer
609 spec.HasLayer = true657 spec.HasLayer = true
@@ -611,6 +659,29 @@ func placementSpec(c Controls, assetID graphics.AssetID, width, height int64, or
611 return spec, nil659 return spec, nil
612 }660 }
613 661
662+// deriveCells returns the cell count along the unspecified axis that preserves
663+// the source aspect ratio: ceil(given*givenCell*otherSrc / (givenSrc*otherCell)),
664+// at least 1. The boolean is false if the result does not fit in int64.
665+func deriveCells(given, givenSrc, otherSrc, givenCell, otherCell int64) (int64, bool) {
666+ num := new(big.Int).Mul(big.NewInt(given), big.NewInt(givenCell))
667+ num.Mul(num, big.NewInt(otherSrc))
668+ den := new(big.Int).Mul(big.NewInt(givenSrc), big.NewInt(otherCell))
669+ if den.Sign() <= 0 {
670+ return 0, false
671+ }
672+ q, r := new(big.Int).QuoRem(num, den, new(big.Int))
673+ if r.Sign() != 0 {
674+ q.Add(q, big.NewInt(1))
675+ }
676+ if q.Sign() == 0 {
677+ return 1, true
678+ }
679+ if !q.IsInt64() {
680+ return 0, false
681+ }
682+ return q.Int64(), true
683+}
684+
614 func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {685 func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {
615 if c.HasParent || c.HasCellOffsetX || c.HasCellOffsetY {686 if c.HasParent || c.HasCellOffsetX || c.HasCellOffsetY {
616 return nil, nil, ErrUnsupported687 return nil, nil, ErrUnsupported
M screen.go +6 -0
@@ -519,6 +519,8 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
519519 }
520520 if display {
521521 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
522+ cellWidth, cellHeight, _ := s.kittyCellPixels()
523+ s.graphics.kitty.SetCellPixels(cellWidth, cellHeight)
522524 }
523525 if display && controls.HasMore && controls.More == 1 {
524526 copy := controls
@@ -628,6 +630,10 @@ func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kitty
628630 if !ok {
629631 return columns, rows
630632 }
633+ // Cell counts derived from a single c/r are already stored on the placement.
634+ if cells := placement.Cells(); !cells.Empty() {
635+ return max(kittyCursorAdvance(uint64(cells.Width)), 1), max(kittyCursorAdvance(uint64(cells.Height)), 1)
636+ }
631637 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
632638 if !pixelsKnown {
633639 return columns, rows
@@ -519,6 +519,8 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
519 }519 }
520 if display {520 if display {
521 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())521 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
522+ cellWidth, cellHeight, _ := s.kittyCellPixels()
523+ s.graphics.kitty.SetCellPixels(cellWidth, cellHeight)
522 }524 }
523 if display && controls.HasMore && controls.More == 1 {525 if display && controls.HasMore && controls.More == 1 {
524 copy := controls526 copy := controls
@@ -628,6 +630,10 @@ func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kitty
628 if !ok {630 if !ok {
629 return columns, rows631 return columns, rows
630 }632 }
633+ // Cell counts derived from a single c/r are already stored on the placement.
634+ if cells := placement.Cells(); !cells.Empty() {
635+ return max(kittyCursorAdvance(uint64(cells.Width)), 1), max(kittyCursorAdvance(uint64(cells.Height)), 1)
636+ }
631 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()637 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
632 if !pixelsKnown {638 if !pixelsKnown {
633 return columns, rows639 return columns, rows
M screen_graphics_test.go +30 -0
@@ -1,7 +1,10 @@
11 package vt
22
33 import (
4+ "bytes"
45 "encoding/base64"
6+ "image"
7+ "image/png"
58 "math"
69 "runtime"
710 "strconv"
@@ -463,3 +466,30 @@ func TestScreenKittyGraphicsDoesNotAliasCallerWriteBuffer(t *testing.T) {
463466 require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))
464467 })
465468 }
469+
470+func TestScreenKittySingleColumnOrRowPlacement(t *testing.T) {
471+ var buf bytes.Buffer
472+ require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))))
473+ payload := base64.StdEncoding.EncodeToString(buf.Bytes())
474+ tests := []struct {
475+ name string
476+ header string
477+ cols, rows int64
478+ }{
479+ {"columns only", "c=5", 5, 3},
480+ {"rows only", "r=3", 6, 3},
481+ {"both", "c=5,r=3", 5, 3},
482+ }
483+ for _, tt := range tests {
484+ t.Run(tt.name, func(t *testing.T) {
485+ screen := NewScreen(100, 30)
486+ screen.Write([]byte("\x1b_Ga=T,f=100,C=1," + tt.header + ";" + payload + "\x1b\\"))
487+ usage := screen.GraphicsSnapshot().Usage()
488+ require.Equal(t, uint64(1), usage.Assets)
489+ require.Equal(t, uint64(1), usage.Placements)
490+ cells := screen.GraphicsSnapshot().Placements()[0].Cells()
491+ require.Equal(t, tt.cols, cells.Width)
492+ require.Equal(t, tt.rows, cells.Height)
493+ })
494+ }
495+}
@@ -1,7 +1,10 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/base64"5 "encoding/base64"
6+ "image"
7+ "image/png"
5 "math"8 "math"
6 "runtime"9 "runtime"
7 "strconv"10 "strconv"
@@ -463,3 +466,30 @@ func TestScreenKittyGraphicsDoesNotAliasCallerWriteBuffer(t *testing.T) {
463 require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))466 require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))
464 })467 })
465 }468 }
469+
470+func TestScreenKittySingleColumnOrRowPlacement(t *testing.T) {
471+ var buf bytes.Buffer
472+ require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))))
473+ payload := base64.StdEncoding.EncodeToString(buf.Bytes())
474+ tests := []struct {
475+ name string
476+ header string
477+ cols, rows int64
478+ }{
479+ {"columns only", "c=5", 5, 3},
480+ {"rows only", "r=3", 6, 3},
481+ {"both", "c=5,r=3", 5, 3},
482+ }
483+ for _, tt := range tests {
484+ t.Run(tt.name, func(t *testing.T) {
485+ screen := NewScreen(100, 30)
486+ screen.Write([]byte("\x1b_Ga=T,f=100,C=1," + tt.header + ";" + payload + "\x1b\\"))
487+ usage := screen.GraphicsSnapshot().Usage()
488+ require.Equal(t, uint64(1), usage.Assets)
489+ require.Equal(t, uint64(1), usage.Placements)
490+ cells := screen.GraphicsSnapshot().Placements()[0].Cells()
491+ require.Equal(t, tt.cols, cells.Width)
492+ require.Equal(t, tt.rows, cells.Height)
493+ })
494+ }
495+}

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