detached ef1a3c7

Merge ab6686c0df2c672931f719eeddf9864d3d88deb8 into ff50ee4f516f41b85c96fa6606beb4103e06465f

commit
ef1a3c7c2eabc52920d28f4a194366f12f0d91c6
author
ermite · · 2026-10-21 10:07 UTC
merge of
ff50ee4 + ab6686c · diff against the first parent
signature
unsigned
5 files · +221 -10
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 +87 -0
@@ -584,3 +584,90 @@ 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+}
650+
651+func TestDeriveCells(t *testing.T) {
652+ const maxInt64 = int64(^uint64(0) >> 1)
653+ tests := []struct {
654+ name string
655+ given, givenSrc, otherSrc, givenCell, otherCell int64
656+ want int64
657+ wantOK bool
658+ }{
659+ {"exact division", 4, 4, 4, 10, 20, 2, true},
660+ {"rounds up", 5, 4, 4, 10, 20, 3, true},
661+ {"tiny result is at least one", 1, 1000, 1, 1, 1, 1, true},
662+ {"overflow", maxInt64, 1, 1000, 1000, 1, 0, false},
663+ {"zero source", 5, 0, 4, 10, 20, 0, false},
664+ }
665+ for _, tt := range tests {
666+ t.Run(tt.name, func(t *testing.T) {
667+ got, ok := deriveCells(tt.given, tt.givenSrc, tt.otherSrc, tt.givenCell, tt.otherCell)
668+ if got != tt.want || ok != tt.wantOK {
669+ t.Fatalf("deriveCells = %d, %v; want %d, %v", got, ok, tt.want, tt.wantOK)
670+ }
671+ })
672+ }
673+}
@@ -584,3 +584,90 @@ 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+}
650+
651+func TestDeriveCells(t *testing.T) {
652+ const maxInt64 = int64(^uint64(0) >> 1)
653+ tests := []struct {
654+ name string
655+ given, givenSrc, otherSrc, givenCell, otherCell int64
656+ want int64
657+ wantOK bool
658+ }{
659+ {"exact division", 4, 4, 4, 10, 20, 2, true},
660+ {"rounds up", 5, 4, 4, 10, 20, 3, true},
661+ {"tiny result is at least one", 1, 1000, 1, 1, 1, 1, true},
662+ {"overflow", maxInt64, 1, 1000, 1000, 1, 0, false},
663+ {"zero source", 5, 0, 4, 10, 20, 0, false},
664+ }
665+ for _, tt := range tests {
666+ t.Run(tt.name, func(t *testing.T) {
667+ got, ok := deriveCells(tt.given, tt.givenSrc, tt.otherSrc, tt.givenCell, tt.otherCell)
668+ if got != tt.want || ok != tt.wantOK {
669+ t.Fatalf("deriveCells = %d, %v; want %d, %v", got, ok, tt.want, tt.wantOK)
670+ }
671+ })
672+ }
673+}
M protocol/kittygraphics/session.go +74 -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,27 @@ 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+// All inputs are positive, so the result is at least 1. The boolean is false
665+// if an input is not positive or the result does not fit in int64.
666+func deriveCells(given, givenSrc, otherSrc, givenCell, otherCell int64) (int64, bool) {
667+ if given <= 0 || givenSrc <= 0 || otherSrc <= 0 || givenCell <= 0 || otherCell <= 0 {
668+ return 0, false
669+ }
670+ num := new(big.Int).Mul(big.NewInt(given), big.NewInt(givenCell))
671+ num.Mul(num, big.NewInt(otherSrc))
672+ den := new(big.Int).Mul(big.NewInt(givenSrc), big.NewInt(otherCell))
673+ q, r := new(big.Int).QuoRem(num, den, new(big.Int))
674+ if r.Sign() != 0 {
675+ q.Add(q, big.NewInt(1))
676+ }
677+ if !q.IsInt64() {
678+ return 0, false
679+ }
680+ return q.Int64(), true
681+}
682+
614683 func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {
615684 if c.HasParent || c.HasCellOffsetX || c.HasCellOffsetY {
616685 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,27 @@ 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+// All inputs are positive, so the result is at least 1. The boolean is false
665+// if an input is not positive or the result does not fit in int64.
666+func deriveCells(given, givenSrc, otherSrc, givenCell, otherCell int64) (int64, bool) {
667+ if given <= 0 || givenSrc <= 0 || otherSrc <= 0 || givenCell <= 0 || otherCell <= 0 {
668+ return 0, false
669+ }
670+ num := new(big.Int).Mul(big.NewInt(given), big.NewInt(givenCell))
671+ num.Mul(num, big.NewInt(otherSrc))
672+ den := new(big.Int).Mul(big.NewInt(givenSrc), big.NewInt(otherCell))
673+ q, r := new(big.Int).QuoRem(num, den, new(big.Int))
674+ if r.Sign() != 0 {
675+ q.Add(q, big.NewInt(1))
676+ }
677+ if !q.IsInt64() {
678+ return 0, false
679+ }
680+ return q.Int64(), true
681+}
682+
614 func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {683 func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {
615 if c.HasParent || c.HasCellOffsetX || c.HasCellOffsetY {684 if c.HasParent || c.HasCellOffsetX || c.HasCellOffsetY {
616 return nil, nil, ErrUnsupported685 return nil, nil, ErrUnsupported
M screen.go +14 -5
@@ -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
@@ -614,30 +616,37 @@ func (s *Screen) advanceKittyCursorRows(rows int) {
614616 }
615617
616618 func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
619+ // c=0 and r=0 mean unset, as in Kitty.
620+ hasColumns := c.HasColumns && c.Columns != 0
621+ hasRows := c.HasRows && c.Rows != 0
617622 columns, rows = 1, 1
618- if c.HasColumns {
623+ if hasColumns {
619624 columns = max(kittyCursorAdvance(c.Columns), 1)
620625 }
621- if c.HasRows {
626+ if hasRows {
622627 rows = max(kittyCursorAdvance(c.Rows), 1)
623628 }
624- if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil {
629+ if hasColumns && hasRows || s.graphics == nil || s.graphics.scene == nil {
625630 return columns, rows
626631 }
627632 placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
628633 if !ok {
629634 return columns, rows
630635 }
636+ // Cell counts derived from a single c/r are already stored on the placement.
637+ if cells := placement.Cells(); !cells.Empty() {
638+ return max(kittyCursorAdvance(uint64(cells.Width)), 1), max(kittyCursorAdvance(uint64(cells.Height)), 1)
639+ }
631640 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
632641 if !pixelsKnown {
633642 return columns, rows
634643 }
635644 destination := placement.Destination()
636- if !c.HasColumns {
645+ if !hasColumns {
637646 offset := destination.X % int64(cellWidth)
638647 columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
639648 }
640- if !c.HasRows {
649+ if !hasRows {
641650 offset := destination.Y % int64(cellHeight)
642651 rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
643652 }
@@ -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
@@ -614,30 +616,37 @@ func (s *Screen) advanceKittyCursorRows(rows int) {
614 }616 }
615 617
616 func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {618 func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
619+ // c=0 and r=0 mean unset, as in Kitty.
620+ hasColumns := c.HasColumns && c.Columns != 0
621+ hasRows := c.HasRows && c.Rows != 0
617 columns, rows = 1, 1622 columns, rows = 1, 1
618- if c.HasColumns {623+ if hasColumns {
619 columns = max(kittyCursorAdvance(c.Columns), 1)624 columns = max(kittyCursorAdvance(c.Columns), 1)
620 }625 }
621- if c.HasRows {626+ if hasRows {
622 rows = max(kittyCursorAdvance(c.Rows), 1)627 rows = max(kittyCursorAdvance(c.Rows), 1)
623 }628 }
624- if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil {629+ if hasColumns && hasRows || s.graphics == nil || s.graphics.scene == nil {
625 return columns, rows630 return columns, rows
626 }631 }
627 placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)632 placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
628 if !ok {633 if !ok {
629 return columns, rows634 return columns, rows
630 }635 }
636+ // Cell counts derived from a single c/r are already stored on the placement.
637+ if cells := placement.Cells(); !cells.Empty() {
638+ return max(kittyCursorAdvance(uint64(cells.Width)), 1), max(kittyCursorAdvance(uint64(cells.Height)), 1)
639+ }
631 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()640 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
632 if !pixelsKnown {641 if !pixelsKnown {
633 return columns, rows642 return columns, rows
634 }643 }
635 destination := placement.Destination()644 destination := placement.Destination()
636- if !c.HasColumns {645+ if !hasColumns {
637 offset := destination.X % int64(cellWidth)646 offset := destination.X % int64(cellWidth)
638 columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)647 columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
639 }648 }
640- if !c.HasRows {649+ if !hasRows {
641 offset := destination.Y % int64(cellHeight)650 offset := destination.Y % int64(cellHeight)
642 rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)651 rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
643 }652 }
M screen_graphics_test.go +42 -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,42 @@ 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+ // 4x4 image. Unknown pixel size uses a 1:2 cell; the 10x20 cell geometry
475+ // comes from SetGeometry. Without C=1 the cursor moves past the placement.
476+ tests := []struct {
477+ name string
478+ geometry bool
479+ commands string
480+ cols, rows int64
481+ wantCol, wantRow int
482+ }{
483+ {"columns only", false, "a=T,f=100,c=5;" + payload, 5, 3, 5, 2},
484+ {"rows only", false, "a=T,f=100,r=3;" + payload, 6, 3, 6, 2},
485+ {"zero rows is unset", false, "a=T,f=100,c=5,r=0;" + payload, 5, 3, 5, 2},
486+ {"zero columns is unset", false, "a=T,f=100,c=0,r=3;" + payload, 6, 3, 6, 2},
487+ {"known cell pixels", true, "a=T,f=100,c=4;" + payload, 4, 2, 4, 1},
488+ {"put existing image", false, "a=t,i=7,f=100;" + payload + "\x1b\\\x1b_Ga=p,i=7,q=2,c=5", 5, 3, 5, 2},
489+ }
490+ for _, tt := range tests {
491+ t.Run(tt.name, func(t *testing.T) {
492+ screen := NewScreen(100, 30)
493+ if tt.geometry {
494+ screen.SetGeometry(Geometry{Cols: 100, Rows: 30, PixelWidth: 1000, PixelHeight: 600})
495+ }
496+ screen.Write([]byte("\x1b_G" + tt.commands + "\x1b\\"))
497+ usage := screen.GraphicsSnapshot().Usage()
498+ require.Equal(t, uint64(1), usage.Assets)
499+ require.Equal(t, uint64(1), usage.Placements)
500+ cells := screen.GraphicsSnapshot().Placements()[0].Cells()
501+ require.Equal(t, tt.cols, cells.Width)
502+ require.Equal(t, tt.rows, cells.Height)
503+ require.Equal(t, tt.wantCol, screen.Col)
504+ require.Equal(t, tt.wantRow, screen.Row)
505+ })
506+ }
507+}
@@ -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,42 @@ 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+ // 4x4 image. Unknown pixel size uses a 1:2 cell; the 10x20 cell geometry
475+ // comes from SetGeometry. Without C=1 the cursor moves past the placement.
476+ tests := []struct {
477+ name string
478+ geometry bool
479+ commands string
480+ cols, rows int64
481+ wantCol, wantRow int
482+ }{
483+ {"columns only", false, "a=T,f=100,c=5;" + payload, 5, 3, 5, 2},
484+ {"rows only", false, "a=T,f=100,r=3;" + payload, 6, 3, 6, 2},
485+ {"zero rows is unset", false, "a=T,f=100,c=5,r=0;" + payload, 5, 3, 5, 2},
486+ {"zero columns is unset", false, "a=T,f=100,c=0,r=3;" + payload, 6, 3, 6, 2},
487+ {"known cell pixels", true, "a=T,f=100,c=4;" + payload, 4, 2, 4, 1},
488+ {"put existing image", false, "a=t,i=7,f=100;" + payload + "\x1b\\\x1b_Ga=p,i=7,q=2,c=5", 5, 3, 5, 2},
489+ }
490+ for _, tt := range tests {
491+ t.Run(tt.name, func(t *testing.T) {
492+ screen := NewScreen(100, 30)
493+ if tt.geometry {
494+ screen.SetGeometry(Geometry{Cols: 100, Rows: 30, PixelWidth: 1000, PixelHeight: 600})
495+ }
496+ screen.Write([]byte("\x1b_G" + tt.commands + "\x1b\\"))
497+ usage := screen.GraphicsSnapshot().Usage()
498+ require.Equal(t, uint64(1), usage.Assets)
499+ require.Equal(t, uint64(1), usage.Placements)
500+ cells := screen.GraphicsSnapshot().Placements()[0].Cells()
501+ require.Equal(t, tt.cols, cells.Width)
502+ require.Equal(t, tt.rows, cells.Height)
503+ require.Equal(t, tt.wantCol, screen.Col)
504+ require.Equal(t, tt.wantRow, screen.Row)
505+ })
506+ }
507+}

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