package tui
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/BourgeoisBear/rasterm"
"github.com/charmbracelet/x/ansi/kitty"
)
var forceChars = false
type imageMode int
const (
imageChars imageMode = iota
imageKitty
imageNone
)
const (
baseImageID = 7800
basePlacementID = 7800
)
func detectImageMode() imageMode {
override := strings.ToLower(strings.TrimSpace(os.Getenv("INTAKE_IMAGES")))
if supportsKittyGraphics() {
switch override {
case "none", "off":
return imageNone
case "chafa", "chars", "symbols":
return imageChars
default:
return imageKitty
}
}
switch override {
case "none", "off":
return imageNone
default:
return imageChars
}
}
func supportsKittyGraphics() bool {
if os.Getenv("KITTY_WINDOW_ID") != "" {
return true
}
term := strings.ToLower(os.Getenv("TERM"))
if strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") {
return true
}
switch strings.ToLower(os.Getenv("TERM_PROGRAM")) {
case "wezterm", "ghostty", "kitty":
return true
}
return false
}
type kittyPlacement struct {
path string
cols, rows int
displayCols, displayRows int
pixelW, pixelH int
tempPath string
id, placement int
}
func (k kittyPlacement) transmit() string {
if k.path == "" || k.cols < 2 || k.rows < 1 {
return ""
}
dc, dr := k.cols, k.rows
filePath := k.tempPath
if filePath == "" {
filePath = k.path
}
// t=f not t=t, which would claim a file the terminal is allowed to delete and
// doesn't go with f=100. no C=1 either, that's "don't move the cursor" and
// only means something for an image placed at the cursor.
header := rasterm.KittyImgOpts{
DstCols: uint32(dc),
DstRows: uint32(dr),
ImageId: uint32(k.id),
PlacementId: uint32(k.placement),
}.ToHeader("a=T", "t=f", "f=100", "U=1", "q=2")
var b strings.Builder
b.WriteString(strings.TrimSuffix(header, ";"))
b.WriteString(";")
b.WriteString(base64Path(filePath))
b.WriteString(rasterm.KITTY_IMG_FTR)
return b.String()
}
func (k kittyPlacement) clear() string {
if k.id == 0 {
return ""
}
return fmt.Sprintf("%sa=d,d=i,i=%d,p=%d,q=2%s", kittyEscape, k.id, k.placement, st)
}
const (
kittyEscape = "\x1b_G"
st = "\x1b\\"
)
const placeholderRune = kitty.Placeholder
func placeholderGrid(cols, rows, imageID int) []string {
// the image id rides in the foreground colour. a cell is the placeholder rune
// plus three diacritics: row, column, and the id's high byte, since a colour
// only carries 24 bits. putting the id in extra runes instead does nothing
// and the terminal draws the rune as text.
high := diacriticAt((imageID >> 24) & 0xFF)
idColor := fmt.Sprintf("\x1b[38:2:%d:%d:%dm",
(imageID>>16)&0xFF, (imageID>>8)&0xFF, imageID&0xFF)
out := make([]string, 0, rows)
for row := 0; row < rows; row++ {
var b strings.Builder
b.WriteString(idColor)
for col := 0; col < cols; col++ {
b.WriteRune(kitty.Placeholder)
b.WriteRune(diacriticAt(row))
b.WriteRune(diacriticAt(col))
b.WriteRune(high)
}
b.WriteString("\x1b[39m")
out = append(out, b.String())
}
return out
}
func base64Path(path string) string {
return base64.StdEncoding.EncodeToString([]byte(path))
}
func stageTempImage(dir, src string) string {
// f=100 means png and kitty has no jpeg format at all, so anything that isn't
// png gets re-encoded. embedded flac art is usually jpeg, so that's the normal
// path, not the edge case. a jpeg handed over as-is comes back EBADPNG.
data, err := os.ReadFile(src)
if err != nil {
return ""
}
_, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return ""
}
payload := data
if format != "png" || pngExceedsPreview(data) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return ""
}
b := img.Bounds()
if dw, dh := fitPixels(b.Dx(), b.Dy(), maxPreviewPixels); dw != b.Dx() || dh != b.Dy() {
img = scaleDown(img, dw, dh)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return ""
}
payload = buf.Bytes()
}
// One name per source image, since the directory is shared. The terminal
// reads the file by path and never deletes it for the file medium.
sum := sha256.Sum256([]byte(src))
dst := filepath.Join(dir, fmt.Sprintf("cover-%x.png", sum[:8]))
// Written to a unique temporary name and renamed into place, so that a
// background warm and a foreground render staging the same source cannot
// interleave their writes into one file.
f, err := os.CreateTemp(dir, "staging-*.png")
if err != nil {
return ""
}
if _, err := f.Write(payload); err != nil {
f.Close()
os.Remove(f.Name())
return ""
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return ""
}
if err := os.Rename(f.Name(), dst); err != nil {
os.Remove(f.Name())
return ""
}
return dst
}
// Warm stages images ahead of them being asked for, on a background goroutine.
//
// Staging decodes, rescales and re-encodes, which takes around 270ms for a
// 2000x2000 cover. Doing that on the UI goroutine stalls the whole interface
// every time the cursor moves to a candidate that has not been looked at yet, so
// the candidates are prepared in the background as soon as they are known and a
// render usually finds its image already waiting.
func (p *ImagePreviewer) Warm(paths []string) {
if p.mode != imageKitty || forceChars {
return
}
p.mu.Lock()
if p.staged == nil {
p.staged = map[string]string{}
}
dir := p.stageDir()
var todo []string
for _, path := range paths {
if path == "" {
continue
}
if staged, ok := p.staged[path]; ok && staged != "" {
continue
}
todo = append(todo, path)
}
p.mu.Unlock()
if dir == "" || len(todo) == 0 {
return
}
go func() {
for _, path := range todo {
staged := stageTempImage(dir, path)
if staged == "" {
continue
}
p.mu.Lock()
if _, exists := p.staged[path]; !exists {
p.staged[path] = staged
}
p.mu.Unlock()
}
}()
}
// stageDir returns the directory staged images live in, creating it on first
// use. One directory for the whole session, rather than one per image: nothing
// deletes the file afterwards, so a directory per image leaked every cover that
// was ever previewed until /tmp was cleared.
func (p *ImagePreviewer) stageDir() string {
if p.dir != "" {
return p.dir
}
dir, err := os.MkdirTemp("", "intake-tty-graphics-protocol-")
if err != nil {
return ""
}
p.dir = dir
return dir
}
// Close removes the staged images. Safe to call more than once.
func (p *ImagePreviewer) Close() {
p.mu.Lock()
defer p.mu.Unlock()
if p.dir != "" {
os.RemoveAll(p.dir)
p.dir = ""
}
p.staged = map[string]string{}
}
func imagePixels(path string) (int, int) {
f, err := os.Open(path)
if err != nil {
return 0, 0
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0
}
return cfg.Width, cfg.Height
}
func drawImage(p kittyPlacement) tea.Cmd {
seq := p.transmit()
if seq == "" {
return nil
}
return tea.Raw(seq)
}
func clearImage(p kittyPlacement) tea.Cmd {
seq := p.clear()
if seq == "" {
return nil
}
return tea.Raw(seq)
}
func fitToAspect(pw, ph, boxCols, boxRows int) (int, int) {
if pw <= 0 || ph <= 0 || boxCols < 2 || boxRows < 1 {
return boxCols, boxRows
}
boxHalfW := boxCols * 2
if ph*boxHalfW/pw <= boxRows*2 {
rows := ph * boxHalfW / pw / 2
if rows < 1 {
rows = 1
}
return boxCols, rows
}
cols := pw * boxRows * 2 / ph / 2
if cols < 1 {
cols = 1
}
return cols, boxRows
}