v0.1.0 7deb448

buffer.go RawHistoryFolder

1package vt
2
3import "github.com/bnema/vev/pkg/renderer"
4
5// buffer owns the visible VT grid and the physical-row boundaries needed to
6// reconstruct logical lines. History deliberately is not part of this type:
7// reflow is bounded by the live grid.
8type buffer struct {
9	frame      renderer.Frame
10	boundaries []LineBound
11}
12
13// LineBound describes a physical row's logical extent. End is exclusive: it is
14// the count of meaningful cells, so the last significant column is End-1. It
15// excludes padding introduced when a wide rune was moved off the right edge.
16// Soft reports that the row continues into the following physical row.
17type LineBound struct {
18	End  int
19	Soft bool
20}
21
22func newBuffer(width, height int) *buffer {
23	return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height)}
24}
25
26func bufferFromFrame(frame renderer.Frame) *buffer {
27	return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height)}
28}
29
30func (b *buffer) clone() *buffer {
31	out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]LineBound(nil), b.boundaries...)}
32	return out
33}
34
35func (b *buffer) content(y, end int) {
36	if y < 0 || y >= len(b.boundaries) {
37		return
38	}
39	b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
40}
41
42// bound reports the logical extent of row y. Callers on eviction paths may run
43// against a nil or shorter buffer than the frame they are walking, so an
44// unknown row reads as a hard row of no extent rather than panicking.
45func (b *buffer) bound(y int) LineBound {
46	if b == nil || y < 0 || y >= len(b.boundaries) {
47		return LineBound{}
48	}
49	return b.boundaries[y]
50}
51
52func (b *buffer) truncate(y, end int) {
53	if y < 0 || y >= len(b.boundaries) {
54		return
55	}
56	b.boundaries[y].End = min(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
57}
58
59// insert retains the meaningful shifted tail when insertion happens within it.
60func (b *buffer) insert(y, at, width int) {
61	if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].End {
62		return
63	}
64	b.boundaries[y].End = min(b.boundaries[y].End+width, b.frame.Width)
65}
66
67func (b *buffer) hard(y int) {
68	if y >= 0 && y < len(b.boundaries) {
69		b.boundaries[y].Soft = false
70	}
71}
72
73func (b *buffer) soft(y int) {
74	if y >= 0 && y < len(b.boundaries) {
75		b.boundaries[y].End = b.frame.Width
76		b.boundaries[y].Soft = true
77	}
78}
79
80func (b *buffer) continueRow(y int) {
81	if y >= 0 && y < len(b.boundaries) {
82		b.boundaries[y].Soft = true
83	}
84}
85
86func (b *buffer) clear(y, x0, x1 int) {
87	if y < 0 || y >= len(b.boundaries) {
88		return
89	}
90	b.content(y, x1)
91	if x1 >= b.frame.Width {
92		// Erasing through the right edge leaves nothing flowing onto the next
93		// row: the logical line ends here. Keeping a stale soft link would let
94		// reflow merge a repainted row with the unrelated row below it.
95		b.boundaries[y].Soft = false
96	}
97	if x0 == 0 && x1 >= b.frame.Width {
98		b.boundaries[y] = LineBound{}
99	}
100}
101
102func (b *buffer) scrollUp(top, bottom, n int) {
103	// A region operation changes which rows meet at both region edges. A soft
104	// boundary belongs to the row it follows, not the cells moved into its
105	// neighbor, so sever links crossing either edge before reflow can observe
106	// them.
107	b.hard(top - 1)
108	copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
109	for y := bottom - n + 1; y <= bottom; y++ {
110		b.boundaries[y] = LineBound{}
111	}
112	b.hard(bottom - n)
113}
114
115func (b *buffer) scrollDown(top, bottom, n int) {
116	// See scrollUp: the old top no longer follows the row above the region, and
117	// the last moved row no longer precedes the row below it.
118	b.hard(top - 1)
119	copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
120	for y := top; y < top+n; y++ {
121		b.boundaries[y] = LineBound{}
122	}
123	b.hard(bottom)
124}
125
126func (b *buffer) hydrate() {
127	for y := range b.boundaries {
128		if b.boundaries[y].End != 0 {
129			continue
130		}
131		for x := b.frame.Width - 1; x >= 0; x-- {
132			if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
133				b.boundaries[y].End = x + 1
134				break
135			}
136		}
137	}
138}
139
140type bufferCursor struct{ row, col int }
141
142func (b *buffer) hasSoft() bool {
143	for _, boundary := range b.boundaries {
144		if boundary.Soft {
145			return true
146		}
147	}
148	return false
149}
150
151// resizeFixed keeps hard physical lines independent. It is the common shell
152// path and avoids constructing logical-line scratch state when nothing wraps.
153func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
154	anchor := 0
155	if active != nil {
156		anchor = active.row
157	}
158	shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
159	evicted := make([][]renderer.Cell, 0, shift)
160	evictedBounds := make([]LineBound, 0, shift)
161	for y := range shift {
162		evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
163		// The evicted row keeps its source width, so its extent needs no clamping.
164		evictedBounds = append(evictedBounds, b.boundaries[y])
165	}
166	// Fixed-line resizes can retain the boundary backing store. In particular,
167	// keep its capacity through a short viewport so the common shrink/grow
168	// sequence does not add metadata allocation to every resize epoch.
169	boundaries := b.boundaries
170	if cap(boundaries) < height {
171		boundaries = make([]LineBound, height)
172	} else {
173		boundaries = boundaries[:height]
174	}
175	next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries}
176	copied := 0
177	for y := range height {
178		sy := y + shift
179		if sy >= b.frame.Height {
180			break
181		}
182		copy(next.frame.Row(y), b.frame.Row(sy))
183		next.boundaries[y] = b.boundaries[sy]
184		next.boundaries[y].End = min(next.boundaries[y].End, width)
185		repairFrameRow(next.frame, y)
186		copied++
187	}
188	clear(next.boundaries[copied:])
189	for _, cur := range [2]*bufferCursor{active, saved} {
190		if cur != nil {
191			cur.row = clamp(cur.row-shift, 0, height-1)
192			cur.col = clamp(cur.col, 0, width)
193		}
194	}
195	*b = next
196	return evicted, evictedBounds
197}
198
199type reflowPoint struct {
200	line, offset int
201	row, col     int
202}
203
204// cursorReflowPoints maps source cursor positions to offsets in their logical
205// lines. There are exactly two callers (active and DECSC), so this stays flat
206// and stack allocated rather than building a row/column lookup map.
207func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint {
208	points := [2]reflowPoint{{line: -1}, {line: -1}}
209	cursors := [2]*bufferCursor{active, saved}
210	for start := 0; start < b.frame.Height; {
211		end := start
212		for b.boundaries[end].Soft && end+1 < b.frame.Height {
213			end++
214		}
215		for i, cur := range cursors {
216			if cur == nil || cur.row < start || cur.row > end {
217				continue
218			}
219			offset := 0
220			for y := start; y < cur.row; y++ {
221				offset += b.boundaries[y].End
222			}
223			points[i] = reflowPoint{
224				line:   start,
225				offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].End),
226			}
227		}
228		start = end + 1
229	}
230	return points
231}
232
233// resize lays out only the current grid. It does two direct, bounded passes:
234// the first maps both cursors and counts rows; the second writes only the
235// retained viewport (and the rows genuinely evicted to history). It never
236// materializes logical lines, per-cell position maps, or temporary output rows.
237func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
238	if !b.hasSoft() {
239		return b.resizeFixed(width, height, active, saved)
240	}
241	// Only reflow needs meaningful extents; hard physical rows are copied as
242	// cells, so scanning blank rows on the shell fast path is wasted work.
243	b.hydrate()
244	for _, cur := range [2]*bufferCursor{active, saved} {
245		if cur != nil {
246			b.content(cur.row, cur.col)
247		}
248	}
249
250	points := b.cursorReflowPoints(active, saved)
251	rows := b.layoutReflow(width, &points, nil, 0, nil, nil)
252	anchor := 0
253	if active != nil {
254		anchor = points[0].row
255	}
256	shift := clamp(anchor-(height-1), 0, max(rows-height, 0))
257
258	next := newBuffer(width, height)
259	var evicted [][]renderer.Cell
260	var evictedCells []renderer.Cell
261	var evictedBounds []LineBound
262	if shift > 0 {
263		// History needs owned rows. One flat backing store replaces a temporary
264		// allocation per evicted output row.
265		evicted = make([][]renderer.Cell, shift)
266		evictedCells = make([]renderer.Cell, shift*width)
267		evictedBounds = make([]LineBound, shift)
268		for y := range evicted {
269			evicted[y] = evictedCells[y*width : (y+1)*width]
270		}
271	}
272	b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)
273	for i, cur := range [2]*bufferCursor{active, saved} {
274		if cur != nil {
275			cur.row = clamp(points[i].row-shift, 0, height-1)
276			cur.col = clamp(points[i].col, 0, width)
277		}
278	}
279	*b = *next
280	return evicted, evictedBounds
281}
282
283// layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
284// [shift, shift+dst.height). Rows before shift are written straight into the
285// contiguous eviction backing store. The return is the logical output height.
286func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int {
287	row, col := 0, 0
288	blankEvicted := func(outputRow int) []renderer.Cell {
289		if outputRow < shift {
290			out := evicted[outputRow*width : (outputRow+1)*width]
291			for i := range out {
292				out[i] = renderer.BlankCell()
293			}
294			return out
295		}
296		if dst != nil && outputRow < shift+dst.frame.Height {
297			return dst.frame.Row(outputRow - shift)
298		}
299		return nil
300	}
301	var output []renderer.Cell
302	if dst != nil && (shift > 0 || dst.frame.Height > 0) {
303		output = blankEvicted(0)
304	}
305	finishRow := func(soft bool) {
306		// An output row belongs either to history or to the retained viewport, and
307		// only this pass knows where it ended, so both destinations are written here.
308		bound := LineBound{End: col, Soft: soft}
309		switch {
310		case row < shift:
311			if evictedBounds != nil {
312				evictedBounds[row] = bound
313			}
314		case dst != nil && row < shift+dst.frame.Height:
315			dst.boundaries[row-shift] = bound
316		}
317		row++
318		col = 0
319		if dst != nil {
320			output = blankEvicted(row)
321		}
322	}
323	setPoint := func(line, offset, pointRow, pointCol int) {
324		for i := range points {
325			if points[i].line == line && points[i].offset == offset {
326				points[i].row, points[i].col = pointRow, pointCol
327			}
328		}
329	}
330	setRemainingPoints := func(line, offset, pointRow, pointCol int) {
331		for i := range points {
332			if points[i].line == line && points[i].offset >= offset {
333				points[i].row, points[i].col = pointRow, pointCol
334			}
335		}
336	}
337
338	for start := 0; start < b.frame.Height; {
339		end := start
340		for b.boundaries[end].Soft && end+1 < b.frame.Height {
341			end++
342		}
343		reflow := end > start
344		offset := 0
345		truncated := false
346		for y := start; y <= end && !truncated; y++ {
347			cells := b.frame.Row(y)
348			limit := b.boundaries[y].End
349			for x := 0; x < limit; {
350				cell := cells[x]
351				if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
352					setPoint(start, offset, row, col)
353					x++
354					offset++
355					continue
356				}
357				wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation
358				w := 1
359				if wide && width >= 2 {
360					w = 2
361				}
362				if col+w > width && col > 0 {
363					if !reflow {
364						setRemainingPoints(start, offset, row, col)
365						truncated = true
366						break
367					}
368					finishRow(true)
369				}
370				setPoint(start, offset, row, col)
371				if wide && width >= 2 {
372					if output != nil {
373						output[col], output[col+1] = cell, cells[x+1]
374					}
375					setPoint(start, offset+1, row, col+1)
376					col += 2
377					x += 2
378					offset += 2
379					continue
380				}
381				if wide {
382					cell.Rune = '\uFFFD'
383					cell.Continuation = false
384				}
385				if output != nil {
386					output[col] = cell
387				}
388				col++
389				x++
390				offset++
391			}
392		}
393		setPoint(start, offset, row, col)
394		finishRow(false)
395		start = end + 1
396	}
397	return row
398}

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