v0.1.54 7134fb7

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

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