1package copy
2
3import (
4 "strings"
5 "unicode"
6
7 vt "github.com/bnema/vev-vt"
8 renderer "github.com/bnema/vev-vt/ansi"
9)
10
11// Pos identifies a terminal display cell in a document. Valid positions are
12// always glyph heads; Normalize converts continuation cells to their head.
13type Pos struct {
14 Row int
15 Col int
16}
17
18// CellRange is an inclusive display-cell range on one physical document row.
19type CellRange struct {
20 Row int
21 Start int
22 End int
23}
24
25// Document provides immutable display-cell and text operations over a Snapshot.
26type Document struct {
27 snapshot Snapshot
28 wordSeparators map[rune]struct{}
29}
30
31func NewDocument(snapshot Snapshot, separators string) *Document {
32 set := make(map[rune]struct{}, len([]rune(separators)))
33 for _, r := range separators {
34 set[r] = struct{}{}
35 }
36 return &Document{snapshot: snapshot, wordSeparators: set}
37}
38
39func (d *Document) Snapshot() Snapshot { return d.snapshot }
40func (d *Document) Len() int { return d.snapshot.Len() }
41func (d *Document) Width() int { return d.snapshot.Width }
42func (d *Document) Height() int { return d.snapshot.Height }
43func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
44func (d *Document) RowWidth(row int) int { return d.snapshot.RowWidth(row) }
45func (d *Document) Cell(x, y int) renderer.Cell { return d.snapshot.Cell(x, y) }
46func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) }
47func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) }
48
49// Normalize returns pos as a valid glyph head. Empty physical rows have the
50// stable logical position {row, 0}, which is useful to vertical navigation.
51func (d *Document) Normalize(pos Pos) (Pos, bool) {
52 if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 {
53 return Pos{}, false
54 }
55 width := d.RowWidth(pos.Row)
56 if width == 0 {
57 if pos.Col == 0 {
58 return pos, true
59 }
60 return Pos{}, false
61 }
62 if pos.Col >= width {
63 return Pos{}, false
64 }
65 for pos.Col > 0 && d.Cell(pos.Col, pos.Row).Continuation {
66 pos.Col--
67 }
68 if d.Cell(pos.Col, pos.Row).Continuation {
69 return Pos{}, false
70 }
71 return pos, true
72}
73
74// PrevGlyph returns the previous glyph head on pos's physical row.
75func (d *Document) PrevGlyph(pos Pos) (Pos, bool) {
76 pos, ok := d.Normalize(pos)
77 if !ok || d.RowWidth(pos.Row) == 0 {
78 return Pos{}, false
79 }
80 for col := pos.Col - 1; col >= 0; col-- {
81 if !d.Cell(col, pos.Row).Continuation {
82 return Pos{Row: pos.Row, Col: col}, true
83 }
84 }
85 return Pos{}, false
86}
87
88// NextGlyph returns the next glyph head on pos's physical row.
89func (d *Document) NextGlyph(pos Pos) (Pos, bool) {
90 pos, ok := d.Normalize(pos)
91 if !ok {
92 return Pos{}, false
93 }
94 width := d.RowWidth(pos.Row)
95 for col := pos.Col + 1; col < width; col++ {
96 if !d.Cell(col, pos.Row).Continuation {
97 return Pos{Row: pos.Row, Col: col}, true
98 }
99 }
100 return Pos{}, false
101}
102
103// WordBounds returns the inclusive bounds of the word containing pos. Unicode
104// whitespace and configured separators do not belong to words.
105func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) {
106 pos, ok := d.Normalize(pos)
107 if !ok || !d.isGlyph(pos) || d.isSeparator(d.runeAt(pos)) {
108 return Pos{}, Pos{}, false
109 }
110 start, end := pos, pos
111 for {
112 prev, ok := d.PrevGlyph(start)
113 if !ok || d.isSeparator(d.runeAt(prev)) {
114 break
115 }
116 start = prev
117 }
118 for {
119 next, ok := d.NextGlyph(end)
120 if !ok || d.isSeparator(d.runeAt(next)) {
121 break
122 }
123 end = next
124 }
125 return start, end, true
126}
127
128// NextWordStart returns the beginning of the next word after pos, crossing
129// physical rows as necessary. It walks only the rows between pos and the
130// target instead of materializing every glyph in the document.
131func (d *Document) NextWordStart(pos Pos) (Pos, bool) {
132 pos, ok := d.Normalize(pos)
133 if !ok {
134 return Pos{}, false
135 }
136
137 candidate := pos
138 if d.isGlyph(candidate) && !d.isSeparator(d.runeAt(candidate)) {
139 for {
140 next, ok := d.nextDocumentGlyph(candidate)
141 if !ok {
142 return Pos{}, false
143 }
144 if !d.sameWord(next, candidate) {
145 candidate = next
146 break
147 }
148 candidate = next
149 }
150 } else {
151 candidate, ok = d.nextDocumentGlyph(candidate)
152 if !ok {
153 return Pos{}, false
154 }
155 }
156 for d.isSeparator(d.runeAt(candidate)) {
157 candidate, ok = d.nextDocumentGlyph(candidate)
158 if !ok {
159 return Pos{}, false
160 }
161 }
162 return candidate, true
163}
164
165// PreviousWordStart returns the beginning of the current word, or of the
166// preceding word when pos is already at a word beginning.
167func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) {
168 pos, ok := d.Normalize(pos)
169 if !ok {
170 return Pos{}, false
171 }
172
173 if d.isGlyph(pos) && !d.isSeparator(d.runeAt(pos)) {
174 previous, ok := d.previousDocumentGlyph(pos)
175 if ok && d.sameWord(previous, pos) {
176 for d.sameWord(previous, pos) {
177 pos = previous
178 previous, ok = d.previousDocumentGlyph(pos)
179 if !ok {
180 break
181 }
182 }
183 return pos, true
184 }
185 }
186
187 candidate, ok := d.previousDocumentGlyph(pos)
188 if !ok {
189 return Pos{}, false
190 }
191 for d.isSeparator(d.runeAt(candidate)) {
192 candidate, ok = d.previousDocumentGlyph(candidate)
193 if !ok {
194 return Pos{}, false
195 }
196 }
197 for {
198 previous, ok := d.previousDocumentGlyph(candidate)
199 if !ok || !d.sameWord(previous, candidate) {
200 return candidate, true
201 }
202 candidate = previous
203 }
204}
205
206// NextWordEnd returns the inclusive end of the current word, or of the next
207// word when pos is on a separator or at the end of a word.
208func (d *Document) NextWordEnd(pos Pos) (Pos, bool) {
209 pos, ok := d.Normalize(pos)
210 if !ok || !d.isGlyph(pos) {
211 return Pos{}, false
212 }
213
214 candidate := pos
215 if !d.isSeparator(d.runeAt(candidate)) {
216 next, ok := d.nextDocumentGlyph(candidate)
217 if !ok {
218 return Pos{}, false
219 }
220 if !d.sameWord(next, candidate) {
221 candidate = next
222 }
223 }
224 for d.isSeparator(d.runeAt(candidate)) {
225 candidate, ok = d.nextDocumentGlyph(candidate)
226 if !ok {
227 return Pos{}, false
228 }
229 }
230 for {
231 next, ok := d.nextDocumentGlyph(candidate)
232 if !ok || !d.sameWord(next, candidate) {
233 return candidate, true
234 }
235 candidate = next
236 }
237}
238
239// LineText returns row text without duplicated continuation cells and retains
240// the established copy-mode behavior of trimming trailing blank spaces.
241func (d *Document) LineText(row int) string {
242 return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
243}
244
245// Extract renders inclusive ranges as copyable text. Every emitted line drops
246// the grid padding to its right, and rows the VT marked as soft-wrapped are
247// joined only when the next emitted segment is the next physical row.
248func (d *Document) Extract(ranges []CellRange) string {
249 if len(ranges) == 0 {
250 return ""
251 }
252
253 // Filter invalid ranges before deciding which segment is final or adjacent.
254 // rangeText with logicalEnd=true performs the same normalization and bounds
255 // checks while deliberately trimming, but this validation text is discarded.
256 emitted := make([]CellRange, 0, len(ranges))
257 for _, r := range ranges {
258 if _, _, ok := d.rangeText(r, true); ok {
259 emitted = append(emitted, r)
260 }
261 }
262
263 var out strings.Builder
264 for i, r := range emitted {
265 last := i == len(emitted)-1
266 adjacent := !last && emitted[i+1].Row == r.Row+1
267 text, soft, _ := d.rangeText(r, last || !adjacent)
268 out.WriteString(text)
269 if !last && !soft {
270 out.WriteByte('\n')
271 }
272 }
273 return out.String()
274}
275
276// rangeText renders one physical row of a selection and reports whether that
277// row continues into the next emitted physical row. A row that continues is cut
278// at its logical extent, keeping trailing spaces the application really printed;
279// any logical end is cut after its last content cell, dropping grid padding.
280func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) {
281 if r.Row < 0 || r.Row >= d.Len() {
282 return "", false, false
283 }
284 row := d.Row(r.Row)
285 if len(row) == 0 {
286 return "", false, r.Start == 0 && r.End == 0
287 }
288 start, end := min(r.Start, r.End), max(r.Start, r.End)
289 if end < 0 || start >= len(row) {
290 return "", false, false
291 }
292 start = max(start, 0)
293 end = min(end, len(row)-1)
294
295 head, ok := d.Normalize(Pos{Row: r.Row, Col: start})
296 if !ok {
297 return "", false, false
298 }
299 start = head.Col
300
301 bound := d.snapshot.Bound(r.Row)
302 soft := bound.Soft && !logicalEnd && end >= len(row)-1
303 if soft {
304 end = min(end, min(max(bound.End, 0), len(row))-1)
305 } else {
306 end = lastContentCol(row, start, end)
307 }
308 if end < start {
309 return "", soft, true
310 }
311
312 tail, ok := d.Normalize(Pos{Row: r.Row, Col: end})
313 if !ok {
314 return "", soft, true
315 }
316 return d.cellsText(row, start, tail.Col), soft, true
317}
318
319// lastContentCol returns the rightmost column in [start, end] holding content,
320// or start-1 when the span is blank. Continuation cells belong to the glyph on
321// their left, so they never count on their own.
322func lastContentCol(row []renderer.Cell, start, end int) int {
323 for col := end; col >= start; col-- {
324 cell := row[col]
325 if cell.Continuation || cell.Rune == 0 || cell.Rune == ' ' {
326 continue
327 }
328 return col
329 }
330 return start - 1
331}
332
333func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
334 if start < 0 || end < start || start >= len(row) {
335 return ""
336 }
337 end = min(end, len(row)-1)
338 var text strings.Builder
339 for col := start; col <= end; col++ {
340 cell := row[col]
341 if cell.Continuation {
342 continue
343 }
344 if cell.Rune == 0 {
345 text.WriteByte(' ')
346 continue
347 }
348 text.WriteRune(cell.Rune)
349 }
350 return text.String()
351}
352
353func (d *Document) isSeparator(r rune) bool {
354 if unicode.IsSpace(r) {
355 return true
356 }
357 _, ok := d.wordSeparators[r]
358 return ok
359}
360
361func (d *Document) isGlyph(pos Pos) bool {
362 return pos.Col >= 0 && pos.Col < d.RowWidth(pos.Row) && !d.Cell(pos.Col, pos.Row).Continuation
363}
364
365func (d *Document) runeAt(pos Pos) rune {
366 cell := d.Cell(pos.Col, pos.Row)
367 if cell.Rune == 0 {
368 return ' '
369 }
370 return cell.Rune
371}
372
373// nextDocumentGlyph finds the next glyph in document order without building
374// an index for unrelated scrollback rows.
375func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
376 for row := pos.Row; row < d.Len(); row++ {
377 width := d.RowWidth(row)
378 start := 0
379 if row == pos.Row {
380 start = pos.Col + 1
381 }
382 for col := start; col < width; col++ {
383 if !d.Cell(col, row).Continuation {
384 return Pos{Row: row, Col: col}, true
385 }
386 }
387 }
388 return Pos{}, false
389}
390
391// previousDocumentGlyph finds the previous glyph in document order without
392// building an index for unrelated scrollback rows.
393func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {
394 for row := pos.Row; row >= 0; row-- {
395 start := d.RowWidth(row) - 1
396 if row == pos.Row {
397 start = min(pos.Col-1, start)
398 }
399 for col := start; col >= 0; col-- {
400 if !d.Cell(col, row).Continuation {
401 return Pos{Row: row, Col: col}, true
402 }
403 }
404 }
405 return Pos{}, false
406}
407
408func (d *Document) sameWord(left, right Pos) bool {
409 return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
410}