v0.3.0 99dc266

mode.go RawHistoryFolder

1package copy
2
3import (
4	"encoding/base64"
5	"math"
6	"slices"
7	"strings"
8	"unicode"
9
10	vt "github.com/bnema/vev-vt"
11	renderer "github.com/bnema/vev-vt/ansi"
12)
13
14const OSC52MaxPayloadBytes = 75_000
15
16// Snapshot is the immutable scrollback document: sealed history plus a cloned
17// screen. screenBounds and screenRowIDs are parallel to screen rows, exactly as
18// the history view's own metadata is parallel to its chunk rows.
19type Snapshot struct {
20	history       vt.HistoryView
21	screen        renderer.Frame
22	screenBounds  []vt.LineBound
23	screenRowIDs  []vt.RowID
24	Width, Height int
25}
26
27func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
28	return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))
29}
30
31// NewSnapshotFromScreen captures a screen while its owner holds the Screen
32// lock. Screen.RowIDs returns the one owned copy required for immutable
33// snapshot state; newSnapshot takes that slice directly without duplicating
34// it. The live screen's rowIDs cannot be transferred or shared because later
35// scroll, resize, and clear mutations update that storage.
36func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {
37	if screen == nil {
38		return NewSnapshot(historySource, renderer.Frame{}, nil, nil)
39	}
40	return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())
41}
42
43func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
44	var history vt.HistoryView
45	if historySource != nil {
46		history = historySource.SealAndView()
47	}
48	return Snapshot{
49		history:      history,
50		screen:       screen.Clone(),
51		screenBounds: append([]vt.LineBound(nil), bounds...),
52		screenRowIDs: screenRowIDs,
53		Width:        screen.Width,
54		Height:       screen.Height,
55	}
56}
57
58// NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It
59// exists for tests; production code goes through NewSnapshot.
60func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot {
61	history := vt.NewHistory(vt.HistoryConfig{
62		MaxRows:   max(len(rows), 1),
63		MaxCells:  snapshotRowsCellBudget(rows),
64		ChunkRows: 256,
65	})
66	for i, row := range rows {
67		bound := vt.LineBound{End: len(row)}
68		if i < len(bounds) {
69			bound = bounds[i]
70		}
71		if err := history.Append(row, bound); err != nil {
72			panic("copy snapshot: configured history rejected supplied row")
73		}
74	}
75	return Snapshot{history: history.SealAndView(), Width: width, Height: height}
76}
77
78// NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines.
79func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
80	return NewSnapshotFromLines(rows, nil, width, height)
81}
82
83// snapshotRowsCellBudget returns a capacity that retains every supplied row.
84// Saturation is safe because a larger representable capacity does not exist.
85func snapshotRowsCellBudget(rows [][]renderer.Cell) int {
86	cells := 0
87	for _, row := range rows {
88		if len(row) > math.MaxInt-cells {
89			return math.MaxInt
90		}
91		cells += len(row)
92	}
93	return cells
94}
95func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
96func (s Snapshot) Row(i int) []renderer.Cell {
97	if i < 0 {
98		return nil
99	}
100	if i < s.history.Len() {
101		return s.history.BorrowedRow(i)
102	}
103	i -= s.history.Len()
104	if i >= s.screen.Height {
105		return nil
106	}
107	return s.screen.Row(i)
108}
109
110// Bound returns the logical extent of row i, dispatching between sealed history
111// and the live screen exactly as Row does.
112func (s Snapshot) Bound(i int) vt.LineBound {
113	if i < 0 {
114		return vt.LineBound{}
115	}
116	if i < s.history.Len() {
117		return s.history.Bound(i)
118	}
119	i -= s.history.Len()
120	if i >= s.screen.Height || i >= len(s.screenBounds) {
121		return vt.LineBound{}
122	}
123	return s.screenBounds[i]
124}
125
126// RowIDs returns an owned copy of physical row identities in document order.
127func (s Snapshot) RowIDs() []vt.RowID {
128	ids := make([]vt.RowID, s.Len())
129	for i := range ids {
130		ids[i] = s.RowID(i)
131	}
132	return ids
133}
134
135// RowID returns the identity of document row i, or zero when it is unavailable.
136func (s Snapshot) RowID(i int) vt.RowID {
137	if i < 0 {
138		return 0
139	}
140	if i < s.history.Len() {
141		return s.history.RowID(i)
142	}
143	i -= s.history.Len()
144	if i >= s.screen.Height || i >= len(s.screenRowIDs) {
145		return 0
146	}
147	return s.screenRowIDs[i]
148}
149
150// FindRowID returns the document row containing id, or -1 when it is absent.
151func (s Snapshot) FindRowID(id vt.RowID) int {
152	if id == 0 {
153		return -1
154	}
155	if row := s.history.FindRowID(id); row >= 0 {
156		return row
157	}
158	offset := s.history.Len()
159	for i, candidate := range s.screenRowIDs {
160		if i >= s.screen.Height {
161			break
162		}
163		if candidate == id {
164			return offset + i
165		}
166	}
167	return -1
168}
169
170func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
171	i := 0
172	stopped := false
173	s.history.Range(func(r []renderer.Cell) bool {
174		if !yield(i, r) {
175			stopped = true
176			return false
177		}
178		i++
179		return true
180	})
181	if stopped {
182		return
183	}
184	for y := range s.screen.Height {
185		if !yield(i+y, s.screen.Row(y)) {
186			return
187		}
188	}
189}
190
191type SearchMatch struct {
192	Row, Start, End int
193	Text            string
194} // End is exclusive display-cell offset.
195
196type Mode struct {
197	document    *Document
198	navigator   Navigator
199	selection   Selection
200	ViewportTop int
201	SearchQuery string
202	Searches    []SearchMatch
203	SearchIndex int
204}
205
206func NewMode(doc *Document) *Mode {
207	m := &Mode{document: doc, SearchIndex: -1}
208	if doc != nil && doc.Len() > 0 {
209		m.navigator = NewNavigator(Pos{Row: doc.Len() - 1})
210		m.navigator.Set(doc, m.navigator.Pos)
211		m.ViewportTop = max(doc.Len()-max(doc.Height(), 1), 0)
212	}
213	return m
214}
215func (m *Mode) Document() *Document {
216	if m == nil {
217		return nil
218	}
219	return m.document
220}
221func (m *Mode) Cursor() Pos {
222	if m == nil {
223		return Pos{}
224	}
225	return m.navigator.Pos
226}
227func (m *Mode) Selection() Selection {
228	if m == nil {
229		return Selection{}
230	}
231	return m.selection
232}
233func (m *Mode) SetPosition(p Pos) bool {
234	if m == nil {
235		return false
236	}
237	changed := m.navigator.Set(m.document, p)
238	m.adjustViewport()
239	return changed
240}
241func (m *Mode) move(op func(*Document) bool, stream bool) bool {
242	if m == nil || m.document == nil {
243		return false
244	}
245	if stream && m.selection.Enabled && m.selection.Granularity == Line {
246		m.selection.AsCharacter()
247	}
248	changed := op(m.document)
249	if changed {
250		if m.selection.Enabled {
251			m.selection.Extend(m.navigator.Pos)
252		}
253		m.adjustViewport()
254	}
255	return changed
256}
257func (m *Mode) Left() bool         { return m.move(m.navigator.Left, true) }
258func (m *Mode) Right() bool        { return m.move(m.navigator.Right, true) }
259func (m *Mode) Up() bool           { return m.move(m.navigator.Up, false) }
260func (m *Mode) Down() bool         { return m.move(m.navigator.Down, false) }
261func (m *Mode) WordNext() bool     { return m.move(m.navigator.WordNext, true) }
262func (m *Mode) WordBackward() bool { return m.move(m.navigator.WordBackward, true) }
263func (m *Mode) WordEnd() bool      { return m.move(m.navigator.WordEnd, true) }
264func (m *Mode) MoveRows(delta int) bool {
265	if delta == 0 {
266		return false
267	}
268	changed := false
269	op := m.Up
270	if delta > 0 {
271		op = m.Down
272	}
273	for range abs(delta) {
274		if !op() {
275			break
276		}
277		changed = true
278	}
279	return changed
280}
281func (m *Mode) Page(pages int) bool {
282	if m == nil || m.document == nil {
283		return false
284	}
285	return m.move(func(d *Document) bool { return m.navigator.Page(d, pages*max(d.Height(), 1)) }, false)
286}
287func (m *Mode) Top() bool    { return m.move(m.navigator.Top, false) }
288func (m *Mode) Bottom() bool { return m.move(m.navigator.Bottom, false) }
289func (m *Mode) AtBottom() bool {
290	if m == nil || m.document == nil || m.document.Len() == 0 {
291		return m != nil && m.ViewportTop == 0
292	}
293	return m.navigator.Pos.Row == m.document.Len()-1 && m.ViewportTop == max(m.document.Len()-max(m.document.Height(), 1), 0)
294}
295func (m *Mode) ToggleLineSelection() {
296	if m.selection.Enabled {
297		m.selection = Selection{}
298		return
299	}
300	m.selection = NewLineSelection(m.navigator.Pos)
301}
302func (m *Mode) StartCharacterSelection(p Pos) bool {
303	if m == nil || m.document == nil {
304		return false
305	}
306	p, ok := m.document.Normalize(p)
307	if !ok {
308		return false
309	}
310	m.navigator.Set(m.document, p)
311	m.selection = Selection{Anchor: p, Active: p, Granularity: Character, Enabled: true}
312	m.adjustViewport()
313	return true
314}
315func (m *Mode) ExtendCharacterSelection(p Pos) bool {
316	if m == nil || !m.selection.Enabled {
317		return false
318	}
319	p, ok := m.document.Normalize(p)
320	if !ok {
321		return false
322	}
323	m.selection.AsCharacter()
324	m.selection.Extend(p)
325	m.navigator.Set(m.document, p)
326	m.adjustViewport()
327	return true
328}
329func (m *Mode) SelectWordAt(p Pos) bool {
330	if m == nil {
331		return false
332	}
333	s, ok := NewWordSelection(m.document, p)
334	if !ok {
335		return false
336	}
337	m.selection = s
338	m.navigator.Set(m.document, s.Active)
339	m.adjustViewport()
340	return true
341}
342func (m *Mode) ExtendWordSelection(p Pos) bool {
343	if m == nil || !m.selection.Enabled {
344		return false
345	}
346	_, end, ok := m.document.WordBounds(p)
347	if !ok {
348		return false
349	}
350	m.selection.Granularity = Word
351	m.selection.Extend(end)
352	m.navigator.Set(m.document, end)
353	m.adjustViewport()
354	return true
355}
356func (m *Mode) Search(query string) bool {
357	if m == nil {
358		return false
359	}
360	return m.SetSearchMatches(query, FindMatches(m.document, query), 0)
361}
362func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) bool {
363	if m == nil {
364		return false
365	}
366	m.SearchQuery = strings.TrimSpace(query)
367	if len(matches) == 0 {
368		m.Searches = nil
369		m.SearchIndex = -1
370		return false
371	}
372	m.Searches = append(m.Searches[:0], matches...)
373	m.SearchIndex = min(max(index, 0), len(matches)-1)
374	m.moveToSearchMatch(matches[m.SearchIndex])
375	return true
376}
377func (m *Mode) NextSearchMatch(delta int) bool {
378	if m == nil || len(m.Searches) == 0 || delta == 0 {
379		return false
380	}
381	m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
382	if m.SearchIndex < 0 {
383		m.SearchIndex += len(m.Searches)
384	}
385	match := m.Searches[m.SearchIndex]
386	m.moveToSearchMatch(match)
387	return true
388}
389func (m *Mode) moveToSearchMatch(match SearchMatch) {
390	if m == nil || m.document == nil {
391		return
392	}
393	pos, ok := m.document.Normalize(Pos{Row: match.Row, Col: match.Start})
394	if !ok {
395		return
396	}
397	m.navigator.Set(m.document, pos)
398	if m.selection.Enabled {
399		m.selection.Extend(m.navigator.Pos)
400	}
401	m.adjustViewport()
402}
403func (m *Mode) SelectedText() string {
404	if m == nil {
405		return ""
406	}
407	return m.selection.Text(m.document)
408}
409func (m *Mode) adjustViewport() {
410	if m == nil || m.document == nil || m.document.Len() == 0 {
411		return
412	}
413	rows := max(m.document.Height(), 1)
414	if m.navigator.Pos.Row < m.ViewportTop {
415		m.ViewportTop = m.navigator.Pos.Row
416	}
417	if m.navigator.Pos.Row >= m.ViewportTop+rows {
418		m.ViewportTop = m.navigator.Pos.Row - rows + 1
419	}
420	m.ViewportTop = min(max(m.ViewportTop, 0), max(m.document.Len()-rows, 0))
421}
422
423func FindMatches(doc *Document, query string) []SearchMatch {
424	query = strings.TrimSpace(query)
425	if doc == nil || query == "" {
426		return nil
427	}
428	needle := lowerRunes(query)
429	matches := []SearchMatch{}
430	doc.Snapshot().rangeRows(func(row int, cells []renderer.Cell) bool {
431		hay, indexes := searchableCells(cells)
432		text := ""
433		for start := 0; start+len(needle) <= len(hay); {
434			if !slices.Equal(hay[start:start+len(needle)], needle) {
435				start++
436				continue
437			}
438			if text == "" {
439				text = doc.LineText(row)
440			}
441			end := start + len(needle)
442			cellEnd := len(cells)
443			if end < len(indexes) {
444				cellEnd = indexes[end]
445			}
446			matches = append(matches, SearchMatch{Row: row, Start: indexes[start], End: cellEnd, Text: text})
447			start += len(needle)
448		}
449		return true
450	})
451	return matches
452}
453func searchableCells(cells []renderer.Cell) ([]rune, []int) {
454	rs := make([]rune, 0, len(cells))
455	is := make([]int, 0, len(cells))
456	for x, c := range cells {
457		if c.Continuation {
458			continue
459		}
460		r := c.Rune
461		if r == 0 {
462			r = ' '
463		}
464		rs = append(rs, unicode.ToLower(r))
465		is = append(is, x)
466	}
467	return rs, is
468}
469func lowerRunes(s string) []rune {
470	rs := []rune(s)
471	for i, r := range rs {
472		rs[i] = unicode.ToLower(r)
473	}
474	return rs
475}
476
477func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
478	if m == nil || m.document == nil {
479		return renderer.NewFrame(0, 0)
480	}
481	d := m.document
482	frame := renderer.NewFrame(d.Width(), d.Height()+1)
483	selectionBounds, hasSelectionBounds := m.selection.bounds(d)
484	selection, hasSelection := optionalStyle(styles, 1)
485	cursor, cursorValid := d.Normalize(m.navigator.Pos)
486	for y := range d.Height() {
487		src := m.ViewportTop + y
488		if src >= d.Len() {
489			break
490		}
491		row := frame.Row(y)
492		copy(row, d.Row(src))
493		if match, ok := m.currentSearchMatchForRow(src); ok {
494			for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
495				applySelectionStyle(&row[x].Style, selection, hasSelection)
496			}
497		}
498		cursorCovered := false
499		if hasSelectionBounds {
500			if r, ok := selectionBounds.rangeForRow(d, src); ok {
501				for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
502					applySelectionStyle(&row[x].Style, selection, hasSelection)
503				}
504				if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
505					cursorCovered = true
506				}
507			}
508		}
509		if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
510			cursorCol := min(cursor.Col, len(row)-1)
511			applySelectionStyle(&row[cursorCol].Style, selection, hasSelection)
512		}
513	}
514	status := inverseStyle()
515	if len(styles) > 0 {
516		status = styles[0]
517	}
518	drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status)
519	return frame
520}
521func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
522	if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
523		return SearchMatch{}, false
524	}
525	v := m.Searches[m.SearchIndex]
526	return v, v.Row == row
527}
528func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
529	if idx >= len(styles) {
530		return renderer.Style{}, false
531	}
532	return styles[idx], true
533}
534func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
535	if !ok || style.Equal(inverseStyle()) {
536		dst.Inverse = true
537		return
538	}
539	*dst = style
540}
541func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
542func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
543	for i := range row {
544		row[i] = renderer.Cell{Rune: ' ', Style: style}
545	}
546	text := " [SCROLL] "
547	if m.selection.Enabled {
548		text = " [SELECT] "
549	}
550	if total > 0 {
551		text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " "
552	} else {
553		text += "0/0 "
554	}
555	if m.SearchQuery != "" {
556		if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
557			text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "
558		}
559		text += "/" + m.SearchQuery + " "
560	}
561	for i, r := range text {
562		if i >= len(row) {
563			break
564		}
565		row[i] = renderer.Cell{Rune: r, Style: style}
566	}
567}
568
569func OSC52(text string) [][]byte {
570	if len([]byte(text)) > OSC52MaxPayloadBytes {
571		return nil
572	}
573	return [][]byte{[]byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x07")}
574}
575func OSC52FromBase64(b64 string) []byte {
576	decoded, err := base64.StdEncoding.DecodeString(b64)
577	if err != nil || len(decoded) > OSC52MaxPayloadBytes {
578		return nil
579	}
580	return []byte("\x1b]52;c;" + b64 + "\x07")
581}
582func strconvItoa(n int) string {
583	if n == 0 {
584		return "0"
585	}
586	var b [20]byte
587	i := len(b)
588	for n > 0 {
589		i--
590		b[i] = byte('0' + n%10)
591		n /= 10
592	}
593	return string(b[i:])
594}
595func abs(n int) int {
596	if n < 0 {
597		return -n
598	}
599	return n
600}

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