Files
arr-tui/internal/ui/kitty.go
T

158 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"strings"
"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
}
// 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 data bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&data, img.SubImage(rect)); 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
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
}