document.go RawHistoryFolder

1package copy
2
3import (
4	"strings"
5	"unicode"
6
7	vt "github.com/bnema/vev-vt"
8	renderer "github.com/bnema/vev-vt/ansi"
9)
10
11// Pos identifies a terminal display cell in a document. Valid positions are
12// always glyph heads; Normalize converts continuation cells to their head.
13type Pos struct {
14	Row int
15	Col int
16}
17
18// CellRange is an inclusive display-cell range on one physical document row.
19type CellRange struct {
20	Row   int
21	Start int
22	End   int
23}
24
25// Document provides immutable display-cell and text operations over a Snapshot.
26type Document struct {
27	snapshot       Snapshot
28	wordSeparators map[rune]struct{}
29}
30
31func NewDocument(snapshot Snapshot, separators string) *Document {
32	set := make(map[rune]struct{}, len([]rune(separators)))
33	for _, r := range separators {
34		set[r] = struct{}{}
35	}
36	return &Document{snapshot: snapshot, wordSeparators: set}
37}
38
39func (d *Document) Snapshot() Snapshot          { return d.snapshot }
40func (d *Document) Len() int                    { return d.snapshot.Len() }
41func (d *Document) Width() int                  { return d.snapshot.Width }
42func (d *Document) Height() int                 { return d.snapshot.Height }
43func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
44func (d *Document) RowWidth(row int) int        { return d.snapshot.RowWidth(row) }
45func (d *Document) Cell(x, y int) renderer.Cell { return d.snapshot.Cell(x, y) }
46func (d *Document) RowID(row int) vt.RowID      { return d.snapshot.RowID(row) }
47func (d *Document) FindRowID(id vt.RowID) int   { return d.snapshot.FindRowID(id) }
48
49// Normalize returns pos as a valid glyph head. Empty physical rows have the
50// stable logical position {row, 0}, which is useful to vertical navigation.
51func (d *Document) Normalize(pos Pos) (Pos, bool) {
52	if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 {
53		return Pos{}, false
54	}
55	width := d.RowWidth(pos.Row)
56	if width == 0 {
57		if pos.Col == 0 {
58			return pos, true
59		}
60		return Pos{}, false
61	}
62	if pos.Col >= width {
63		return Pos{}, false
64	}
65	for pos.Col > 0 && d.Cell(pos.Col, pos.Row).Continuation {
66		pos.Col--
67	}
68	if d.Cell(pos.Col, pos.Row).Continuation {
69		return Pos{}, false
70	}
71	return pos, true
72}
73
74// PrevGlyph returns the previous glyph head on pos's physical row.
75func (d *Document) PrevGlyph(pos Pos) (Pos, bool) {
76	pos, ok := d.Normalize(pos)
77	if !ok || d.RowWidth(pos.Row) == 0 {
78		return Pos{}, false
79	}
80	for col := pos.Col - 1; col >= 0; col-- {
81		if !d.Cell(col, pos.Row).Continuation {
82			return Pos{Row: pos.Row, Col: col}, true
83		}
84	}
85	return Pos{}, false
86}
87
88// NextGlyph returns the next glyph head on pos's physical row.
89func (d *Document) NextGlyph(pos Pos) (Pos, bool) {
90	pos, ok := d.Normalize(pos)
91	if !ok {
92		return Pos{}, false
93	}
94	width := d.RowWidth(pos.Row)
95	for col := pos.Col + 1; col < width; col++ {
96		if !d.Cell(col, pos.Row).Continuation {
97			return Pos{Row: pos.Row, Col: col}, true
98		}
99	}
100	return Pos{}, false
101}
102
103// WordBounds returns the inclusive bounds of the word containing pos. Unicode
104// whitespace and configured separators do not belong to words.
105func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) {
106	pos, ok := d.Normalize(pos)
107	if !ok || !d.isGlyph(pos) || d.isSeparator(d.runeAt(pos)) {
108		return Pos{}, Pos{}, false
109	}
110	start, end := pos, pos
111	for {
112		prev, ok := d.PrevGlyph(start)
113		if !ok || d.isSeparator(d.runeAt(prev)) {
114			break
115		}
116		start = prev
117	}
118	for {
119		next, ok := d.NextGlyph(end)
120		if !ok || d.isSeparator(d.runeAt(next)) {
121			break
122		}
123		end = next
124	}
125	return start, end, true
126}
127
128// NextWordStart returns the beginning of the next word after pos, crossing
129// physical rows as necessary. It walks only the rows between pos and the
130// target instead of materializing every glyph in the document.
131func (d *Document) NextWordStart(pos Pos) (Pos, bool) {
132	pos, ok := d.Normalize(pos)
133	if !ok {
134		return Pos{}, false
135	}
136
137	candidate := pos
138	if d.isGlyph(candidate) && !d.isSeparator(d.runeAt(candidate)) {
139		for {
140			next, ok := d.nextDocumentGlyph(candidate)
141			if !ok {
142				return Pos{}, false
143			}
144			if !d.sameWord(next, candidate) {
145				candidate = next
146				break
147			}
148			candidate = next
149		}
150	} else {
151		candidate, ok = d.nextDocumentGlyph(candidate)
152		if !ok {
153			return Pos{}, false
154		}
155	}
156	for d.isSeparator(d.runeAt(candidate)) {
157		candidate, ok = d.nextDocumentGlyph(candidate)
158		if !ok {
159			return Pos{}, false
160		}
161	}
162	return candidate, true
163}
164
165// PreviousWordStart returns the beginning of the current word, or of the
166// preceding word when pos is already at a word beginning.
167func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) {
168	pos, ok := d.Normalize(pos)
169	if !ok {
170		return Pos{}, false
171	}
172
173	if d.isGlyph(pos) && !d.isSeparator(d.runeAt(pos)) {
174		previous, ok := d.previousDocumentGlyph(pos)
175		if ok && d.sameWord(previous, pos) {
176			for d.sameWord(previous, pos) {
177				pos = previous
178				previous, ok = d.previousDocumentGlyph(pos)
179				if !ok {
180					break
181				}
182			}
183			return pos, true
184		}
185	}
186
187	candidate, ok := d.previousDocumentGlyph(pos)
188	if !ok {
189		return Pos{}, false
190	}
191	for d.isSeparator(d.runeAt(candidate)) {
192		candidate, ok = d.previousDocumentGlyph(candidate)
193		if !ok {
194			return Pos{}, false
195		}
196	}
197	for {
198		previous, ok := d.previousDocumentGlyph(candidate)
199		if !ok || !d.sameWord(previous, candidate) {
200			return candidate, true
201		}
202		candidate = previous
203	}
204}
205
206// NextWordEnd returns the inclusive end of the current word, or of the next
207// word when pos is on a separator or at the end of a word.
208func (d *Document) NextWordEnd(pos Pos) (Pos, bool) {
209	pos, ok := d.Normalize(pos)
210	if !ok || !d.isGlyph(pos) {
211		return Pos{}, false
212	}
213
214	candidate := pos
215	if !d.isSeparator(d.runeAt(candidate)) {
216		next, ok := d.nextDocumentGlyph(candidate)
217		if !ok {
218			return Pos{}, false
219		}
220		if !d.sameWord(next, candidate) {
221			candidate = next
222		}
223	}
224	for d.isSeparator(d.runeAt(candidate)) {
225		candidate, ok = d.nextDocumentGlyph(candidate)
226		if !ok {
227			return Pos{}, false
228		}
229	}
230	for {
231		next, ok := d.nextDocumentGlyph(candidate)
232		if !ok || !d.sameWord(next, candidate) {
233			return candidate, true
234		}
235		candidate = next
236	}
237}
238
239// LineText returns row text without duplicated continuation cells and retains
240// the established copy-mode behavior of trimming trailing blank spaces.
241func (d *Document) LineText(row int) string {
242	return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
243}
244
245// Extract renders inclusive ranges as copyable text. Every emitted line drops
246// the grid padding to its right, and rows the VT marked as soft-wrapped are
247// joined only when the next emitted segment is the next physical row.
248func (d *Document) Extract(ranges []CellRange) string {
249	if len(ranges) == 0 {
250		return ""
251	}
252
253	// Filter invalid ranges before deciding which segment is final or adjacent.
254	// rangeText with logicalEnd=true performs the same normalization and bounds
255	// checks while deliberately trimming, but this validation text is discarded.
256	emitted := make([]CellRange, 0, len(ranges))
257	for _, r := range ranges {
258		if _, _, ok := d.rangeText(r, true); ok {
259			emitted = append(emitted, r)
260		}
261	}
262
263	var out strings.Builder
264	for i, r := range emitted {
265		last := i == len(emitted)-1
266		adjacent := !last && emitted[i+1].Row == r.Row+1
267		text, soft, _ := d.rangeText(r, last || !adjacent)
268		out.WriteString(text)
269		if !last && !soft {
270			out.WriteByte('\n')
271		}
272	}
273	return out.String()
274}
275
276// rangeText renders one physical row of a selection and reports whether that
277// row continues into the next emitted physical row. A row that continues is cut
278// at its logical extent, keeping trailing spaces the application really printed;
279// any logical end is cut after its last content cell, dropping grid padding.
280func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) {
281	if r.Row < 0 || r.Row >= d.Len() {
282		return "", false, false
283	}
284	row := d.Row(r.Row)
285	if len(row) == 0 {
286		return "", false, r.Start == 0 && r.End == 0
287	}
288	start, end := min(r.Start, r.End), max(r.Start, r.End)
289	if end < 0 || start >= len(row) {
290		return "", false, false
291	}
292	start = max(start, 0)
293	end = min(end, len(row)-1)
294
295	head, ok := d.Normalize(Pos{Row: r.Row, Col: start})
296	if !ok {
297		return "", false, false
298	}
299	start = head.Col
300
301	bound := d.snapshot.Bound(r.Row)
302	soft := bound.Soft && !logicalEnd && end >= len(row)-1
303	if soft {
304		end = min(end, min(max(bound.End, 0), len(row))-1)
305	} else {
306		end = lastContentCol(row, start, end)
307	}
308	if end < start {
309		return "", soft, true
310	}
311
312	tail, ok := d.Normalize(Pos{Row: r.Row, Col: end})
313	if !ok {
314		return "", soft, true
315	}
316	return d.cellsText(row, start, tail.Col), soft, true
317}
318
319// lastContentCol returns the rightmost column in [start, end] holding content,
320// or start-1 when the span is blank. Continuation cells belong to the glyph on
321// their left, so they never count on their own.
322func lastContentCol(row []renderer.Cell, start, end int) int {
323	for col := end; col >= start; col-- {
324		cell := row[col]
325		if cell.Continuation || cell.Rune == 0 || cell.Rune == ' ' {
326			continue
327		}
328		return col
329	}
330	return start - 1
331}
332
333func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
334	if start < 0 || end < start || start >= len(row) {
335		return ""
336	}
337	end = min(end, len(row)-1)
338	var text strings.Builder
339	for col := start; col <= end; col++ {
340		cell := row[col]
341		if cell.Continuation {
342			continue
343		}
344		if cell.Rune == 0 {
345			text.WriteByte(' ')
346			continue
347		}
348		text.WriteRune(cell.Rune)
349	}
350	return text.String()
351}
352
353func (d *Document) isSeparator(r rune) bool {
354	if unicode.IsSpace(r) {
355		return true
356	}
357	_, ok := d.wordSeparators[r]
358	return ok
359}
360
361func (d *Document) isGlyph(pos Pos) bool {
362	return pos.Col >= 0 && pos.Col < d.RowWidth(pos.Row) && !d.Cell(pos.Col, pos.Row).Continuation
363}
364
365func (d *Document) runeAt(pos Pos) rune {
366	cell := d.Cell(pos.Col, pos.Row)
367	if cell.Rune == 0 {
368		return ' '
369	}
370	return cell.Rune
371}
372
373// nextDocumentGlyph finds the next glyph in document order without building
374// an index for unrelated scrollback rows.
375func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
376	for row := pos.Row; row < d.Len(); row++ {
377		width := d.RowWidth(row)
378		start := 0
379		if row == pos.Row {
380			start = pos.Col + 1
381		}
382		for col := start; col < width; col++ {
383			if !d.Cell(col, row).Continuation {
384				return Pos{Row: row, Col: col}, true
385			}
386		}
387	}
388	return Pos{}, false
389}
390
391// previousDocumentGlyph finds the previous glyph in document order without
392// building an index for unrelated scrollback rows.
393func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {
394	for row := pos.Row; row >= 0; row-- {
395		start := d.RowWidth(row) - 1
396		if row == pos.Row {
397			start = min(pos.Col-1, start)
398		}
399		for col := start; col >= 0; col-- {
400			if !d.Cell(col, row).Continuation {
401				return Pos{Row: row, Col: col}, true
402			}
403		}
404	}
405	return Pos{}, false
406}
407
408func (d *Document) sameWord(left, right Pos) bool {
409	return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
410}

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