v0.1.47 5a64f87

render.go RawHistoryFolder

1package tui
2
3import (
4	"io"
5	"strings"
6
7	"github.com/mattn/go-runewidth"
8)
9
10// runewidthRune reports the number of cells a rune occupies, pinned
11// here so the renderer does not depend on the editor's helper.
12func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
13
14// Renderer maintains a previous frame and writes only the lines that
15// changed on each Draw(). Callers pass a full target frame (slice of
16// styled lines, already wrapped to width).
17type Renderer struct {
18	out  io.Writer
19	prev []string
20	rows int // terminal rows
21	cols int // terminal cols
22
23	// Cursor position after last draw (for placing input cursor).
24	cursorRow int
25	cursorCol int
26
27	// hideCursor when true prevents ShowCursor from being emitted.
28	hideCursor bool
29
30	// prevHadImage tracks whether the previous frame contained an
31	// inline-image escape so we can force a full clear+repaint whenever
32	// the image set changes. Only matters when inline images are
33	// enabled via ZOT_INLINE_IMAGES; defaults to false.
34	prevHadImage bool
35}
36
37// NewRenderer returns a renderer that writes to out.
38func NewRenderer(out io.Writer) *Renderer {
39	return &Renderer{out: out}
40}
41
42// Resize tells the renderer the current terminal size.
43//
44// On a real size change we also issue a clear-screen so the next Draw
45// starts from a blank slate. Without the clear, characters from the
46// old (wider) layout linger past the new right edge and rows from
47// before the new bottom hang around as garbage.
48func (r *Renderer) Resize(cols, rows int) {
49	if cols != r.cols || rows != r.rows {
50		r.cols = cols
51		r.rows = rows
52		r.prev = nil
53		if r.out != nil {
54			_, _ = io.WriteString(r.out, SeqClearScreen)
55		}
56	}
57}
58
59// Clear forces a full repaint on the next Draw and clears the screen.
60func (r *Renderer) Clear() {
61	r.prev = nil
62	_, _ = io.WriteString(r.out, SeqClearScreen)
63}
64
65// Invalidate forces a full repaint on the next Draw without clearing the
66// whole terminal first. Useful when the cached diff is unreliable but a
67// visible full-screen flash would be too distracting.
68func (r *Renderer) Invalidate() {
69	r.prev = nil
70}
71
72// Draw updates the terminal so that the visible frame ends with the
73// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
74// the lines slice indicating where to place the terminal cursor; use
75// -1 to hide it.
76// containsImageEscape reports whether the line carries an inline-image
77// escape we must repaint rather than diff against the previous frame.
78func containsImageEscape(s string) bool {
79	return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
80}
81
82// truncateToWidth clips s so its on-screen width doesn't exceed cols
83// cells, preserving ANSI CSI escape sequences (which don't consume
84// cells). Lines carrying an inline-image escape are returned as-is
85// since we can't measure their painted size.
86//
87// Fast path: a byte-length <= cols is a conservative upper bound
88// guaranteeing the cell width is also <= cols, so we skip all the
89// rune-width math. That covers the vast majority of lines in a
90// transcript (narrow terminals wrap early; wide ones leave headroom).
91func truncateToWidth(s string, cols int) string {
92	if cols <= 0 || containsImageEscape(s) {
93		return s
94	}
95	if len(s) <= cols {
96		return s
97	}
98	var out strings.Builder
99	out.Grow(len(s))
100	seen := 0
101	runes := []rune(s)
102	for i := 0; i < len(runes); {
103		r := runes[i]
104		// CSI escape sequence (ESC [ ... final): zero-width.
105		if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
106			out.WriteRune(r)
107			out.WriteRune(runes[i+1])
108			i += 2
109			for i < len(runes) {
110				c := runes[i]
111				out.WriteRune(c)
112				i++
113				if c >= 0x40 && c <= 0x7e {
114					break
115				}
116			}
117			continue
118		}
119		rw := runewidthRune(r)
120		if seen+rw > cols {
121			// Flush any trailing ANSI escapes (resets, erase-to-EOL)
122			// so background colors and cleanup sequences survive.
123			for i < len(runes) {
124				if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
125					out.WriteRune(runes[i])
126					out.WriteRune(runes[i+1])
127					i += 2
128					for i < len(runes) {
129						c := runes[i]
130						out.WriteRune(c)
131						i++
132						if c >= 0x40 && c <= 0x7e {
133							break
134						}
135					}
136				} else {
137					break
138				}
139			}
140			break
141		}
142		out.WriteRune(r)
143		seen += rw
144		i++
145	}
146	return out.String()
147}
148
149func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
150	if r.cols == 0 || r.rows == 0 {
151		return
152	}
153	// Bottom-align: only the last r.rows lines are visible.
154	visible := lines
155	if len(visible) > r.rows {
156		visible = visible[len(visible)-r.rows:]
157		cursorRow -= len(lines) - len(visible)
158	}
159	// Pad to r.rows with empty lines at the top. Every line is also
160	// hard-truncated to cols so the terminal never soft-wraps our output
161	// (which would push the status bar out of its row).
162	frame := make([]string, r.rows)
163	top := r.rows - len(visible)
164	for i := 0; i < top; i++ {
165		frame[i] = ""
166	}
167	for i, line := range visible {
168		frame[top+i] = truncateToWidth(line, r.cols)
169	}
170
171	var w strings.Builder
172	w.WriteString(SeqSynchronizedOn)
173	w.WriteString(SeqHideCursor)
174
175	// When inline images are in play we always full-repaint (clear
176	// screen first, then rewrite every row). Terminals manage image
177	// pixels in a layer we cannot diff against, so the per-line cache
178	// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
179	// the common code path below is the fast cached diff.
180	curHasImage := false
181	curHasKittyImage := false
182	for _, l := range frame {
183		if containsImageEscape(l) {
184			curHasImage = true
185			if strings.Contains(l, "\x1b_G") {
186				curHasKittyImage = true
187			}
188		}
189	}
190	forceAll := curHasImage || r.prevHadImage
191	if forceAll {
192		w.WriteString(SeqClearScreen)
193		if curHasKittyImage {
194			// Delete previously placed kitty images once per frame,
195			// before rewriting all rows. Doing this inside each image
196			// escape makes only the last image in the frame survive.
197			w.WriteString("\x1b_Ga=d\x1b\\")
198		}
199	}
200
201	// Detect selection highlights: if the current OR previous frame
202	// has selection-background rows, force full repaint. VS Code's
203	// terminal doesn't reliably clear background colors on row
204	// overwrites, leaving ghost highlights behind.
205	hasSelection := false
206	for _, l := range frame {
207		if strings.Contains(l, "\x1b[48;5;") {
208			hasSelection = true
209			break
210		}
211	}
212	if !hasSelection && r.prev != nil {
213		for _, l := range r.prev {
214			if strings.Contains(l, "\x1b[48;5;") {
215				hasSelection = true
216				break
217			}
218		}
219	}
220
221	full := r.prev == nil || len(r.prev) != r.rows
222	for i := 0; i < r.rows; i++ {
223		if full || forceAll || hasSelection || r.prev[i] != frame[i] {
224			w.WriteString(MoveTo(i+1, 1))
225			w.WriteString("\x1b[0m") // reset all attributes first
226			w.WriteString(SeqClearLine)
227			w.WriteString(frame[i])
228		}
229	}
230
231	if cursorRow >= 0 {
232		absRow := top + cursorRow + 1
233		absCol := cursorCol + 1
234		if absRow >= 1 && absRow <= r.rows {
235			w.WriteString(MoveTo(absRow, absCol))
236			w.WriteString(SeqShowCursor)
237		}
238	}
239	w.WriteString(SeqSynchronizedOff)
240
241	_, _ = io.WriteString(r.out, w.String())
242
243	r.prev = frame
244	r.prevHadImage = curHasImage
245	r.cursorRow = cursorRow
246	r.cursorCol = cursorCol
247}

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