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