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, "") }