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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user