Real-image posters: a new size is a new image id, never a changed placement

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 00:29:16 +02:00
co-authored by Claude Sonnet 5.5
parent 2ba47cb4ee
commit cf07aaf8fb
5 changed files with 82 additions and 112 deletions
+3 -3
View File
@@ -193,8 +193,8 @@ func New(cfg *config.Config) *App {
}
// noteWindow notices when the terminal's size or cell size changed. The
// placements of real-image posters are then made again: the picture would
// otherwise rely on the terminal rescaling an existing placement correctly.
// real-image posters are then uploaded and placed again from scratch instead of
// relying on the terminal to rescale what is there.
func (a *App) noteWindow(s tcell.Screen) {
w, h := s.Size()
win := [3]int{w, h, int(a.cellAspect * 1000)}
@@ -204,7 +204,7 @@ func (a *App) noteWindow(s tcell.Screen) {
first := a.lastWindow == [3]int{}
a.lastWindow = win
if !first && a.gfx != nil {
a.gfx.invalidate()
a.gfx.deleteAll()
}
}
+1 -4
View File
@@ -401,10 +401,7 @@ func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool
if c.gfx == nil || screen.Colors() < 1<<24 || cols > maxPlaceholders || rows > maxPlaceholders {
return false
}
id, err := c.gfx.imageID(c.img, cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells()))
if err == nil {
err = c.gfx.place(id, cols, rows)
}
id, err := c.gfx.show(c.img, cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells()), cols, rows)
if err != nil {
return false
}
+29 -47
View File
@@ -28,22 +28,29 @@ const placeholderRune = '\U0010EEEE'
const kittyChunk = 4096 // bytes of base64 per escape sequence, the protocol maximum
// kittyGraphics uploads images to the terminal and keeps track of them.
//
// Every footprint of a poster (the part of it that is shown and the number of
// cells it covers) is uploaded as its own image with its own id and placed once.
// An existing placement is never changed: terminals do not reliably replace the
// size of a placement that is already there (Ghostty kept showing the old, small
// one after the window was enlarged), whereas a new image id always starts clean.
type kittyGraphics struct {
write func([]byte) error // writes to the terminal, in step with the screen
nextID uint32
ids map[cropKey]uint32
latest map[*image.RGBA]cropKey // the crop of each image that is currently uploaded
placed map[uint32][2]int // columns and rows each image is currently placed with
ids map[footprint]uint32
latest map[*image.RGBA]footprint // the footprint of each poster that is currently uploaded
}
// cropKey identifies an uploaded image: a poster and the part of it that was sent.
type cropKey struct {
img *image.RGBA
rect image.Rectangle
// footprint identifies an uploaded image: a poster, the part of it that was
// sent, and the cells it covers.
type footprint struct {
img *image.RGBA
rect image.Rectangle
cols, rows int
}
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
return &kittyGraphics{write: write, nextID: 1, ids: map[cropKey]uint32{}, latest: map[*image.RGBA]cropKey{}, placed: map[uint32][2]int{}}
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}}
}
// kittyAPC builds one graphics command: ESC _ G <keys> ; <payload> ESC \.
@@ -59,11 +66,12 @@ func kittyAPC(keys string, payload []byte) []byte {
return b.Bytes()
}
// imageID returns the terminal's id for the part rect of the image,
// uploading it on first use. When the same image is needed with another crop
// (after a resize) the earlier upload is removed.
func (k *kittyGraphics) imageID(img *image.RGBA, rect image.Rectangle) (uint32, error) {
key := cropKey{img, rect}
// show makes sure the part rect of the image is uploaded and placed over
// cols×rows cells, and returns the id the placeholder cells must carry. The same
// footprint is reused; a different one for the same poster replaces the old
// upload (which is removed from the terminal).
func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows int) (uint32, error) {
key := footprint{img, rect, cols, rows}
if id, ok := k.ids[key]; ok {
return id, nil
}
@@ -91,11 +99,14 @@ func (k *kittyGraphics) imageID(img *image.RGBA, rect image.Rectangle) (uint32,
}
rest = rest[n:]
}
// U=1: a virtual placement, shown wherever placeholder cells refer to the id.
if err := k.write(kittyAPC(fmt.Sprintf("a=p,U=1,i=%d,c=%d,r=%d,q=2", id, cols, rows), nil)); err != nil {
return 0, err
}
k.nextID++
if old, ok := k.latest[img]; ok {
if oldID, ok := k.ids[old]; ok {
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", oldID), nil))
delete(k.placed, oldID)
delete(k.ids, old)
}
}
@@ -104,43 +115,14 @@ func (k *kittyGraphics) imageID(img *image.RGBA, rect image.Rectangle) (uint32,
return id, nil
}
// place makes the image cover cols×rows cells for the placeholders. A new size
// replaces the previous placement.
func (k *kittyGraphics) place(id uint32, cols, rows int) error {
if p, ok := k.placed[id]; ok {
if p == [2]int{cols, rows} {
return nil
}
// Remove the old placement (the uploaded data stays) before making a new one.
if err := k.write(kittyAPC(fmt.Sprintf("a=d,d=i,i=%d,q=2", id), nil)); err != nil {
return err
}
}
// U=1: a virtual placement, shown wherever placeholder cells refer to it.
if err := k.write(kittyAPC(fmt.Sprintf("a=p,U=1,i=%d,c=%d,r=%d,q=2", id, cols, rows), nil)); err != nil {
return err
}
k.placed[id] = [2]int{cols, rows}
return nil
}
// invalidate drops every placement (the uploaded pictures stay) so the next
// draw places them again, e.g. after the terminal was resized.
func (k *kittyGraphics) invalidate() {
for id := range k.placed {
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=i,i=%d,q=2", id), nil))
}
k.placed = map[uint32][2]int{}
}
// deleteAll removes every image uploaded so far from the terminal.
// deleteAll removes every image uploaded so far from the terminal. The next
// draw uploads what it needs again, which is also how a resize is handled.
func (k *kittyGraphics) deleteAll() {
for _, id := range k.ids {
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", id), nil))
}
k.ids = map[cropKey]uint32{}
k.latest = map[*image.RGBA]cropKey{}
k.placed = map[uint32][2]int{}
k.ids = map[footprint]uint32{}
k.latest = map[*image.RGBA]footprint{}
}
// placeholderCell returns what to draw in the cell at (row, col) of the image
+48 -57
View File
@@ -51,16 +51,21 @@ func TestKittyUploadChunksAndEncodes(t *testing.T) {
k := newKittyGraphics(rec.write)
img := noisy(200, 300) // incompressible: far more than one 4096-byte chunk of base64
id, err := k.imageID(img, img.Bounds())
id, err := k.show(img, img.Bounds(), 20, 15)
if err != nil || id != 1 {
t.Fatalf("id=%d err=%v", id, err)
}
if len(rec.cmds) < 3 {
if len(rec.cmds) < 4 {
t.Fatalf("only %d commands for a large image", len(rec.cmds))
}
// The upload is followed by the placement, once.
if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=p,U=1,i=1,c=20,r=15,q=2\x1b\\" {
t.Errorf("last command = %q", last)
}
uploads := rec.cmds[:len(rec.cmds)-1]
re := regexp.MustCompile(`^\x1b_G([^;]*);(.*)\x1b\\$`)
var payload strings.Builder
for i, c := range rec.cmds {
for i, c := range uploads {
m := re.FindStringSubmatch(c)
if m == nil {
t.Fatalf("command %d is not a graphics command: %q", i, c[:min(40, len(c))])
@@ -69,7 +74,7 @@ func TestKittyUploadChunksAndEncodes(t *testing.T) {
if len(data) > kittyChunk {
t.Errorf("chunk %d has %d bytes", i, len(data))
}
last := i == len(rec.cmds)-1
last := i == len(uploads)-1
switch {
case i == 0 && !strings.HasPrefix(keys, "a=t,f=100,t=d,i=1,q=2,m=1"):
t.Errorf("first command keys = %q", keys)
@@ -94,43 +99,51 @@ func TestKittyUploadChunksAndEncodes(t *testing.T) {
t.Error("pixel data changed")
}
// The same image is not uploaded twice; another one gets the next id.
// The same footprint is not uploaded twice; another poster gets the next id.
n := len(rec.cmds)
if again, _ := k.imageID(img, img.Bounds()); again != id || len(rec.cmds) != n {
t.Error("an uploaded image must be reused")
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id || len(rec.cmds) != n {
t.Error("an uploaded footprint must be reused")
}
if other, _ := k.imageID(noisy(4, 4), image.Rect(0, 0, 4, 4)); other != 2 {
small := noisy(4, 4)
if other, _ := k.show(small, small.Bounds(), 3, 2); other != 2 {
t.Errorf("second image id = %d", other)
}
}
func TestKittyPlacement(t *testing.T) {
func TestKittyNewSizeIsANewImage(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
if err := k.place(7, 20, 15); err != nil {
t.Fatal(err)
}
if len(rec.cmds) != 1 || rec.cmds[0] != "\x1b_Ga=p,U=1,i=7,c=20,r=15,q=2\x1b\\" {
t.Fatalf("first placement: %q", rec.cmds)
}
k.place(7, 20, 15) // unchanged: nothing to send
if len(rec.cmds) != 1 {
t.Errorf("unchanged placement sent %d commands", len(rec.cmds))
}
k.place(7, 10, 8) // a new size replaces the placement
if len(rec.cmds) != 3 || rec.cmds[1] != "\x1b_Ga=d,d=i,i=7,q=2\x1b\\" || rec.cmds[2] != "\x1b_Ga=p,U=1,i=7,c=10,r=8,q=2\x1b\\" {
t.Errorf("resize: %q", rec.cmds[1:])
img := noisy(10, 15)
id1, _ := k.show(img, img.Bounds(), 20, 15)
rec.cmds = nil
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id1 || len(rec.cmds) != 0 {
t.Fatalf("same footprint: id %d, %d commands", again, len(rec.cmds))
}
rec.cmds = nil
small := noisy(2, 2)
id, _ := k.imageID(small, small.Bounds())
k.deleteAll()
if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=d,d=I,i="+itoa(int(id))+",q=2\x1b\\" {
t.Errorf("deleteAll sent %q", last)
// A different size is never done by changing the placement (a terminal can keep the
// old one): the poster is uploaded under a new id, placed, and the old id removed.
id2, _ := k.show(img, img.Bounds(), 10, 8)
if id2 == id1 {
t.Fatal("a new footprint must get a new id")
}
if len(k.ids) != 0 || len(k.placed) != 0 {
t.Error("deleteAll must forget everything")
all := strings.Join(rec.cmds, "")
if !strings.Contains(all, "a=t,f=100,t=d,i=2") || !strings.Contains(all, "a=p,U=1,i=2,c=10,r=8") || !strings.Contains(all, "a=d,d=I,i=1") {
t.Errorf("resize sent %q", rec.cmds)
}
if strings.Contains(all, "a=p,U=1,i=1") {
t.Error("the old placement must not be touched")
}
// After deleteAll (what a window resize triggers) everything is uploaded afresh.
rec.cmds = nil
k.deleteAll()
id3, _ := k.show(img, img.Bounds(), 10, 8)
if id3 == id2 || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=I,i=2") || !strings.Contains(strings.Join(rec.cmds, ""), "a=t,f=100,t=d,i=3") {
t.Errorf("after deleteAll: id %d, %q", id3, rec.cmds)
}
if len(k.ids) != 1 || len(k.latest) != 1 {
t.Errorf("bookkeeping: %d ids, %d latest", len(k.ids), len(k.latest))
}
}
@@ -243,15 +256,11 @@ func TestCoverViewGraphicsDrawsPlaceholders(t *testing.T) {
// A cell aspect of 2.5 (taller cells) changes the footprint of the same box.
c.cellAspect = func() float64 { return 2.5 }
c.Draw(screen)
// The box is now 17×10 cells, a different shape: a new crop is uploaded, the
// old upload is removed, and the image is placed afresh.
var placements []string
for _, cmd := range rec.cmds[2:] {
placements = append(placements, cmd[:min(len(cmd), 30)])
}
last := rec.cmds[len(rec.cmds)-1]
if !strings.Contains(last, "c=17,r=10") || !strings.Contains(strings.Join(rec.cmds[2:], ""), "a=d,d=I,i=1") {
t.Errorf("after the cell aspect changed: %q", placements)
// The box is now 17×10 cells, a different shape: a new image id is uploaded and
// placed, the old upload is removed.
all := strings.Join(rec.cmds[2:], "")
if !strings.Contains(all, "a=p,U=1,i=2,c=17,r=10") || !strings.Contains(all, "a=d,d=I,i=1") {
t.Errorf("after the cell aspect changed: %q", rec.cmds[2:])
}
}
@@ -280,24 +289,6 @@ func TestCropToBox(t *testing.T) {
}
}
func TestInvalidateReplacesPlacements(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
k.place(3, 17, 13)
k.place(4, 10, 8)
rec.cmds = nil
k.invalidate()
if len(rec.cmds) != 2 || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=i,i=3") || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=i,i=4") {
t.Fatalf("invalidate sent %q", rec.cmds)
}
// The same size is placed again afterwards, since the placement is gone.
rec.cmds = nil
k.place(3, 17, 13)
if len(rec.cmds) != 1 || !strings.Contains(rec.cmds[0], "a=p,U=1,i=3,c=17,r=13") {
t.Errorf("after invalidate: %q", rec.cmds)
}
}
func TestCellAspectFromWindow(t *testing.T) {
// 140×50 cells of 9×20 px plus 4 and 11 px of padding: plain division
// would say 1264/140=9.03 and 1011/50=20.22 (aspect 2.24), the real shape is 20/9.