Ghostty shows an image smaller than its placement at its own size, so library posters (Radarr's poster-500 is 333x500, not 500x750) came out smaller than the 426x640 remote ones. Images are now scaled (bilinear) to exactly cols*cellWidth x rows*cellHeight pixels before upload, and library titles use the original poster for real images. The disk cache stores posters as decoded (JPEG) instead of the possibly large originals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
7.8 KiB
Go
203 lines
7.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)
|
||
}
|
||
|
||
// 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{}}
|
||
}
|
||
|
||
// 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. 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)
|
||
if k.cellPixels != nil {
|
||
if cw, ch := k.cellPixels(); cw > 0 && ch > 0 {
|
||
src = scaleImage(src, cols*cw, rows*ch)
|
||
}
|
||
}
|
||
var data bytes.Buffer
|
||
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
||
if err := enc.Encode(&data, src); err != nil {
|
||
return 0, err
|
||
}
|
||
encoded := base64.StdEncoding.EncodeToString(data.Bytes())
|
||
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(), rect, src.Bounds().Dx(), src.Bounds().Dy(), 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{}
|
||
}
|
||
|
||
// 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
|
||
}
|