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 vt.CellSource, 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, screen.LineBounds(), screen.RowIDs())
41}
42
43func newSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
44 owned := renderer.NewFrame(screen.Columns(), screen.Rows())
45 for y := range screen.Rows() {
46 for x := range screen.Columns() {
47 owned.Set(x, y, screen.Cell(x, y))
48 }
49 }
50 var history vt.HistoryView
51 if historySource != nil {
52 history = historySource.SealAndView()
53 }
54 return Snapshot{
55 history: history,
56 screen: owned,
57 screenBounds: append([]vt.LineBound(nil), bounds...),
58 screenRowIDs: screenRowIDs,
59 Width: screen.Columns(),
60 Height: screen.Rows(),
61 }
62}
63
64// NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It
65// exists for tests; production code goes through NewSnapshot.
66func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot {
67 history := vt.NewHistory(vt.HistoryConfig{
68 MaxRows: max(len(rows), 1),
69 MaxBytes: math.MaxUint64, // Input is already owned and bounded by the caller.
70 ChunkRows: 256,
71 })
72 for i, row := range rows {
73 bound := vt.LineBound{End: len(row)}
74 if i < len(bounds) {
75 bound = bounds[i]
76 }
77 if err := history.Append(row, bound); err != nil {
78 panic("copy snapshot: configured history rejected supplied row")
79 }
80 }
81 return Snapshot{history: history.SealAndView(), Width: width, Height: height}
82}
83
84// NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines.
85func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
86 return NewSnapshotFromLines(rows, nil, width, height)
87}
88
89func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
90func (s Snapshot) Row(i int) []renderer.Cell {
91 if i < 0 {
92 return nil
93 }
94 if i < s.history.Len() {
95 return s.history.Row(i)
96 }
97 i -= s.history.Len()
98 if i >= s.screen.Height {
99 return nil
100 }
101 return s.screen.Row(i)
102}
103
104func (s Snapshot) RowWidth(y int) int {
105 if y < 0 {
106 return 0
107 }
108 if y < s.history.Len() {
109 return s.history.RowWidth(y)
110 }
111 y -= s.history.Len()
112 if y >= s.screen.Height {
113 return 0
114 }
115 return s.screen.Width
116}
117
118func (s Snapshot) Cell(x, y int) renderer.Cell {
119 if y < 0 || x < 0 {
120 return renderer.BlankCell()
121 }
122 if y < s.history.Len() {
123 return s.history.Cell(x, y)
124 }
125 y -= s.history.Len()
126 if y >= s.screen.Height || x >= s.screen.Width {
127 return renderer.BlankCell()
128 }
129 return s.screen.Cell(x, y)
130}
131
132func (s Snapshot) CopyRow(y int, dst []renderer.Cell) int {
133 if y < 0 {
134 return 0
135 }
136 if y < s.history.Len() {
137 return s.history.CopyRow(y, dst)
138 }
139 y -= s.history.Len()
140 if y >= s.screen.Height {
141 return 0
142 }
143 n := min(len(dst), s.screen.Width)
144 for x := range n {
145 dst[x] = s.screen.Cell(x, y)
146 }
147 return n
148}
149
150// Bound returns the logical extent of row i, dispatching between sealed history
151// and the live screen exactly as Row does.
152func (s Snapshot) Bound(i int) vt.LineBound {
153 if i < 0 {
154 return vt.LineBound{}
155 }
156 if i < s.history.Len() {
157 return s.history.Bound(i)
158 }
159 i -= s.history.Len()
160 if i >= s.screen.Height || i >= len(s.screenBounds) {
161 return vt.LineBound{}
162 }
163 return s.screenBounds[i]
164}
165
166// RowIDs returns an owned copy of physical row identities in document order.
167func (s Snapshot) RowIDs() []vt.RowID {
168 ids := make([]vt.RowID, s.Len())
169 for i := range ids {
170 ids[i] = s.RowID(i)
171 }
172 return ids
173}
174
175// RowID returns the identity of document row i, or zero when it is unavailable.
176func (s Snapshot) RowID(i int) vt.RowID {
177 if i < 0 {
178 return 0
179 }
180 if i < s.history.Len() {
181 return s.history.RowID(i)
182 }
183 i -= s.history.Len()
184 if i >= s.screen.Height || i >= len(s.screenRowIDs) {
185 return 0
186 }
187 return s.screenRowIDs[i]
188}
189
190// FindRowID returns the document row containing id, or -1 when it is absent.
191func (s Snapshot) FindRowID(id vt.RowID) int {
192 if id == 0 {
193 return -1
194 }
195 if row := s.history.FindRowID(id); row >= 0 {
196 return row
197 }
198 offset := s.history.Len()
199 for i, candidate := range s.screenRowIDs {
200 if i >= s.screen.Height {
201 break
202 }
203 if candidate == id {
204 return offset + i
205 }
206 }
207 return -1
208}
209
210func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) error {
211 i := 0
212 stopped := false
213 err := s.history.Range(func(r []renderer.Cell) bool {
214 if !yield(i, r) {
215 stopped = true
216 return false
217 }
218 i++
219 return true
220 })
221 if err != nil {
222 return err
223 }
224 if stopped {
225 return nil
226 }
227 for y := range s.screen.Height {
228 if !yield(i+y, s.screen.Row(y)) {
229 return nil
230 }
231 }
232 return nil
233}
234
235type SearchMatch struct {
236 Row, Start, End int
237 Text string
238} // End is exclusive display-cell offset.
239
240type Mode struct {
241 document *Document
242 navigator Navigator
243 selection Selection
244 ViewportTop int
245 SearchQuery string
246 Searches []SearchMatch
247 SearchIndex int
248}
249
250func NewMode(doc *Document) *Mode {
251 m := &Mode{document: doc, SearchIndex: -1}
252 if doc != nil && doc.Len() > 0 {
253 m.navigator = NewNavigator(Pos{Row: doc.Len() - 1})
254 m.navigator.Set(doc, m.navigator.Pos)
255 m.ViewportTop = max(doc.Len()-max(doc.Height(), 1), 0)
256 }
257 return m
258}
259func (m *Mode) Document() *Document {
260 if m == nil {
261 return nil
262 }
263 return m.document
264}
265func (m *Mode) Cursor() Pos {
266 if m == nil {
267 return Pos{}
268 }
269 return m.navigator.Pos
270}
271func (m *Mode) Selection() Selection {
272 if m == nil {
273 return Selection{}
274 }
275 return m.selection
276}
277func (m *Mode) SetPosition(p Pos) bool {
278 if m == nil {
279 return false
280 }
281 changed := m.navigator.Set(m.document, p)
282 m.adjustViewport()
283 return changed
284}
285func (m *Mode) move(op func(*Document) bool, stream bool) bool {
286 if m == nil || m.document == nil {
287 return false
288 }
289 if stream && m.selection.Enabled && m.selection.Granularity == Line {
290 m.selection.AsCharacter()
291 }
292 changed := op(m.document)
293 if changed {
294 if m.selection.Enabled {
295 m.selection.Extend(m.navigator.Pos)
296 }
297 m.adjustViewport()
298 }
299 return changed
300}
301func (m *Mode) Left() bool { return m.move(m.navigator.Left, true) }
302func (m *Mode) Right() bool { return m.move(m.navigator.Right, true) }
303func (m *Mode) Up() bool { return m.move(m.navigator.Up, false) }
304func (m *Mode) Down() bool { return m.move(m.navigator.Down, false) }
305func (m *Mode) WordNext() bool { return m.move(m.navigator.WordNext, true) }
306func (m *Mode) WordBackward() bool { return m.move(m.navigator.WordBackward, true) }
307func (m *Mode) WordEnd() bool { return m.move(m.navigator.WordEnd, true) }
308func (m *Mode) MoveRows(delta int) bool {
309 if delta == 0 {
310 return false
311 }
312 changed := false
313 op := m.Up
314 if delta > 0 {
315 op = m.Down
316 }
317 for range abs(delta) {
318 if !op() {
319 break
320 }
321 changed = true
322 }
323 return changed
324}
325
326// ScrollRows moves the viewport and cursor together for mouse-wheel input.
327// Unlike keyboard navigation, reversing direction scrolls immediately rather
328// than first walking the cursor to the opposite edge of the viewport.
329func (m *Mode) ScrollRows(delta int) bool {
330 if m == nil || m.document == nil || m.document.Len() == 0 || delta == 0 {
331 return false
332 }
333 d := m.document
334 oldTop := m.ViewportTop
335 maxTop := max(d.Len()-max(d.Height(), 1), 0)
336 top := oldTop + min(max(delta, -oldTop), maxTop-oldTop)
337 row := m.navigator.Pos.Row
338 row += min(max(delta, -row), d.Len()-1-row)
339 changed := m.move(func(d *Document) bool { return m.navigator.moveVertical(d, row) }, false)
340 m.ViewportTop = top
341 return changed || oldTop != top
342}
343
344func (m *Mode) Page(pages int) bool {
345 if m == nil || m.document == nil {
346 return false
347 }
348 return m.move(func(d *Document) bool { return m.navigator.Page(d, pages*max(d.Height(), 1)) }, false)
349}
350func (m *Mode) Top() bool { return m.move(m.navigator.Top, false) }
351func (m *Mode) Bottom() bool { return m.move(m.navigator.Bottom, false) }
352func (m *Mode) AtBottom() bool {
353 if m == nil || m.document == nil || m.document.Len() == 0 {
354 return m != nil && m.ViewportTop == 0
355 }
356 return m.navigator.Pos.Row == m.document.Len()-1 && m.ViewportTop == max(m.document.Len()-max(m.document.Height(), 1), 0)
357}
358func (m *Mode) ToggleLineSelection() {
359 if m.selection.Enabled {
360 m.selection = Selection{}
361 return
362 }
363 m.selection = NewLineSelection(m.navigator.Pos)
364}
365func (m *Mode) StartCharacterSelection(p Pos) bool {
366 if m == nil || m.document == nil {
367 return false
368 }
369 p, ok := m.document.Normalize(p)
370 if !ok {
371 return false
372 }
373 m.navigator.Set(m.document, p)
374 m.selection = Selection{Anchor: p, Active: p, Granularity: Character, Enabled: true}
375 m.adjustViewport()
376 return true
377}
378func (m *Mode) ExtendCharacterSelection(p Pos) bool {
379 if m == nil || !m.selection.Enabled {
380 return false
381 }
382 p, ok := m.document.Normalize(p)
383 if !ok {
384 return false
385 }
386 m.selection.AsCharacter()
387 m.selection.Extend(p)
388 m.navigator.Set(m.document, p)
389 m.adjustViewport()
390 return true
391}
392func (m *Mode) SelectWordAt(p Pos) bool {
393 if m == nil {
394 return false
395 }
396 s, ok := NewWordSelection(m.document, p)
397 if !ok {
398 return false
399 }
400 m.selection = s
401 m.navigator.Set(m.document, s.Active)
402 m.adjustViewport()
403 return true
404}
405func (m *Mode) ExtendWordSelection(p Pos) bool {
406 if m == nil || !m.selection.Enabled {
407 return false
408 }
409 _, end, ok := m.document.WordBounds(p)
410 if !ok {
411 return false
412 }
413 m.selection.Granularity = Word
414 m.selection.Extend(end)
415 m.navigator.Set(m.document, end)
416 m.adjustViewport()
417 return true
418}
419func (m *Mode) Search(query string) bool {
420 if m == nil {
421 return false
422 }
423 return m.SetSearchMatches(query, FindMatches(m.document, query), 0)
424}
425func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) bool {
426 if m == nil {
427 return false
428 }
429 m.SearchQuery = strings.TrimSpace(query)
430 if len(matches) == 0 {
431 m.Searches = nil
432 m.SearchIndex = -1
433 return false
434 }
435 m.Searches = append(m.Searches[:0], matches...)
436 m.SearchIndex = min(max(index, 0), len(matches)-1)
437 m.moveToSearchMatch(matches[m.SearchIndex])
438 return true
439}
440func (m *Mode) NextSearchMatch(delta int) bool {
441 if m == nil || len(m.Searches) == 0 || delta == 0 {
442 return false
443 }
444 m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
445 if m.SearchIndex < 0 {
446 m.SearchIndex += len(m.Searches)
447 }
448 match := m.Searches[m.SearchIndex]
449 m.moveToSearchMatch(match)
450 return true
451}
452func (m *Mode) moveToSearchMatch(match SearchMatch) {
453 if m == nil || m.document == nil {
454 return
455 }
456 pos, ok := m.document.Normalize(Pos{Row: match.Row, Col: match.Start})
457 if !ok {
458 return
459 }
460 m.navigator.Set(m.document, pos)
461 if m.selection.Enabled {
462 m.selection.Extend(m.navigator.Pos)
463 }
464 m.adjustViewport()
465}
466func (m *Mode) SelectedText() string {
467 if m == nil {
468 return ""
469 }
470 return m.selection.Text(m.document)
471}
472func (m *Mode) adjustViewport() {
473 if m == nil || m.document == nil || m.document.Len() == 0 {
474 return
475 }
476 rows := max(m.document.Height(), 1)
477 if m.navigator.Pos.Row < m.ViewportTop {
478 m.ViewportTop = m.navigator.Pos.Row
479 }
480 if m.navigator.Pos.Row >= m.ViewportTop+rows {
481 m.ViewportTop = m.navigator.Pos.Row - rows + 1
482 }
483 m.ViewportTop = min(max(m.ViewportTop, 0), max(m.document.Len()-rows, 0))
484}
485
486func FindMatches(doc *Document, query string) []SearchMatch {
487 query = strings.TrimSpace(query)
488 if doc == nil || query == "" {
489 return nil
490 }
491 return findMatches(doc, query, doc.Snapshot().rangeRows)
492}
493
494func findMatches(doc *Document, query string, rangeRows func(func(int, []renderer.Cell) bool) error) []SearchMatch {
495 needle := lowerRunes(query)
496 matches := []SearchMatch{}
497 err := rangeRows(func(row int, cells []renderer.Cell) bool {
498 hay, indexes := searchableCells(cells)
499 text := ""
500 for start := 0; start+len(needle) <= len(hay); {
501 if !slices.Equal(hay[start:start+len(needle)], needle) {
502 start++
503 continue
504 }
505 if text == "" {
506 text = doc.LineText(row)
507 }
508 end := start + len(needle)
509 cellEnd := len(cells)
510 if end < len(indexes) {
511 cellEnd = indexes[end]
512 }
513 matches = append(matches, SearchMatch{Row: row, Start: indexes[start], End: cellEnd, Text: text})
514 start += len(needle)
515 }
516 return true
517 })
518 if err != nil {
519 // Discard partial results without taking down the daemon on corrupt history.
520 return nil
521 }
522 return matches
523}
524func searchableCells(cells []renderer.Cell) ([]rune, []int) {
525 rs := make([]rune, 0, len(cells))
526 is := make([]int, 0, len(cells))
527 for x, c := range cells {
528 if c.Continuation {
529 continue
530 }
531 r := c.Rune
532 if r == 0 {
533 r = ' '
534 }
535 rs = append(rs, unicode.ToLower(r))
536 is = append(is, x)
537 }
538 return rs, is
539}
540func lowerRunes(s string) []rune {
541 rs := []rune(s)
542 for i, r := range rs {
543 rs[i] = unicode.ToLower(r)
544 }
545 return rs
546}
547
548func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
549 if m == nil || m.document == nil {
550 return renderer.NewFrame(0, 0)
551 }
552 frame := renderer.NewFrame(m.document.Width(), m.document.Height()+1)
553 m.RenderRows(func(y int, row []renderer.Cell) { frame.WriteRow(y, 0, row) }, styles...)
554 return frame
555}
556
557// RenderRows paints viewport rows followed by the status row at Document.Height().
558// The callback borrows a reusable semantic row only until it returns. Rendering
559// directly into a compositor avoids building and then decoding a compact frame.
560func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) {
561 if m == nil || m.document == nil {
562 return
563 }
564 m.RenderRowsRange(0, m.document.Height(), paint, styles...)
565}
566
567// RenderRowsRange paints viewport rows in [start, end), then the status row.
568// Row coordinates remain viewport-relative and the callback borrows each row.
569func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), styles ...renderer.Style) {
570 if m == nil || m.document == nil {
571 return
572 }
573 d := m.document
574 selectionBounds, hasSelectionBounds := m.selection.bounds(d)
575 selection, hasSelection := optionalStyle(styles, 1)
576 cursor, cursorValid := d.Normalize(m.navigator.Pos)
577 row := make([]renderer.Cell, d.Width())
578 for y := max(start, 0); y < min(end, d.Height()); y++ {
579 src := m.ViewportTop + y
580 for x := range row {
581 row[x] = renderer.BlankCell()
582 }
583 if src >= d.Len() {
584 paint(y, row)
585 continue
586 }
587 d.snapshot.CopyRow(src, row)
588 if match, ok := m.currentSearchMatchForRow(src); ok {
589 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
590 applySelectionStyle(&row[x].Style, selection, hasSelection)
591 }
592 }
593 cursorCovered := false
594 if hasSelectionBounds {
595 if r, ok := selectionBounds.rangeForRow(d, src); ok {
596 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
597 applySelectionStyle(&row[x].Style, selection, hasSelection)
598 }
599 if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
600 cursorCovered = true
601 }
602 }
603 }
604 if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
605 cursorCol := min(cursor.Col, len(row)-1)
606 applySelectionStyle(&row[cursorCol].Style, selection, hasSelection)
607 }
608 paint(y, row)
609 }
610 status := inverseStyle()
611 if len(styles) > 0 {
612 status = styles[0]
613 }
614 drawCopyStatus(row, m, d.Len(), status)
615 paint(d.Height(), row)
616}
617func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
618 if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
619 return SearchMatch{}, false
620 }
621 v := m.Searches[m.SearchIndex]
622 return v, v.Row == row
623}
624func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
625 if idx >= len(styles) {
626 return renderer.Style{}, false
627 }
628 return styles[idx], true
629}
630func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
631 if !ok || style.Equal(inverseStyle()) {
632 dst.Inverse = true
633 return
634 }
635 *dst = style
636}
637func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
638func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
639 for i := range row {
640 row[i] = renderer.Cell{Rune: ' ', Style: style}
641 }
642 text := " [SCROLL] "
643 if m.selection.Enabled {
644 text = " [SELECT] "
645 }
646 if total > 0 {
647 text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " "
648 } else {
649 text += "0/0 "
650 }
651 if m.SearchQuery != "" {
652 if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
653 text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "
654 }
655 text += "/" + m.SearchQuery + " "
656 }
657 for i, r := range text {
658 if i >= len(row) {
659 break
660 }
661 row[i] = renderer.Cell{Rune: r, Style: style}
662 }
663}
664
665func OSC52(text string) [][]byte {
666 if len([]byte(text)) > OSC52MaxPayloadBytes {
667 return nil
668 }
669 return [][]byte{[]byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x07")}
670}
671func OSC52FromBase64(b64 string) []byte {
672 decoded, err := base64.StdEncoding.DecodeString(b64)
673 if err != nil || len(decoded) > OSC52MaxPayloadBytes {
674 return nil
675 }
676 return []byte("\x1b]52;c;" + b64 + "\x07")
677}
678func strconvItoa(n int) string {
679 if n == 0 {
680 return "0"
681 }
682 var b [20]byte
683 i := len(b)
684 for n > 0 {
685 i--
686 b[i] = byte('0' + n%10)
687 n /= 10
688 }
689 return string(b[i:])
690}
691func abs(n int) int {
692 if n < 0 {
693 return -n
694 }
695 return n
696}