1package tui
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "image"
8 _ "image/gif"
9 _ "image/jpeg"
10 "image/png"
11 "math"
12 "os"
13 "strconv"
14 "strings"
15)
16
17// ImageProtocol describes which inline-image escape the current
18// terminal understands.
19type ImageProtocol int
20
21const (
22 ImageProtocolNone ImageProtocol = iota // no inline images, use text fallback
23 ImageProtocolITerm2 // iTerm2 proprietary OSC 1337 File= (also: WezTerm)
24 ImageProtocolKitty // Kitty graphics protocol
25)
26
27// DetectImageProtocol returns the best inline-image protocol supported
28// by the current terminal, or ImageProtocolNone.
29//
30// The default is to auto-detect: if the terminal advertises iTerm2 or
31// Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var
32// overrides the default:
33//
34// ZOT_INLINE_IMAGES=off -> force text fallback
35// ZOT_INLINE_IMAGES=placeholder -> force text fallback (alias for off)
36// ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol
37// ZOT_INLINE_IMAGES=kitty -> force Kitty protocol
38// ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default)
39func DetectImageProtocol() ImageProtocol {
40 switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) {
41 case "off", "none", "false", "0", "placeholder", "text":
42 return ImageProtocolNone
43 case "iterm", "iterm2":
44 return ImageProtocolITerm2
45 case "kitty":
46 return ImageProtocolKitty
47 }
48 return detectImageProtocolAuto()
49}
50
51// detectImageProtocolAuto returns the best protocol by sniffing the
52// current terminal via env vars. Same detection logic as before.
53func detectImageProtocolAuto() ImageProtocol {
54 termProgram := os.Getenv("TERM_PROGRAM")
55 term := os.Getenv("TERM")
56 kittyWindow := os.Getenv("KITTY_WINDOW_ID")
57
58 // VS Code's integrated terminal and Herdr panes can inherit protocol
59 // indicators from their host terminal without reliably rendering the
60 // corresponding image escapes. Default to the text fallback in these
61 // intermediaries. Users can still force a protocol via ZOT_INLINE_IMAGES.
62 if strings.EqualFold(termProgram, "vscode") || os.Getenv("HERDR_ENV") != "" {
63 return ImageProtocolNone
64 }
65
66 if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") {
67 return ImageProtocolKitty
68 }
69 if termProgram == "ghostty" || termProgram == "kitty" {
70 return ImageProtocolKitty
71 }
72 if termProgram == "WezTerm" {
73 return ImageProtocolITerm2
74 }
75 // iTerm2's OSC 1337 inline image rendering is incompatible with
76 // zot's full-screen per-row redraw: subsequent row clears erase
77 // all but the first rendered image row, and doNotMoveCursor=1 is
78 // not reliable across iTerm versions. Leave ghostty/kitty untouched
79 // and fall back to the text placeholder in iTerm by default. Users
80 // can still force the protocol with ZOT_INLINE_IMAGES=iterm.
81 if termProgram == "iTerm.app" {
82 return ImageProtocolNone
83 }
84 if strings.Contains(strings.ToLower(termProgram), "ghostty") {
85 return ImageProtocolKitty
86 }
87 return ImageProtocolNone
88}
89
90// RenderInlineImage returns a terminal escape sequence that draws data
91// inline. If the protocol is None, returns "" so the caller can fall
92// back to a text placeholder.
93//
94// maxCellsWide caps the rendered width in terminal cells (columns) for
95// protocols that honor it. 0 means "let the terminal decide".
96func RenderInlineImage(proto ImageProtocol, data []byte, mime string, maxCellsWide int) string {
97 return RenderInlineImageScaled(proto, data, mime, maxCellsWide, 0)
98}
99
100// RenderInlineImageScaled renders an image with both width and height
101// clamps (in terminal cells). Values <= 0 mean "let the terminal decide".
102func RenderInlineImageScaled(proto ImageProtocol, data []byte, mime string, maxCellsWide, maxCellsHigh int) string {
103 switch proto {
104 case ImageProtocolITerm2:
105 return renderITerm2(data, maxCellsWide, maxCellsHigh)
106 case ImageProtocolKitty:
107 return renderKitty(data, maxCellsWide, maxCellsHigh)
108 }
109 return ""
110}
111
112// renderITerm2 builds an OSC 1337 File= sequence. Works in iTerm2 and WezTerm.
113//
114// Reference: https://iterm2.com/documentation-images.html
115func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string {
116 b64 := base64.StdEncoding.EncodeToString(data)
117 var sb strings.Builder
118 sb.WriteString("\x1b]1337;File=inline=1")
119 if maxCellsWide > 0 {
120 fmt.Fprintf(&sb, ";width=%d", maxCellsWide)
121 }
122 if maxCellsHigh > 0 {
123 fmt.Fprintf(&sb, ";height=%d", maxCellsHigh)
124 }
125 sb.WriteString(";preserveAspectRatio=1")
126 // doNotMoveCursor=1 keeps the cursor at the start of the image
127 // after rendering. We need that so the caller can pad with spaces
128 // and draw the box's closing │ at a fixed terminal column without
129 // having to know how many cells the image actually occupied (the
130 // rendered width depends on the terminal's cell aspect ratio).
131 sb.WriteString(";doNotMoveCursor=1:")
132 sb.WriteString(b64)
133 sb.WriteString("\x07")
134 return sb.String()
135}
136
137// kittyEnsurePNG returns data unchanged when it is already a PNG, and
138// otherwise decodes and re-encodes it to PNG so the Kitty graphics
139// protocol (f=100, PNG-only) can render it. On any decode/encode error
140// the original bytes are returned untouched: the worst case is the
141// pre-existing empty-box behaviour rather than a panic or a broken
142// escape, and ImageDimensions still works off the original bytes.
143func kittyEnsurePNG(data []byte) []byte {
144 if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) {
145 return data
146 }
147 img, _, err := image.Decode(bytes.NewReader(data))
148 if err != nil {
149 return data
150 }
151 var buf bytes.Buffer
152 if err := png.Encode(&buf, img); err != nil {
153 return data
154 }
155 return buf.Bytes()
156}
157
158// renderKitty builds a Kitty graphics protocol sequence. Supports chunked
159// data via the "m" continuation flag; chunk size is 4096 to stay under
160// terminal escape-buffer limits.
161//
162// The Kitty protocol preserves aspect ratio automatically when only one
163// of c= (columns) or r= (rows) is set. Setting both causes the image to
164// be stretched non-uniformly. We pick whichever constraint is tighter
165// for the input image so it fits inside maxCellsWide x maxCellsHigh.
166//
167// Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
168func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
169 // The Kitty graphics protocol with f=100 expects a PNG payload; it
170 // has no JPEG/GIF decoder. Feeding it non-PNG bytes makes kitty/
171 // ghostty reserve the cell rectangle but paint nothing, leaving an
172 // empty box. Re-encode anything that is not already PNG to PNG so
173 // the image actually renders regardless of its source format.
174 data = kittyEnsurePNG(data)
175 b64 := base64.StdEncoding.EncodeToString(data)
176 const chunk = 4096
177 var sb strings.Builder
178
179 // Pick the most constraining dimension and use only it. Kitty
180 // preserves aspect ratio when exactly one of c/r is provided.
181 //
182 // C=1 tells kitty/ghostty to not move the cursor after rendering
183 // the image. We need that so the caller can pad with spaces and
184 // draw the box's closing │ at a fixed terminal column without
185 // having to know how many cells the image actually occupied (the
186 // rendered width depends on the terminal's cell aspect ratio,
187 // which differs across fonts/terminals).
188 hdr := "a=T,f=100,C=1"
189 if maxCellsWide > 0 && maxCellsHigh > 0 {
190 if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {
191 // rows that the native width would produce at maxCellsWide.
192 // Round up so we don't under-reserve and let following text
193 // paint over the image on terminals whose cell metrics differ.
194 nativeRows := int(math.Ceil(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio()))
195 if nativeRows > maxCellsHigh {
196 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
197 } else {
198 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
199 }
200 } else {
201 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
202 }
203 } else if maxCellsWide > 0 {
204 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
205 } else if maxCellsHigh > 0 {
206 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
207 }
208
209 for i := 0; i < len(b64); i += chunk {
210 end := i + chunk
211 if end > len(b64) {
212 end = len(b64)
213 }
214 more := 1
215 if end == len(b64) {
216 more = 0
217 }
218 if i == 0 {
219 fmt.Fprintf(&sb, "\x1b_G%s,m=%d;%s\x1b\\", hdr, more, b64[i:end])
220 } else {
221 fmt.Fprintf(&sb, "\x1b_Gm=%d;%s\x1b\\", more, b64[i:end])
222 }
223 }
224 return sb.String()
225}
226
227// ImageDimensions returns width and height in pixels, or zeros on error.
228// Used for the text fallback so the user sees something useful.
229func ImageDimensions(data []byte) (int, int) {
230 cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
231 if err != nil {
232 return 0, 0
233 }
234 return cfg.Width, cfg.Height
235}
236
237// defaultCellAspectRatio approximates how many pixel-rows one terminal
238// row occupies. Typical monospace cells are ~2x tall as wide; we use 2.0
239// as a safe default. Used to compute the rendered row count when scaling
240// an image to fit a cell width.
241const defaultCellAspectRatio = 2.0
242
243// CellAspectRatio returns the pixel-height / pixel-width ratio for one
244// terminal cell. ZOT_CELL_ASPECT lets users tune inline-image row
245// reservation for terminals/fonts where the default causes overlap or
246// excessive blank space. Values outside a sane range are ignored.
247func CellAspectRatio() float64 {
248 v := strings.TrimSpace(os.Getenv("ZOT_CELL_ASPECT"))
249 if v == "" {
250 return defaultCellAspectRatio
251 }
252 f, err := strconv.ParseFloat(v, 64)
253 if err != nil || f < 0.5 || f > 5 {
254 return defaultCellAspectRatio
255 }
256 return f
257}
258
259// RowsForInlineImage returns the number of terminal rows an image
260// rendered at cellsWide columns will occupy, preserving aspect ratio.
261// Clamped to maxRows. Returns 0 if the image cannot be decoded.
262func RowsForInlineImage(data []byte, cellsWide, maxRows int) int {
263 rows, _ := InlineImageFootprint(data, cellsWide, maxRows)
264 return rows
265}
266
267// InlineImageFootprint returns the rendered (rows, cells) footprint
268// of an image at the requested cellsWide / maxRows budget,
269// preserving aspect ratio. When the image's natural height exceeds
270// maxRows, the rendered width shrinks below cellsWide so the
271// aspect ratio is preserved within the row clamp; callers wrapping
272// the image in a frame need that actual width to place a closing
273// border at the right column.
274func InlineImageFootprint(data []byte, cellsWide, maxRows int) (int, int) {
275 w, h := ImageDimensions(data)
276 if w <= 0 || h <= 0 || cellsWide <= 0 {
277 return 0, 0
278 }
279 scaleX := float64(w) / float64(cellsWide)
280 rowsF := float64(h) / (scaleX * CellAspectRatio())
281 rows := int(math.Ceil(rowsF))
282 if rows < 1 {
283 rows = 1
284 }
285 actualCells := cellsWide
286 if maxRows > 0 && rows > maxRows {
287 rows = maxRows
288 // Image is constrained by height; recompute the width so
289 // aspect ratio is preserved. cellsHigh = maxRows; cellsWide
290 // = pxW * maxRows * CellAspectRatio / pxH.
291 newCells := int(math.Floor(float64(w)*float64(maxRows)*CellAspectRatio()/float64(h) + 0.5))
292 if newCells < 1 {
293 newCells = 1
294 }
295 if newCells > cellsWide {
296 newCells = cellsWide
297 }
298 actualCells = newCells
299 }
300 return rows, actualCells
301}