Real-image posters: scale and encode in the background

Done in Draw it took about 200 ms per new poster (BiLinear scale to the
box plus PNG encoding), so each step to another title stalled the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 22:22:41 +02:00
co-authored by Claude Opus 5.5
parent f0bc284e82
commit 45e8daaa71
5 changed files with 108 additions and 10 deletions
+70 -10
View File
@@ -49,8 +49,27 @@ type kittyGraphics struct {
// an image that is smaller than its placement (Ghostty shows it at its own
// size, so a small poster came out smaller than the others).
cellPixels func() (w, h int)
// background, if set, runs prepare off the UI goroutine and then ready on
// it. Scaling and encoding a poster takes about 200 ms; done in Draw, every
// step to a title with a poster not uploaded yet waited that long. Without
// it (tests) posters are prepared at once.
background func(prepare func() prepared, ready func(prepared))
preparing map[footprint]bool
done map[footprint]prepared // prepared, not uploaded yet
}
// prepared is a poster encoded for upload.
type prepared struct {
encoded string // base64 PNG
w, h int // its size in pixels
err error
}
// maxPrepared bounds the prepared posters that wait for a draw (the
// selection moved on before they were ready).
const maxPrepared = 8
// settleDelay is how long the window size must stay the same before real
// images are shown again. While a window is dragged to another size, tens of
// sizes go by; uploading and placing images for each of them races with the
@@ -73,7 +92,8 @@ type footprint struct {
}
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}}
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{},
preparing: map[footprint]bool{}, done: map[footprint]prepared{}}
}
// kittyAPC builds one graphics command: ESC _ G <keys> ; <payload> ESC \.
@@ -90,26 +110,65 @@ func kittyAPC(keys string, payload []byte) []byte {
}
// 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).
// cols×rows cells, and returns the id the placeholder cells must carry, or 0
// while the poster is still being prepared in the background (show is called
// again on the redraw when it is ready). 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
}
var src image.Image = img.SubImage(rect)
cw, ch := 0, 0
if k.cellPixels != nil {
if cw, ch := k.cellPixels(); cw > 0 && ch > 0 {
src = scaleImage(src, cols*cw, rows*ch)
cw, ch = k.cellPixels()
}
prepare := func() prepared { return encodePoster(img, rect, cols*cw, rows*ch) }
p, ok := k.done[key]
switch {
case ok:
delete(k.done, key)
case k.background == nil:
p = prepare()
default:
if !k.preparing[key] {
k.preparing[key] = true
k.background(prepare, func(p prepared) {
delete(k.preparing, key)
if len(k.done) >= maxPrepared {
clear(k.done)
}
k.done[key] = p
})
}
return 0, nil
}
if p.err != nil {
return 0, p.err
}
return k.upload(key, p)
}
// encodePoster scales the part rect of img to w×h pixels (if both are known)
// and encodes it. Safe for use from any goroutine.
func encodePoster(img *image.RGBA, rect image.Rectangle, w, h int) prepared {
var src image.Image = img.SubImage(rect)
if w > 0 && h > 0 {
src = scaleImage(src, w, h)
}
var data bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&data, src); err != nil {
return 0, err
return prepared{err: err}
}
encoded := base64.StdEncoding.EncodeToString(data.Bytes())
return prepared{encoded: base64.StdEncoding.EncodeToString(data.Bytes()), w: src.Bounds().Dx(), h: src.Bounds().Dy()}
}
// upload sends a prepared poster and places it.
func (k *kittyGraphics) upload(key footprint, p prepared) (uint32, error) {
img, cols, rows := key.img, key.cols, key.rows
encoded := p.encoded
id := k.nextID
for first, rest := true, encoded; len(rest) > 0; first = false {
n := min(len(rest), kittyChunk)
@@ -143,7 +202,7 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i
k.latest[img] = key
if k.debug != nil {
k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, sent as %dx%d px, placed over %dx%d cells",
id, img.Bounds().Dx(), img.Bounds().Dy(), rect, src.Bounds().Dx(), src.Bounds().Dy(), cols, rows))
id, img.Bounds().Dx(), img.Bounds().Dy(), key.rect, p.w, p.h, cols, rows))
}
return id, nil
}
@@ -156,6 +215,7 @@ func (k *kittyGraphics) deleteAll() {
}
k.ids = map[footprint]uint32{}
k.latest = map[*image.RGBA]footprint{}
clear(k.done) // posters in preparation still arrive, but are not used without a draw asking for them
}
// placeholderCell returns what to draw in the cell at (row, col) of the image