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
212// textRows streams every row as text: one rune per non-continuation cell
213// (zero runes read as spaces) plus each rune's cell column and the row width.
214// The slices are only valid during the callback.
215type textRows func(yield func(row int, runes []rune, columns []int, width int) bool) error
216
217func (s Snapshot) rangeText(yield func(int, []rune, []int, int) bool) error {
218 stopped := false
219 err := s.history.RangeText(func(i int, runes []rune, columns []int, width int) bool {
220 if !yield(i, runes, columns, width) {
221 stopped = true
222 return false
223 }
224 return true
225 })
226 if err != nil || stopped {
227 return err
228 }
229 offset := s.history.Len()
230 var runes []rune
231 var columns []int
232 for y := range s.screen.Height {
233 runes, columns = s.screen.AppendRowText(runes[:0], columns[:0], y)
234 if !yield(offset+y, runes, columns, s.screen.Width) {
235 return nil
236 }
237 }
238 return nil
239}
240
241type SearchMatch struct {
242 Row, Start, End int
243 Text string
244} // End is exclusive display-cell offset.
245
246type Mode struct {
247 document *Document
248 navigator Navigator
249 selection Selection
250 ViewportTop int
251 SearchQuery string
252 Searches []SearchMatch
253 SearchIndex int
254}
255
256// rowPool recycles the semantic rows lent to RenderRowsRange callbacks.
257// Renderers snapshot Mode by value, so the buffer cannot live on Mode.
258var rowPool sync.Pool
259
260func NewMode(doc *Document) *Mode {
261 m := &Mode{document: doc, SearchIndex: -1}
262 if doc != nil && doc.Len() > 0 {
263 m.navigator = NewNavigator(Pos{Row: doc.Len() - 1})
264 m.navigator.Set(doc, m.navigator.Pos)
265 m.ViewportTop = max(doc.Len()-max(doc.Height(), 1), 0)
266 }
267 return m
268}
269func (m *Mode) Document() *Document {
270 if m == nil {
271 return nil
272 }
273 return m.document
274}
275func (m *Mode) Cursor() Pos {
276 if m == nil {
277 return Pos{}
278 }
279 return m.navigator.Pos
280}
281func (m *Mode) Selection() Selection {
282 if m == nil {
283 return Selection{}
284 }
285 return m.selection
286}
287func (m *Mode) SetPosition(p Pos) bool {
288 if m == nil {
289 return false
290 }
291 changed := m.navigator.Set(m.document, p)
292 m.adjustViewport()
293 return changed
294}
295func (m *Mode) move(op func(*Document) bool, stream bool) bool {
296 if m == nil || m.document == nil {
297 return false
298 }
299 if stream && m.selection.Enabled && m.selection.Granularity == Line {
300 m.selection.AsCharacter()
301 }
302 changed := op(m.document)
303 if changed {
304 if m.selection.Enabled {
305 m.selection.Extend(m.navigator.Pos)
306 }
307 m.adjustViewport()
308 }
309 return changed
310}
311func (m *Mode) Left() bool { return m.move(m.navigator.Left, true) }
312func (m *Mode) Right() bool { return m.move(m.navigator.Right, true) }
313func (m *Mode) Up() bool { return m.move(m.navigator.Up, false) }
314func (m *Mode) Down() bool { return m.move(m.navigator.Down, false) }
315func (m *Mode) WordNext() bool { return m.move(m.navigator.WordNext, true) }
316func (m *Mode) WordBackward() bool { return m.move(m.navigator.WordBackward, true) }
317func (m *Mode) WordEnd() bool { return m.move(m.navigator.WordEnd, true) }
318func (m *Mode) MoveRows(delta int) bool {
319 if delta == 0 {
320 return false
321 }
322 changed := false
323 op := m.Up
324 if delta > 0 {
325 op = m.Down
326 }
327 for range abs(delta) {
328 if !op() {
329 break
330 }
331 changed = true
332 }
333 return changed
334}
335
336// ScrollRows moves the viewport and cursor together for mouse-wheel input.
337// Unlike keyboard navigation, reversing direction scrolls immediately rather
338// than first walking the cursor to the opposite edge of the viewport.
339func (m *Mode) ScrollRows(delta int) bool {
340 if m == nil || m.document == nil || m.document.Len() == 0 || delta == 0 {
341 return false
342 }
343 d := m.document
344 oldTop := m.ViewportTop
345 maxTop := max(d.Len()-max(d.Height(), 1), 0)
346 top := oldTop + min(max(delta, -oldTop), maxTop-oldTop)
347 row := m.navigator.Pos.Row
348 row += min(max(delta, -row), d.Len()-1-row)
349 changed := m.move(func(d *Document) bool { return m.navigator.moveVertical(d, row) }, false)
350 m.ViewportTop = top
351 return changed || oldTop != top
352}
353
354func (m *Mode) Page(pages int) bool {
355 if m == nil || m.document == nil {
356 return false
357 }
358 return m.move(func(d *Document) bool { return m.navigator.Page(d, pages*max(d.Height(), 1)) }, false)
359}
360func (m *Mode) Top() bool { return m.move(m.navigator.Top, false) }
361func (m *Mode) Bottom() bool { return m.move(m.navigator.Bottom, false) }
362func (m *Mode) AtBottom() bool {
363 if m == nil || m.document == nil || m.document.Len() == 0 {
364 return m != nil && m.ViewportTop == 0
365 }
366 return m.navigator.Pos.Row == m.document.Len()-1 && m.ViewportTop == max(m.document.Len()-max(m.document.Height(), 1), 0)
367}
368func (m *Mode) ToggleLineSelection() {
369 if m.selection.Enabled {
370 m.selection = Selection{}
371 return
372 }
373 m.selection = NewLineSelection(m.navigator.Pos)
374}
375func (m *Mode) StartCharacterSelection(p Pos) bool {
376 if m == nil || m.document == nil {
377 return false
378 }
379 p, ok := m.document.Normalize(p)
380 if !ok {
381 return false
382 }
383 m.navigator.Set(m.document, p)
384 m.selection = Selection{Anchor: p, Active: p, Granularity: Character, Enabled: true}
385 m.adjustViewport()
386 return true
387}
388func (m *Mode) ExtendCharacterSelection(p Pos) bool {
389 if m == nil || !m.selection.Enabled {
390 return false
391 }
392 p, ok := m.document.Normalize(p)
393 if !ok {
394 return false
395 }
396 m.selection.AsCharacter()
397 m.selection.Extend(p)
398 m.navigator.Set(m.document, p)
399 m.adjustViewport()
400 return true
401}
402func (m *Mode) SelectWordAt(p Pos) bool {
403 if m == nil {
404 return false
405 }
406 s, ok := NewWordSelection(m.document, p)
407 if !ok {
408 return false
409 }
410 m.selection = s
411 m.navigator.Set(m.document, s.Active)
412 m.adjustViewport()
413 return true
414}
415func (m *Mode) ExtendWordSelection(p Pos) bool {
416 if m == nil || !m.selection.Enabled {
417 return false
418 }
419 _, end, ok := m.document.WordBounds(p)
420 if !ok {
421 return false
422 }
423 m.selection.Granularity = Word
424 m.selection.Extend(end)
425 m.navigator.Set(m.document, end)
426 m.adjustViewport()
427 return true
428}
429func (m *Mode) Search(query string) bool {
430 if m == nil {
431 return false
432 }
433 return m.SetSearchMatches(query, FindMatches(m.document, query), 0)
434}
435func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) bool {
436 if m == nil {
437 return false
438 }
439 m.SearchQuery = strings.TrimSpace(query)
440 if len(matches) == 0 {
441 m.Searches = nil
442 m.SearchIndex = -1
443 return false
444 }
445 m.Searches = append(m.Searches[:0], matches...)
446 m.SearchIndex = min(max(index, 0), len(matches)-1)
447 m.moveToSearchMatch(matches[m.SearchIndex])
448 return true
449}
450func (m *Mode) NextSearchMatch(delta int) bool {
451 if m == nil || len(m.Searches) == 0 || delta == 0 {
452 return false
453 }
454 m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
455 if m.SearchIndex < 0 {
456 m.SearchIndex += len(m.Searches)
457 }
458 match := m.Searches[m.SearchIndex]
459 m.moveToSearchMatch(match)
460 return true
461}
462func (m *Mode) moveToSearchMatch(match SearchMatch) {
463 if m == nil || m.document == nil {
464 return
465 }
466 pos, ok := m.document.Normalize(Pos{Row: match.Row, Col: match.Start})
467 if !ok {
468 return
469 }
470 m.navigator.Set(m.document, pos)
471 if m.selection.Enabled {
472 m.selection.Extend(m.navigator.Pos)
473 }
474 m.adjustViewport()
475}
476func (m *Mode) SelectedText() string {
477 if m == nil {
478 return ""
479 }
480 return m.selection.Text(m.document)
481}
482func (m *Mode) adjustViewport() {
483 if m == nil || m.document == nil || m.document.Len() == 0 {
484 return
485 }
486 rows := max(m.document.Height(), 1)
487 if m.navigator.Pos.Row < m.ViewportTop {
488 m.ViewportTop = m.navigator.Pos.Row
489 }
490 if m.navigator.Pos.Row >= m.ViewportTop+rows {
491 m.ViewportTop = m.navigator.Pos.Row - rows + 1
492 }
493 m.ViewportTop = min(max(m.ViewportTop, 0), max(m.document.Len()-rows, 0))
494}
495
496func FindMatches(doc *Document, query string) []SearchMatch {
497 query = strings.TrimSpace(query)
498 if doc == nil || query == "" {
499 return nil
500 }
501 return findMatches(query, doc.Snapshot().rangeText)
502}
503
504func findMatches(query string, rows textRows) []SearchMatch {
505 needle := lowerRunes(query)
506 matches := []SearchMatch{}
507 var hay []rune
508 err := rows(func(row int, runes []rune, columns []int, width int) bool {
509 hay = hay[:0]
510 for _, r := range runes {
511 hay = append(hay, unicode.ToLower(r))
512 }
513 text := ""
514 for start := 0; start+len(needle) <= len(hay); {
515 if !slices.Equal(hay[start:start+len(needle)], needle) {
516 start++
517 continue
518 }
519 if text == "" {
520 // Same text as Document.LineText, built from the runes in hand.
521 text = strings.TrimRight(string(runes), " ")
522 }
523 end := start + len(needle)
524 cellEnd := width
525 if end < len(columns) {
526 cellEnd = columns[end]
527 }
528 matches = append(matches, SearchMatch{Row: row, Start: columns[start], End: cellEnd, Text: text})
529 start += len(needle)
530 }
531 return true
532 })
533 if err != nil {
534 // Discard partial results without taking down the daemon on corrupt history.
535 return nil
536 }
537 return matches
538}
539func lowerRunes(s string) []rune {
540 rs := []rune(s)
541 for i, r := range rs {
542 rs[i] = unicode.ToLower(r)
543 }
544 return rs
545}
546
547func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
548 if m == nil || m.document == nil {
549 return renderer.NewFrame(0, 0)
550 }
551 frame := renderer.NewFrame(m.document.Width(), m.document.Height()+1)
552 m.RenderRows(func(y int, row []renderer.Cell) { frame.WriteRow(y, 0, row) }, styles...)
553 return frame
554}
555
556// RenderRows paints viewport rows followed by the status row at Document.Height().
557// The callback borrows a reusable semantic row only until it returns. Rendering
558// directly into a compositor avoids building and then decoding a compact frame.
559func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) {
560 if m == nil || m.document == nil {
561 return
562 }
563 m.RenderRowsRange(0, m.document.Height(), paint, styles...)
564}
565
566// RenderRowsRange paints viewport rows in [start, end), then the status row.
567// Row coordinates remain viewport-relative and the callback borrows each row.
568func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), styles ...renderer.Style) {
569 if m == nil || m.document == nil {
570 return
571 }
572 d := m.document
573 selectionBounds, hasSelectionBounds := m.selection.bounds(d)
574 selection, hasSelection := optionalStyle(styles, 1)
575 cursor, cursorValid := d.Normalize(m.navigator.Pos)
576 buf, _ := rowPool.Get().(*[]renderer.Cell)
577 if buf == nil || cap(*buf) < d.Width() {
578 row := make([]renderer.Cell, d.Width())
579 buf = &row
580 }
581 defer rowPool.Put(buf)
582 row := (*buf)[:d.Width()]
583 for y := max(start, 0); y < min(end, d.Height()); y++ {
584 src := m.ViewportTop + y
585 for x := range row {
586 row[x] = renderer.BlankCell()
587 }
588 if src >= d.Len() {
589 paint(y, row)
590 continue
591 }
592 n := d.snapshot.CopyRow(src, row)
593 if match, ok := m.currentSearchMatchForRow(src); ok {
594 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
595 applySelectionStyle(&row[x].Style, selection, hasSelection)
596 }
597 }
598 cursorCovered := false
599 if hasSelectionBounds {
600 bound := d.snapshot.Bound(src)
601 width := d.RowWidth(src)
602 cells, rangeCells := row[:n], row
603 if n < width {
604 cells = d.Row(src)
605 rangeCells = cells
606 }
607 limit := contentEndOf(cells, bound)
608 r, ok := selectionBounds.rangeForCells(src, rangeCells, limit)
609 if ok {
610 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
611 toggleInverse(&row[x].Style)
612 }
613 if col, ok := selectionBounds.newlineMarkerAt(src, limit, width, bound.Soft); ok && col < len(row) {
614 toggleInverse(&row[col].Style)
615 if cursorValid && cursor.Row == src && cursor.Col == col {
616 cursorCovered = true
617 }
618 }
619 if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
620 cursorCovered = true
621 }
622 }
623 }
624 if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
625 cursorCol := min(cursor.Col, len(row)-1)
626 applySelectionStyle(&row[cursorCol].Style, selection, hasSelection)
627 }
628 paint(y, row)
629 }
630 status := inverseStyle()
631 if len(styles) > 0 {
632 status = styles[0]
633 }
634 drawCopyStatus(row, m, d.Len(), status)
635 paint(d.Height(), row)
636}
637func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
638 if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
639 return SearchMatch{}, false
640 }
641 v := m.Searches[m.SearchIndex]
642 return v, v.Row == row
643}
644func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
645 if idx >= len(styles) {
646 return renderer.Style{}, false
647 }
648 return styles[idx], true
649}
650func toggleInverse(dst *renderer.Style) { dst.Inverse = !dst.Inverse }
651
652func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
653 if !ok || style.Equal(inverseStyle()) {
654 dst.Inverse = true
655 return
656 }
657 *dst = style
658}
659func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
660func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
661 for i := range row {
662 row[i] = renderer.Cell{Rune: ' ', Style: style}
663 }
664 col := 0
665 put := func(text string) {
666 for _, r := range text {
667 if col >= len(row) {
668 return
669 }
670 row[col] = renderer.Cell{Rune: r, Style: style}
671 col++
672 }
673 }
674 var digits [20]byte
675 putInt := func(n int) {
676 for _, b := range strconv.AppendInt(digits[:0], int64(n), 10) {
677 if col >= len(row) {
678 return
679 }
680 row[col] = renderer.Cell{Rune: rune(b), Style: style}
681 col++
682 }
683 }
684 if m.selection.Enabled {
685 put(" [SELECT] ")
686 } else {
687 put(" [SCROLL] ")
688 }
689 if total > 0 {
690 putInt(m.navigator.Pos.Row + 1)
691 put("/")
692 putInt(total)
693 put(" ")
694 } else {
695 put("0/0 ")
696 }
697 if m.SearchQuery != "" {
698 if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
699 putInt(m.SearchIndex + 1)
700 put("/")
701 putInt(len(m.Searches))
702 put(" ")
703 }
704 put("/")
705 put(m.SearchQuery)
706 put(" ")
707 }
708}
709
710func OSC52(text string) [][]byte {
711 if len([]byte(text)) > OSC52MaxPayloadBytes {
712 return nil
713 }
714 return [][]byte{[]byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x07")}
715}
716func OSC52FromBase64(b64 string) []byte {
717 decoded, err := base64.StdEncoding.DecodeString(b64)
718 if err != nil || len(decoded) > OSC52MaxPayloadBytes {
719 return nil
720 }
721 return []byte("\x1b]52;c;" + b64 + "\x07")
722}
723func abs(n int) int {
724 if n < 0 {
725 return -n
726 }
727 return n
728}