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}