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>
263 lines
9.8 KiB
Go
263 lines
9.8 KiB
Go
package ui
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/base64"
|
||
"fmt"
|
||
xdraw "golang.org/x/image/draw"
|
||
"image"
|
||
"image/png"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gdamore/tcell/v2"
|
||
"github.com/rivo/tview"
|
||
)
|
||
|
||
// The Kitty graphics protocol with Unicode placeholders, as supported by
|
||
// kitty and Ghostty: the poster is uploaded to the terminal once, and then
|
||
// shown by writing special placeholder characters into ordinary text cells.
|
||
// Because the picture is made of text cells it behaves like text: popups and
|
||
// dialogs drawn over it cover it, it vanishes when the view changes, and the
|
||
// normal screen diffing keeps it in place. The poster is shown at the
|
||
// terminal's full resolution.
|
||
|
||
// placeholderRune marks a cell that shows a piece of an image. It is followed
|
||
// by combining characters that give the row and column in the image; the
|
||
// image id is the cell's foreground color.
|
||
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[footprint]uint32
|
||
latest map[*image.RGBA]footprint // the footprint of each poster that is currently uploaded
|
||
|
||
holdUntil time.Time // no real images until then (the window is being resized)
|
||
debug func(string) // reports what is uploaded, if set
|
||
// cellPixels returns the size of a cell in pixels (0 if unknown): images are
|
||
// scaled to exactly fill their cells, because terminals do not all enlarge
|
||
// 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
|
||
// terminal's own resizing, and the result can be a placement that does not
|
||
// match the cells (a small poster in a big area, or a corner of a big one).
|
||
const settleDelay = 300 * time.Millisecond
|
||
|
||
// hold stops real images being drawn until the window has been left alone for settleDelay.
|
||
func (k *kittyGraphics) hold() { k.holdUntil = time.Now().Add(settleDelay) }
|
||
|
||
// settling reports whether the window size changed so recently that blocks are drawn instead.
|
||
func (k *kittyGraphics) settling() bool { return time.Now().Before(k.holdUntil) }
|
||
|
||
// 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[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 \.
|
||
func kittyAPC(keys string, payload []byte) []byte {
|
||
var b bytes.Buffer
|
||
b.WriteString("\x1b_G")
|
||
b.WriteString(keys)
|
||
if len(payload) > 0 {
|
||
b.WriteByte(';')
|
||
b.Write(payload)
|
||
}
|
||
b.WriteString("\x1b\\")
|
||
return b.Bytes()
|
||
}
|
||
|
||
// 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, 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
|
||
}
|
||
cw, ch := 0, 0
|
||
if k.cellPixels != nil {
|
||
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 prepared{err: err}
|
||
}
|
||
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)
|
||
more := 0
|
||
if n < len(rest) {
|
||
more = 1
|
||
}
|
||
keys := fmt.Sprintf("m=%d", more)
|
||
if first {
|
||
// Transmit only (a=t), PNG data (f=100) directly in the command (t=d),
|
||
// no replies (q=2): a reply would arrive as keyboard input.
|
||
keys = fmt.Sprintf("a=t,f=100,t=d,i=%d,q=2,m=%d", id, more)
|
||
}
|
||
if err := k.write(kittyAPC(keys, []byte(rest[:n]))); err != nil {
|
||
return 0, err
|
||
}
|
||
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.ids, old)
|
||
}
|
||
}
|
||
k.ids[key] = id
|
||
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(), key.rect, p.w, p.h, cols, rows))
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
// 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[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
|
||
// with the given id: the placeholder character, its combining characters and
|
||
// the style that carries the id.
|
||
func placeholderCell(id uint32, row, col int) (main rune, comb []rune, style tcell.Style) {
|
||
comb = []rune{kittyDiacritics[row], kittyDiacritics[col]}
|
||
fg := tcell.NewRGBColor(int32(id>>16&0xFF), int32(id>>8&0xFF), int32(id&0xFF))
|
||
// The background shows wherever the picture does not cover the cell (a
|
||
// sliver at the edge): use the panes' own color so it is not visible.
|
||
return placeholderRune, comb, tcell.StyleDefault.Foreground(fg).Background(tview.Styles.PrimitiveBackgroundColor)
|
||
}
|
||
|
||
// maxPlaceholders is how many rows and columns can be numbered.
|
||
const maxPlaceholders = len(kittyDiacritics)
|
||
|
||
// kittyTerminal reports whether the environment looks like a terminal that
|
||
// supports Kitty graphics with Unicode placeholders: kitty and Ghostty, not
|
||
// inside tmux or screen (which swallow the graphics commands).
|
||
func kittyTerminal(env func(string) string) bool {
|
||
term := strings.ToLower(env("TERM"))
|
||
if env("TMUX") != "" || env("STY") != "" || strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") {
|
||
return false
|
||
}
|
||
switch {
|
||
case strings.Contains(term, "kitty"), strings.Contains(term, "ghostty"):
|
||
return true
|
||
case strings.EqualFold(env("TERM_PROGRAM"), "ghostty"), env("GHOSTTY_RESOURCES_DIR") != "", env("KITTY_WINDOW_ID") != "":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// scaleImage resizes src to w×h pixels, smoothly (bilinear) both up and down.
|
||
// Sizes beyond 4000 pixels are not made (a misreported cell size).
|
||
func scaleImage(src image.Image, w, h int) image.Image {
|
||
b := src.Bounds()
|
||
if w <= 0 || h <= 0 || w > 4000 || h > 4000 || (w == b.Dx() && h == b.Dy()) {
|
||
return src
|
||
}
|
||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, b, xdraw.Src, nil)
|
||
return dst
|
||
}
|