v0.3.80 f3f644f

image.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close