git/intake/main
internal/tui/preview.go
248 lines5 KB last change raw · history
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, "")
}