chore: fixed image rendering
9da95181
7 file(s) · +103 −109
| 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 |
| 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= |
|
| 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 | } |
| 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 { |
| 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 { |
| 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 |
| 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 | } |
|