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