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