perf(copy): avoid full document word scans
- commit
- ef1f76fe7df932d4bd100c1e41c7ccaa26105ad7
- author
- brice · · 2026-07-15 07:14 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- 6af2c31
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +187 -85
M internal/usecase/copy/document.go +102 -85
| @@ -121,29 +121,40 @@ func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) { | |||
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | 123 | // NextWordStart returns the beginning of the next word after pos, crossing | |
| 124 | - | // physical rows as necessary. | |
| 124 | + | // physical rows as necessary. It walks only the rows between pos and the | |
| 125 | + | // target instead of materializing every glyph in the document. | |
| 125 | 126 | func (d *Document) NextWordStart(pos Pos) (Pos, bool) { | |
| 126 | 127 | pos, ok := d.Normalize(pos) | |
| 127 | 128 | if !ok { | |
| 128 | 129 | return Pos{}, false | |
| 129 | 130 | } | |
| 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++ | |
| 131 | + | ||
| 132 | + | candidate := pos | |
| 133 | + | if d.isGlyph(candidate) && !d.isSeparator(d.runeAt(candidate)) { | |
| 134 | + | for { | |
| 135 | + | next, ok := d.nextDocumentGlyph(candidate) | |
| 136 | + | if !ok { | |
| 137 | + | return Pos{}, false | |
| 138 | + | } | |
| 139 | + | if !d.sameWord(next, candidate) { | |
| 140 | + | candidate = next | |
| 141 | + | break | |
| 142 | + | } | |
| 143 | + | candidate = next | |
| 144 | + | } | |
| 145 | + | } else { | |
| 146 | + | candidate, ok = d.nextDocumentGlyph(candidate) | |
| 147 | + | if !ok { | |
| 148 | + | return Pos{}, false | |
| 138 | 149 | } | |
| 139 | 150 | } | |
| 140 | - | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | |
| 141 | - | i++ | |
| 142 | - | } | |
| 143 | - | if i < len(glyphs) { | |
| 144 | - | return glyphs[i], true | |
| 151 | + | for d.isSeparator(d.runeAt(candidate)) { | |
| 152 | + | candidate, ok = d.nextDocumentGlyph(candidate) | |
| 153 | + | if !ok { | |
| 154 | + | return Pos{}, false | |
| 155 | + | } | |
| 145 | 156 | } | |
| 146 | - | return Pos{}, false | |
| 157 | + | return candidate, true | |
| 147 | 158 | } | |
| 148 | 159 | ||
| 149 | 160 | // PreviousWordStart returns the beginning of the current word, or of the | |
| @@ -153,58 +164,71 @@ func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) { | |||
| 153 | 164 | if !ok { | |
| 154 | 165 | return Pos{}, false | |
| 155 | 166 | } | |
| 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-- | |
| 167 | + | ||
| 168 | + | if d.isGlyph(pos) && !d.isSeparator(d.runeAt(pos)) { | |
| 169 | + | previous, ok := d.previousDocumentGlyph(pos) | |
| 170 | + | if ok && d.sameWord(previous, pos) { | |
| 171 | + | for d.sameWord(previous, pos) { | |
| 172 | + | pos = previous | |
| 173 | + | previous, ok = d.previousDocumentGlyph(pos) | |
| 174 | + | if !ok { | |
| 175 | + | break | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return pos, true | |
| 164 | 179 | } | |
| 165 | - | return glyphs[i], true | |
| 166 | - | } | |
| 167 | - | i-- | |
| 168 | - | for i >= 0 && d.isSeparator(d.runeAt(glyphs[i])) { | |
| 169 | - | i-- | |
| 170 | 180 | } | |
| 171 | - | if i < 0 { | |
| 181 | + | ||
| 182 | + | candidate, ok := d.previousDocumentGlyph(pos) | |
| 183 | + | if !ok { | |
| 172 | 184 | return Pos{}, false | |
| 173 | 185 | } | |
| 174 | - | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |
| 175 | - | i-- | |
| 186 | + | for d.isSeparator(d.runeAt(candidate)) { | |
| 187 | + | candidate, ok = d.previousDocumentGlyph(candidate) | |
| 188 | + | if !ok { | |
| 189 | + | return Pos{}, false | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | for { | |
| 193 | + | previous, ok := d.previousDocumentGlyph(candidate) | |
| 194 | + | if !ok || !d.sameWord(previous, candidate) { | |
| 195 | + | return candidate, true | |
| 196 | + | } | |
| 197 | + | candidate = previous | |
| 176 | 198 | } | |
| 177 | - | return glyphs[i], true | |
| 178 | 199 | } | |
| 179 | 200 | ||
| 180 | 201 | // NextWordEnd returns the inclusive end of the current word, or of the next | |
| 181 | 202 | // word when pos is on a separator or at the end of a word. | |
| 182 | 203 | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | |
| 183 | 204 | pos, ok := d.Normalize(pos) | |
| 184 | - | if !ok { | |
| 205 | + | if !ok || !d.isGlyph(pos) { | |
| 185 | 206 | return Pos{}, false | |
| 186 | 207 | } | |
| 187 | - | glyphs := d.glyphs() | |
| 188 | - | i := d.glyphIndex(glyphs, pos) | |
| 189 | - | if i < 0 { | |
| 190 | - | return Pos{}, false | |
| 191 | - | } | |
| 192 | - | if !d.isSeparator(d.runeAt(glyphs[i])) && | |
| 193 | - | (i+1 == len(glyphs) || !d.sameWord(glyphs[i+1], glyphs[i])) { | |
| 194 | - | i++ | |
| 195 | - | } | |
| 196 | - | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | |
| 197 | - | i++ | |
| 208 | + | ||
| 209 | + | candidate := pos | |
| 210 | + | if !d.isSeparator(d.runeAt(candidate)) { | |
| 211 | + | next, ok := d.nextDocumentGlyph(candidate) | |
| 212 | + | if !ok { | |
| 213 | + | return Pos{}, false | |
| 214 | + | } | |
| 215 | + | if !d.sameWord(next, candidate) { | |
| 216 | + | candidate = next | |
| 217 | + | } | |
| 198 | 218 | } | |
| 199 | - | if i == len(glyphs) { | |
| 200 | - | return Pos{}, false | |
| 219 | + | for d.isSeparator(d.runeAt(candidate)) { | |
| 220 | + | candidate, ok = d.nextDocumentGlyph(candidate) | |
| 221 | + | if !ok { | |
| 222 | + | return Pos{}, false | |
| 223 | + | } | |
| 201 | 224 | } | |
| 202 | - | end := glyphs[i] | |
| 203 | - | for i+1 < len(glyphs) && d.sameWord(glyphs[i+1], glyphs[i]) { | |
| 204 | - | i++ | |
| 205 | - | end = glyphs[i] | |
| 225 | + | for { | |
| 226 | + | next, ok := d.nextDocumentGlyph(candidate) | |
| 227 | + | if !ok || !d.sameWord(next, candidate) { | |
| 228 | + | return candidate, true | |
| 229 | + | } | |
| 230 | + | candidate = next | |
| 206 | 231 | } | |
| 207 | - | return end, true | |
| 208 | 232 | } | |
| 209 | 233 | ||
| 210 | 234 | // LineText returns row text without duplicated continuation cells and retains | |
| @@ -299,49 +323,42 @@ func (d *Document) runeAt(pos Pos) rune { | |||
| 299 | 323 | return cell.Rune | |
| 300 | 324 | } | |
| 301 | 325 | ||
| 302 | - | func (d *Document) glyphs() []Pos { | |
| 303 | - | glyphs := make([]Pos, 0) | |
| 304 | - | for row := 0; row < d.Len(); row++ { | |
| 305 | - | for col, cell := range d.Row(row) { | |
| 306 | - | if !cell.Continuation { | |
| 307 | - | glyphs = append(glyphs, Pos{Row: row, Col: col}) | |
| 326 | + | // nextDocumentGlyph finds the next glyph in document order without building | |
| 327 | + | // an index for unrelated scrollback rows. | |
| 328 | + | func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) { | |
| 329 | + | for row := pos.Row; row < d.Len(); row++ { | |
| 330 | + | cells := d.Row(row) | |
| 331 | + | start := 0 | |
| 332 | + | if row == pos.Row { | |
| 333 | + | start = pos.Col + 1 | |
| 334 | + | } | |
| 335 | + | for col := start; col < len(cells); col++ { | |
| 336 | + | if !cells[col].Continuation { | |
| 337 | + | return Pos{Row: row, Col: col}, true | |
| 308 | 338 | } | |
| 309 | 339 | } | |
| 310 | 340 | } | |
| 311 | - | return glyphs | |
| 341 | + | return Pos{}, false | |
| 312 | 342 | } | |
| 313 | 343 | ||
| 314 | - | func (d *Document) glyphIndex(glyphs []Pos, pos Pos) int { | |
| 315 | - | for i, glyph := range glyphs { | |
| 316 | - | if glyph == pos { | |
| 317 | - | return i | |
| 344 | + | // previousDocumentGlyph finds the previous glyph in document order without | |
| 345 | + | // building an index for unrelated scrollback rows. | |
| 346 | + | func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) { | |
| 347 | + | for row := pos.Row; row >= 0; row-- { | |
| 348 | + | cells := d.Row(row) | |
| 349 | + | start := len(cells) - 1 | |
| 350 | + | if row == pos.Row { | |
| 351 | + | start = min(pos.Col-1, start) | |
| 318 | 352 | } | |
| 319 | - | } | |
| 320 | - | return -1 | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | func (d *Document) sameWord(left, right Pos) bool { | |
| 324 | - | return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right)) | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | func (d *Document) firstWordAfterRow(glyphs []Pos, row int) (Pos, bool) { | |
| 328 | - | for _, glyph := range glyphs { | |
| 329 | - | if glyph.Row > row && !d.isSeparator(d.runeAt(glyph)) { | |
| 330 | - | return glyph, true | |
| 353 | + | for col := start; col >= 0; col-- { | |
| 354 | + | if !cells[col].Continuation { | |
| 355 | + | return Pos{Row: row, Col: col}, true | |
| 356 | + | } | |
| 331 | 357 | } | |
| 332 | 358 | } | |
| 333 | 359 | return Pos{}, false | |
| 334 | 360 | } | |
| 335 | 361 | ||
| 336 | - | func (d *Document) lastWordBeforeRow(glyphs []Pos, row int) (Pos, bool) { | |
| 337 | - | for i := len(glyphs) - 1; i >= 0; i-- { | |
| 338 | - | if glyphs[i].Row >= row || d.isSeparator(d.runeAt(glyphs[i])) { | |
| 339 | - | continue | |
| 340 | - | } | |
| 341 | - | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |
| 342 | - | i-- | |
| 343 | - | } | |
| 344 | - | return glyphs[i], true | |
| 345 | - | } | |
| 346 | - | return Pos{}, false | |
| 362 | + | func (d *Document) sameWord(left, right Pos) bool { | |
| 363 | + | return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right)) | |
| 347 | 364 | } | |
| @@ -121,29 +121,40 @@ func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) { | |||||
| 121 | } | 121 | } | ||
| 122 | 122 | ||||
| 123 | // NextWordStart returns the beginning of the next word after pos, crossing | 123 | // NextWordStart returns the beginning of the next word after pos, crossing | ||
| 124 | - | // physical rows as necessary. | 124 | + | // physical rows as necessary. It walks only the rows between pos and the |
| 125 | + | // target instead of materializing every glyph in the document. | |||
| 125 | func (d *Document) NextWordStart(pos Pos) (Pos, bool) { | 126 | func (d *Document) NextWordStart(pos Pos) (Pos, bool) { | ||
| 126 | pos, ok := d.Normalize(pos) | 127 | pos, ok := d.Normalize(pos) | ||
| 127 | if !ok { | 128 | if !ok { | ||
| 128 | return Pos{}, false | 129 | return Pos{}, false | ||
| 129 | } | 130 | } | ||
| 130 | - | glyphs := d.glyphs() | 131 | + | |
| 131 | - | i := d.glyphIndex(glyphs, pos) | 132 | + | candidate := pos |
| 132 | - | if i < 0 { | 133 | + | if d.isGlyph(candidate) && !d.isSeparator(d.runeAt(candidate)) { |
| 133 | - | return d.firstWordAfterRow(glyphs, pos.Row) | 134 | + | for { |
| 134 | - | } | 135 | + | next, ok := d.nextDocumentGlyph(candidate) |
| 135 | - | if !d.isSeparator(d.runeAt(glyphs[i])) { | 136 | + | if !ok { |
| 136 | - | for i < len(glyphs) && d.sameWord(glyphs[i], pos) { | 137 | + | return Pos{}, false |
| 137 | - | i++ | 138 | + | } |
| 139 | + | if !d.sameWord(next, candidate) { | |||
| 140 | + | candidate = next | |||
| 141 | + | break | |||
| 142 | + | } | |||
| 143 | + | candidate = next | |||
| 144 | + | } | |||
| 145 | + | } else { | |||
| 146 | + | candidate, ok = d.nextDocumentGlyph(candidate) | |||
| 147 | + | if !ok { | |||
| 148 | + | return Pos{}, false | |||
| 138 | } | 149 | } | ||
| 139 | } | 150 | } | ||
| 140 | - | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | 151 | + | for d.isSeparator(d.runeAt(candidate)) { |
| 141 | - | i++ | 152 | + | candidate, ok = d.nextDocumentGlyph(candidate) |
| 142 | - | } | 153 | + | if !ok { |
| 143 | - | if i < len(glyphs) { | 154 | + | return Pos{}, false |
| 144 | - | return glyphs[i], true | 155 | + | } |
| 145 | } | 156 | } | ||
| 146 | - | return Pos{}, false | 157 | + | return candidate, true |
| 147 | } | 158 | } | ||
| 148 | 159 | ||||
| 149 | // PreviousWordStart returns the beginning of the current word, or of the | 160 | // PreviousWordStart returns the beginning of the current word, or of the | ||
| @@ -153,58 +164,71 @@ func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) { | |||||
| 153 | if !ok { | 164 | if !ok { | ||
| 154 | return Pos{}, false | 165 | return Pos{}, false | ||
| 155 | } | 166 | } | ||
| 156 | - | glyphs := d.glyphs() | 167 | + | |
| 157 | - | i := d.glyphIndex(glyphs, pos) | 168 | + | if d.isGlyph(pos) && !d.isSeparator(d.runeAt(pos)) { |
| 158 | - | if i < 0 { | 169 | + | previous, ok := d.previousDocumentGlyph(pos) |
| 159 | - | return d.lastWordBeforeRow(glyphs, pos.Row) | 170 | + | if ok && d.sameWord(previous, pos) { |
| 160 | - | } | 171 | + | for d.sameWord(previous, pos) { |
| 161 | - | if !d.isSeparator(d.runeAt(glyphs[i])) && i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | 172 | + | pos = previous |
| 162 | - | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | 173 | + | previous, ok = d.previousDocumentGlyph(pos) |
| 163 | - | i-- | 174 | + | if !ok { |
| 175 | + | break | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | return pos, true | |||
| 164 | } | 179 | } | ||
| 165 | - | return glyphs[i], true | |||
| 166 | - | } | |||
| 167 | - | i-- | |||
| 168 | - | for i >= 0 && d.isSeparator(d.runeAt(glyphs[i])) { | |||
| 169 | - | i-- | |||
| 170 | } | 180 | } | ||
| 171 | - | if i < 0 { | 181 | + | |
| 182 | + | candidate, ok := d.previousDocumentGlyph(pos) | |||
| 183 | + | if !ok { | |||
| 172 | return Pos{}, false | 184 | return Pos{}, false | ||
| 173 | } | 185 | } | ||
| 174 | - | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | 186 | + | for d.isSeparator(d.runeAt(candidate)) { |
| 175 | - | i-- | 187 | + | candidate, ok = d.previousDocumentGlyph(candidate) |
| 188 | + | if !ok { | |||
| 189 | + | return Pos{}, false | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | for { | |||
| 193 | + | previous, ok := d.previousDocumentGlyph(candidate) | |||
| 194 | + | if !ok || !d.sameWord(previous, candidate) { | |||
| 195 | + | return candidate, true | |||
| 196 | + | } | |||
| 197 | + | candidate = previous | |||
| 176 | } | 198 | } | ||
| 177 | - | return glyphs[i], true | |||
| 178 | } | 199 | } | ||
| 179 | 200 | ||||
| 180 | // NextWordEnd returns the inclusive end of the current word, or of the next | 201 | // NextWordEnd returns the inclusive end of the current word, or of the next | ||
| 181 | // word when pos is on a separator or at the end of a word. | 202 | // word when pos is on a separator or at the end of a word. | ||
| 182 | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | 203 | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | ||
| 183 | pos, ok := d.Normalize(pos) | 204 | pos, ok := d.Normalize(pos) | ||
| 184 | - | if !ok { | 205 | + | if !ok || !d.isGlyph(pos) { |
| 185 | return Pos{}, false | 206 | return Pos{}, false | ||
| 186 | } | 207 | } | ||
| 187 | - | glyphs := d.glyphs() | 208 | + | |
| 188 | - | i := d.glyphIndex(glyphs, pos) | 209 | + | candidate := pos |
| 189 | - | if i < 0 { | 210 | + | if !d.isSeparator(d.runeAt(candidate)) { |
| 190 | - | return Pos{}, false | 211 | + | next, ok := d.nextDocumentGlyph(candidate) |
| 191 | - | } | 212 | + | if !ok { |
| 192 | - | if !d.isSeparator(d.runeAt(glyphs[i])) && | 213 | + | return Pos{}, false |
| 193 | - | (i+1 == len(glyphs) || !d.sameWord(glyphs[i+1], glyphs[i])) { | 214 | + | } |
| 194 | - | i++ | 215 | + | if !d.sameWord(next, candidate) { |
| 195 | - | } | 216 | + | candidate = next |
| 196 | - | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | 217 | + | } |
| 197 | - | i++ | |||
| 198 | } | 218 | } | ||
| 199 | - | if i == len(glyphs) { | 219 | + | for d.isSeparator(d.runeAt(candidate)) { |
| 200 | - | return Pos{}, false | 220 | + | candidate, ok = d.nextDocumentGlyph(candidate) |
| 221 | + | if !ok { | |||
| 222 | + | return Pos{}, false | |||
| 223 | + | } | |||
| 201 | } | 224 | } | ||
| 202 | - | end := glyphs[i] | 225 | + | for { |
| 203 | - | for i+1 < len(glyphs) && d.sameWord(glyphs[i+1], glyphs[i]) { | 226 | + | next, ok := d.nextDocumentGlyph(candidate) |
| 204 | - | i++ | 227 | + | if !ok || !d.sameWord(next, candidate) { |
| 205 | - | end = glyphs[i] | 228 | + | return candidate, true |
| 229 | + | } | |||
| 230 | + | candidate = next | |||
| 206 | } | 231 | } | ||
| 207 | - | return end, true | |||
| 208 | } | 232 | } | ||
| 209 | 233 | ||||
| 210 | // LineText returns row text without duplicated continuation cells and retains | 234 | // LineText returns row text without duplicated continuation cells and retains | ||
| @@ -299,49 +323,42 @@ func (d *Document) runeAt(pos Pos) rune { | |||||
| 299 | return cell.Rune | 323 | return cell.Rune | ||
| 300 | } | 324 | } | ||
| 301 | 325 | ||||
| 302 | - | func (d *Document) glyphs() []Pos { | 326 | + | // nextDocumentGlyph finds the next glyph in document order without building |
| 303 | - | glyphs := make([]Pos, 0) | 327 | + | // an index for unrelated scrollback rows. |
| 304 | - | for row := 0; row < d.Len(); row++ { | 328 | + | func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) { |
| 305 | - | for col, cell := range d.Row(row) { | 329 | + | for row := pos.Row; row < d.Len(); row++ { |
| 306 | - | if !cell.Continuation { | 330 | + | cells := d.Row(row) |
| 307 | - | glyphs = append(glyphs, Pos{Row: row, Col: col}) | 331 | + | start := 0 |
| 332 | + | if row == pos.Row { | |||
| 333 | + | start = pos.Col + 1 | |||
| 334 | + | } | |||
| 335 | + | for col := start; col < len(cells); col++ { | |||
| 336 | + | if !cells[col].Continuation { | |||
| 337 | + | return Pos{Row: row, Col: col}, true | |||
| 308 | } | 338 | } | ||
| 309 | } | 339 | } | ||
| 310 | } | 340 | } | ||
| 311 | - | return glyphs | 341 | + | return Pos{}, false |
| 312 | } | 342 | } | ||
| 313 | 343 | ||||
| 314 | - | func (d *Document) glyphIndex(glyphs []Pos, pos Pos) int { | 344 | + | // previousDocumentGlyph finds the previous glyph in document order without |
| 315 | - | for i, glyph := range glyphs { | 345 | + | // building an index for unrelated scrollback rows. |
| 316 | - | if glyph == pos { | 346 | + | func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) { |
| 317 | - | return i | 347 | + | for row := pos.Row; row >= 0; row-- { |
| 348 | + | cells := d.Row(row) | |||
| 349 | + | start := len(cells) - 1 | |||
| 350 | + | if row == pos.Row { | |||
| 351 | + | start = min(pos.Col-1, start) | |||
| 318 | } | 352 | } | ||
| 319 | - | } | 353 | + | for col := start; col >= 0; col-- { |
| 320 | - | return -1 | 354 | + | if !cells[col].Continuation { |
| 321 | - | } | 355 | + | return Pos{Row: row, Col: col}, true |
| 322 | - | 356 | + | } | |
| 323 | - | func (d *Document) sameWord(left, right Pos) bool { | |||
| 324 | - | return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right)) | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | func (d *Document) firstWordAfterRow(glyphs []Pos, row int) (Pos, bool) { | |||
| 328 | - | for _, glyph := range glyphs { | |||
| 329 | - | if glyph.Row > row && !d.isSeparator(d.runeAt(glyph)) { | |||
| 330 | - | return glyph, true | |||
| 331 | } | 357 | } | ||
| 332 | } | 358 | } | ||
| 333 | return Pos{}, false | 359 | return Pos{}, false | ||
| 334 | } | 360 | } | ||
| 335 | 361 | ||||
| 336 | - | func (d *Document) lastWordBeforeRow(glyphs []Pos, row int) (Pos, bool) { | 362 | + | func (d *Document) sameWord(left, right Pos) bool { |
| 337 | - | for i := len(glyphs) - 1; i >= 0; i-- { | 363 | + | return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right)) |
| 338 | - | if glyphs[i].Row >= row || d.isSeparator(d.runeAt(glyphs[i])) { | |||
| 339 | - | continue | |||
| 340 | - | } | |||
| 341 | - | for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) { | |||
| 342 | - | i-- | |||
| 343 | - | } | |||
| 344 | - | return glyphs[i], true | |||
| 345 | - | } | |||
| 346 | - | return Pos{}, false | |||
| 347 | } | 364 | } | ||
M internal/usecase/copy/document_test.go +85 -0
| @@ -154,6 +154,63 @@ func TestDocumentNextWordEndCrossesEmptyRow(t *testing.T) { | |||
| 154 | 154 | require.Equal(t, Pos{Row: 2, Col: 3}, got) | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | + | func TestDocumentWordMovementPreservesWideSeparatorsEmptyRowsAndEndProgression(t *testing.T) { | |
| 158 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: '/'}, {Rune: 'b'}} | |
| 159 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}, documentCells(" cd")}, 5, 3), "/") | |
| 160 | + | ||
| 161 | + | tests := []struct { | |
| 162 | + | name string | |
| 163 | + | move func(Pos) (Pos, bool) | |
| 164 | + | in Pos | |
| 165 | + | want Pos | |
| 166 | + | }{ | |
| 167 | + | {"next start skips wide word and separator", doc.NextWordStart, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}}, | |
| 168 | + | {"previous start crosses separator", doc.PreviousWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 0, Col: 0}}, | |
| 169 | + | {"end advances from wide word", doc.NextWordEnd, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}}, | |
| 170 | + | {"next start crosses empty row", doc.NextWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 1}}, | |
| 171 | + | {"previous start crosses empty row", doc.PreviousWordStart, Pos{Row: 2, Col: 1}, Pos{Row: 0, Col: 4}}, | |
| 172 | + | {"end crosses empty row", doc.NextWordEnd, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 2}}, | |
| 173 | + | } | |
| 174 | + | for _, tt := range tests { | |
| 175 | + | t.Run(tt.name, func(t *testing.T) { | |
| 176 | + | got, ok := tt.move(tt.in) | |
| 177 | + | require.True(t, ok) | |
| 178 | + | require.Equal(t, tt.want, got) | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestDocumentWordMovementDoesNotAllocateWholeDocument(t *testing.T) { | |
| 184 | + | lines := make([]string, 8_192) | |
| 185 | + | lines[0] = "alpha/beta" | |
| 186 | + | for i := 1; i < len(lines); i++ { | |
| 187 | + | lines[i] = "unrelated" | |
| 188 | + | } | |
| 189 | + | doc := documentFromStrings(lines, "/") | |
| 190 | + | ||
| 191 | + | tests := []struct { | |
| 192 | + | name string | |
| 193 | + | move func(Pos) (Pos, bool) | |
| 194 | + | in Pos | |
| 195 | + | want Pos | |
| 196 | + | }{ | |
| 197 | + | {"next start", doc.NextWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 6}}, | |
| 198 | + | {"previous start", doc.PreviousWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 0}}, | |
| 199 | + | {"next end", doc.NextWordEnd, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 4}}, | |
| 200 | + | } | |
| 201 | + | for _, tt := range tests { | |
| 202 | + | t.Run(tt.name, func(t *testing.T) { | |
| 203 | + | allocs := testing.AllocsPerRun(100, func() { | |
| 204 | + | got, ok := tt.move(tt.in) | |
| 205 | + | if !ok || got != tt.want { | |
| 206 | + | t.Fatalf("move() = (%v, %t), want (%v, true)", got, ok, tt.want) | |
| 207 | + | } | |
| 208 | + | }) | |
| 209 | + | require.Zero(t, allocs, "local word movement must not materialize the document") | |
| 210 | + | }) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 157 | 214 | func TestDocumentExtractRanges(t *testing.T) { | |
| 158 | 215 | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |
| 159 | 216 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | |
| @@ -179,6 +236,34 @@ func TestDocumentExtractRanges(t *testing.T) { | |||
| 179 | 236 | } | |
| 180 | 237 | } | |
| 181 | 238 | ||
| 239 | + | var benchmarkDocumentWordPos Pos | |
| 240 | + | ||
| 241 | + | func BenchmarkDocumentWordMovementLocal(b *testing.B) { | |
| 242 | + | lines := make([]string, 32_768) | |
| 243 | + | lines[0] = "alpha/beta" | |
| 244 | + | for i := 1; i < len(lines); i++ { | |
| 245 | + | lines[i] = "unrelated" | |
| 246 | + | } | |
| 247 | + | doc := documentFromStrings(lines, "/") | |
| 248 | + | moves := []func(Pos) (Pos, bool){ | |
| 249 | + | doc.NextWordStart, | |
| 250 | + | doc.PreviousWordStart, | |
| 251 | + | doc.NextWordEnd, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | b.ReportAllocs() | |
| 255 | + | b.ResetTimer() | |
| 256 | + | for i := 0; i < b.N; i++ { | |
| 257 | + | for _, move := range moves { | |
| 258 | + | pos, ok := move(Pos{Row: 0, Col: 2}) | |
| 259 | + | if !ok { | |
| 260 | + | b.Fatal("local word movement failed") | |
| 261 | + | } | |
| 262 | + | benchmarkDocumentWordPos = pos | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 182 | 267 | func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) { | |
| 183 | 268 | rows := [][]renderer.Cell{documentCells("one")} | |
| 184 | 269 | doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_") | |
| @@ -154,6 +154,63 @@ func TestDocumentNextWordEndCrossesEmptyRow(t *testing.T) { | |||||
| 154 | require.Equal(t, Pos{Row: 2, Col: 3}, got) | 154 | require.Equal(t, Pos{Row: 2, Col: 3}, got) | ||
| 155 | } | 155 | } | ||
| 156 | 156 | ||||
| 157 | + | func TestDocumentWordMovementPreservesWideSeparatorsEmptyRowsAndEndProgression(t *testing.T) { | |||
| 158 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: '/'}, {Rune: 'b'}} | |||
| 159 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}, documentCells(" cd")}, 5, 3), "/") | |||
| 160 | + | ||||
| 161 | + | tests := []struct { | |||
| 162 | + | name string | |||
| 163 | + | move func(Pos) (Pos, bool) | |||
| 164 | + | in Pos | |||
| 165 | + | want Pos | |||
| 166 | + | }{ | |||
| 167 | + | {"next start skips wide word and separator", doc.NextWordStart, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}}, | |||
| 168 | + | {"previous start crosses separator", doc.PreviousWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 0, Col: 0}}, | |||
| 169 | + | {"end advances from wide word", doc.NextWordEnd, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}}, | |||
| 170 | + | {"next start crosses empty row", doc.NextWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 1}}, | |||
| 171 | + | {"previous start crosses empty row", doc.PreviousWordStart, Pos{Row: 2, Col: 1}, Pos{Row: 0, Col: 4}}, | |||
| 172 | + | {"end crosses empty row", doc.NextWordEnd, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 2}}, | |||
| 173 | + | } | |||
| 174 | + | for _, tt := range tests { | |||
| 175 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 176 | + | got, ok := tt.move(tt.in) | |||
| 177 | + | require.True(t, ok) | |||
| 178 | + | require.Equal(t, tt.want, got) | |||
| 179 | + | }) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestDocumentWordMovementDoesNotAllocateWholeDocument(t *testing.T) { | |||
| 184 | + | lines := make([]string, 8_192) | |||
| 185 | + | lines[0] = "alpha/beta" | |||
| 186 | + | for i := 1; i < len(lines); i++ { | |||
| 187 | + | lines[i] = "unrelated" | |||
| 188 | + | } | |||
| 189 | + | doc := documentFromStrings(lines, "/") | |||
| 190 | + | ||||
| 191 | + | tests := []struct { | |||
| 192 | + | name string | |||
| 193 | + | move func(Pos) (Pos, bool) | |||
| 194 | + | in Pos | |||
| 195 | + | want Pos | |||
| 196 | + | }{ | |||
| 197 | + | {"next start", doc.NextWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 6}}, | |||
| 198 | + | {"previous start", doc.PreviousWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 0}}, | |||
| 199 | + | {"next end", doc.NextWordEnd, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 4}}, | |||
| 200 | + | } | |||
| 201 | + | for _, tt := range tests { | |||
| 202 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 203 | + | allocs := testing.AllocsPerRun(100, func() { | |||
| 204 | + | got, ok := tt.move(tt.in) | |||
| 205 | + | if !ok || got != tt.want { | |||
| 206 | + | t.Fatalf("move() = (%v, %t), want (%v, true)", got, ok, tt.want) | |||
| 207 | + | } | |||
| 208 | + | }) | |||
| 209 | + | require.Zero(t, allocs, "local word movement must not materialize the document") | |||
| 210 | + | }) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 157 | func TestDocumentExtractRanges(t *testing.T) { | 214 | func TestDocumentExtractRanges(t *testing.T) { | ||
| 158 | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | 215 | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | ||
| 159 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | 216 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | ||
| @@ -179,6 +236,34 @@ func TestDocumentExtractRanges(t *testing.T) { | |||||
| 179 | } | 236 | } | ||
| 180 | } | 237 | } | ||
| 181 | 238 | ||||
| 239 | + | var benchmarkDocumentWordPos Pos | |||
| 240 | + | ||||
| 241 | + | func BenchmarkDocumentWordMovementLocal(b *testing.B) { | |||
| 242 | + | lines := make([]string, 32_768) | |||
| 243 | + | lines[0] = "alpha/beta" | |||
| 244 | + | for i := 1; i < len(lines); i++ { | |||
| 245 | + | lines[i] = "unrelated" | |||
| 246 | + | } | |||
| 247 | + | doc := documentFromStrings(lines, "/") | |||
| 248 | + | moves := []func(Pos) (Pos, bool){ | |||
| 249 | + | doc.NextWordStart, | |||
| 250 | + | doc.PreviousWordStart, | |||
| 251 | + | doc.NextWordEnd, | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | b.ReportAllocs() | |||
| 255 | + | b.ResetTimer() | |||
| 256 | + | for i := 0; i < b.N; i++ { | |||
| 257 | + | for _, move := range moves { | |||
| 258 | + | pos, ok := move(Pos{Row: 0, Col: 2}) | |||
| 259 | + | if !ok { | |||
| 260 | + | b.Fatal("local word movement failed") | |||
| 261 | + | } | |||
| 262 | + | benchmarkDocumentWordPos = pos | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 182 | func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) { | 267 | func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) { | ||
| 183 | rows := [][]renderer.Cell{documentCells("one")} | 268 | rows := [][]renderer.Cell{documentCells("one")} | ||
| 184 | doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_") | 269 | doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_") | ||