package tui
import (
"bytes"
"context"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/charmbracelet/x/ansi"
)
type ImagePreviewer struct {
mode imageMode
chafa bool
mu sync.Mutex
cache map[string][]string
pending *kittyPlacement
staged map[string]string
// dir is the single directory staged images are written to.
dir string
}
func NewImagePreviewer() *ImagePreviewer {
p := &ImagePreviewer{cache: map[string][]string{}, staged: map[string]string{}}
_, err := exec.LookPath("chafa")
p.chafa = err == nil
p.mode = detectImageMode()
if p.mode == imageKitty {
return p
}
if p.mode == imageChars && !p.chafa {
p.mode = imageNone
}
return p
}
func (p *ImagePreviewer) Available() bool {
return p.mode == imageKitty || (p.mode == imageChars && p.chafa)
}
func (p *ImagePreviewer) Mode() string {
switch p.mode {
case imageKitty:
return "kitty graphics"
case imageChars:
return "chafa"
default:
return "none"
}
}
func (p *ImagePreviewer) TakePlacement() (kittyPlacement, bool) {
p.mu.Lock()
defer p.mu.Unlock()
if p.pending == nil {
return kittyPlacement{}, false
}
out := *p.pending
p.pending = nil
return out, true
}
func (p *ImagePreviewer) Render(path string, maxW, maxH int) []string {
if path == "" || maxW < 8 || maxH < 3 {
return nil
}
cols, rows := maxW, maxH
if cols/2 < rows {
rows = cols / 2
}
if rows < 3 {
return nil
}
if p.mode == imageKitty && !forceChars {
pw, ph := imagePixels(path)
dispCols, dispRows := fitToAspect(pw, ph, cols, rows)
place := kittyPlacement{
path: path,
cols: cols,
rows: rows,
displayCols: dispCols,
displayRows: dispRows,
pixelW: pw,
pixelH: ph,
id: baseImageID,
placement: basePlacementID,
}
p.mu.Lock()
if p.staged == nil {
p.staged = map[string]string{}
}
if staged, ok := p.staged[path]; ok {
place.tempPath = staged
} else {
place.tempPath = stageTempImage(p.stageDir(), path)
p.staged[path] = place.tempPath
}
p.mu.Unlock()
if place.tempPath == "" {
return p.renderChars(path, cols, rows)
}
p.pending = &place
return placeholderGrid(cols, rows, place.id)
}
return p.renderChars(path, cols, rows)
}
func (p *ImagePreviewer) renderChars(path string, cols, rows int) []string {
if p.mode == imageNone || !p.chafa {
return nil
}
key := path + "|" + strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
p.mu.Lock()
if cached, ok := p.cache[key]; ok {
p.mu.Unlock()
return cached
}
p.mu.Unlock()
size := strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
attempts := [][]string{
{"--symbols", "block", "--colors", "256", "--fg-only"},
{"--symbols", "vhalf+border", "--colors", "16", "--fg-only"},
{"--symbols", "ascii", "--colors", "none"},
}
var lines []string
for _, opts := range attempts {
out, err := p.run(append(opts, "--size", size, "--stretch", path)...)
if err != nil {
continue
}
cand := trimPreview(strings.Split(out, "\n"), cols)
if visibleGlyphs(cand) >= minPreviewGlyphs {
lines = cand
break
}
}
p.mu.Lock()
p.cache[key] = lines
p.mu.Unlock()
return lines
}
const minPreviewGlyphs = 24
func visibleGlyphs(lines []string) int {
n := 0
for _, l := range lines {
for _, r := range stripANSI(l) {
if r != ' ' && r != '\t' {
n++
}
}
}
return n
}
func (p *ImagePreviewer) run(args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
full := append([]string{"--format", "symbols"}, args...)
cmd := exec.CommandContext(ctx, "chafa", full...)
if t := os.Getenv("TERM"); t == "" || t == "dumb" {
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
}
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return "", err
}
return out.String(), nil
}
func trimPreview(lines []string, width int) []string {
for len(lines) > 0 && strings.TrimSpace(stripANSI(lines[len(lines)-1])) == "" {
lines = lines[:len(lines)-1]
}
for i := len(lines) - 1; i > 0; i-- {
if strings.TrimSpace(stripANSI(lines[i])) == "" {
lines = lines[i+1:]
break
}
}
out := make([]string, 0, len(lines))
for _, l := range lines {
l = strings.TrimRight(l, " ")
if w := displayWidth(l); w > width {
l = truncateANSI(l, width)
}
out = append(out, strings.TrimRight(l, " "))
}
return out
}
func stripANSI(s string) string {
return ansi.Strip(s)
}
const cellSentinel = 'x'
func displayWidth(s string) int {
// a placeholder cell is one column, but it's a base rune with three combining
// diacritics on it and the unicode tables call U+10EEEE three columns wide.
// counting runes or trusting the tables measures the grid too wide, and the
// layout then clips cells off the end of the image.
if !strings.ContainsRune(s, placeholderRune) {
return ansi.StringWidth(s)
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
if r == placeholderRune {
b.WriteByte(cellSentinel)
i += size
for i < len(s) {
r2, size2 := utf8.DecodeRuneInString(s[i:])
if !unicode.Is(unicode.Mn, r2) {
break
}
i += size2
}
continue
}
b.WriteRune(r)
i += size
}
return ansi.StringWidth(b.String())
}
func truncateANSI(s string, width int) string {
if width <= 0 {
return ""
}
return ansi.Truncate(s, width, "")
}