v0.5.0 31affde

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
15// Capabilities describes the fixed output features of a Renderer target.
16type Capabilities struct {
17	SynchronizedOutput bool
18}
19
20type Renderer struct {
21	caps         Capabilities
22	colorProfile ColorProfile
23	width        int
24	height       int
25	hasCommitted bool
26	committed    Frame
27}
28
29// PreparedDraw owns encoded output and its transactional delta until Commit.
30// It is returned by value so ordinary prepared draws do not allocate.
31type PreparedDraw struct {
32	renderer   *Renderer
33	candidate  DeltaCandidate
34	data       []byte
35	commitOnce *sync.Once
36}
37
38func New(caps Capabilities) *Renderer {
39	return NewWithColorProfile(caps, ColorProfileTrueColor)
40}
41
42// NewWithColorProfile constructs a renderer for a target color profile.
43func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer {
44	return &Renderer{caps: caps, colorProfile: profile}
45}
46
47func (r *Renderer) Reset() {
48	r.width = 0
49	r.height = 0
50	r.hasCommitted = false
51	r.committed = Frame{}
52}
53
54// Bytes returns the prepared ANSI output. The returned bytes remain valid after
55// another draw.
56func (p PreparedDraw) Bytes() []byte { return p.data }
57
58// Commit applies the prepared delta exactly once. Discarding it leaves the
59// renderer's committed state unchanged.
60func (p *PreparedDraw) Commit() {
61	if p == nil || p.renderer == nil || p.commitOnce == nil {
62		return
63	}
64	p.commitOnce.Do(func() {
65		committed := p.renderer.committedFrame()
66		if p.candidate.Plan.Snapshot {
67			// PreparedDraw privately owns this snapshot. Its shared Once makes
68			// transfer safe even when the caller retains copies of the draw.
69			committed = p.candidate.frame
70		} else {
71			p.candidate.Commit(&committed)
72		}
73		p.renderer.setCommittedFrame(committed)
74	})
75}
76
77// Prepare plans and encodes a transactional draw. The renderer advances only
78// when the returned draw is committed. Keep at most one prepared draw
79// outstanding; commit or discard it before calling Prepare again.
80func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {
81	var candidate DeltaCandidate
82	var err error
83	if err = validateCellSource(frame); err != nil {
84		return PreparedDraw{}, err
85	}
86	columns, rows := frame.Columns(), frame.Rows()
87	if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
88		plan := planSingleDamage(frame, damage[0])
89		candidate = DeltaCandidate{Plan: plan}
90		if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
91			candidate.frame = cloneCellSource(frame)
92		}
93	} else {
94		candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted)
95	}
96	if err != nil {
97		return PreparedDraw{}, err
98	}
99	prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}
100	plan := candidate.Plan
101	if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
102		return prepared, nil
103	}
104
105	buf, ok := bufferPool.Get().(*bytes.Buffer)
106	if !ok {
107		buf = new(bytes.Buffer)
108	}
109	buf.Reset()
110	defer putBuffer(buf)
111	if r.caps.SynchronizedOutput {
112		buf.WriteString(SyncStartCSI)
113	}
114
115	st := newDrawStateForProfile(r.colorProfile)
116	if plan.Snapshot {
117		if len(plan.Spans) > 0 {
118			r.emitDamageSpans(buf, frame, plan.Spans, &st)
119		} else {
120			r.writeFull(buf, frame, &st)
121		}
122	} else {
123		if plan.Scroll.Height != 0 {
124			scroll := plan.Scroll
125			emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count})
126		}
127		for _, span := range plan.Spans {
128			r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
129		}
130		buf.WriteString("\x1b[0m")
131	}
132	if r.caps.SynchronizedOutput {
133		buf.WriteString(SyncEndCSI)
134	}
135	prepared.data = copyBytes(buf)
136	return prepared, nil
137}
138
139func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) {
140	prepared, err := r.Prepare(frame, damage, false)
141	if err != nil {
142		return nil, err
143	}
144	prepared.Commit()
145	return prepared.Bytes(), nil
146}
147
148func (r *Renderer) committedFrame() Frame {
149	return r.committed
150}
151
152func (r *Renderer) setCommittedFrame(frame Frame) {
153	r.width = frame.Width
154	r.height = frame.Height
155	r.hasCommitted = true
156	r.committed = frame
157}
158
159// copyBytes copies the buffer contents into a fresh byte slice and is used
160// to return output that is independent of the pooled scratch buffer.
161func copyBytes(buf *bytes.Buffer) []byte {
162	b := buf.Bytes()
163	out := make([]byte, len(b))
164	copy(out, b)
165	return out
166}
167
168func putBuffer(buf *bytes.Buffer) {
169	if buf.Cap() > maxPooledBufferCap {
170		return
171	}
172	bufferPool.Put(buf)
173}
174
175func needsFull(damage []Damage) bool {
176	if len(damage) == 0 {
177		return false
178	}
179	for _, d := range damage {
180		if d.Kind == DamageFullRedraw {
181			return true
182		}
183	}
184	return false
185}
186
187func hasScrollDamage(damage []Damage) bool {
188	for _, d := range damage {
189		if d.Kind == DamageScrollUp {
190			return true
191		}
192	}
193	return false
194}
195
196func (r *Renderer) writeFull(out *bytes.Buffer, frame CellSource, st *drawState) {
197	columns := frame.Columns()
198	for y := range frame.Rows() {
199		r.emitSpan(out, frame, y, 0, columns, st)
200	}
201	out.WriteString("\x1b[0m")
202}
203
204func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame CellSource, spans []Span, st *drawState) {
205	for _, span := range spans {
206		r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
207	}
208	out.WriteString("\x1b[0m")
209}
210func clampRect(frame CellSource, x, y, width, height int) (int, int, int, int, bool) {
211	x, width, okX := clampRange(x, width, frame.Columns())
212	y, height, okY := clampRange(y, height, frame.Rows())
213	if !okX || !okY {
214		return 0, 0, 0, 0, false
215	}
216	return x, y, width, height, true
217}
218
219// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
220// overflowing endpoint from untrusted damage coordinates.
221func clampRange(pos, size, limit int) (int, int, bool) {
222	if size <= 0 || limit <= 0 || pos >= limit {
223		return 0, 0, false
224	}
225	if pos < 0 {
226		// -size is safe because size is positive. If pos is at or before that
227		// point, the rectangle ends at or before zero.
228		if pos <= -size {
229			return 0, 0, false
230		}
231		end := pos + size // pos > -size proves this addition cannot overflow.
232		if end > limit {
233			end = limit
234		}
235		return 0, end, true
236	}
237
238	available := limit - pos
239	return pos, min(size, available), true
240}
241
242// writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
243// allocations. It uses a stack-allocated buffer for integer formatting.
244func writeCursor(out *bytes.Buffer, y, x int) {
245	out.WriteString("\x1b[")
246	var b [16]byte
247	n := strconv.AppendInt(b[:0], int64(y+1), 10)
248	out.Write(n)
249	out.WriteByte(';')
250	n = strconv.AppendInt(b[:0], int64(x+1), 10)
251	out.Write(n)
252	out.WriteByte('H')
253}
254
255func sameDamage(a, b Damage) bool {
256	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
257}

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