Show posters as real images in kitty and Ghostty (Kitty graphics with Unicode placeholders)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 23:03:29 +02:00
co-authored by Claude Sonnet 5.5
parent dc82b0b95d
commit 108541af91
12 changed files with 601 additions and 49 deletions
+144
View File
@@ -0,0 +1,144 @@
package ui
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"strings"
"github.com/gdamore/tcell/v2"
)
// 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.
type kittyGraphics struct {
write func([]byte) error // writes to the terminal, in step with the screen
nextID uint32
ids map[*image.RGBA]uint32
placed map[uint32][2]int // columns and rows each image is currently placed with
}
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
return &kittyGraphics{write: write, nextID: 1, ids: map[*image.RGBA]uint32{}, placed: map[uint32][2]int{}}
}
// 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()
}
// imageID returns the terminal's id for the image, uploading it on first use.
func (k *kittyGraphics) imageID(img *image.RGBA) (uint32, error) {
if id, ok := k.ids[img]; ok {
return id, nil
}
var data bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&data, img); 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:]
}
k.nextID++
k.ids[img] = id
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
}
// deleteAll removes every image uploaded so far from the terminal.
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[*image.RGBA]uint32{}
k.placed = map[uint32][2]int{}
}
// 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))
return placeholderRune, comb, tcell.StyleDefault.Foreground(fg)
}
// 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
}