1package tui
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "image"
8 _ "image/gif"
9 _ "image/jpeg"
10 _ "image/png"
11 "os"
12 "strings"
13)
14
15// ImageProtocol describes which inline-image escape the current
16// terminal understands.
17type ImageProtocol int
18
19const (
20 ImageProtocolNone ImageProtocol = iota // no inline images, use text fallback
21 ImageProtocolITerm2 // iTerm2 proprietary OSC 1337 File= (also: WezTerm)
22 ImageProtocolKitty // Kitty graphics protocol
23)
24
25// DetectImageProtocol returns the best inline-image protocol supported
26// by the current terminal, or ImageProtocolNone.
27//
28// The default is to auto-detect: if the terminal advertises iTerm2 or
29// Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var
30// overrides the default:
31//
32// ZOT_INLINE_IMAGES=off -> force text fallback
33// ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol
34// ZOT_INLINE_IMAGES=kitty -> force Kitty protocol
35// ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default)
36func DetectImageProtocol() ImageProtocol {
37 switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) {
38 case "off", "none", "false", "0":
39 return ImageProtocolNone
40 case "iterm", "iterm2":
41 return ImageProtocolITerm2
42 case "kitty":
43 return ImageProtocolKitty
44 }
45 return detectImageProtocolAuto()
46}
47
48// detectImageProtocolAuto returns the best protocol by sniffing the
49// current terminal via env vars. Same detection logic as before.
50func detectImageProtocolAuto() ImageProtocol {
51 termProgram := os.Getenv("TERM_PROGRAM")
52 term := os.Getenv("TERM")
53 kittyWindow := os.Getenv("KITTY_WINDOW_ID")
54
55 if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") {
56 return ImageProtocolKitty
57 }
58 if termProgram == "ghostty" || termProgram == "kitty" {
59 return ImageProtocolKitty
60 }
61 if termProgram == "iTerm.app" || termProgram == "WezTerm" {
62 return ImageProtocolITerm2
63 }
64 if strings.Contains(strings.ToLower(termProgram), "ghostty") {
65 return ImageProtocolKitty
66 }
67 return ImageProtocolNone
68}
69
70// RenderInlineImage returns a terminal escape sequence that draws data
71// inline. If the protocol is None, returns "" so the caller can fall
72// back to a text placeholder.
73//
74// maxCellsWide caps the rendered width in terminal cells (columns) for
75// protocols that honor it. 0 means "let the terminal decide".
76func RenderInlineImage(proto ImageProtocol, data []byte, mime string, maxCellsWide int) string {
77 return RenderInlineImageScaled(proto, data, mime, maxCellsWide, 0)
78}
79
80// RenderInlineImageScaled renders an image with both width and height
81// clamps (in terminal cells). Values <= 0 mean "let the terminal decide".
82func RenderInlineImageScaled(proto ImageProtocol, data []byte, mime string, maxCellsWide, maxCellsHigh int) string {
83 switch proto {
84 case ImageProtocolITerm2:
85 return renderITerm2(data, maxCellsWide, maxCellsHigh)
86 case ImageProtocolKitty:
87 return renderKitty(data, maxCellsWide, maxCellsHigh)
88 }
89 return ""
90}
91
92// renderITerm2 builds an OSC 1337 File= sequence. Works in iTerm2 and WezTerm.
93//
94// Reference: https://iterm2.com/documentation-images.html
95func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string {
96 b64 := base64.StdEncoding.EncodeToString(data)
97 var sb strings.Builder
98 sb.WriteString("\x1b]1337;File=inline=1")
99 if maxCellsWide > 0 {
100 fmt.Fprintf(&sb, ";width=%d", maxCellsWide)
101 }
102 if maxCellsHigh > 0 {
103 fmt.Fprintf(&sb, ";height=%d", maxCellsHigh)
104 }
105 sb.WriteString(";preserveAspectRatio=1:")
106 sb.WriteString(b64)
107 sb.WriteString("\x07")
108 return sb.String()
109}
110
111// renderKitty builds a Kitty graphics protocol sequence. Supports chunked
112// data via the "m" continuation flag; chunk size is 4096 to stay under
113// terminal escape-buffer limits.
114//
115// The Kitty protocol preserves aspect ratio automatically when only one
116// of c= (columns) or r= (rows) is set. Setting both causes the image to
117// be stretched non-uniformly. We pick whichever constraint is tighter
118// for the input image so it fits inside maxCellsWide x maxCellsHigh.
119//
120// Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
121func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
122 b64 := base64.StdEncoding.EncodeToString(data)
123 const chunk = 4096
124 var sb strings.Builder
125
126 // Pick the most constraining dimension and use only it. Kitty
127 // preserves aspect ratio when exactly one of c/r is provided.
128 hdr := "a=T,f=100"
129 if maxCellsWide > 0 && maxCellsHigh > 0 {
130 if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {
131 // rows that the native width would produce at maxCellsWide
132 nativeRows := int(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio)
133 if nativeRows > maxCellsHigh {
134 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
135 } else {
136 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
137 }
138 } else {
139 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
140 }
141 } else if maxCellsWide > 0 {
142 hdr += fmt.Sprintf(",c=%d", maxCellsWide)
143 } else if maxCellsHigh > 0 {
144 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
145 }
146
147 for i := 0; i < len(b64); i += chunk {
148 end := i + chunk
149 if end > len(b64) {
150 end = len(b64)
151 }
152 more := 1
153 if end == len(b64) {
154 more = 0
155 }
156 if i == 0 {
157 fmt.Fprintf(&sb, "\x1b_G%s,m=%d;%s\x1b\\", hdr, more, b64[i:end])
158 } else {
159 fmt.Fprintf(&sb, "\x1b_Gm=%d;%s\x1b\\", more, b64[i:end])
160 }
161 }
162 return sb.String()
163}
164
165// ImageDimensions returns width and height in pixels, or zeros on error.
166// Used for the text fallback so the user sees something useful.
167func ImageDimensions(data []byte) (int, int) {
168 cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
169 if err != nil {
170 return 0, 0
171 }
172 return cfg.Width, cfg.Height
173}
174
175// CellAspectRatio approximates how many pixel-rows one terminal row
176// occupies. Typical monospace cells are ~2x tall as wide; we use 2.0
177// as a safe default. Used to compute the rendered row count when
178// scaling an image to fit a cell width.
179const CellAspectRatio = 2.0
180
181// RowsForInlineImage returns the number of terminal rows an image
182// rendered at cellsWide columns will occupy, preserving aspect ratio.
183// Clamped to maxRows. Returns 0 if the image cannot be decoded.
184func RowsForInlineImage(data []byte, cellsWide, maxRows int) int {
185 w, h := ImageDimensions(data)
186 if w <= 0 || h <= 0 || cellsWide <= 0 {
187 return 0
188 }
189 // pixels per cell (horizontal)
190 // scaleX = imageWidthPx / cellsWide
191 // rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio)
192 scaleX := float64(w) / float64(cellsWide)
193 rows := int(float64(h) / (scaleX * CellAspectRatio))
194 if rows < 1 {
195 rows = 1
196 }
197 if maxRows > 0 && rows > maxRows {
198 rows = maxRows
199 }
200 return rows
201}