package ui import ( "bytes" "encoding/base64" "fmt" "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 } // 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 ; 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 if k.debug != nil { k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, placed over %dx%d cells", id, img.Bounds().Dx(), img.Bounds().Dy(), rect, 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 }