git/intake/main
internal/tui/kitty.go
328 lines8 KB last change raw · history
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
}