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 }