v0.1.18 9afd123

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 == "iTerm.app" || termProgram == "WezTerm" {
73		return ImageProtocolITerm2
74	}
75	if strings.Contains(strings.ToLower(termProgram), "ghostty") {
76		return ImageProtocolKitty
77	}
78	return ImageProtocolNone
79}
80
81// RenderInlineImage returns a terminal escape sequence that draws data
82// inline. If the protocol is None, returns "" so the caller can fall
83// back to a text placeholder.
84//
85// maxCellsWide caps the rendered width in terminal cells (columns) for
86// protocols that honor it. 0 means "let the terminal decide".
87func RenderInlineImage(proto ImageProtocol, data []byte, mime string, maxCellsWide int) string {
88	return RenderInlineImageScaled(proto, data, mime, maxCellsWide, 0)
89}
90
91// RenderInlineImageScaled renders an image with both width and height
92// clamps (in terminal cells). Values <= 0 mean "let the terminal decide".
93func RenderInlineImageScaled(proto ImageProtocol, data []byte, mime string, maxCellsWide, maxCellsHigh int) string {
94	switch proto {
95	case ImageProtocolITerm2:
96		return renderITerm2(data, maxCellsWide, maxCellsHigh)
97	case ImageProtocolKitty:
98		return renderKitty(data, maxCellsWide, maxCellsHigh)
99	}
100	return ""
101}
102
103// renderITerm2 builds an OSC 1337 File= sequence. Works in iTerm2 and WezTerm.
104//
105// Reference: https://iterm2.com/documentation-images.html
106func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string {
107	b64 := base64.StdEncoding.EncodeToString(data)
108	var sb strings.Builder
109	sb.WriteString("\x1b]1337;File=inline=1")
110	if maxCellsWide > 0 {
111		fmt.Fprintf(&sb, ";width=%d", maxCellsWide)
112	}
113	if maxCellsHigh > 0 {
114		fmt.Fprintf(&sb, ";height=%d", maxCellsHigh)
115	}
116	sb.WriteString(";preserveAspectRatio=1:")
117	sb.WriteString(b64)
118	sb.WriteString("\x07")
119	return sb.String()
120}
121
122// renderKitty builds a Kitty graphics protocol sequence. Supports chunked
123// data via the "m" continuation flag; chunk size is 4096 to stay under
124// terminal escape-buffer limits.
125//
126// The Kitty protocol preserves aspect ratio automatically when only one
127// of c= (columns) or r= (rows) is set. Setting both causes the image to
128// be stretched non-uniformly. We pick whichever constraint is tighter
129// for the input image so it fits inside maxCellsWide x maxCellsHigh.
130//
131// Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
132func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
133	b64 := base64.StdEncoding.EncodeToString(data)
134	const chunk = 4096
135	var sb strings.Builder
136
137	// Pick the most constraining dimension and use only it. Kitty
138	// preserves aspect ratio when exactly one of c/r is provided.
139	hdr := "a=T,f=100"
140	if maxCellsWide > 0 && maxCellsHigh > 0 {
141		if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {
142			// rows that the native width would produce at maxCellsWide.
143			// Round up so we don't under-reserve and let following text
144			// paint over the image on terminals whose cell metrics differ.
145			nativeRows := int(math.Ceil(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio()))
146			if nativeRows > maxCellsHigh {
147				hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
148			} else {
149				hdr += fmt.Sprintf(",c=%d", maxCellsWide)
150			}
151		} else {
152			hdr += fmt.Sprintf(",c=%d", maxCellsWide)
153		}
154	} else if maxCellsWide > 0 {
155		hdr += fmt.Sprintf(",c=%d", maxCellsWide)
156	} else if maxCellsHigh > 0 {
157		hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
158	}
159
160	for i := 0; i < len(b64); i += chunk {
161		end := i + chunk
162		if end > len(b64) {
163			end = len(b64)
164		}
165		more := 1
166		if end == len(b64) {
167			more = 0
168		}
169		if i == 0 {
170			fmt.Fprintf(&sb, "\x1b_G%s,m=%d;%s\x1b\\", hdr, more, b64[i:end])
171		} else {
172			fmt.Fprintf(&sb, "\x1b_Gm=%d;%s\x1b\\", more, b64[i:end])
173		}
174	}
175	return sb.String()
176}
177
178// ImageDimensions returns width and height in pixels, or zeros on error.
179// Used for the text fallback so the user sees something useful.
180func ImageDimensions(data []byte) (int, int) {
181	cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
182	if err != nil {
183		return 0, 0
184	}
185	return cfg.Width, cfg.Height
186}
187
188// defaultCellAspectRatio approximates how many pixel-rows one terminal
189// row occupies. Typical monospace cells are ~2x tall as wide; we use 2.0
190// as a safe default. Used to compute the rendered row count when scaling
191// an image to fit a cell width.
192const defaultCellAspectRatio = 2.0
193
194// CellAspectRatio returns the pixel-height / pixel-width ratio for one
195// terminal cell. ZOT_CELL_ASPECT lets users tune inline-image row
196// reservation for terminals/fonts where the default causes overlap or
197// excessive blank space. Values outside a sane range are ignored.
198func CellAspectRatio() float64 {
199	v := strings.TrimSpace(os.Getenv("ZOT_CELL_ASPECT"))
200	if v == "" {
201		return defaultCellAspectRatio
202	}
203	f, err := strconv.ParseFloat(v, 64)
204	if err != nil || f < 0.5 || f > 5 {
205		return defaultCellAspectRatio
206	}
207	return f
208}
209
210// RowsForInlineImage returns the number of terminal rows an image
211// rendered at cellsWide columns will occupy, preserving aspect ratio.
212// Clamped to maxRows. Returns 0 if the image cannot be decoded.
213func RowsForInlineImage(data []byte, cellsWide, maxRows int) int {
214	w, h := ImageDimensions(data)
215	if w <= 0 || h <= 0 || cellsWide <= 0 {
216		return 0
217	}
218	// pixels per cell (horizontal)
219	// scaleX = imageWidthPx / cellsWide
220	// rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio)
221	// Round up: under-reserving by even one row is much worse than one
222	// extra blank line, because following text can overwrite the image.
223	scaleX := float64(w) / float64(cellsWide)
224	rows := int(math.Ceil(float64(h) / (scaleX * CellAspectRatio())))
225	if rows < 1 {
226		rows = 1
227	}
228	if maxRows > 0 && rows > maxRows {
229		rows = maxRows
230	}
231	return rows
232}

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