| 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 | } |