detached ab6686c

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

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