v0.7.1 ff50ee4

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	// scratch receives the snapshot of the frame being prepared. It never
28	// aliases committed or caller storage: a snapshot commit swaps the two so
29	// both buffers are reused across draws without allocating.
30	scratch Frame
31	// generation identifies the latest prepared draw that owns scratch.
32	generation uint64
33}
34
35// PreparedDraw owns encoded output and its transactional delta until Commit.
36// It is returned by value so ordinary prepared draws do not allocate.
37type PreparedDraw struct {
38	renderer   *Renderer
39	candidate  DeltaCandidate
40	data       []byte
41	generation uint64
42	commitOnce *sync.Once
43}
44
45func New(caps Capabilities) *Renderer {
46	return NewWithColorProfile(caps, ColorProfileTrueColor)
47}
48
49// NewWithColorProfile constructs a renderer for a target color profile.
50func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer {
51	return &Renderer{caps: caps, colorProfile: profile}
52}
53
54// Reset forgets the committed shadow and invalidates every outstanding
55// prepared draw. The private frame buffers are kept for reuse.
56func (r *Renderer) Reset() {
57	r.width = 0
58	r.height = 0
59	r.hasCommitted = false
60	r.generation++
61}
62
63// Bytes returns the prepared ANSI output. The returned bytes remain valid after
64// another draw.
65func (p PreparedDraw) Bytes() []byte { return p.data }
66
67// Commit applies the prepared delta exactly once. Discarding it leaves the
68// renderer's committed state unchanged.
69//
70// Committing a draw that a later Prepare has superseded does not apply it:
71// the renderer instead drops its committed state, so the next Prepare
72// re-emits a full snapshot instead of diffing against a shadow that may no
73// longer match the terminal. Draws without output are unaffected.
74func (p *PreparedDraw) Commit() {
75	if p == nil || p.renderer == nil || p.commitOnce == nil {
76		return
77	}
78	p.commitOnce.Do(func() {
79		r := p.renderer
80		plan := p.candidate.Plan
81		if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
82			return
83		}
84		if p.generation != r.generation {
85			// A later Prepare or Reset reused the scratch frame this draw's
86			// snapshot lives in, so it cannot be applied. Its bytes may
87			// nevertheless have reached the terminal, which would then differ
88			// from the committed shadow in unknown ways: forget the shadow and
89			// invalidate every outstanding draw, so even a delta prepared
90			// after this one cannot re-establish a shadow on commit and the
91			// next Prepare emits a full snapshot.
92			r.hasCommitted = false
93			r.generation++
94			return
95		}
96		if !plan.Snapshot && !r.hasCommitted {
97			// Unreachable for current draws: Prepare plans a snapshot whenever
98			// no shadow exists, and every path that drops the shadow also
99			// advances the generation. Keep the shadow dropped defensively.
100			return
101		}
102		if plan.Snapshot {
103			// The snapshot lives in the renderer's scratch buffer; promote it
104			// and recycle the previous committed buffer as the next scratch.
105			r.committed, r.scratch = p.candidate.frame, r.committed
106		} else {
107			p.candidate.Commit(&r.committed)
108		}
109		r.width = r.committed.Width
110		r.height = r.committed.Height
111		r.hasCommitted = true
112	})
113}
114
115// Prepare plans and encodes a transactional draw. The renderer advances only
116// when the returned draw is committed. Keep at most one prepared draw
117// outstanding; commit or discard it before calling Prepare again. A draw
118// superseded by a later Prepare is not applied: committing it forces the next
119// Prepare to emit a full snapshot.
120//
121// The renderer copies frame into reusable private storage, so the caller may
122// keep mutating frame between draws.
123func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {
124	var candidate DeltaCandidate
125	var err error
126	if err = validateCellSource(frame); err != nil {
127		return PreparedDraw{}, err
128	}
129	// Any earlier prepared draw loses its claim on the scratch snapshot, even
130	// if this Prepare fails after partially rewriting it.
131	r.generation++
132	columns, rows := frame.Columns(), frame.Rows()
133	if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
134		plan := planSingleDamage(frame, damage[0])
135		candidate = newDeltaCandidate(frame, plan, &r.scratch)
136	} else {
137		candidate, err = planDelta(frame, damage, r.committed, reset || !r.hasCommitted, &r.scratch)
138	}
139	if err != nil {
140		return PreparedDraw{}, err
141	}
142	prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)}
143	plan := candidate.Plan
144	if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
145		return prepared, nil
146	}
147
148	buf, ok := bufferPool.Get().(*bytes.Buffer)
149	if !ok {
150		buf = new(bytes.Buffer)
151	}
152	buf.Reset()
153	defer putBuffer(buf)
154	if r.caps.SynchronizedOutput {
155		buf.WriteString(SyncStartCSI)
156	}
157
158	st := newDrawStateForProfile(r.colorProfile)
159	if plan.Snapshot {
160		if len(plan.Spans) > 0 {
161			r.emitDamageSpans(buf, frame, plan.Spans, &st)
162		} else {
163			r.writeFull(buf, frame, &st)
164		}
165	} else {
166		if plan.Scroll.Height != 0 {
167			scroll := plan.Scroll
168			kind := DamageScrollUp
169			if scroll.Down {
170				kind = DamageScrollDown
171			}
172			emitScroll(buf, Damage{Kind: kind, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count})
173		}
174		for _, span := range plan.Spans {
175			r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
176		}
177		buf.WriteString("\x1b[0m")
178	}
179	if r.caps.SynchronizedOutput {
180		buf.WriteString(SyncEndCSI)
181	}
182	prepared.data = copyBytes(buf)
183	return prepared, nil
184}
185
186func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) {
187	prepared, err := r.Prepare(frame, damage, false)
188	if err != nil {
189		return nil, err
190	}
191	prepared.Commit()
192	return prepared.Bytes(), nil
193}
194
195// copyBytes copies the buffer contents into a fresh byte slice and is used
196// to return output that is independent of the pooled scratch buffer.
197func copyBytes(buf *bytes.Buffer) []byte {
198	b := buf.Bytes()
199	out := make([]byte, len(b))
200	copy(out, b)
201	return out
202}
203
204func putBuffer(buf *bytes.Buffer) {
205	if buf.Cap() > maxPooledBufferCap {
206		return
207	}
208	bufferPool.Put(buf)
209}
210
211func needsFull(damage []Damage) bool {
212	if len(damage) == 0 {
213		return false
214	}
215	for _, d := range damage {
216		if d.Kind == DamageFullRedraw {
217			return true
218		}
219	}
220	return false
221}
222
223func hasScrollDamage(damage []Damage) bool {
224	for _, d := range damage {
225		if d.Kind == DamageScrollUp || d.Kind == DamageScrollDown {
226			return true
227		}
228	}
229	return false
230}
231
232func (r *Renderer) writeFull(out *bytes.Buffer, frame CellSource, st *drawState) {
233	columns := frame.Columns()
234	for y := range frame.Rows() {
235		r.emitSpan(out, frame, y, 0, columns, st)
236	}
237	out.WriteString("\x1b[0m")
238}
239
240func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame CellSource, spans []Span, st *drawState) {
241	for _, span := range spans {
242		r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
243	}
244	out.WriteString("\x1b[0m")
245}
246func clampRect(frame CellSource, x, y, width, height int) (int, int, int, int, bool) {
247	x, width, okX := clampRange(x, width, frame.Columns())
248	y, height, okY := clampRange(y, height, frame.Rows())
249	if !okX || !okY {
250		return 0, 0, 0, 0, false
251	}
252	return x, y, width, height, true
253}
254
255// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
256// overflowing endpoint from untrusted damage coordinates.
257func clampRange(pos, size, limit int) (int, int, bool) {
258	if size <= 0 || limit <= 0 || pos >= limit {
259		return 0, 0, false
260	}
261	if pos < 0 {
262		// -size is safe because size is positive. If pos is at or before that
263		// point, the rectangle ends at or before zero.
264		if pos <= -size {
265			return 0, 0, false
266		}
267		end := pos + size // pos > -size proves this addition cannot overflow.
268		if end > limit {
269			end = limit
270		}
271		return 0, end, true
272	}
273
274	available := limit - pos
275	return pos, min(size, available), true
276}
277
278// writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
279// allocations. It uses a stack-allocated buffer for integer formatting.
280func writeCursor(out *bytes.Buffer, y, x int) {
281	out.WriteString("\x1b[")
282	var b [16]byte
283	n := strconv.AppendInt(b[:0], int64(y+1), 10)
284	out.Write(n)
285	out.WriteByte(';')
286	n = strconv.AppendInt(b[:0], int64(x+1), 10)
287	out.Write(n)
288	out.WriteByte('H')
289}
290
291func sameDamage(a, b Damage) bool {
292	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
293}

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