v0.2.0 4859eb8

renderer.go RawHistoryFolder

1package ansi
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	committed Frame
25}
26
27// PreparedDraw owns encoded output and its transactional delta until Commit.
28// It is returned by value so ordinary prepared draws do not allocate.
29type PreparedDraw struct {
30	renderer   *Renderer
31	candidate  DeltaCandidate
32	data       []byte
33	commitOnce *sync.Once
34}
35
36func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
37
38func (r *Renderer) Reset() {
39	r.width = 0
40	r.height = 0
41	r.shadow = nil
42	r.committed = Frame{}
43}
44
45// Bytes returns the prepared ANSI output. The returned bytes remain valid after
46// another draw.
47func (p PreparedDraw) Bytes() []byte { return p.data }
48
49// Commit applies the prepared delta exactly once. Discarding it leaves the
50// renderer's committed state unchanged.
51func (p *PreparedDraw) Commit() {
52	if p == nil || p.renderer == nil || p.commitOnce == nil {
53		return
54	}
55	p.commitOnce.Do(func() {
56		committed := p.renderer.committedFrame()
57		p.candidate.Commit(&committed)
58		p.renderer.setCommittedFrame(committed)
59	})
60}
61
62// Prepare plans and encodes a transactional draw. The renderer advances only
63// when the returned draw is committed. Keep at most one prepared draw
64// outstanding; commit or discard it before calling Prepare again.
65func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) {
66	var candidate DeltaCandidate
67	var err error
68	if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
69		if err = frame.Validate(); err == nil {
70			plan := planSingleDamage(frame, damage[0])
71			candidate = DeltaCandidate{Plan: plan}
72			if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
73				candidate.frame = frame.Clone()
74			}
75		}
76	} else {
77		candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
78	}
79	if err != nil {
80		return PreparedDraw{}, err
81	}
82	prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}
83	plan := candidate.Plan
84	if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
85		return prepared, nil
86	}
87
88	buf, ok := bufferPool.Get().(*bytes.Buffer)
89	if !ok {
90		buf = new(bytes.Buffer)
91	}
92	buf.Reset()
93	defer putBuffer(buf)
94	if r.caps.SynchronizedOutput {
95		buf.WriteString(SyncStartCSI)
96	}
97
98	st := newDrawState()
99	if plan.Snapshot {
100		if len(plan.Spans) > 0 {
101			r.emitDamageSpans(buf, frame, plan.Spans, &st)
102		} else {
103			r.writeFull(buf, frame, &st)
104		}
105	} else {
106		if plan.Scroll.Height != 0 {
107			scroll := plan.Scroll
108			emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: frame.Width, Height: scroll.Height, Count: scroll.Count})
109		}
110		for _, span := range plan.Spans {
111			r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
112		}
113		buf.WriteString("\x1b[0m")
114	}
115	if r.caps.SynchronizedOutput {
116		buf.WriteString(SyncEndCSI)
117	}
118	prepared.data = copyBytes(buf)
119	return prepared, nil
120}
121
122func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
123	prepared, err := r.Prepare(frame, damage, false)
124	if err != nil {
125		return nil, err
126	}
127	prepared.Commit()
128	return prepared.Bytes(), nil
129}
130
131func (r *Renderer) committedFrame() Frame {
132	return r.committed
133}
134
135func (r *Renderer) setCommittedFrame(frame Frame) {
136	r.width = frame.Width
137	r.height = frame.Height
138	r.shadow = frame.Cells
139	r.committed = frame
140}
141
142// copyBytes copies the buffer contents into a fresh byte slice and is used
143// to return output that is independent of the pooled scratch buffer.
144func copyBytes(buf *bytes.Buffer) []byte {
145	b := buf.Bytes()
146	out := make([]byte, len(b))
147	copy(out, b)
148	return out
149}
150
151func putBuffer(buf *bytes.Buffer) {
152	if buf.Cap() > maxPooledBufferCap {
153		return
154	}
155	bufferPool.Put(buf)
156}
157
158func needsFull(damage []Damage) bool {
159	if len(damage) == 0 {
160		return false
161	}
162	for _, d := range damage {
163		if d.Kind == DamageFullRedraw {
164			return true
165		}
166	}
167	return false
168}
169
170func hasScrollDamage(damage []Damage) bool {
171	for _, d := range damage {
172		if d.Kind == DamageScrollUp {
173			return true
174		}
175	}
176	return false
177}
178
179func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
180	for y := range frame.Height {
181		r.emitSpan(out, frame, y, 0, frame.Width, st)
182	}
183	out.WriteString("\x1b[0m")
184}
185
186func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []Span, st *drawState) {
187	for _, span := range spans {
188		r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
189	}
190	out.WriteString("\x1b[0m")
191}
192func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
193	x, width, okX := clampRange(x, width, frame.Width)
194	y, height, okY := clampRange(y, height, frame.Height)
195	if !okX || !okY {
196		return 0, 0, 0, 0, false
197	}
198	return x, y, width, height, true
199}
200
201// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
202// overflowing endpoint from untrusted damage coordinates.
203func clampRange(pos, size, limit int) (int, int, bool) {
204	if size <= 0 || limit <= 0 || pos >= limit {
205		return 0, 0, false
206	}
207	if pos < 0 {
208		// -size is safe because size is positive. If pos is at or before that
209		// point, the rectangle ends at or before zero.
210		if pos <= -size {
211			return 0, 0, false
212		}
213		end := pos + size // pos > -size proves this addition cannot overflow.
214		if end > limit {
215			end = limit
216		}
217		return 0, end, true
218	}
219
220	available := limit - pos
221	return pos, min(size, available), true
222}
223
224// writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
225// allocations. It uses a stack-allocated buffer for integer formatting.
226func writeCursor(out *bytes.Buffer, y, x int) {
227	out.WriteString("\x1b[")
228	var b [16]byte
229	n := strconv.AppendInt(b[:0], int64(y+1), 10)
230	out.Write(n)
231	out.WriteByte(';')
232	n = strconv.AppendInt(b[:0], int64(x+1), 10)
233	out.Write(n)
234	out.WriteByte('H')
235}
236
237// writeStyle emits SGR style parameters without fmt.Fprintf allocations.
238func writeStyle(out *bytes.Buffer, style Style) {
239	out.WriteString("\x1b[0")
240	if style.Bold {
241		out.WriteString(";1")
242	}
243	if style.Attrs&AttrDim != 0 {
244		out.WriteString(";2")
245	}
246	if style.Italic {
247		out.WriteString(";3")
248	}
249	if style.Attrs&AttrUnderline != 0 {
250		switch style.UnderlineStyle {
251		case UnderlineDouble:
252			out.WriteString(";21")
253		case UnderlineCurly:
254			out.WriteString(";4:3")
255		case UnderlineDotted:
256			out.WriteString(";4:4")
257		case UnderlineDashed:
258			out.WriteString(";4:5")
259		default:
260			out.WriteString(";4")
261		}
262	}
263	if style.Attrs&AttrBlink != 0 {
264		out.WriteString(";5")
265	}
266	if style.Inverse {
267		out.WriteString(";7")
268	}
269	if style.Attrs&AttrStrikethrough != 0 {
270		out.WriteString(";9")
271	}
272	var b [16]byte
273	if style.HasForegroundRGB {
274		out.WriteString(";38;2;")
275		writeRGB(out, &b, style.ForegroundRGB)
276	} else if style.Foreground >= 0 {
277		out.WriteString(";38;5;")
278		n := strconv.AppendInt(b[:0], int64(style.Foreground), 10)
279		out.Write(n)
280	}
281	if style.HasBackgroundRGB {
282		out.WriteString(";48;2;")
283		writeRGB(out, &b, style.BackgroundRGB)
284	} else if style.Background >= 0 {
285		out.WriteString(";48;5;")
286		n := strconv.AppendInt(b[:0], int64(style.Background), 10)
287		out.Write(n)
288	}
289	if style.HasUnderlineColorRGB {
290		out.WriteString(";58;2;")
291		writeRGB(out, &b, style.UnderlineColorRGB)
292	} else if style.HasUnderlineColor {
293		out.WriteString(";58;5;")
294		n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10)
295		out.Write(n)
296	}
297	out.WriteByte('m')
298}
299
300func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) {
301	n := strconv.AppendInt(b[:0], int64(rgb.R), 10)
302	out.Write(n)
303	out.WriteByte(';')
304	n = strconv.AppendInt(b[:0], int64(rgb.G), 10)
305	out.Write(n)
306	out.WriteByte(';')
307	n = strconv.AppendInt(b[:0], int64(rgb.B), 10)
308	out.Write(n)
309}
310
311func sameDamage(a, b Damage) bool {
312	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
313}

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 2

Tags 13

↑↓ moveenter openesc close