apps/blobs/preview/preview.go 2.1 K raw
1
// Package preview renders images as half-block ANSI text suitable for
2
// embedding in a Bubble Tea viewport. Uses charmbracelet/x/mosaic.
3
package preview
4
5
import (
6
	"bytes"
7
	"fmt"
8
	"image"
9
	_ "image/gif"
10
	_ "image/jpeg"
11
	_ "image/png"
12
13
	"github.com/charmbracelet/x/mosaic"
14
	_ "golang.org/x/image/webp"
15
)
16
17
// Protocol is kept for API compatibility with the rest of the TUI.
18
// Only two states matter now: available or not.
19
type Protocol int
20
21
const (
22
	ProtoNone Protocol = iota
23
	ProtoMosaic
24
)
25
26
// Detect always returns ProtoMosaic — mosaic is pure Go and has no
27
// terminal/runtime requirements beyond truecolor support.
28
func Detect() Protocol { return ProtoMosaic }
29
30
// Render decodes img and returns a half-block ANSI rendering sized to
31
// cols x rows character cells.
32
func Render(p Protocol, img []byte, cols, rows int) (string, error) {
33
	if p != ProtoMosaic {
34
		return "", fmt.Errorf("preview disabled")
35
	}
36
	if cols <= 0 {
37
		cols = 40
38
	}
39
	if rows <= 0 {
40
		rows = 20
41
	}
42
	decoded, _, err := image.Decode(bytes.NewReader(img))
43
	if err != nil {
44
		return "", err
45
	}
46
	fitCols, fitRows := fitAspect(decoded.Bounds().Dx(), decoded.Bounds().Dy(), cols, rows)
47
	// mosaic's Width/Height are pixel dims; it emits one cell per 2 pixels.
48
	m := mosaic.New().
49
		Width(fitCols).
50
		Height(fitRows).
51
		Symbol(mosaic.All).
52
		Dither(true)
53
	return m.Render(decoded), nil
54
}
55
56
// fitAspect returns the largest (cols, rows) inside the maxCols x maxRows
57
// box that preserves the image's aspect ratio. Assumes terminal cells are
58
// roughly 1:2 (width:height), so one row covers about 2 image-units of
59
// vertical space per 1 image-unit horizontal per column.
60
func fitAspect(imgW, imgH, maxCols, maxRows int) (int, int) {
61
	if imgW <= 0 || imgH <= 0 {
62
		return maxCols, maxRows
63
	}
64
	const cellAspect = 2.0 // cell height / cell width
65
	imgAspect := float64(imgW) / float64(imgH)
66
	// rows of "image pixels" per cell column to keep aspect:
67
	// cols/rows = imgAspect * cellAspect
68
	cols := maxCols
69
	rows := int(float64(cols) / imgAspect / cellAspect)
70
	if rows > maxRows {
71
		rows = maxRows
72
		cols = int(float64(rows) * imgAspect * cellAspect)
73
	}
74
	if cols < 1 {
75
		cols = 1
76
	}
77
	if rows < 1 {
78
		rows = 1
79
	}
80
	return cols, rows
81
}