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

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