v0.3.0 99dc266

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