main ece7682

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	cells := d.Row(row)
245	return strings.TrimRight(d.cellsText(cells, 0, len(cells)-1), " ")
246}
247
248// Extract renders inclusive ranges as copyable text. Every emitted line drops
249// the grid padding to its right, and rows the VT marked as soft-wrapped are
250// joined only when the next emitted segment is the next physical row.
251func (d *Document) Extract(ranges []CellRange) string {
252	if len(ranges) == 0 {
253		return ""
254	}
255
256	// Filter invalid ranges before deciding which segment is final or adjacent.
257	// rangeText with logicalEnd=true performs the same normalization and bounds
258	// checks while deliberately trimming, but this validation text is discarded.
259	emitted := make([]CellRange, 0, len(ranges))
260	for _, r := range ranges {
261		if _, _, ok := d.rangeText(r, true); ok {
262			emitted = append(emitted, r)
263		}
264	}
265
266	var out strings.Builder
267	for i, r := range emitted {
268		last := i == len(emitted)-1
269		adjacent := !last && emitted[i+1].Row == r.Row+1
270		text, soft, _ := d.rangeText(r, last || !adjacent)
271		out.WriteString(text)
272		if !last && !soft {
273			out.WriteByte('\n')
274		}
275	}
276	return out.String()
277}
278
279// rangeText renders one physical row of a selection and reports whether that
280// row continues into the next emitted physical row. A row that continues is cut
281// at its logical extent, keeping trailing spaces the application really printed;
282// any logical end is cut after its last content cell, dropping grid padding.
283func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) {
284	if r.Row < 0 || r.Row >= d.Len() {
285		return "", false, false
286	}
287	row := d.Row(r.Row)
288	if len(row) == 0 {
289		return "", false, r.Start == 0 && r.End == 0
290	}
291	if r.End == r.Start-1 && r.Start >= 0 && r.Start <= len(row) {
292		bound := d.snapshot.Bound(r.Row)
293		return "", bound.Soft && !logicalEnd, true
294	}
295	start, end := min(r.Start, r.End), max(r.Start, r.End)
296	if end < 0 || start >= len(row) {
297		return "", false, false
298	}
299	start = max(start, 0)
300	end = min(end, len(row)-1)
301
302	head, ok := d.Normalize(Pos{Row: r.Row, Col: start})
303	if !ok {
304		return "", false, false
305	}
306	start = head.Col
307
308	bound := d.snapshot.Bound(r.Row)
309	limit := contentEndOf(row, bound)
310	soft := bound.Soft && !logicalEnd && end >= limit-1
311	if soft {
312		end = min(end, limit-1)
313	} else {
314		end = lastContentCol(row, start, end)
315	}
316	if end < start {
317		return "", soft, true
318	}
319
320	tail, ok := d.Normalize(Pos{Row: r.Row, Col: end})
321	if !ok {
322		return "", soft, true
323	}
324	return d.cellsText(row, start, tail.Col), soft, true
325}
326
327// contentEnd returns the exclusive display-cell end of row's copyable content.
328// Soft rows end at their logical bound; hard rows drop blank padding.
329func (d *Document) contentEnd(row int) int {
330	return contentEndOf(d.Row(row), d.snapshot.Bound(row))
331}
332
333func contentEndOf(cells []renderer.Cell, bound vt.LineBound) int {
334	if len(cells) == 0 {
335		return 0
336	}
337	if bound.Soft {
338		return min(max(bound.End, 0), len(cells))
339	}
340	last := lastContentCol(cells, 0, len(cells)-1)
341	if last < 0 {
342		return 0
343	}
344	return glyphEnd(cells, last) + 1
345}
346
347// lastContentCol returns the rightmost column in [start, end] holding content,
348// or start-1 when the span is blank. Continuation cells belong to the glyph on
349// their left, so they never count on their own.
350func lastContentCol(row []renderer.Cell, start, end int) int {
351	for col := end; col >= start; col-- {
352		cell := row[col]
353		if cell.Continuation || cell.Rune == 0 || cell.Rune == ' ' {
354			continue
355		}
356		return col
357	}
358	return start - 1
359}
360
361func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
362	if start < 0 || end < start || start >= len(row) {
363		return ""
364	}
365	end = min(end, len(row)-1)
366	var text strings.Builder
367	for col := start; col <= end; col++ {
368		cell := row[col]
369		if cell.Continuation {
370			continue
371		}
372		if cell.Rune == 0 {
373			text.WriteByte(' ')
374			continue
375		}
376		text.WriteRune(cell.Rune)
377	}
378	return text.String()
379}
380
381func (d *Document) isSeparator(r rune) bool {
382	if unicode.IsSpace(r) {
383		return true
384	}
385	_, ok := d.wordSeparators[r]
386	return ok
387}
388
389func (d *Document) isGlyph(pos Pos) bool {
390	return pos.Col >= 0 && pos.Col < d.RowWidth(pos.Row) && !d.Cell(pos.Col, pos.Row).Continuation
391}
392
393func (d *Document) runeAt(pos Pos) rune {
394	cell := d.Cell(pos.Col, pos.Row)
395	if cell.Rune == 0 {
396		return ' '
397	}
398	return cell.Rune
399}
400
401// nextDocumentGlyph finds the next glyph in document order without building
402// an index for unrelated scrollback rows.
403func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
404	for row := pos.Row; row < d.Len(); row++ {
405		width := d.RowWidth(row)
406		start := 0
407		if row == pos.Row {
408			start = pos.Col + 1
409		}
410		for col := start; col < width; col++ {
411			if !d.Cell(col, row).Continuation {
412				return Pos{Row: row, Col: col}, true
413			}
414		}
415	}
416	return Pos{}, false
417}
418
419// previousDocumentGlyph finds the previous glyph in document order without
420// building an index for unrelated scrollback rows.
421func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {
422	for row := pos.Row; row >= 0; row-- {
423		start := d.RowWidth(row) - 1
424		if row == pos.Row {
425			start = min(pos.Col-1, start)
426		}
427		for col := start; col >= 0; col-- {
428			if !d.Cell(col, row).Continuation {
429				return Pos{Row: row, Col: col}, true
430			}
431		}
432	}
433	return Pos{}, false
434}
435
436func (d *Document) sameWord(left, right Pos) bool {
437	return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
438}

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