v0.1.0 7deb448

renderer.go RawHistoryFolder

1package renderer
2
3import (
4	"bytes"
5	"strconv"
6	"sync"
7)
8
9const maxPooledBufferCap = 1 << 20
10
11var bufferPool = sync.Pool{
12	New: func() any { return new(bytes.Buffer) },
13}
14
15type Capabilities struct {
16	SynchronizedOutput bool
17}
18
19type Renderer struct {
20	caps   Capabilities
21	width  int
22	height int
23	shadow []Cell
24}
25
26func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
27
28func (r *Renderer) Reset() {
29	r.width = 0
30	r.height = 0
31	r.shadow = nil
32}
33
34func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
35	if err := frame.Validate(); err != nil {
36		return nil, err
37	}
38
39	knownSameDimensions := r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells)
40
41	// Unchanged frame no-op: same dimensions, shadow populated, and either no
42	// damage or a structural redraw request that can be satisfied from the
43	// known terminal shadow.
44	if knownSameDimensions {
45		if len(damage) == 0 && !r.hasDirtyLines(frame) {
46			return nil, nil
47		}
48		if needsFull(damage) && !r.hasDirtyLines(frame) {
49			return nil, nil
50		}
51	}
52
53	buf := bufferPool.Get().(*bytes.Buffer)
54	buf.Reset()
55	defer putBuffer(buf)
56
57	if r.caps.SynchronizedOutput {
58		buf.WriteString(SyncStartCSI)
59	}
60
61	// Per-Draw terminal state model shared by every emission path so cursor
62	// tracking and the SGR pen persist across all rects within one Draw.
63	st := newDrawState()
64
65	if !knownSameDimensions {
66		r.writeFull(buf, frame, &st)
67		r.advanceShadow(frame)
68		if r.caps.SynchronizedOutput {
69			buf.WriteString(SyncEndCSI)
70		}
71		return copyBytes(buf), nil
72	}
73
74	if needsFull(damage) {
75		r.writeDamage(buf, frame, nil, nil, &st)
76		r.advanceShadow(frame)
77		if r.caps.SynchronizedOutput {
78			buf.WriteString(SyncEndCSI)
79		}
80		return copyBytes(buf), nil
81	}
82
83	if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
84		spans, full := buildDamagePlan(frame, damage, &scroll)
85		if full {
86			r.writeFull(buf, frame, &st)
87			r.advanceShadow(frame)
88			if r.caps.SynchronizedOutput {
89				buf.WriteString(SyncEndCSI)
90			}
91			return copyBytes(buf), nil
92		}
93
94		emitScrollUp(buf, scroll)
95		// emitScrollUp resets the SGR pen to default (matching st's initial
96		// pen) but leaves the cursor wherever the DECSTBM restore put it —
97		// terminal-dependent, so cursor tracking stays invalidated.
98		r.applyScroll(scroll)
99		r.emitDamageSpans(buf, frame, spans, &st)
100		r.advanceDamage(frame, damage, &scroll)
101		if r.caps.SynchronizedOutput {
102			buf.WriteString(SyncEndCSI)
103		}
104		return copyBytes(buf), nil
105	}
106
107	// Scroll damage changes rows outside the explicit text/clear rectangles.
108	// If the fast path is not safe, redraw the frame instead of applying
109	// partial damage and leaving the terminal/shadow stale.
110	if hasScrollDamage(damage) {
111		r.writeFull(buf, frame, &st)
112		r.advanceShadow(frame)
113		if r.caps.SynchronizedOutput {
114			buf.WriteString(SyncEndCSI)
115		}
116		return copyBytes(buf), nil
117	}
118
119	if r.writeDamage(buf, frame, damage, nil, &st) {
120		r.advanceShadow(frame)
121	} else {
122		r.advanceDamage(frame, damage, nil)
123	}
124	if r.caps.SynchronizedOutput {
125		buf.WriteString(SyncEndCSI)
126	}
127	return copyBytes(buf), nil
128}
129
130// copyBytes copies the buffer contents into a fresh byte slice and is used
131// to return output that is independent of the pooled scratch buffer.
132func copyBytes(buf *bytes.Buffer) []byte {
133	b := buf.Bytes()
134	out := make([]byte, len(b))
135	copy(out, b)
136	return out
137}
138
139func putBuffer(buf *bytes.Buffer) {
140	if buf.Cap() > maxPooledBufferCap {
141		return
142	}
143	bufferPool.Put(buf)
144}
145
146func (r *Renderer) hasDirtyLines(frame Frame) bool {
147	for y := range frame.Height {
148		if r.lineDirty(frame, y) {
149			return true
150		}
151	}
152	return false
153}
154
155func needsFull(damage []Damage) bool {
156	if len(damage) == 0 {
157		return false
158	}
159	for _, d := range damage {
160		if d.Kind == DamageFullRedraw {
161			return true
162		}
163	}
164	return false
165}
166
167func hasScrollDamage(damage []Damage) bool {
168	for _, d := range damage {
169		if d.Kind == DamageScrollUp {
170			return true
171		}
172	}
173	return false
174}
175
176func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
177	for y := range frame.Height {
178		r.emitSpan(out, frame, y, 0, frame.Width, st)
179	}
180	out.WriteString("\x1b[0m")
181}
182
183// writeDamage emits a canonical, bounded view of text and clear damage. It
184// reports whether the planner exceeded its budget and emitted a full redraw.
185func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) bool {
186	if len(damage) == 0 {
187		for y := range frame.Height {
188			if r.lineDirty(frame, y) {
189				r.emitSpan(out, frame, y, 0, frame.Width, st)
190			}
191		}
192		out.WriteString("\x1b[0m")
193		return false
194	}
195
196	spans, full := buildDamagePlan(frame, damage, skip)
197	if full {
198		r.writeFull(out, frame, st)
199		return true
200	}
201	r.emitDamageSpans(out, frame, spans, st)
202	return false
203}
204
205func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []damageSpan, st *drawState) {
206	for _, span := range spans {
207		r.emitSpan(out, frame, span.y, span.x, span.width, st)
208	}
209	out.WriteString("\x1b[0m")
210}
211
212func (r *Renderer) lineDirty(frame Frame, y int) bool {
213	// The shadow is stored in canonical (logical) row order; the frame is read
214	// through its logical accessor so the frame's physical layout is invisible.
215	start := y * frame.Width
216	row := frame.Row(y)
217	for i := range frame.Width {
218		if !r.shadow[start+i].Equal(row[i]) {
219			return true
220		}
221	}
222	return false
223}
224
225func (r *Renderer) advanceShadow(frame Frame) {
226	r.replaceShadow(frame)
227}
228
229func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) {
230	r.syncDamage(frame, damage, scroll)
231}
232
233func (r *Renderer) replaceShadow(frame Frame) {
234	r.width = frame.Width
235	r.height = frame.Height
236	n := len(frame.Cells)
237	if cap(r.shadow) >= n {
238		r.shadow = r.shadow[:n]
239	} else {
240		r.shadow = make([]Cell, n)
241	}
242	// Copy logical rows into canonical shadow order so the frame's line-offset
243	// rotation never leaks into the renderer's own buffer.
244	for y := range frame.Height {
245		copy(r.shadow[y*frame.Width:], frame.Row(y))
246	}
247}
248
249func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) {
250	if len(damage) == 0 {
251		r.replaceShadow(frame)
252		return
253	}
254	if scroll != nil {
255		r.syncRect(frame, scroll.X, scroll.Y+scroll.Height-scroll.Count, scroll.Width, scroll.Count)
256	}
257	for _, d := range damage {
258		switch d.Kind {
259		case DamageText, DamageClear:
260			r.syncRect(frame, d.X, d.Y, d.Width, d.Height)
261		}
262	}
263}
264
265func (r *Renderer) syncRect(frame Frame, x, y, width, height int) {
266	x, y, width, height, ok := clampRect(frame, x, y, width, height)
267	if !ok {
268		return
269	}
270	for row := y; row < y+height; row++ {
271		start := row*frame.Width + x
272		frameRow := frame.Row(row)
273		copy(r.shadow[start:start+width], frameRow[x:x+width])
274	}
275}
276
277func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
278	x, width, okX := clampRange(x, width, frame.Width)
279	y, height, okY := clampRange(y, height, frame.Height)
280	if !okX || !okY {
281		return 0, 0, 0, 0, false
282	}
283	return x, y, width, height, true
284}
285
286// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
287// overflowing endpoint from untrusted damage coordinates.
288func clampRange(pos, size, limit int) (int, int, bool) {
289	if size <= 0 || limit <= 0 || pos >= limit {
290		return 0, 0, false
291	}
292	if pos < 0 {
293		// -size is safe because size is positive. If pos is at or before that
294		// point, the rectangle ends at or before zero.
295		if pos <= -size {
296			return 0, 0, false
297		}
298		end := pos + size // pos > -size proves this addition cannot overflow.
299		if end > limit {
300			end = limit
301		}
302		return 0, end, true
303	}
304
305	available := limit - pos
306	if size > available {
307		size = available
308	}
309	return pos, size, true
310}
311
312// writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
313// allocations. It uses a stack-allocated buffer for integer formatting.
314func writeCursor(out *bytes.Buffer, y, x int) {
315	out.WriteString("\x1b[")
316	var b [16]byte
317	n := strconv.AppendInt(b[:0], int64(y+1), 10)
318	out.Write(n)
319	out.WriteByte(';')
320	n = strconv.AppendInt(b[:0], int64(x+1), 10)
321	out.Write(n)
322	out.WriteByte('H')
323}
324
325// writeStyle emits SGR style parameters without fmt.Fprintf allocations.
326func writeStyle(out *bytes.Buffer, style Style) {
327	out.WriteString("\x1b[0")
328	if style.Bold {
329		out.WriteString(";1")
330	}
331	if style.Attrs&AttrDim != 0 {
332		out.WriteString(";2")
333	}
334	if style.Italic {
335		out.WriteString(";3")
336	}
337	if style.Attrs&AttrUnderline != 0 {
338		switch style.UnderlineStyle {
339		case UnderlineDouble:
340			out.WriteString(";21")
341		case UnderlineCurly:
342			out.WriteString(";4:3")
343		case UnderlineDotted:
344			out.WriteString(";4:4")
345		case UnderlineDashed:
346			out.WriteString(";4:5")
347		default:
348			out.WriteString(";4")
349		}
350	}
351	if style.Attrs&AttrBlink != 0 {
352		out.WriteString(";5")
353	}
354	if style.Inverse {
355		out.WriteString(";7")
356	}
357	if style.Attrs&AttrStrikethrough != 0 {
358		out.WriteString(";9")
359	}
360	var b [16]byte
361	if style.HasForegroundRGB {
362		out.WriteString(";38;2;")
363		writeRGB(out, &b, style.ForegroundRGB)
364	} else if style.Foreground >= 0 {
365		out.WriteString(";38;5;")
366		n := strconv.AppendInt(b[:0], int64(style.Foreground), 10)
367		out.Write(n)
368	}
369	if style.HasBackgroundRGB {
370		out.WriteString(";48;2;")
371		writeRGB(out, &b, style.BackgroundRGB)
372	} else if style.Background >= 0 {
373		out.WriteString(";48;5;")
374		n := strconv.AppendInt(b[:0], int64(style.Background), 10)
375		out.Write(n)
376	}
377	if style.HasUnderlineColorRGB {
378		out.WriteString(";58;2;")
379		writeRGB(out, &b, style.UnderlineColorRGB)
380	} else if style.HasUnderlineColor {
381		out.WriteString(";58;5;")
382		n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10)
383		out.Write(n)
384	}
385	out.WriteByte('m')
386}
387
388func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) {
389	n := strconv.AppendInt(b[:0], int64(rgb.R), 10)
390	out.Write(n)
391	out.WriteByte(';')
392	n = strconv.AppendInt(b[:0], int64(rgb.G), 10)
393	out.Write(n)
394	out.WriteByte(';')
395	n = strconv.AppendInt(b[:0], int64(rgb.B), 10)
396	out.Write(n)
397}
398
399func sameDamage(a, b Damage) bool {
400	return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count
401}

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 12

Tags 11

↑↓ moveenter openesc close