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 return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
245}
246
247// Extract renders inclusive ranges as copyable text. Every emitted line drops
248// the grid padding to its right, and rows the VT marked as soft-wrapped are
249// joined only when the next emitted segment is the next physical row.
250func (d *Document) Extract(ranges []CellRange) string {
251 if len(ranges) == 0 {
252 return ""
253 }
254
255 // Filter invalid ranges before deciding which segment is final or adjacent.
256 // rangeText with logicalEnd=true performs the same normalization and bounds
257 // checks while deliberately trimming, but this validation text is discarded.
258 emitted := make([]CellRange, 0, len(ranges))
259 for _, r := range ranges {
260 if _, _, ok := d.rangeText(r, true); ok {
261 emitted = append(emitted, r)
262 }
263 }
264
265 var out strings.Builder
266 for i, r := range emitted {
267 last := i == len(emitted)-1
268 adjacent := !last && emitted[i+1].Row == r.Row+1
269 text, soft, _ := d.rangeText(r, last || !adjacent)
270 out.WriteString(text)
271 if !last && !soft {
272 out.WriteByte('\n')
273 }
274 }
275 return out.String()
276}
277
278// rangeText renders one physical row of a selection and reports whether that
279// row continues into the next emitted physical row. A row that continues is cut
280// at its logical extent, keeping trailing spaces the application really printed;
281// any logical end is cut after its last content cell, dropping grid padding.
282func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) {
283 if r.Row < 0 || r.Row >= d.Len() {
284 return "", false, false
285 }
286 row := d.Row(r.Row)
287 if len(row) == 0 {
288 return "", false, r.Start == 0 && r.End == 0
289 }
290 if r.End == r.Start-1 && r.Start >= 0 && r.Start <= len(row) {
291 bound := d.snapshot.Bound(r.Row)
292 return "", bound.Soft && !logicalEnd, true
293 }
294 start, end := min(r.Start, r.End), max(r.Start, r.End)
295 if end < 0 || start >= len(row) {
296 return "", false, false
297 }
298 start = max(start, 0)
299 end = min(end, len(row)-1)
300
301 head, ok := d.Normalize(Pos{Row: r.Row, Col: start})
302 if !ok {
303 return "", false, false
304 }
305 start = head.Col
306
307 bound := d.snapshot.Bound(r.Row)
308 limit := contentEndOf(row, bound)
309 soft := bound.Soft && !logicalEnd && end >= limit-1
310 if soft {
311 end = min(end, limit-1)
312 } else {
313 end = lastContentCol(row, start, end)
314 }
315 if end < start {
316 return "", soft, true
317 }
318
319 tail, ok := d.Normalize(Pos{Row: r.Row, Col: end})
320 if !ok {
321 return "", soft, true
322 }
323 return d.cellsText(row, start, tail.Col), soft, true
324}
325
326// contentEnd returns the exclusive display-cell end of row's copyable content.
327// Soft rows end at their logical bound; hard rows drop blank padding.
328func (d *Document) contentEnd(row int) int {
329 return contentEndOf(d.Row(row), d.snapshot.Bound(row))
330}
331
332func contentEndOf(cells []renderer.Cell, bound vt.LineBound) int {
333 if len(cells) == 0 {
334 return 0
335 }
336 if bound.Soft {
337 return min(max(bound.End, 0), len(cells))
338 }
339 last := lastContentCol(cells, 0, len(cells)-1)
340 if last < 0 {
341 return 0
342 }
343 return glyphEnd(cells, last) + 1
344}
345
346// lastContentCol returns the rightmost column in [start, end] holding content,
347// or start-1 when the span is blank. Continuation cells belong to the glyph on
348// their left, so they never count on their own.
349func lastContentCol(row []renderer.Cell, start, end int) int {
350 for col := end; col >= start; col-- {
351 cell := row[col]
352 if cell.Continuation || cell.Rune == 0 || cell.Rune == ' ' {
353 continue
354 }
355 return col
356 }
357 return start - 1
358}
359
360func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
361 if start < 0 || end < start || start >= len(row) {
362 return ""
363 }
364 end = min(end, len(row)-1)
365 var text strings.Builder
366 for col := start; col <= end; col++ {
367 cell := row[col]
368 if cell.Continuation {
369 continue
370 }
371 if cell.Rune == 0 {
372 text.WriteByte(' ')
373 continue
374 }
375 text.WriteRune(cell.Rune)
376 }
377 return text.String()
378}
379
380func (d *Document) isSeparator(r rune) bool {
381 if unicode.IsSpace(r) {
382 return true
383 }
384 _, ok := d.wordSeparators[r]
385 return ok
386}
387
388func (d *Document) isGlyph(pos Pos) bool {
389 return pos.Col >= 0 && pos.Col < d.RowWidth(pos.Row) && !d.Cell(pos.Col, pos.Row).Continuation
390}
391
392func (d *Document) runeAt(pos Pos) rune {
393 cell := d.Cell(pos.Col, pos.Row)
394 if cell.Rune == 0 {
395 return ' '
396 }
397 return cell.Rune
398}
399
400// nextDocumentGlyph finds the next glyph in document order without building
401// an index for unrelated scrollback rows.
402func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
403 for row := pos.Row; row < d.Len(); row++ {
404 width := d.RowWidth(row)
405 start := 0
406 if row == pos.Row {
407 start = pos.Col + 1
408 }
409 for col := start; col < width; col++ {
410 if !d.Cell(col, row).Continuation {
411 return Pos{Row: row, Col: col}, true
412 }
413 }
414 }
415 return Pos{}, false
416}
417
418// previousDocumentGlyph finds the previous glyph in document order without
419// building an index for unrelated scrollback rows.
420func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {
421 for row := pos.Row; row >= 0; row-- {
422 start := d.RowWidth(row) - 1
423 if row == pos.Row {
424 start = min(pos.Col-1, start)
425 }
426 for col := start; col >= 0; col-- {
427 if !d.Cell(col, row).Continuation {
428 return Pos{Row: row, Col: col}, true
429 }
430 }
431 }
432 return Pos{}, false
433}
434
435func (d *Document) sameWord(left, right Pos) bool {
436 return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
437}