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 exposes several protocol-ish env
59 // combinations depending on the underlying shell/pty, but its image
60 // layer is inconsistent enough that auto-enable is more annoying than
61 // useful. Users can still force a protocol via ZOT_INLINE_IMAGES.
62 if strings.EqualFold(termProgram, "vscode") {
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// renderKitty builds a Kitty graphics protocol sequence. Supports chunked
138// data via the "m" continuation flag; chunk size is 4096 to stay under
139// terminal escape-buffer limits.
140//
141// The Kitty protocol preserves aspect ratio automatically when only one
142// of c= (columns) or r= (rows) is set. Setting both causes the image to
143// be stretched non-uniformly. We pick whichever constraint is tighter
144// for the input image so it fits inside maxCellsWide x maxCellsHigh.
145//
146// Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
147func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
148 b64 := base64.StdEncoding.EncodeToString(data)
149 const chunk = 4096
150 var sb strings.Builder
151
152 // Pick the most constraining dimension and use only it. Kitty
153 // preserves aspect ratio when exactly one of c/r is provided.
154 //
155 // C=1 tells kitty/ghostty to not move the cursor after rendering
156 // the image. We need that so the caller can pad with spaces and
157 // draw the box's closing │ at a fixed terminal column without
158 // having to know how many cells the image actually occupied (the
159 // rendered width depends on the terminal's cell aspect ratio,
160 // which differs across fonts/terminals).
161 hdr := "a=T,f=100,C=1"
162 if maxCellsWide > 0 && maxCellsHigh > 0 {
163 if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {
164 // rows that the native width would produce at maxCellsWide.
165 // Round up so we don't under-reserve and let following text
166 // paint over the image on terminals whose cell metrics differ.
167 nativeRows := int(math.Ceil(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio()))
168 if nativeRows > maxCellsHigh {
169 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
170 } else {
171 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
172 }
173 } else {
174 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
175 }
176 } else if maxCellsWide > 0 {
177 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
178 } else if maxCellsHigh > 0 {
179 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
180 }
181
182 for i := 0; i < len(b64); i += chunk {
183 end := i + chunk
184 if end > len(b64) {
185 end = len(b64)
186 }
187 more := 1
188 if end == len(b64) {
189 more = 0
190 }
191 if i == 0 {
192 fmt.Fprintf(&sb, "\x1b_G%s,m=%d;%s\x1b\\", hdr, more, b64[i:end])
193 } else {
194 fmt.Fprintf(&sb, "\x1b_Gm=%d;%s\x1b\\", more, b64[i:end])
195 }
196 }
197 return sb.String()
198}
199
200// ImageDimensions returns width and height in pixels, or zeros on error.
201// Used for the text fallback so the user sees something useful.
202func ImageDimensions(data []byte) (int, int) {
203 cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
204 if err != nil {
205 return 0, 0
206 }
207 return cfg.Width, cfg.Height
208}
209
210// defaultCellAspectRatio approximates how many pixel-rows one terminal
211// row occupies. Typical monospace cells are ~2x tall as wide; we use 2.0
212// as a safe default. Used to compute the rendered row count when scaling
213// an image to fit a cell width.
214const defaultCellAspectRatio = 2.0
215
216// CellAspectRatio returns the pixel-height / pixel-width ratio for one
217// terminal cell. ZOT_CELL_ASPECT lets users tune inline-image row
218// reservation for terminals/fonts where the default causes overlap or
219// excessive blank space. Values outside a sane range are ignored.
220func CellAspectRatio() float64 {
221 v := strings.TrimSpace(os.Getenv("ZOT_CELL_ASPECT"))
222 if v == "" {
223 return defaultCellAspectRatio
224 }
225 f, err := strconv.ParseFloat(v, 64)
226 if err != nil || f < 0.5 || f > 5 {
227 return defaultCellAspectRatio
228 }
229 return f
230}
231
232// RowsForInlineImage returns the number of terminal rows an image
233// rendered at cellsWide columns will occupy, preserving aspect ratio.
234// Clamped to maxRows. Returns 0 if the image cannot be decoded.
235func RowsForInlineImage(data []byte, cellsWide, maxRows int) int {
236 rows, _ := InlineImageFootprint(data, cellsWide, maxRows)
237 return rows
238}
239
240// InlineImageFootprint returns the rendered (rows, cells) footprint
241// of an image at the requested cellsWide / maxRows budget,
242// preserving aspect ratio. When the image's natural height exceeds
243// maxRows, the rendered width shrinks below cellsWide so the
244// aspect ratio is preserved within the row clamp; callers wrapping
245// the image in a frame need that actual width to place a closing
246// border at the right column.
247func InlineImageFootprint(data []byte, cellsWide, maxRows int) (int, int) {
248 w, h := ImageDimensions(data)
249 if w <= 0 || h <= 0 || cellsWide <= 0 {
250 return 0, 0
251 }
252 scaleX := float64(w) / float64(cellsWide)
253 rowsF := float64(h) / (scaleX * CellAspectRatio())
254 rows := int(math.Ceil(rowsF))
255 if rows < 1 {
256 rows = 1
257 }
258 actualCells := cellsWide
259 if maxRows > 0 && rows > maxRows {
260 rows = maxRows
261 // Image is constrained by height; recompute the width so
262 // aspect ratio is preserved. cellsHigh = maxRows; cellsWide
263 // = pxW * maxRows * CellAspectRatio / pxH.
264 newCells := int(math.Floor(float64(w)*float64(maxRows)*CellAspectRatio()/float64(h) + 0.5))
265 if newCells < 1 {
266 newCells = 1
267 }
268 if newCells > cellsWide {
269 newCells = cellsWide
270 }
271 actualCells = newCells
272 }
273 return rows, actualCells
274}