feat(copy): add display-cell document primitives
- commit
- b3cac20002dd3dc3237a3c42adfbe5d2bac31ea6
- author
- brice · · 2026-07-15 06:31 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- bb3050a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +553 -0
A internal/usecase/copy/document.go +343 -0
| @@ -0,0 +1,343 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "unicode" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Pos identifies a terminal display cell in a document. Valid positions are | |
| 11 | + | // always glyph heads; Normalize converts continuation cells to their head. | |
| 12 | + | type Pos struct { | |
| 13 | + | Row int | |
| 14 | + | Col int | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // CellRange is an inclusive display-cell range on one physical document row. | |
| 18 | + | type CellRange struct { | |
| 19 | + | Row int | |
| 20 | + | Start int | |
| 21 | + | End int | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Document provides immutable display-cell and text operations over a Snapshot. | |
| 25 | + | type Document struct { | |
| 26 | + | snapshot Snapshot | |
| 27 | + | wordSeparators map[rune]struct{} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func NewDocument(snapshot Snapshot, separators string) *Document { | |
| 31 | + | set := make(map[rune]struct{}, len([]rune(separators))) | |
| 32 | + | for _, r := range separators { | |
| 33 | + | set[r] = struct{}{} | |
| 34 | + | } | |
| 35 | + | return &Document{snapshot: snapshot, wordSeparators: set} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (d *Document) Snapshot() Snapshot { return d.snapshot } | |
| 39 | + | func (d *Document) Len() int { return d.snapshot.Len() } | |
| 40 | + | func (d *Document) Width() int { return d.snapshot.Width } | |
| 41 | + | func (d *Document) Height() int { return d.snapshot.Height } | |
| 42 | + | func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) } | |
| 43 | + | ||
| 44 | + | // Normalize returns pos as a valid glyph head. Empty physical rows have the | |
| 45 | + | // stable logical position {row, 0}, which is useful to vertical navigation. | |
| 46 | + | func (d *Document) Normalize(pos Pos) (Pos, bool) { | |
| 47 | + | if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 { | |
| 48 | + | return Pos{}, false | |
| 49 | + | } | |
| 50 | + | row := d.Row(pos.Row) | |
| 51 | + | if len(row) == 0 { | |
| 52 | + | if pos.Col == 0 { | |
| 53 | + | return pos, true | |
| 54 | + | } | |
| 55 | + | return Pos{}, false | |
| 56 | + | } | |
| 57 | + | if pos.Col >= len(row) { | |
| 58 | + | return Pos{}, false | |
| 59 | + | } | |
| 60 | + | for pos.Col > 0 && row[pos.Col].Continuation { | |
| 61 | + | pos.Col-- | |
| 62 | + | } | |
| 63 | + | if row[pos.Col].Continuation { | |
| 64 | + | return Pos{}, false | |
| 65 | + | } | |
| 66 | + | return pos, true | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // PrevGlyph returns the previous glyph head on pos's physical row. | |
| 70 | + | func (d *Document) PrevGlyph(pos Pos) (Pos, bool) { | |
| 71 | + | pos, ok := d.Normalize(pos) | |
| 72 | + | if !ok || len(d.Row(pos.Row)) == 0 { | |
| 73 | + | return Pos{}, false | |
| 74 | + | } | |
| 75 | + | for col := pos.Col - 1; col >= 0; col-- { | |
| 76 | + | if !d.Row(pos.Row)[col].Continuation { | |
| 77 | + | return Pos{Row: pos.Row, Col: col}, true | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | return Pos{}, false | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // NextGlyph returns the next glyph head on pos's physical row. | |
| 84 | + | func (d *Document) NextGlyph(pos Pos) (Pos, bool) { | |
| 85 | + | pos, ok := d.Normalize(pos) | |
| 86 | + | if !ok { | |
| 87 | + | return Pos{}, false | |
| 88 | + | } | |
| 89 | + | row := d.Row(pos.Row) | |
| 90 | + | for col := pos.Col + 1; col < len(row); col++ { | |
| 91 | + | if !row[col].Continuation { | |
| 92 | + | return Pos{Row: pos.Row, Col: col}, true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return Pos{}, false | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // WordBounds returns the inclusive bounds of the word containing pos. Unicode | |
| 99 | + | // whitespace and configured separators do not belong to words. | |
| 100 | + | func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) { | |
| 101 | + | pos, ok := d.Normalize(pos) | |
| 102 | + | if !ok || !d.isGlyph(pos) || d.isSeparator(d.runeAt(pos)) { | |
| 103 | + | return Pos{}, Pos{}, false | |
| 104 | + | } | |
| 105 | + | start, end := pos, pos | |
| 106 | + | for { | |
| 107 | + | prev, ok := d.PrevGlyph(start) | |
| 108 | + | if !ok || d.isSeparator(d.runeAt(prev)) { | |
| 109 | + | break | |
| 110 | + | } | |
| 111 | + | start = prev | |
| 112 | + | } | |
| 113 | + | for { | |
| 114 | + | next, ok := d.NextGlyph(end) | |
| 115 | + | if !ok || d.isSeparator(d.runeAt(next)) { | |
| 116 | + | break | |
| 117 | + | } | |
| 118 | + | end = next | |
| 119 | + | } | |
| 120 | + | return start, end, true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // NextWordStart returns the beginning of the next word after pos, crossing | |
| 124 | + | // physical rows as necessary. | |
| 125 | + | func (d *Document) NextWordStart(pos Pos) (Pos, bool) { | |
| 126 | + | pos, ok := d.Normalize(pos) | |
| 127 | + | if !ok { | |
| 128 | + | return Pos{}, false | |
| 129 | + | } | |
| 130 | + | glyphs := d.glyphs() | |
| 131 | + | i := d.glyphIndex(glyphs, pos) | |
| 132 | + | if i < 0 { | |
| 133 | + | return d.firstWordAfterRow(glyphs, pos.Row) | |
| 134 | + | } | |
| 135 | + | if !d.isSeparator(d.runeAt(glyphs[i])) { | |
| 136 | + | for i < len(glyphs) && d.sameWord(glyphs[i], pos) { | |
| 137 | + | i++ | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | |
| 141 | + | i++ | |
| 142 | + | } | |
| 143 | + | if i < len(glyphs) { | |
| 144 | + | return glyphs[i], true | |
| 145 | + | } | |
| 146 | + | return Pos{}, false | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // PreviousWordStart returns the beginning of the current word, or of the | |
| 150 | + | // preceding word when pos is already at a word beginning. | |
| 151 | + | func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) { | |
| 152 | + | pos, ok := d.Normalize(pos) | |
| 153 | + | if !ok { | |
| 154 | + | return Pos{}, false | |
| 155 | + | } | |
| 156 | + | glyphs := d.glyphs() | |
| 157 | + | i := d.glyphIndex(glyphs, pos) | |
| 158 | + | if i < 0 { | |
| 159 | + | return d.lastWordBeforeRow(glyphs, pos.Row) | |
| 160 | + | } | |
| 161 | + | if !d.isSeparator(d.runeAt(glyphs[i])) && i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |
| 162 | + | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |
| 163 | + | i-- | |
| 164 | + | } | |
| 165 | + | return glyphs[i], true | |
| 166 | + | } | |
| 167 | + | i-- | |
| 168 | + | for i >= 0 && d.isSeparator(d.runeAt(glyphs[i])) { | |
| 169 | + | i-- | |
| 170 | + | } | |
| 171 | + | if i < 0 { | |
| 172 | + | return Pos{}, false | |
| 173 | + | } | |
| 174 | + | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |
| 175 | + | i-- | |
| 176 | + | } | |
| 177 | + | return glyphs[i], true | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // NextWordEnd returns the inclusive end of the current word, or of the next | |
| 181 | + | // word when pos is on a separator. | |
| 182 | + | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | |
| 183 | + | pos, ok := d.Normalize(pos) | |
| 184 | + | if !ok { | |
| 185 | + | return Pos{}, false | |
| 186 | + | } | |
| 187 | + | glyphs := d.glyphs() | |
| 188 | + | i := d.glyphIndex(glyphs, pos) | |
| 189 | + | if i < 0 { | |
| 190 | + | return Pos{}, false | |
| 191 | + | } | |
| 192 | + | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | |
| 193 | + | i++ | |
| 194 | + | } | |
| 195 | + | if i == len(glyphs) { | |
| 196 | + | return Pos{}, false | |
| 197 | + | } | |
| 198 | + | end := glyphs[i] | |
| 199 | + | for i+1 < len(glyphs) && d.sameWord(glyphs[i+1], glyphs[i]) { | |
| 200 | + | i++ | |
| 201 | + | end = glyphs[i] | |
| 202 | + | } | |
| 203 | + | return end, true | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // LineText returns row text without duplicated continuation cells and retains | |
| 207 | + | // the established copy-mode behavior of trimming trailing blank spaces. | |
| 208 | + | func (d *Document) LineText(row int) string { | |
| 209 | + | return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ") | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Extract returns inclusive ranges joined by physical-row newlines. Line-wise | |
| 213 | + | // extraction trims trailing blank spaces; stream extraction preserves them. | |
| 214 | + | func (d *Document) Extract(ranges []CellRange, linewise bool) string { | |
| 215 | + | if len(ranges) == 0 { | |
| 216 | + | return "" | |
| 217 | + | } | |
| 218 | + | lines := make([]string, 0, len(ranges)) | |
| 219 | + | for _, r := range ranges { | |
| 220 | + | text, ok := d.rangeText(r) | |
| 221 | + | if !ok { | |
| 222 | + | continue | |
| 223 | + | } | |
| 224 | + | if linewise { | |
| 225 | + | text = strings.TrimRight(text, " ") | |
| 226 | + | } | |
| 227 | + | lines = append(lines, text) | |
| 228 | + | } | |
| 229 | + | return strings.Join(lines, "\n") | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func (d *Document) rangeText(r CellRange) (string, bool) { | |
| 233 | + | if r.Row < 0 || r.Row >= d.Len() { | |
| 234 | + | return "", false | |
| 235 | + | } | |
| 236 | + | row := d.Row(r.Row) | |
| 237 | + | if len(row) == 0 { | |
| 238 | + | return "", r.Start == 0 && r.End == 0 | |
| 239 | + | } | |
| 240 | + | start, end := min(r.Start, r.End), max(r.Start, r.End) | |
| 241 | + | if end < 0 || start >= len(row) { | |
| 242 | + | return "", false | |
| 243 | + | } | |
| 244 | + | start = max(start, 0) | |
| 245 | + | end = min(end, len(row)-1) | |
| 246 | + | headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start}) | |
| 247 | + | if !ok { | |
| 248 | + | return "", false | |
| 249 | + | } | |
| 250 | + | headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end}) | |
| 251 | + | if !ok { | |
| 252 | + | return "", false | |
| 253 | + | } | |
| 254 | + | return d.cellsText(row, headStart.Col, headEnd.Col), true | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func (d *Document) cellsText(row []renderer.Cell, start, end int) string { | |
| 258 | + | if start < 0 || end < start || start >= len(row) { | |
| 259 | + | return "" | |
| 260 | + | } | |
| 261 | + | end = min(end, len(row)-1) | |
| 262 | + | var text strings.Builder | |
| 263 | + | for col := start; col <= end; col++ { | |
| 264 | + | cell := row[col] | |
| 265 | + | if cell.Continuation { | |
| 266 | + | continue | |
| 267 | + | } | |
| 268 | + | if cell.Rune == 0 { | |
| 269 | + | text.WriteByte(' ') | |
| 270 | + | continue | |
| 271 | + | } | |
| 272 | + | text.WriteRune(cell.Rune) | |
| 273 | + | } | |
| 274 | + | return text.String() | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (d *Document) isSeparator(r rune) bool { | |
| 278 | + | if unicode.IsSpace(r) { | |
| 279 | + | return true | |
| 280 | + | } | |
| 281 | + | _, ok := d.wordSeparators[r] | |
| 282 | + | return ok | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func (d *Document) isGlyph(pos Pos) bool { | |
| 286 | + | row := d.Row(pos.Row) | |
| 287 | + | return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (d *Document) runeAt(pos Pos) rune { | |
| 291 | + | cell := d.Row(pos.Row)[pos.Col] | |
| 292 | + | if cell.Rune == 0 { | |
| 293 | + | return ' ' | |
| 294 | + | } | |
| 295 | + | return cell.Rune | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (d *Document) glyphs() []Pos { | |
| 299 | + | glyphs := make([]Pos, 0) | |
| 300 | + | for row := 0; row < d.Len(); row++ { | |
| 301 | + | for col, cell := range d.Row(row) { | |
| 302 | + | if !cell.Continuation { | |
| 303 | + | glyphs = append(glyphs, Pos{Row: row, Col: col}) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | return glyphs | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func (d *Document) glyphIndex(glyphs []Pos, pos Pos) int { | |
| 311 | + | for i, glyph := range glyphs { | |
| 312 | + | if glyph == pos { | |
| 313 | + | return i | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | return -1 | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func (d *Document) sameWord(left, right Pos) bool { | |
| 320 | + | return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right)) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func (d *Document) firstWordAfterRow(glyphs []Pos, row int) (Pos, bool) { | |
| 324 | + | for _, glyph := range glyphs { | |
| 325 | + | if glyph.Row > row && !d.isSeparator(d.runeAt(glyph)) { | |
| 326 | + | return glyph, true | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | return Pos{}, false | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (d *Document) lastWordBeforeRow(glyphs []Pos, row int) (Pos, bool) { | |
| 333 | + | for i := len(glyphs) - 1; i >= 0; i-- { | |
| 334 | + | if glyphs[i].Row >= row || d.isSeparator(d.runeAt(glyphs[i])) { | |
| 335 | + | continue | |
| 336 | + | } | |
| 337 | + | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |
| 338 | + | i-- | |
| 339 | + | } | |
| 340 | + | return glyphs[i], true | |
| 341 | + | } | |
| 342 | + | return Pos{}, false | |
| 343 | + | } | |
| @@ -0,0 +1,343 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "unicode" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Pos identifies a terminal display cell in a document. Valid positions are | |||
| 11 | + | // always glyph heads; Normalize converts continuation cells to their head. | |||
| 12 | + | type Pos struct { | |||
| 13 | + | Row int | |||
| 14 | + | Col int | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // CellRange is an inclusive display-cell range on one physical document row. | |||
| 18 | + | type CellRange struct { | |||
| 19 | + | Row int | |||
| 20 | + | Start int | |||
| 21 | + | End int | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // Document provides immutable display-cell and text operations over a Snapshot. | |||
| 25 | + | type Document struct { | |||
| 26 | + | snapshot Snapshot | |||
| 27 | + | wordSeparators map[rune]struct{} | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func NewDocument(snapshot Snapshot, separators string) *Document { | |||
| 31 | + | set := make(map[rune]struct{}, len([]rune(separators))) | |||
| 32 | + | for _, r := range separators { | |||
| 33 | + | set[r] = struct{}{} | |||
| 34 | + | } | |||
| 35 | + | return &Document{snapshot: snapshot, wordSeparators: set} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (d *Document) Snapshot() Snapshot { return d.snapshot } | |||
| 39 | + | func (d *Document) Len() int { return d.snapshot.Len() } | |||
| 40 | + | func (d *Document) Width() int { return d.snapshot.Width } | |||
| 41 | + | func (d *Document) Height() int { return d.snapshot.Height } | |||
| 42 | + | func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) } | |||
| 43 | + | ||||
| 44 | + | // Normalize returns pos as a valid glyph head. Empty physical rows have the | |||
| 45 | + | // stable logical position {row, 0}, which is useful to vertical navigation. | |||
| 46 | + | func (d *Document) Normalize(pos Pos) (Pos, bool) { | |||
| 47 | + | if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 { | |||
| 48 | + | return Pos{}, false | |||
| 49 | + | } | |||
| 50 | + | row := d.Row(pos.Row) | |||
| 51 | + | if len(row) == 0 { | |||
| 52 | + | if pos.Col == 0 { | |||
| 53 | + | return pos, true | |||
| 54 | + | } | |||
| 55 | + | return Pos{}, false | |||
| 56 | + | } | |||
| 57 | + | if pos.Col >= len(row) { | |||
| 58 | + | return Pos{}, false | |||
| 59 | + | } | |||
| 60 | + | for pos.Col > 0 && row[pos.Col].Continuation { | |||
| 61 | + | pos.Col-- | |||
| 62 | + | } | |||
| 63 | + | if row[pos.Col].Continuation { | |||
| 64 | + | return Pos{}, false | |||
| 65 | + | } | |||
| 66 | + | return pos, true | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // PrevGlyph returns the previous glyph head on pos's physical row. | |||
| 70 | + | func (d *Document) PrevGlyph(pos Pos) (Pos, bool) { | |||
| 71 | + | pos, ok := d.Normalize(pos) | |||
| 72 | + | if !ok || len(d.Row(pos.Row)) == 0 { | |||
| 73 | + | return Pos{}, false | |||
| 74 | + | } | |||
| 75 | + | for col := pos.Col - 1; col >= 0; col-- { | |||
| 76 | + | if !d.Row(pos.Row)[col].Continuation { | |||
| 77 | + | return Pos{Row: pos.Row, Col: col}, true | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | return Pos{}, false | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // NextGlyph returns the next glyph head on pos's physical row. | |||
| 84 | + | func (d *Document) NextGlyph(pos Pos) (Pos, bool) { | |||
| 85 | + | pos, ok := d.Normalize(pos) | |||
| 86 | + | if !ok { | |||
| 87 | + | return Pos{}, false | |||
| 88 | + | } | |||
| 89 | + | row := d.Row(pos.Row) | |||
| 90 | + | for col := pos.Col + 1; col < len(row); col++ { | |||
| 91 | + | if !row[col].Continuation { | |||
| 92 | + | return Pos{Row: pos.Row, Col: col}, true | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | return Pos{}, false | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // WordBounds returns the inclusive bounds of the word containing pos. Unicode | |||
| 99 | + | // whitespace and configured separators do not belong to words. | |||
| 100 | + | func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) { | |||
| 101 | + | pos, ok := d.Normalize(pos) | |||
| 102 | + | if !ok || !d.isGlyph(pos) || d.isSeparator(d.runeAt(pos)) { | |||
| 103 | + | return Pos{}, Pos{}, false | |||
| 104 | + | } | |||
| 105 | + | start, end := pos, pos | |||
| 106 | + | for { | |||
| 107 | + | prev, ok := d.PrevGlyph(start) | |||
| 108 | + | if !ok || d.isSeparator(d.runeAt(prev)) { | |||
| 109 | + | break | |||
| 110 | + | } | |||
| 111 | + | start = prev | |||
| 112 | + | } | |||
| 113 | + | for { | |||
| 114 | + | next, ok := d.NextGlyph(end) | |||
| 115 | + | if !ok || d.isSeparator(d.runeAt(next)) { | |||
| 116 | + | break | |||
| 117 | + | } | |||
| 118 | + | end = next | |||
| 119 | + | } | |||
| 120 | + | return start, end, true | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // NextWordStart returns the beginning of the next word after pos, crossing | |||
| 124 | + | // physical rows as necessary. | |||
| 125 | + | func (d *Document) NextWordStart(pos Pos) (Pos, bool) { | |||
| 126 | + | pos, ok := d.Normalize(pos) | |||
| 127 | + | if !ok { | |||
| 128 | + | return Pos{}, false | |||
| 129 | + | } | |||
| 130 | + | glyphs := d.glyphs() | |||
| 131 | + | i := d.glyphIndex(glyphs, pos) | |||
| 132 | + | if i < 0 { | |||
| 133 | + | return d.firstWordAfterRow(glyphs, pos.Row) | |||
| 134 | + | } | |||
| 135 | + | if !d.isSeparator(d.runeAt(glyphs[i])) { | |||
| 136 | + | for i < len(glyphs) && d.sameWord(glyphs[i], pos) { | |||
| 137 | + | i++ | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | |||
| 141 | + | i++ | |||
| 142 | + | } | |||
| 143 | + | if i < len(glyphs) { | |||
| 144 | + | return glyphs[i], true | |||
| 145 | + | } | |||
| 146 | + | return Pos{}, false | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // PreviousWordStart returns the beginning of the current word, or of the | |||
| 150 | + | // preceding word when pos is already at a word beginning. | |||
| 151 | + | func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) { | |||
| 152 | + | pos, ok := d.Normalize(pos) | |||
| 153 | + | if !ok { | |||
| 154 | + | return Pos{}, false | |||
| 155 | + | } | |||
| 156 | + | glyphs := d.glyphs() | |||
| 157 | + | i := d.glyphIndex(glyphs, pos) | |||
| 158 | + | if i < 0 { | |||
| 159 | + | return d.lastWordBeforeRow(glyphs, pos.Row) | |||
| 160 | + | } | |||
| 161 | + | if !d.isSeparator(d.runeAt(glyphs[i])) && i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |||
| 162 | + | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |||
| 163 | + | i-- | |||
| 164 | + | } | |||
| 165 | + | return glyphs[i], true | |||
| 166 | + | } | |||
| 167 | + | i-- | |||
| 168 | + | for i >= 0 && d.isSeparator(d.runeAt(glyphs[i])) { | |||
| 169 | + | i-- | |||
| 170 | + | } | |||
| 171 | + | if i < 0 { | |||
| 172 | + | return Pos{}, false | |||
| 173 | + | } | |||
| 174 | + | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |||
| 175 | + | i-- | |||
| 176 | + | } | |||
| 177 | + | return glyphs[i], true | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // NextWordEnd returns the inclusive end of the current word, or of the next | |||
| 181 | + | // word when pos is on a separator. | |||
| 182 | + | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | |||
| 183 | + | pos, ok := d.Normalize(pos) | |||
| 184 | + | if !ok { | |||
| 185 | + | return Pos{}, false | |||
| 186 | + | } | |||
| 187 | + | glyphs := d.glyphs() | |||
| 188 | + | i := d.glyphIndex(glyphs, pos) | |||
| 189 | + | if i < 0 { | |||
| 190 | + | return Pos{}, false | |||
| 191 | + | } | |||
| 192 | + | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | |||
| 193 | + | i++ | |||
| 194 | + | } | |||
| 195 | + | if i == len(glyphs) { | |||
| 196 | + | return Pos{}, false | |||
| 197 | + | } | |||
| 198 | + | end := glyphs[i] | |||
| 199 | + | for i+1 < len(glyphs) && d.sameWord(glyphs[i+1], glyphs[i]) { | |||
| 200 | + | i++ | |||
| 201 | + | end = glyphs[i] | |||
| 202 | + | } | |||
| 203 | + | return end, true | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // LineText returns row text without duplicated continuation cells and retains | |||
| 207 | + | // the established copy-mode behavior of trimming trailing blank spaces. | |||
| 208 | + | func (d *Document) LineText(row int) string { | |||
| 209 | + | return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ") | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // Extract returns inclusive ranges joined by physical-row newlines. Line-wise | |||
| 213 | + | // extraction trims trailing blank spaces; stream extraction preserves them. | |||
| 214 | + | func (d *Document) Extract(ranges []CellRange, linewise bool) string { | |||
| 215 | + | if len(ranges) == 0 { | |||
| 216 | + | return "" | |||
| 217 | + | } | |||
| 218 | + | lines := make([]string, 0, len(ranges)) | |||
| 219 | + | for _, r := range ranges { | |||
| 220 | + | text, ok := d.rangeText(r) | |||
| 221 | + | if !ok { | |||
| 222 | + | continue | |||
| 223 | + | } | |||
| 224 | + | if linewise { | |||
| 225 | + | text = strings.TrimRight(text, " ") | |||
| 226 | + | } | |||
| 227 | + | lines = append(lines, text) | |||
| 228 | + | } | |||
| 229 | + | return strings.Join(lines, "\n") | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func (d *Document) rangeText(r CellRange) (string, bool) { | |||
| 233 | + | if r.Row < 0 || r.Row >= d.Len() { | |||
| 234 | + | return "", false | |||
| 235 | + | } | |||
| 236 | + | row := d.Row(r.Row) | |||
| 237 | + | if len(row) == 0 { | |||
| 238 | + | return "", r.Start == 0 && r.End == 0 | |||
| 239 | + | } | |||
| 240 | + | start, end := min(r.Start, r.End), max(r.Start, r.End) | |||
| 241 | + | if end < 0 || start >= len(row) { | |||
| 242 | + | return "", false | |||
| 243 | + | } | |||
| 244 | + | start = max(start, 0) | |||
| 245 | + | end = min(end, len(row)-1) | |||
| 246 | + | headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start}) | |||
| 247 | + | if !ok { | |||
| 248 | + | return "", false | |||
| 249 | + | } | |||
| 250 | + | headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end}) | |||
| 251 | + | if !ok { | |||
| 252 | + | return "", false | |||
| 253 | + | } | |||
| 254 | + | return d.cellsText(row, headStart.Col, headEnd.Col), true | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func (d *Document) cellsText(row []renderer.Cell, start, end int) string { | |||
| 258 | + | if start < 0 || end < start || start >= len(row) { | |||
| 259 | + | return "" | |||
| 260 | + | } | |||
| 261 | + | end = min(end, len(row)-1) | |||
| 262 | + | var text strings.Builder | |||
| 263 | + | for col := start; col <= end; col++ { | |||
| 264 | + | cell := row[col] | |||
| 265 | + | if cell.Continuation { | |||
| 266 | + | continue | |||
| 267 | + | } | |||
| 268 | + | if cell.Rune == 0 { | |||
| 269 | + | text.WriteByte(' ') | |||
| 270 | + | continue | |||
| 271 | + | } | |||
| 272 | + | text.WriteRune(cell.Rune) | |||
| 273 | + | } | |||
| 274 | + | return text.String() | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func (d *Document) isSeparator(r rune) bool { | |||
| 278 | + | if unicode.IsSpace(r) { | |||
| 279 | + | return true | |||
| 280 | + | } | |||
| 281 | + | _, ok := d.wordSeparators[r] | |||
| 282 | + | return ok | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func (d *Document) isGlyph(pos Pos) bool { | |||
| 286 | + | row := d.Row(pos.Row) | |||
| 287 | + | return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func (d *Document) runeAt(pos Pos) rune { | |||
| 291 | + | cell := d.Row(pos.Row)[pos.Col] | |||
| 292 | + | if cell.Rune == 0 { | |||
| 293 | + | return ' ' | |||
| 294 | + | } | |||
| 295 | + | return cell.Rune | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func (d *Document) glyphs() []Pos { | |||
| 299 | + | glyphs := make([]Pos, 0) | |||
| 300 | + | for row := 0; row < d.Len(); row++ { | |||
| 301 | + | for col, cell := range d.Row(row) { | |||
| 302 | + | if !cell.Continuation { | |||
| 303 | + | glyphs = append(glyphs, Pos{Row: row, Col: col}) | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | return glyphs | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func (d *Document) glyphIndex(glyphs []Pos, pos Pos) int { | |||
| 311 | + | for i, glyph := range glyphs { | |||
| 312 | + | if glyph == pos { | |||
| 313 | + | return i | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | return -1 | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func (d *Document) sameWord(left, right Pos) bool { | |||
| 320 | + | return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right)) | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | func (d *Document) firstWordAfterRow(glyphs []Pos, row int) (Pos, bool) { | |||
| 324 | + | for _, glyph := range glyphs { | |||
| 325 | + | if glyph.Row > row && !d.isSeparator(d.runeAt(glyph)) { | |||
| 326 | + | return glyph, true | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | return Pos{}, false | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func (d *Document) lastWordBeforeRow(glyphs []Pos, row int) (Pos, bool) { | |||
| 333 | + | for i := len(glyphs) - 1; i >= 0; i-- { | |||
| 334 | + | if glyphs[i].Row >= row || d.isSeparator(d.runeAt(glyphs[i])) { | |||
| 335 | + | continue | |||
| 336 | + | } | |||
| 337 | + | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |||
| 338 | + | i-- | |||
| 339 | + | } | |||
| 340 | + | return glyphs[i], true | |||
| 341 | + | } | |||
| 342 | + | return Pos{}, false | |||
| 343 | + | } | |||
A internal/usecase/copy/document_test.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func documentCells(text string) []renderer.Cell { | |
| 11 | + | cells := make([]renderer.Cell, 0, len([]rune(text))) | |
| 12 | + | for _, r := range text { | |
| 13 | + | cells = append(cells, renderer.Cell{Rune: r}) | |
| 14 | + | } | |
| 15 | + | return cells | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func documentFromStrings(lines []string, separators string) *Document { | |
| 19 | + | rows := make([][]renderer.Cell, len(lines)) | |
| 20 | + | for i, line := range lines { | |
| 21 | + | rows[i] = documentCells(line) | |
| 22 | + | } | |
| 23 | + | return NewDocument(NewSnapshotFromRows(rows, 32, 4), separators) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func mustNormalize(t *testing.T, doc *Document, pos Pos) Pos { | |
| 27 | + | t.Helper() | |
| 28 | + | got, ok := doc.Normalize(pos) | |
| 29 | + | require.True(t, ok) | |
| 30 | + | return got | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func mustPrev(t *testing.T, doc *Document, pos Pos) Pos { | |
| 34 | + | t.Helper() | |
| 35 | + | got, ok := doc.PrevGlyph(pos) | |
| 36 | + | require.True(t, ok) | |
| 37 | + | return got | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func mustNext(t *testing.T, doc *Document, pos Pos) Pos { | |
| 41 | + | t.Helper() | |
| 42 | + | got, ok := doc.NextGlyph(pos) | |
| 43 | + | require.True(t, ok) | |
| 44 | + | return got | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func mustWordStart(t *testing.T, doc *Document, pos Pos) Pos { | |
| 48 | + | t.Helper() | |
| 49 | + | got, _, ok := doc.WordBounds(pos) | |
| 50 | + | require.True(t, ok) | |
| 51 | + | return got | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func mustWordEnd(t *testing.T, doc *Document, pos Pos) Pos { | |
| 55 | + | t.Helper() | |
| 56 | + | _, got, ok := doc.WordBounds(pos) | |
| 57 | + | require.True(t, ok) | |
| 58 | + | return got | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestDocumentNormalize(t *testing.T) { | |
| 62 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |
| 63 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}}, 5, 1), " -_@") | |
| 64 | + | ||
| 65 | + | tests := []struct { | |
| 66 | + | name string | |
| 67 | + | in Pos | |
| 68 | + | want Pos | |
| 69 | + | ok bool | |
| 70 | + | }{ | |
| 71 | + | {"glyph head", Pos{0, 1}, Pos{0, 1}, true}, | |
| 72 | + | {"wide continuation", Pos{0, 2}, Pos{0, 1}, true}, | |
| 73 | + | {"empty row stable position", Pos{1, 0}, Pos{1, 0}, true}, | |
| 74 | + | {"negative row", Pos{-1, 0}, Pos{}, false}, | |
| 75 | + | {"past row", Pos{2, 0}, Pos{}, false}, | |
| 76 | + | {"negative column", Pos{0, -1}, Pos{}, false}, | |
| 77 | + | {"past column", Pos{0, 5}, Pos{}, false}, | |
| 78 | + | {"empty row past column", Pos{1, 1}, Pos{}, false}, | |
| 79 | + | } | |
| 80 | + | for _, tt := range tests { | |
| 81 | + | t.Run(tt.name, func(t *testing.T) { | |
| 82 | + | got, ok := doc.Normalize(tt.in) | |
| 83 | + | require.Equal(t, tt.ok, ok) | |
| 84 | + | if ok { | |
| 85 | + | require.Equal(t, tt.want, got) | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestDocumentGlyphNavigation(t *testing.T) { | |
| 92 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |
| 93 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide}, 5, 1), " -_@") | |
| 94 | + | ||
| 95 | + | tests := []struct { | |
| 96 | + | name string | |
| 97 | + | fn func(Pos) (Pos, bool) | |
| 98 | + | in Pos | |
| 99 | + | want Pos | |
| 100 | + | ok bool | |
| 101 | + | }{ | |
| 102 | + | {"next narrow to wide", doc.NextGlyph, Pos{0, 0}, Pos{0, 1}, true}, | |
| 103 | + | {"next skips continuation", doc.NextGlyph, Pos{0, 1}, Pos{0, 3}, true}, | |
| 104 | + | {"next at end", doc.NextGlyph, Pos{0, 4}, Pos{}, false}, | |
| 105 | + | {"previous skips continuation", doc.PrevGlyph, Pos{0, 3}, Pos{0, 1}, true}, | |
| 106 | + | {"previous continuation normalizes", doc.PrevGlyph, Pos{0, 2}, Pos{0, 0}, true}, | |
| 107 | + | {"previous at start", doc.PrevGlyph, Pos{0, 0}, Pos{}, false}, | |
| 108 | + | } | |
| 109 | + | for _, tt := range tests { | |
| 110 | + | t.Run(tt.name, func(t *testing.T) { | |
| 111 | + | got, ok := tt.fn(tt.in) | |
| 112 | + | require.Equal(t, tt.ok, ok) | |
| 113 | + | if ok { | |
| 114 | + | require.Equal(t, tt.want, got) | |
| 115 | + | } | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestDocumentWordBounds(t *testing.T) { | |
| 121 | + | doc := documentFromStrings([]string{"alpha/beta gamma", "été\u2003東京", ""}, "/") | |
| 122 | + | ||
| 123 | + | tests := []struct { | |
| 124 | + | name string | |
| 125 | + | in Pos | |
| 126 | + | start Pos | |
| 127 | + | end Pos | |
| 128 | + | ok bool | |
| 129 | + | }{ | |
| 130 | + | {"configured separator", Pos{0, 5}, Pos{}, Pos{}, false}, | |
| 131 | + | {"second word", Pos{0, 8}, Pos{0, 6}, Pos{0, 9}, true}, | |
| 132 | + | {"unicode whitespace", Pos{1, 3}, Pos{}, Pos{}, false}, | |
| 133 | + | {"unicode word", Pos{1, 5}, Pos{1, 4}, Pos{1, 5}, true}, | |
| 134 | + | {"empty row", Pos{2, 0}, Pos{}, Pos{}, false}, | |
| 135 | + | {"out of range", Pos{4, 0}, Pos{}, Pos{}, false}, | |
| 136 | + | } | |
| 137 | + | for _, tt := range tests { | |
| 138 | + | t.Run(tt.name, func(t *testing.T) { | |
| 139 | + | start, end, ok := doc.WordBounds(tt.in) | |
| 140 | + | require.Equal(t, tt.ok, ok) | |
| 141 | + | if ok { | |
| 142 | + | require.Equal(t, tt.start, start) | |
| 143 | + | require.Equal(t, tt.end, end) | |
| 144 | + | } | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestDocumentWordMovement(t *testing.T) { | |
| 150 | + | doc := documentFromStrings([]string{"alpha/beta", " gamma", "delta"}, "/") | |
| 151 | + | ||
| 152 | + | tests := []struct { | |
| 153 | + | name string | |
| 154 | + | fn func(Pos) (Pos, bool) | |
| 155 | + | in Pos | |
| 156 | + | want Pos | |
| 157 | + | }{ | |
| 158 | + | {"next skips current word and separator", doc.NextWordStart, Pos{0, 2}, Pos{0, 6}}, | |
| 159 | + | {"next crosses rows", doc.NextWordStart, Pos{0, 7}, Pos{1, 1}}, | |
| 160 | + | {"previous returns current word start", doc.PreviousWordStart, Pos{1, 3}, Pos{1, 1}}, | |
| 161 | + | {"previous crosses rows", doc.PreviousWordStart, Pos{1, 1}, Pos{0, 6}}, | |
| 162 | + | {"next end returns current word end", doc.NextWordEnd, Pos{0, 2}, Pos{0, 4}}, | |
| 163 | + | {"next end skips separators", doc.NextWordEnd, Pos{0, 5}, Pos{0, 9}}, | |
| 164 | + | } | |
| 165 | + | for _, tt := range tests { | |
| 166 | + | t.Run(tt.name, func(t *testing.T) { | |
| 167 | + | got, ok := tt.fn(tt.in) | |
| 168 | + | require.True(t, ok) | |
| 169 | + | require.Equal(t, tt.want, got) | |
| 170 | + | }) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestDocumentExtractRanges(t *testing.T) { | |
| 175 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |
| 176 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | |
| 177 | + | ||
| 178 | + | tests := []struct { | |
| 179 | + | name string | |
| 180 | + | ranges []CellRange | |
| 181 | + | linewise bool | |
| 182 | + | want string | |
| 183 | + | }{ | |
| 184 | + | {"inclusive partial multiline preserves spaces", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, false, "pha \nbe"}, | |
| 185 | + | {"linewise trims trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, true, "alpha"}, | |
| 186 | + | {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, false, "界"}, | |
| 187 | + | {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, false, "界"}, | |
| 188 | + | {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, false, ""}, | |
| 189 | + | {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, false, ""}, | |
| 190 | + | {"no ranges", nil, false, ""}, | |
| 191 | + | } | |
| 192 | + | for _, tt := range tests { | |
| 193 | + | t.Run(tt.name, func(t *testing.T) { | |
| 194 | + | require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise)) | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) { | |
| 200 | + | rows := [][]renderer.Cell{documentCells("one")} | |
| 201 | + | doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_") | |
| 202 | + | rows[0][0].Rune = 'x' | |
| 203 | + | ||
| 204 | + | require.Equal(t, 1, doc.Len()) | |
| 205 | + | require.Equal(t, 9, doc.Width()) | |
| 206 | + | require.Equal(t, 2, doc.Height()) | |
| 207 | + | require.Equal(t, "one", doc.LineText(0)) | |
| 208 | + | require.Equal(t, doc.Snapshot().Row(0), doc.Row(0)) | |
| 209 | + | require.Nil(t, doc.Row(1)) | |
| 210 | + | } | |
| @@ -0,0 +1,210 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func documentCells(text string) []renderer.Cell { | |||
| 11 | + | cells := make([]renderer.Cell, 0, len([]rune(text))) | |||
| 12 | + | for _, r := range text { | |||
| 13 | + | cells = append(cells, renderer.Cell{Rune: r}) | |||
| 14 | + | } | |||
| 15 | + | return cells | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func documentFromStrings(lines []string, separators string) *Document { | |||
| 19 | + | rows := make([][]renderer.Cell, len(lines)) | |||
| 20 | + | for i, line := range lines { | |||
| 21 | + | rows[i] = documentCells(line) | |||
| 22 | + | } | |||
| 23 | + | return NewDocument(NewSnapshotFromRows(rows, 32, 4), separators) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func mustNormalize(t *testing.T, doc *Document, pos Pos) Pos { | |||
| 27 | + | t.Helper() | |||
| 28 | + | got, ok := doc.Normalize(pos) | |||
| 29 | + | require.True(t, ok) | |||
| 30 | + | return got | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func mustPrev(t *testing.T, doc *Document, pos Pos) Pos { | |||
| 34 | + | t.Helper() | |||
| 35 | + | got, ok := doc.PrevGlyph(pos) | |||
| 36 | + | require.True(t, ok) | |||
| 37 | + | return got | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func mustNext(t *testing.T, doc *Document, pos Pos) Pos { | |||
| 41 | + | t.Helper() | |||
| 42 | + | got, ok := doc.NextGlyph(pos) | |||
| 43 | + | require.True(t, ok) | |||
| 44 | + | return got | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func mustWordStart(t *testing.T, doc *Document, pos Pos) Pos { | |||
| 48 | + | t.Helper() | |||
| 49 | + | got, _, ok := doc.WordBounds(pos) | |||
| 50 | + | require.True(t, ok) | |||
| 51 | + | return got | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func mustWordEnd(t *testing.T, doc *Document, pos Pos) Pos { | |||
| 55 | + | t.Helper() | |||
| 56 | + | _, got, ok := doc.WordBounds(pos) | |||
| 57 | + | require.True(t, ok) | |||
| 58 | + | return got | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestDocumentNormalize(t *testing.T) { | |||
| 62 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |||
| 63 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}}, 5, 1), " -_@") | |||
| 64 | + | ||||
| 65 | + | tests := []struct { | |||
| 66 | + | name string | |||
| 67 | + | in Pos | |||
| 68 | + | want Pos | |||
| 69 | + | ok bool | |||
| 70 | + | }{ | |||
| 71 | + | {"glyph head", Pos{0, 1}, Pos{0, 1}, true}, | |||
| 72 | + | {"wide continuation", Pos{0, 2}, Pos{0, 1}, true}, | |||
| 73 | + | {"empty row stable position", Pos{1, 0}, Pos{1, 0}, true}, | |||
| 74 | + | {"negative row", Pos{-1, 0}, Pos{}, false}, | |||
| 75 | + | {"past row", Pos{2, 0}, Pos{}, false}, | |||
| 76 | + | {"negative column", Pos{0, -1}, Pos{}, false}, | |||
| 77 | + | {"past column", Pos{0, 5}, Pos{}, false}, | |||
| 78 | + | {"empty row past column", Pos{1, 1}, Pos{}, false}, | |||
| 79 | + | } | |||
| 80 | + | for _, tt := range tests { | |||
| 81 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 82 | + | got, ok := doc.Normalize(tt.in) | |||
| 83 | + | require.Equal(t, tt.ok, ok) | |||
| 84 | + | if ok { | |||
| 85 | + | require.Equal(t, tt.want, got) | |||
| 86 | + | } | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestDocumentGlyphNavigation(t *testing.T) { | |||
| 92 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |||
| 93 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide}, 5, 1), " -_@") | |||
| 94 | + | ||||
| 95 | + | tests := []struct { | |||
| 96 | + | name string | |||
| 97 | + | fn func(Pos) (Pos, bool) | |||
| 98 | + | in Pos | |||
| 99 | + | want Pos | |||
| 100 | + | ok bool | |||
| 101 | + | }{ | |||
| 102 | + | {"next narrow to wide", doc.NextGlyph, Pos{0, 0}, Pos{0, 1}, true}, | |||
| 103 | + | {"next skips continuation", doc.NextGlyph, Pos{0, 1}, Pos{0, 3}, true}, | |||
| 104 | + | {"next at end", doc.NextGlyph, Pos{0, 4}, Pos{}, false}, | |||
| 105 | + | {"previous skips continuation", doc.PrevGlyph, Pos{0, 3}, Pos{0, 1}, true}, | |||
| 106 | + | {"previous continuation normalizes", doc.PrevGlyph, Pos{0, 2}, Pos{0, 0}, true}, | |||
| 107 | + | {"previous at start", doc.PrevGlyph, Pos{0, 0}, Pos{}, false}, | |||
| 108 | + | } | |||
| 109 | + | for _, tt := range tests { | |||
| 110 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 111 | + | got, ok := tt.fn(tt.in) | |||
| 112 | + | require.Equal(t, tt.ok, ok) | |||
| 113 | + | if ok { | |||
| 114 | + | require.Equal(t, tt.want, got) | |||
| 115 | + | } | |||
| 116 | + | }) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestDocumentWordBounds(t *testing.T) { | |||
| 121 | + | doc := documentFromStrings([]string{"alpha/beta gamma", "été\u2003東京", ""}, "/") | |||
| 122 | + | ||||
| 123 | + | tests := []struct { | |||
| 124 | + | name string | |||
| 125 | + | in Pos | |||
| 126 | + | start Pos | |||
| 127 | + | end Pos | |||
| 128 | + | ok bool | |||
| 129 | + | }{ | |||
| 130 | + | {"configured separator", Pos{0, 5}, Pos{}, Pos{}, false}, | |||
| 131 | + | {"second word", Pos{0, 8}, Pos{0, 6}, Pos{0, 9}, true}, | |||
| 132 | + | {"unicode whitespace", Pos{1, 3}, Pos{}, Pos{}, false}, | |||
| 133 | + | {"unicode word", Pos{1, 5}, Pos{1, 4}, Pos{1, 5}, true}, | |||
| 134 | + | {"empty row", Pos{2, 0}, Pos{}, Pos{}, false}, | |||
| 135 | + | {"out of range", Pos{4, 0}, Pos{}, Pos{}, false}, | |||
| 136 | + | } | |||
| 137 | + | for _, tt := range tests { | |||
| 138 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 139 | + | start, end, ok := doc.WordBounds(tt.in) | |||
| 140 | + | require.Equal(t, tt.ok, ok) | |||
| 141 | + | if ok { | |||
| 142 | + | require.Equal(t, tt.start, start) | |||
| 143 | + | require.Equal(t, tt.end, end) | |||
| 144 | + | } | |||
| 145 | + | }) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestDocumentWordMovement(t *testing.T) { | |||
| 150 | + | doc := documentFromStrings([]string{"alpha/beta", " gamma", "delta"}, "/") | |||
| 151 | + | ||||
| 152 | + | tests := []struct { | |||
| 153 | + | name string | |||
| 154 | + | fn func(Pos) (Pos, bool) | |||
| 155 | + | in Pos | |||
| 156 | + | want Pos | |||
| 157 | + | }{ | |||
| 158 | + | {"next skips current word and separator", doc.NextWordStart, Pos{0, 2}, Pos{0, 6}}, | |||
| 159 | + | {"next crosses rows", doc.NextWordStart, Pos{0, 7}, Pos{1, 1}}, | |||
| 160 | + | {"previous returns current word start", doc.PreviousWordStart, Pos{1, 3}, Pos{1, 1}}, | |||
| 161 | + | {"previous crosses rows", doc.PreviousWordStart, Pos{1, 1}, Pos{0, 6}}, | |||
| 162 | + | {"next end returns current word end", doc.NextWordEnd, Pos{0, 2}, Pos{0, 4}}, | |||
| 163 | + | {"next end skips separators", doc.NextWordEnd, Pos{0, 5}, Pos{0, 9}}, | |||
| 164 | + | } | |||
| 165 | + | for _, tt := range tests { | |||
| 166 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 167 | + | got, ok := tt.fn(tt.in) | |||
| 168 | + | require.True(t, ok) | |||
| 169 | + | require.Equal(t, tt.want, got) | |||
| 170 | + | }) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestDocumentExtractRanges(t *testing.T) { | |||
| 175 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |||
| 176 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | |||
| 177 | + | ||||
| 178 | + | tests := []struct { | |||
| 179 | + | name string | |||
| 180 | + | ranges []CellRange | |||
| 181 | + | linewise bool | |||
| 182 | + | want string | |||
| 183 | + | }{ | |||
| 184 | + | {"inclusive partial multiline preserves spaces", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, false, "pha \nbe"}, | |||
| 185 | + | {"linewise trims trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, true, "alpha"}, | |||
| 186 | + | {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, false, "界"}, | |||
| 187 | + | {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, false, "界"}, | |||
| 188 | + | {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, false, ""}, | |||
| 189 | + | {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, false, ""}, | |||
| 190 | + | {"no ranges", nil, false, ""}, | |||
| 191 | + | } | |||
| 192 | + | for _, tt := range tests { | |||
| 193 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 194 | + | require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise)) | |||
| 195 | + | }) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) { | |||
| 200 | + | rows := [][]renderer.Cell{documentCells("one")} | |||
| 201 | + | doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_") | |||
| 202 | + | rows[0][0].Rune = 'x' | |||
| 203 | + | ||||
| 204 | + | require.Equal(t, 1, doc.Len()) | |||
| 205 | + | require.Equal(t, 9, doc.Width()) | |||
| 206 | + | require.Equal(t, 2, doc.Height()) | |||
| 207 | + | require.Equal(t, "one", doc.LineText(0)) | |||
| 208 | + | require.Equal(t, doc.Snapshot().Row(0), doc.Row(0)) | |||
| 209 | + | require.Nil(t, doc.Row(1)) | |||
| 210 | + | } | |||