chore: fixed image rendering 9da95181
Steve · 2026-05-26 00:02 7 file(s) · +103 −109
apps/blobs/go.mod +2 −0
10 10
	github.com/aws/aws-sdk-go-v2 v1.41.7
11 11
	github.com/aws/aws-sdk-go-v2/credentials v1.19.16
12 12
	github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
13 +
	github.com/charmbracelet/x/mosaic v0.0.0-20260525135217-abeec2b8bf0b
13 14
	github.com/stevedylandev/andromeda/pkg/auth v0.0.0
14 15
	github.com/stevedylandev/andromeda/pkg/config v0.0.0
15 16
	github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0
16 17
	github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0
17 18
	github.com/stevedylandev/andromeda/pkg/tui v0.0.0
18 19
	github.com/stevedylandev/andromeda/pkg/web v0.0.0
20 +
	golang.org/x/image v0.41.0
19 21
	golang.org/x/term v0.36.0
20 22
)
21 23
apps/blobs/go.sum +4 −0
42 42
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
43 43
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
44 44
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
45 +
github.com/charmbracelet/x/mosaic v0.0.0-20260525135217-abeec2b8bf0b h1:lx38/9bc8Lpy64h+Ki8Iy0ahMG3JhQE3QmNRHF78eI8=
46 +
github.com/charmbracelet/x/mosaic v0.0.0-20260525135217-abeec2b8bf0b/go.mod h1:XaPvhIpKtjZOj8uQ+HabXB9uLJedjw/zJpIymbWw3mY=
45 47
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
46 48
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
47 49
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
82 84
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
83 85
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
84 86
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
87 +
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
88 +
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
85 89
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
86 90
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
87 91
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
apps/blobs/preview/preview.go +57 −92
1 -
// Package preview renders images inline in supported terminals.
2 -
//
3 -
// Detection cascade: kitty/ghostty graphics → iTerm2 inline images →
4 -
// chafa fallback → metadata-only text.
1 +
// Package preview renders images as half-block ANSI text suitable for
2 +
// embedding in a Bubble Tea viewport. Uses charmbracelet/x/mosaic.
5 3
package preview
6 4
7 5
import (
8 6
	"bytes"
9 -
	"encoding/base64"
10 7
	"fmt"
11 -
	"os"
12 -
	"os/exec"
13 -
	"strings"
8 +
	"image"
9 +
	_ "image/gif"
10 +
	_ "image/jpeg"
11 +
	_ "image/png"
12 +
13 +
	"github.com/charmbracelet/x/mosaic"
14 +
	_ "golang.org/x/image/webp"
14 15
)
15 16
17 +
// Protocol is kept for API compatibility with the rest of the TUI.
18 +
// Only two states matter now: available or not.
16 19
type Protocol int
17 20
18 21
const (
19 22
	ProtoNone Protocol = iota
20 -
	ProtoKitty
21 -
	ProtoITerm
22 -
	ProtoChafa
23 +
	ProtoMosaic
23 24
)
24 25
25 -
// Detect inspects env vars to pick a preview protocol.
26 -
// Order: explicit BLOBS_PREVIEW override → kitty/ghostty → iTerm2/wezterm → chafa binary.
27 -
func Detect() Protocol {
28 -
	if v := strings.ToLower(os.Getenv("BLOBS_PREVIEW")); v != "" {
29 -
		switch v {
30 -
		case "kitty":
31 -
			return ProtoKitty
32 -
		case "iterm", "iterm2":
33 -
			return ProtoITerm
34 -
		case "chafa":
35 -
			if _, err := exec.LookPath("chafa"); err == nil {
36 -
				return ProtoChafa
37 -
			}
38 -
		case "none", "off":
39 -
			return ProtoNone
40 -
		}
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")
41 35
	}
42 -
	if os.Getenv("KITTY_WINDOW_ID") != "" || os.Getenv("GHOSTTY_RESOURCES_DIR") != "" {
43 -
		return ProtoKitty
36 +
	if cols <= 0 {
37 +
		cols = 40
44 38
	}
45 -
	switch strings.ToLower(os.Getenv("TERM_PROGRAM")) {
46 -
	case "iterm.app", "wezterm":
47 -
		return ProtoITerm
39 +
	if rows <= 0 {
40 +
		rows = 20
48 41
	}
49 -
	if strings.HasPrefix(os.Getenv("TERM"), "xterm-kitty") {
50 -
		return ProtoKitty
42 +
	decoded, _, err := image.Decode(bytes.NewReader(img))
43 +
	if err != nil {
44 +
		return "", err
51 45
	}
52 -
	if _, err := exec.LookPath("chafa"); err == nil {
53 -
		return ProtoChafa
54 -
	}
55 -
	return ProtoNone
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
56 54
}
57 55
58 -
// Render returns a string to print into a TUI viewport / pane.
59 -
// w, h are character cell dimensions of the target pane.
60 -
func Render(p Protocol, img []byte, w, h int) (string, error) {
61 -
	switch p {
62 -
	case ProtoKitty:
63 -
		return kittyEscape(img), nil
64 -
	case ProtoITerm:
65 -
		return itermEscape(img), nil
66 -
	case ProtoChafa:
67 -
		return chafaRender(img, w, h)
68 -
	default:
69 -
		return "", fmt.Errorf("no preview protocol available")
70 -
	}
71 -
}
72 -
73 -
func kittyEscape(img []byte) string {
74 -
	enc := base64.StdEncoding.EncodeToString(img)
75 -
	const chunk = 4096
76 -
	var b strings.Builder
77 -
	for i := 0; i < len(enc); i += chunk {
78 -
		end := i + chunk
79 -
		if end > len(enc) {
80 -
			end = len(enc)
81 -
		}
82 -
		more := 1
83 -
		if end == len(enc) {
84 -
			more = 0
85 -
		}
86 -
		if i == 0 {
87 -
			fmt.Fprintf(&b, "\x1b_Ga=T,f=100,m=%d;%s\x1b\\", more, enc[i:end])
88 -
		} else {
89 -
			fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, enc[i:end])
90 -
		}
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
91 63
	}
92 -
	return b.String()
93 -
}
94 -
95 -
func itermEscape(img []byte) string {
96 -
	enc := base64.StdEncoding.EncodeToString(img)
97 -
	return fmt.Sprintf("\x1b]1337;File=inline=1;preserveAspectRatio=1:%s\x07", enc)
98 -
}
99 -
100 -
func chafaRender(img []byte, w, h int) (string, error) {
101 -
	if w <= 0 {
102 -
		w = 40
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)
103 73
	}
104 -
	if h <= 0 {
105 -
		h = 20
74 +
	if cols < 1 {
75 +
		cols = 1
106 76
	}
107 -
	size := fmt.Sprintf("%dx%d", w, h)
108 -
	cmd := exec.Command("chafa", "--size", size, "--format", "symbols", "-")
109 -
	cmd.Stdin = bytes.NewReader(img)
110 -
	var out bytes.Buffer
111 -
	cmd.Stdout = &out
112 -
	if err := cmd.Run(); err != nil {
113 -
		return "", err
77 +
	if rows < 1 {
78 +
		rows = 1
114 79
	}
115 -
	return out.String(), nil
80 +
	return cols, rows
116 81
}
apps/blobs/tui_commands.go +17 −9
35 35
36 36
const previewMaxBytes = 5 << 20
37 37
38 -
func loadPreviewCmd(s3 *S3Client, proto preview.Protocol, bucket, key string, w, h int) tea.Cmd {
38 +
func loadPreviewCmd(s3 *S3Client, proto preview.Protocol, seq int, bucket, key string, w, h int) tea.Cmd {
39 39
	return func() tea.Msg {
40 40
		if !isImageName(key) {
41 -
			return previewLoadedMsg{Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "")}
41 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "")}
42 42
		}
43 43
		if proto == preview.ProtoNone {
44 -
			return previewLoadedMsg{Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "no preview backend")}
44 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "no preview backend (install chafa)")}
45 45
		}
46 46
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
47 47
		defer cancel()
48 48
		body, meta, err := s3.Get(ctx, bucket, key)
49 49
		if err != nil {
50 -
			return previewLoadedMsg{Bucket: bucket, Key: key, Err: err}
50 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Err: err}
51 51
		}
52 52
		defer body.Close()
53 53
		if meta != nil && meta.Size > previewMaxBytes {
54 -
			return previewLoadedMsg{Bucket: bucket, Key: key, Content: previewMetaText(key, meta.Size, "too large to preview")}
54 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, meta.Size, "too large to preview")}
55 55
		}
56 56
		buf, err := io.ReadAll(io.LimitReader(body, previewMaxBytes+1))
57 57
		if err != nil {
58 -
			return previewLoadedMsg{Bucket: bucket, Key: key, Err: err}
58 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Err: err}
59 59
		}
60 60
		if len(buf) > previewMaxBytes {
61 -
			return previewLoadedMsg{Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "too large to preview")}
61 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "too large to preview")}
62 62
		}
63 63
		rendered, rerr := preview.Render(proto, buf, w, h)
64 64
		if rerr != nil {
65 -
			return previewLoadedMsg{Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "render: "+rerr.Error())}
65 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "render: "+rerr.Error())}
66 66
		}
67 -
		return previewLoadedMsg{Bucket: bucket, Key: key, Content: rendered}
67 +
		return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: rendered}
68 68
	}
69 +
}
70 +
71 +
const previewDebounce = 150 * time.Millisecond
72 +
73 +
func debouncePreviewCmd(seq int, bucket, key string, w, h int) tea.Cmd {
74 +
	return tea.Tick(previewDebounce, func(time.Time) tea.Msg {
75 +
		return previewDebounceMsg{Seq: seq, Bucket: bucket, Key: key, W: w, H: h}
76 +
	})
69 77
}
70 78
71 79
func previewMetaText(key string, size int64, note string) string {
apps/blobs/tui_messages.go +8 −0
18 18
}
19 19
20 20
type previewLoadedMsg struct {
21 +
	Seq     int
21 22
	Bucket  string
22 23
	Key     string
23 24
	Content string // pre-rendered ANSI (image) or text
24 25
	Err     error
26 +
}
27 +
28 +
type previewDebounceMsg struct {
29 +
	Seq    int
30 +
	Bucket string
31 +
	Key    string
32 +
	W, H   int
25 33
}
26 34
27 35
type deletedMsg struct {
apps/blobs/tui_model.go +1 −0
39 39
	preview      viewport.Model
40 40
	previewProto preview.Protocol
41 41
	showPreview  bool
42 +
	previewSeq   int
42 43
43 44
	currentBucket string
44 45
	currentPrefix string
apps/blobs/tui_update.go +14 −8
42 42
		cmd := setItemsFromListing(&m.browseList, msg.Prefix, msg.Folders, msg.Files)
43 43
		return m, tea.Batch(cmd, m.maybePreviewCmd())
44 44
45 +
	case previewDebounceMsg:
46 +
		if msg.Seq != m.previewSeq || msg.Bucket != m.currentBucket {
47 +
			return m, nil
48 +
		}
49 +
		return m, loadPreviewCmd(m.s3, m.previewProto, msg.Seq, msg.Bucket, msg.Key, msg.W, msg.H)
50 +
45 51
	case previewLoadedMsg:
52 +
		if msg.Seq != m.previewSeq || msg.Bucket != m.currentBucket {
53 +
			return m, nil
54 +
		}
46 55
		if msg.Err != nil {
47 56
			m.preview.SetContent("preview error: " + msg.Err.Error())
48 57
			return m, nil
49 58
		}
50 -
		if msg.Bucket == m.currentBucket {
51 -
			m.preview.SetContent(msg.Content)
52 -
			m.preview.GotoTop()
53 -
		}
59 +
		m.preview.SetContent(msg.Content)
60 +
		m.preview.GotoTop()
54 61
		return m, nil
55 62
56 63
	case deletedMsg:
288 295
	return m, cmd
289 296
}
290 297
291 -
func (m tuiModel) maybePreviewCmd() tea.Cmd {
298 +
func (m *tuiModel) maybePreviewCmd() tea.Cmd {
292 299
	if !m.showPreview {
293 300
		return nil
294 301
	}
297 304
		m.preview.SetContent("")
298 305
		return nil
299 306
	}
300 -
	w := m.preview.Width()
301 -
	h := m.preview.Height()
302 -
	return loadPreviewCmd(m.s3, m.previewProto, m.currentBucket, f.Key, w, h)
307 +
	m.previewSeq++
308 +
	return debouncePreviewCmd(m.previewSeq, m.currentBucket, f.Key, m.preview.Width(), m.preview.Height())
303 309
}