v0.3.0 52147d7

buffer.go RawHistoryFolder

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

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