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:
+3
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user