v0.5.1 8e4df5c

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			kind := DamageScrollUp
126			if scroll.Down {
127				kind = DamageScrollDown
128			}
129			emitScroll(buf, Damage{Kind: kind, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count})
130		}
131		for _, span := range plan.Spans {
132			r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
133		}
134		buf.WriteString("\x1b[0m")
135	}
136	if r.caps.SynchronizedOutput {
137		buf.WriteString(SyncEndCSI)
138	}
139	prepared.data = copyBytes(buf)
140	return prepared, nil
141}
142
143func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) {
144	prepared, err := r.Prepare(frame, damage, false)
145	if err != nil {
146		return nil, err
147	}
148	prepared.Commit()
149	return prepared.Bytes(), nil
150}
151
152func (r *Renderer) committedFrame() Frame {
153	return r.committed
154}
155
156func (r *Renderer) setCommittedFrame(frame Frame) {
157	r.width = frame.Width
158	r.height = frame.Height
159	r.hasCommitted = true
160	r.committed = frame
161}
162
163// copyBytes copies the buffer contents into a fresh byte slice and is used
164// to return output that is independent of the pooled scratch buffer.
165func copyBytes(buf *bytes.Buffer) []byte {
166	b := buf.Bytes()
167	out := make([]byte, len(b))
168	copy(out, b)
169	return out
170}
171
172func putBuffer(buf *bytes.Buffer) {
173	if buf.Cap() > maxPooledBufferCap {
174		return
175	}
176	bufferPool.Put(buf)
177}
178
179func needsFull(damage []Damage) bool {
180	if len(damage) == 0 {
181		return false
182	}
183	for _, d := range damage {
184		if d.Kind == DamageFullRedraw {
185			return true
186		}
187	}
188	return false
189}
190
191func hasScrollDamage(damage []Damage) bool {
192	for _, d := range damage {
193		if d.Kind == DamageScrollUp || d.Kind == DamageScrollDown {
194			return true
195		}
196	}
197	return false
198}
199
200func (r *Renderer) writeFull(out *bytes.Buffer, frame CellSource, st *drawState) {
201	columns := frame.Columns()
202	for y := range frame.Rows() {
203		r.emitSpan(out, frame, y, 0, columns, st)
204	}
205	out.WriteString("\x1b[0m")
206}
207
208func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame CellSource, spans []Span, st *drawState) {
209	for _, span := range spans {
210		r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
211	}
212	out.WriteString("\x1b[0m")
213}
214func clampRect(frame CellSource, x, y, width, height int) (int, int, int, int, bool) {
215	x, width, okX := clampRange(x, width, frame.Columns())
216	y, height, okY := clampRange(y, height, frame.Rows())
217	if !okX || !okY {
218		return 0, 0, 0, 0, false
219	}
220	return x, y, width, height, true
221}
222
223// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
224// overflowing endpoint from untrusted damage coordinates.
225func clampRange(pos, size, limit int) (int, int, bool) {
226	if size <= 0 || limit <= 0 || pos >= limit {
227		return 0, 0, false
228	}
229	if pos < 0 {
230		// -size is safe because size is positive. If pos is at or before that
231		// point, the rectangle ends at or before zero.
232		if pos <= -size {
233			return 0, 0, false
234		}
235		end := pos + size // pos > -size proves this addition cannot overflow.
236		if end > limit {
237			end = limit
238		}
239		return 0, end, true
240	}
241
242	available := limit - pos
243	return pos, min(size, available), true
244}
245
246// writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
247// allocations. It uses a stack-allocated buffer for integer formatting.
248func writeCursor(out *bytes.Buffer, y, x int) {
249	out.WriteString("\x1b[")
250	var b [16]byte
251	n := strconv.AppendInt(b[:0], int64(y+1), 10)
252	out.Write(n)
253	out.WriteByte(';')
254	n = strconv.AppendInt(b[:0], int64(x+1), 10)
255	out.Write(n)
256	out.WriteByte('H')
257}
258
259func sameDamage(a, b Damage) bool {
260	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
261}

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