source dump of claude code
at main 1530 lines 47 kB view raw
1import { stringWidth } from '../ink/stringWidth.js' 2import { wrapAnsi } from '../ink/wrapAnsi.js' 3import { 4 firstGrapheme, 5 getGraphemeSegmenter, 6 getWordSegmenter, 7} from './intl.js' 8 9/** 10 * Kill ring for storing killed (cut) text that can be yanked (pasted) with Ctrl+Y. 11 * This is global state that shares one kill ring across all input fields. 12 * 13 * Consecutive kills accumulate in the kill ring until the user types some 14 * other key. Alt+Y cycles through previous kills after a yank. 15 */ 16const KILL_RING_MAX_SIZE = 10 17let killRing: string[] = [] 18let killRingIndex = 0 19let lastActionWasKill = false 20 21// Track yank state for yank-pop (alt-y) 22let lastYankStart = 0 23let lastYankLength = 0 24let lastActionWasYank = false 25 26export function pushToKillRing( 27 text: string, 28 direction: 'prepend' | 'append' = 'append', 29): void { 30 if (text.length > 0) { 31 if (lastActionWasKill && killRing.length > 0) { 32 // Accumulate with the most recent kill 33 if (direction === 'prepend') { 34 killRing[0] = text + killRing[0] 35 } else { 36 killRing[0] = killRing[0] + text 37 } 38 } else { 39 // Add new entry to front of ring 40 killRing.unshift(text) 41 if (killRing.length > KILL_RING_MAX_SIZE) { 42 killRing.pop() 43 } 44 } 45 lastActionWasKill = true 46 // Reset yank state when killing new text 47 lastActionWasYank = false 48 } 49} 50 51export function getLastKill(): string { 52 return killRing[0] ?? '' 53} 54 55export function getKillRingItem(index: number): string { 56 if (killRing.length === 0) return '' 57 const normalizedIndex = 58 ((index % killRing.length) + killRing.length) % killRing.length 59 return killRing[normalizedIndex] ?? '' 60} 61 62export function getKillRingSize(): number { 63 return killRing.length 64} 65 66export function clearKillRing(): void { 67 killRing = [] 68 killRingIndex = 0 69 lastActionWasKill = false 70 lastActionWasYank = false 71 lastYankStart = 0 72 lastYankLength = 0 73} 74 75export function resetKillAccumulation(): void { 76 lastActionWasKill = false 77} 78 79// Yank tracking for yank-pop 80export function recordYank(start: number, length: number): void { 81 lastYankStart = start 82 lastYankLength = length 83 lastActionWasYank = true 84 killRingIndex = 0 85} 86 87export function canYankPop(): boolean { 88 return lastActionWasYank && killRing.length > 1 89} 90 91export function yankPop(): { 92 text: string 93 start: number 94 length: number 95} | null { 96 if (!lastActionWasYank || killRing.length <= 1) { 97 return null 98 } 99 // Cycle to next item in kill ring 100 killRingIndex = (killRingIndex + 1) % killRing.length 101 const text = killRing[killRingIndex] ?? '' 102 return { text, start: lastYankStart, length: lastYankLength } 103} 104 105export function updateYankLength(length: number): void { 106 lastYankLength = length 107} 108 109export function resetYankState(): void { 110 lastActionWasYank = false 111} 112 113/** 114 * Text Processing Flow for Unicode Normalization: 115 * 116 * User Input (raw text, potentially mixed NFD/NFC) 117 * ↓ 118 * MeasuredText (normalizes to NFC + builds grapheme info) 119 * ↓ 120 * All cursor operations use normalized text/offsets 121 * ↓ 122 * Display uses normalized text from wrappedLines 123 * 124 * This flow ensures consistent Unicode handling: 125 * - NFD/NFC normalization differences don't break cursor movement 126 * - Grapheme clusters (like 👨‍👩‍👧‍👦) are treated as single units 127 * - Display width calculations are accurate for CJK characters 128 * 129 * RULE: Once text enters MeasuredText, all operations 130 * work on the normalized version. 131 */ 132 133// Pre-compiled regex patterns for Vim word detection (avoid creating in hot loops) 134export const VIM_WORD_CHAR_REGEX = /^[\p{L}\p{N}\p{M}_]$/u 135export const WHITESPACE_REGEX = /\s/ 136 137// Exported helper functions for Vim character classification 138export const isVimWordChar = (ch: string): boolean => 139 VIM_WORD_CHAR_REGEX.test(ch) 140export const isVimWhitespace = (ch: string): boolean => 141 WHITESPACE_REGEX.test(ch) 142export const isVimPunctuation = (ch: string): boolean => 143 ch.length > 0 && !isVimWhitespace(ch) && !isVimWordChar(ch) 144 145type WrappedText = string[] 146type Position = { 147 line: number 148 column: number 149} 150 151export class Cursor { 152 readonly offset: number 153 constructor( 154 readonly measuredText: MeasuredText, 155 offset: number = 0, 156 readonly selection: number = 0, 157 ) { 158 // it's ok for the cursor to be 1 char beyond the end of the string 159 this.offset = Math.max(0, Math.min(this.text.length, offset)) 160 } 161 162 static fromText( 163 text: string, 164 columns: number, 165 offset: number = 0, 166 selection: number = 0, 167 ): Cursor { 168 // make MeasuredText on less than columns width, to account for cursor 169 return new Cursor(new MeasuredText(text, columns - 1), offset, selection) 170 } 171 172 getViewportStartLine(maxVisibleLines?: number): number { 173 if (maxVisibleLines === undefined || maxVisibleLines <= 0) return 0 174 const { line } = this.getPosition() 175 const allLines = this.measuredText.getWrappedText() 176 if (allLines.length <= maxVisibleLines) return 0 177 const half = Math.floor(maxVisibleLines / 2) 178 let startLine = Math.max(0, line - half) 179 const endLine = Math.min(allLines.length, startLine + maxVisibleLines) 180 if (endLine - startLine < maxVisibleLines) { 181 startLine = Math.max(0, endLine - maxVisibleLines) 182 } 183 return startLine 184 } 185 186 getViewportCharOffset(maxVisibleLines?: number): number { 187 const startLine = this.getViewportStartLine(maxVisibleLines) 188 if (startLine === 0) return 0 189 const wrappedLines = this.measuredText.getWrappedLines() 190 return wrappedLines[startLine]?.startOffset ?? 0 191 } 192 193 getViewportCharEnd(maxVisibleLines?: number): number { 194 const startLine = this.getViewportStartLine(maxVisibleLines) 195 const allLines = this.measuredText.getWrappedLines() 196 if (maxVisibleLines === undefined || maxVisibleLines <= 0) 197 return this.text.length 198 const endLine = Math.min(allLines.length, startLine + maxVisibleLines) 199 if (endLine >= allLines.length) return this.text.length 200 return allLines[endLine]?.startOffset ?? this.text.length 201 } 202 203 render( 204 cursorChar: string, 205 mask: string, 206 invert: (text: string) => string, 207 ghostText?: { text: string; dim: (text: string) => string }, 208 maxVisibleLines?: number, 209 ) { 210 const { line, column } = this.getPosition() 211 const allLines = this.measuredText.getWrappedText() 212 213 const startLine = this.getViewportStartLine(maxVisibleLines) 214 const endLine = 215 maxVisibleLines !== undefined && maxVisibleLines > 0 216 ? Math.min(allLines.length, startLine + maxVisibleLines) 217 : allLines.length 218 219 return allLines 220 .slice(startLine, endLine) 221 .map((text, i) => { 222 const currentLine = i + startLine 223 let displayText = text 224 if (mask) { 225 const graphemes = Array.from(getGraphemeSegmenter().segment(text)) 226 if (currentLine === allLines.length - 1) { 227 // Last line: mask all but the trailing 6 chars so the user can 228 // confirm they pasted the right thing without exposing the full token 229 const visibleCount = Math.min(6, graphemes.length) 230 const maskCount = graphemes.length - visibleCount 231 const splitOffset = 232 graphemes.length > visibleCount ? graphemes[maskCount]!.index : 0 233 displayText = mask.repeat(maskCount) + text.slice(splitOffset) 234 } else { 235 // Earlier wrapped lines: fully mask. Previously only the last line 236 // was masked, leaking the start of the token on narrow terminals 237 // where the pasted OAuth code wraps across multiple lines. 238 displayText = mask.repeat(graphemes.length) 239 } 240 } 241 // looking for the line with the cursor 242 if (line !== currentLine) return displayText.trimEnd() 243 244 // Split the line into before/at/after cursor in a single pass over the 245 // graphemes, accumulating display width until we reach the cursor column. 246 // This replaces a two-pass approach (displayWidthToStringIndex + a second 247 // segmenter pass) — the intermediate stringIndex from that approach is 248 // always a grapheme boundary, so the "cursor in the middle of a 249 // multi-codepoint character" branch was unreachable. 250 let beforeCursor = '' 251 let atCursor = cursorChar 252 let afterCursor = '' 253 let currentWidth = 0 254 let cursorFound = false 255 256 for (const { segment } of getGraphemeSegmenter().segment(displayText)) { 257 if (cursorFound) { 258 afterCursor += segment 259 continue 260 } 261 const nextWidth = currentWidth + stringWidth(segment) 262 if (nextWidth > column) { 263 atCursor = segment 264 cursorFound = true 265 } else { 266 currentWidth = nextWidth 267 beforeCursor += segment 268 } 269 } 270 271 // Only invert the cursor if we have a cursor character to show 272 // When ghost text is present and cursor is at end, show first ghost char in cursor 273 let renderedCursor: string 274 let ghostSuffix = '' 275 if ( 276 ghostText && 277 currentLine === allLines.length - 1 && 278 this.isAtEnd() && 279 ghostText.text.length > 0 280 ) { 281 // First ghost character goes in the inverted cursor (grapheme-safe) 282 const firstGhostChar = 283 firstGrapheme(ghostText.text) || ghostText.text[0]! 284 renderedCursor = cursorChar ? invert(firstGhostChar) : firstGhostChar 285 // Rest of ghost text is dimmed after cursor 286 const ghostRest = ghostText.text.slice(firstGhostChar.length) 287 if (ghostRest.length > 0) { 288 ghostSuffix = ghostText.dim(ghostRest) 289 } 290 } else { 291 renderedCursor = cursorChar ? invert(atCursor) : atCursor 292 } 293 294 return ( 295 beforeCursor + renderedCursor + ghostSuffix + afterCursor.trimEnd() 296 ) 297 }) 298 .join('\n') 299 } 300 301 left(): Cursor { 302 if (this.offset === 0) return this 303 304 const chip = this.imageRefEndingAt(this.offset) 305 if (chip) return new Cursor(this.measuredText, chip.start) 306 307 const prevOffset = this.measuredText.prevOffset(this.offset) 308 return new Cursor(this.measuredText, prevOffset) 309 } 310 311 right(): Cursor { 312 if (this.offset >= this.text.length) return this 313 314 const chip = this.imageRefStartingAt(this.offset) 315 if (chip) return new Cursor(this.measuredText, chip.end) 316 317 const nextOffset = this.measuredText.nextOffset(this.offset) 318 return new Cursor(this.measuredText, Math.min(nextOffset, this.text.length)) 319 } 320 321 /** 322 * If an [Image #N] chip ends at `offset`, return its bounds. Used by left() 323 * to hop the cursor over the chip instead of stepping into it. 324 */ 325 imageRefEndingAt(offset: number): { start: number; end: number } | null { 326 const m = this.text.slice(0, offset).match(/\[Image #\d+\]$/) 327 return m ? { start: offset - m[0].length, end: offset } : null 328 } 329 330 imageRefStartingAt(offset: number): { start: number; end: number } | null { 331 const m = this.text.slice(offset).match(/^\[Image #\d+\]/) 332 return m ? { start: offset, end: offset + m[0].length } : null 333 } 334 335 /** 336 * If offset lands strictly inside an [Image #N] chip, snap it to the given 337 * boundary. Used by word-movement methods so Ctrl+W / Alt+D never leave a 338 * partial chip. 339 */ 340 snapOutOfImageRef(offset: number, toward: 'start' | 'end'): number { 341 const re = /\[Image #\d+\]/g 342 let m 343 while ((m = re.exec(this.text)) !== null) { 344 const start = m.index 345 const end = start + m[0].length 346 if (offset > start && offset < end) { 347 return toward === 'start' ? start : end 348 } 349 } 350 return offset 351 } 352 353 up(): Cursor { 354 const { line, column } = this.getPosition() 355 if (line === 0) { 356 return this 357 } 358 359 const prevLine = this.measuredText.getWrappedText()[line - 1] 360 if (prevLine === undefined) { 361 return this 362 } 363 364 const prevLineDisplayWidth = stringWidth(prevLine) 365 if (column > prevLineDisplayWidth) { 366 const newOffset = this.getOffset({ 367 line: line - 1, 368 column: prevLineDisplayWidth, 369 }) 370 return new Cursor(this.measuredText, newOffset, 0) 371 } 372 373 const newOffset = this.getOffset({ line: line - 1, column }) 374 return new Cursor(this.measuredText, newOffset, 0) 375 } 376 377 down(): Cursor { 378 const { line, column } = this.getPosition() 379 if (line >= this.measuredText.lineCount - 1) { 380 return this 381 } 382 383 // If there is no next line, stay on the current line, 384 // and let the caller handle it (e.g. for prompt input, 385 // we move to the next history entry) 386 const nextLine = this.measuredText.getWrappedText()[line + 1] 387 if (nextLine === undefined) { 388 return this 389 } 390 391 // If the current column is past the end of the next line, 392 // move to the end of the next line 393 const nextLineDisplayWidth = stringWidth(nextLine) 394 if (column > nextLineDisplayWidth) { 395 const newOffset = this.getOffset({ 396 line: line + 1, 397 column: nextLineDisplayWidth, 398 }) 399 return new Cursor(this.measuredText, newOffset, 0) 400 } 401 402 // Otherwise, move to the same column on the next line 403 const newOffset = this.getOffset({ 404 line: line + 1, 405 column, 406 }) 407 return new Cursor(this.measuredText, newOffset, 0) 408 } 409 410 /** 411 * Move to the start of the current line (column 0). 412 * This is the raw version used internally by startOfLine. 413 */ 414 private startOfCurrentLine(): Cursor { 415 const { line } = this.getPosition() 416 return new Cursor( 417 this.measuredText, 418 this.getOffset({ 419 line, 420 column: 0, 421 }), 422 0, 423 ) 424 } 425 426 startOfLine(): Cursor { 427 const { line, column } = this.getPosition() 428 429 // If already at start of line and not at first line, move to previous line 430 if (column === 0 && line > 0) { 431 return new Cursor( 432 this.measuredText, 433 this.getOffset({ 434 line: line - 1, 435 column: 0, 436 }), 437 0, 438 ) 439 } 440 441 return this.startOfCurrentLine() 442 } 443 444 firstNonBlankInLine(): Cursor { 445 const { line } = this.getPosition() 446 const lineText = this.measuredText.getWrappedText()[line] || '' 447 448 const match = lineText.match(/^\s*\S/) 449 const column = match?.index ? match.index + match[0].length - 1 : 0 450 const offset = this.getOffset({ line, column }) 451 452 return new Cursor(this.measuredText, offset, 0) 453 } 454 455 endOfLine(): Cursor { 456 const { line } = this.getPosition() 457 const column = this.measuredText.getLineLength(line) 458 const offset = this.getOffset({ line, column }) 459 return new Cursor(this.measuredText, offset, 0) 460 } 461 462 // Helper methods for finding logical line boundaries 463 private findLogicalLineStart(fromOffset: number = this.offset): number { 464 const prevNewline = this.text.lastIndexOf('\n', fromOffset - 1) 465 return prevNewline === -1 ? 0 : prevNewline + 1 466 } 467 468 private findLogicalLineEnd(fromOffset: number = this.offset): number { 469 const nextNewline = this.text.indexOf('\n', fromOffset) 470 return nextNewline === -1 ? this.text.length : nextNewline 471 } 472 473 // Helper to get logical line bounds for current position 474 private getLogicalLineBounds(): { start: number; end: number } { 475 return { 476 start: this.findLogicalLineStart(), 477 end: this.findLogicalLineEnd(), 478 } 479 } 480 481 // Helper to create cursor with preserved column, clamped to line length 482 // Snaps to grapheme boundary to avoid landing mid-grapheme 483 private createCursorWithColumn( 484 lineStart: number, 485 lineEnd: number, 486 targetColumn: number, 487 ): Cursor { 488 const lineLength = lineEnd - lineStart 489 const clampedColumn = Math.min(targetColumn, lineLength) 490 const rawOffset = lineStart + clampedColumn 491 const offset = this.measuredText.snapToGraphemeBoundary(rawOffset) 492 return new Cursor(this.measuredText, offset, 0) 493 } 494 495 endOfLogicalLine(): Cursor { 496 return new Cursor(this.measuredText, this.findLogicalLineEnd(), 0) 497 } 498 499 startOfLogicalLine(): Cursor { 500 return new Cursor(this.measuredText, this.findLogicalLineStart(), 0) 501 } 502 503 firstNonBlankInLogicalLine(): Cursor { 504 const { start, end } = this.getLogicalLineBounds() 505 const lineText = this.text.slice(start, end) 506 const match = lineText.match(/\S/) 507 const offset = start + (match?.index ?? 0) 508 return new Cursor(this.measuredText, offset, 0) 509 } 510 511 upLogicalLine(): Cursor { 512 const { start: currentStart } = this.getLogicalLineBounds() 513 514 // At first line - stay at beginning 515 if (currentStart === 0) { 516 return new Cursor(this.measuredText, 0, 0) 517 } 518 519 // Calculate target column position 520 const currentColumn = this.offset - currentStart 521 522 // Find previous line bounds 523 const prevLineEnd = currentStart - 1 524 const prevLineStart = this.findLogicalLineStart(prevLineEnd) 525 526 return this.createCursorWithColumn( 527 prevLineStart, 528 prevLineEnd, 529 currentColumn, 530 ) 531 } 532 533 downLogicalLine(): Cursor { 534 const { start: currentStart, end: currentEnd } = this.getLogicalLineBounds() 535 536 // At last line - stay at end 537 if (currentEnd >= this.text.length) { 538 return new Cursor(this.measuredText, this.text.length, 0) 539 } 540 541 // Calculate target column position 542 const currentColumn = this.offset - currentStart 543 544 // Find next line bounds 545 const nextLineStart = currentEnd + 1 546 const nextLineEnd = this.findLogicalLineEnd(nextLineStart) 547 548 return this.createCursorWithColumn( 549 nextLineStart, 550 nextLineEnd, 551 currentColumn, 552 ) 553 } 554 555 // Vim word vs WORD movements: 556 // - word (lowercase w/b/e): sequences of letters, digits, and underscores 557 // - WORD (uppercase W/B/E): sequences of non-whitespace characters 558 // For example, in "hello-world!", word movements see 3 words: "hello", "world", and nothing 559 // But WORD movements see 1 WORD: "hello-world!" 560 561 nextWord(): Cursor { 562 if (this.isAtEnd()) { 563 return this 564 } 565 566 // Use Intl.Segmenter for proper word boundary detection (including CJK) 567 const wordBoundaries = this.measuredText.getWordBoundaries() 568 569 // Find the next word start boundary after current position 570 for (const boundary of wordBoundaries) { 571 if (boundary.isWordLike && boundary.start > this.offset) { 572 return new Cursor(this.measuredText, boundary.start) 573 } 574 } 575 576 // If no next word found, go to end 577 return new Cursor(this.measuredText, this.text.length) 578 } 579 580 endOfWord(): Cursor { 581 if (this.isAtEnd()) { 582 return this 583 } 584 585 // Use Intl.Segmenter for proper word boundary detection (including CJK) 586 const wordBoundaries = this.measuredText.getWordBoundaries() 587 588 // Find the current word boundary we're in 589 for (const boundary of wordBoundaries) { 590 if (!boundary.isWordLike) continue 591 592 // If we're inside this word but NOT at the last character 593 if (this.offset >= boundary.start && this.offset < boundary.end - 1) { 594 // Move to end of this word (last character position) 595 return new Cursor(this.measuredText, boundary.end - 1) 596 } 597 598 // If we're at the last character of a word (end - 1), find the next word's end 599 if (this.offset === boundary.end - 1) { 600 // Find next word 601 for (const nextBoundary of wordBoundaries) { 602 if (nextBoundary.isWordLike && nextBoundary.start > this.offset) { 603 return new Cursor(this.measuredText, nextBoundary.end - 1) 604 } 605 } 606 return this 607 } 608 } 609 610 // If not in a word, find the next word and go to its end 611 for (const boundary of wordBoundaries) { 612 if (boundary.isWordLike && boundary.start > this.offset) { 613 return new Cursor(this.measuredText, boundary.end - 1) 614 } 615 } 616 617 return this 618 } 619 620 prevWord(): Cursor { 621 if (this.isAtStart()) { 622 return this 623 } 624 625 // Use Intl.Segmenter for proper word boundary detection (including CJK) 626 const wordBoundaries = this.measuredText.getWordBoundaries() 627 628 // Find the previous word start boundary before current position 629 // We need to iterate in reverse to find the previous word 630 let prevWordStart: number | null = null 631 632 for (const boundary of wordBoundaries) { 633 if (!boundary.isWordLike) continue 634 635 // If we're at or after the start of this word, but this word starts before us 636 if (boundary.start < this.offset) { 637 // If we're inside this word (not at the start), go to its start 638 if (this.offset > boundary.start && this.offset <= boundary.end) { 639 return new Cursor(this.measuredText, boundary.start) 640 } 641 // Otherwise, remember this as a candidate for previous word 642 prevWordStart = boundary.start 643 } 644 } 645 646 if (prevWordStart !== null) { 647 return new Cursor(this.measuredText, prevWordStart) 648 } 649 650 return new Cursor(this.measuredText, 0) 651 } 652 653 // Vim-specific word methods 654 // In Vim, a "word" is either: 655 // 1. A sequence of word characters (letters, digits, underscore) - including Unicode 656 // 2. A sequence of non-blank, non-word characters (punctuation/symbols) 657 658 nextVimWord(): Cursor { 659 if (this.isAtEnd()) { 660 return this 661 } 662 663 let pos = this.offset 664 const advance = (p: number): number => this.measuredText.nextOffset(p) 665 666 const currentGrapheme = this.graphemeAt(pos) 667 if (!currentGrapheme) { 668 return this 669 } 670 671 if (isVimWordChar(currentGrapheme)) { 672 while (pos < this.text.length && isVimWordChar(this.graphemeAt(pos))) { 673 pos = advance(pos) 674 } 675 } else if (isVimPunctuation(currentGrapheme)) { 676 while (pos < this.text.length && isVimPunctuation(this.graphemeAt(pos))) { 677 pos = advance(pos) 678 } 679 } 680 681 while ( 682 pos < this.text.length && 683 WHITESPACE_REGEX.test(this.graphemeAt(pos)) 684 ) { 685 pos = advance(pos) 686 } 687 688 return new Cursor(this.measuredText, pos) 689 } 690 691 endOfVimWord(): Cursor { 692 if (this.isAtEnd()) { 693 return this 694 } 695 696 const text = this.text 697 let pos = this.offset 698 const advance = (p: number): number => this.measuredText.nextOffset(p) 699 700 if (this.graphemeAt(pos) === '') { 701 return this 702 } 703 704 pos = advance(pos) 705 706 while (pos < text.length && WHITESPACE_REGEX.test(this.graphemeAt(pos))) { 707 pos = advance(pos) 708 } 709 710 if (pos >= text.length) { 711 return new Cursor(this.measuredText, text.length) 712 } 713 714 const charAtPos = this.graphemeAt(pos) 715 if (isVimWordChar(charAtPos)) { 716 while (pos < text.length) { 717 const nextPos = advance(pos) 718 if (nextPos >= text.length || !isVimWordChar(this.graphemeAt(nextPos))) 719 break 720 pos = nextPos 721 } 722 } else if (isVimPunctuation(charAtPos)) { 723 while (pos < text.length) { 724 const nextPos = advance(pos) 725 if ( 726 nextPos >= text.length || 727 !isVimPunctuation(this.graphemeAt(nextPos)) 728 ) 729 break 730 pos = nextPos 731 } 732 } 733 734 return new Cursor(this.measuredText, pos) 735 } 736 737 prevVimWord(): Cursor { 738 if (this.isAtStart()) { 739 return this 740 } 741 742 let pos = this.offset 743 const retreat = (p: number): number => this.measuredText.prevOffset(p) 744 745 pos = retreat(pos) 746 747 while (pos > 0 && WHITESPACE_REGEX.test(this.graphemeAt(pos))) { 748 pos = retreat(pos) 749 } 750 751 // At position 0 with whitespace means no previous word exists, go to start 752 if (pos === 0 && WHITESPACE_REGEX.test(this.graphemeAt(0))) { 753 return new Cursor(this.measuredText, 0) 754 } 755 756 const charAtPos = this.graphemeAt(pos) 757 if (isVimWordChar(charAtPos)) { 758 while (pos > 0) { 759 const prevPos = retreat(pos) 760 if (!isVimWordChar(this.graphemeAt(prevPos))) break 761 pos = prevPos 762 } 763 } else if (isVimPunctuation(charAtPos)) { 764 while (pos > 0) { 765 const prevPos = retreat(pos) 766 if (!isVimPunctuation(this.graphemeAt(prevPos))) break 767 pos = prevPos 768 } 769 } 770 771 return new Cursor(this.measuredText, pos) 772 } 773 774 nextWORD(): Cursor { 775 // eslint-disable-next-line @typescript-eslint/no-this-alias 776 let nextCursor: Cursor = this 777 // If we're on a non-whitespace character, move to the next whitespace 778 while (!nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) { 779 nextCursor = nextCursor.right() 780 } 781 // now move to the next non-whitespace character 782 while (nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) { 783 nextCursor = nextCursor.right() 784 } 785 return nextCursor 786 } 787 788 endOfWORD(): Cursor { 789 if (this.isAtEnd()) { 790 return this 791 } 792 793 // eslint-disable-next-line @typescript-eslint/no-this-alias 794 let cursor: Cursor = this 795 796 // Check if we're already at the end of a WORD 797 // (current character is non-whitespace, but next character is whitespace or we're at the end) 798 const atEndOfWORD = 799 !cursor.isOverWhitespace() && 800 (cursor.right().isOverWhitespace() || cursor.right().isAtEnd()) 801 802 if (atEndOfWORD) { 803 // We're already at the end of a WORD, move to the next WORD 804 cursor = cursor.right() 805 return cursor.endOfWORD() 806 } 807 808 // If we're on a whitespace character, find the next WORD 809 if (cursor.isOverWhitespace()) { 810 cursor = cursor.nextWORD() 811 } 812 813 // Now move to the end of the current WORD 814 while (!cursor.right().isOverWhitespace() && !cursor.isAtEnd()) { 815 cursor = cursor.right() 816 } 817 818 return cursor 819 } 820 821 prevWORD(): Cursor { 822 // eslint-disable-next-line @typescript-eslint/no-this-alias 823 let cursor: Cursor = this 824 825 // if we are already at the beginning of a WORD, step off it 826 if (cursor.left().isOverWhitespace()) { 827 cursor = cursor.left() 828 } 829 830 // Move left over any whitespace characters 831 while (cursor.isOverWhitespace() && !cursor.isAtStart()) { 832 cursor = cursor.left() 833 } 834 835 // If we're over a non-whitespace character, move to the start of this WORD 836 if (!cursor.isOverWhitespace()) { 837 while (!cursor.left().isOverWhitespace() && !cursor.isAtStart()) { 838 cursor = cursor.left() 839 } 840 } 841 842 return cursor 843 } 844 845 modifyText(end: Cursor, insertString: string = ''): Cursor { 846 const startOffset = this.offset 847 const endOffset = end.offset 848 849 const newText = 850 this.text.slice(0, startOffset) + 851 insertString + 852 this.text.slice(endOffset) 853 854 return Cursor.fromText( 855 newText, 856 this.columns, 857 startOffset + insertString.normalize('NFC').length, 858 ) 859 } 860 861 insert(insertString: string): Cursor { 862 const newCursor = this.modifyText(this, insertString) 863 return newCursor 864 } 865 866 del(): Cursor { 867 if (this.isAtEnd()) { 868 return this 869 } 870 return this.modifyText(this.right()) 871 } 872 873 backspace(): Cursor { 874 if (this.isAtStart()) { 875 return this 876 } 877 return this.left().modifyText(this) 878 } 879 880 deleteToLineStart(): { cursor: Cursor; killed: string } { 881 // If cursor is right after a newline (at start of line), delete just that 882 // newline — symmetric with deleteToLineEnd's newline handling. This lets 883 // repeated ctrl+u clear across lines. 884 if (this.offset > 0 && this.text[this.offset - 1] === '\n') { 885 return { cursor: this.left().modifyText(this), killed: '\n' } 886 } 887 888 // Use startOfLine() so that at column 0 of a wrapped visual line, 889 // the cursor moves to the previous visual line's start instead of 890 // getting stuck. 891 const startCursor = this.startOfLine() 892 const killed = this.text.slice(startCursor.offset, this.offset) 893 return { cursor: startCursor.modifyText(this), killed } 894 } 895 896 deleteToLineEnd(): { cursor: Cursor; killed: string } { 897 // If cursor is on a newline character, delete just that character 898 if (this.text[this.offset] === '\n') { 899 return { cursor: this.modifyText(this.right()), killed: '\n' } 900 } 901 902 const endCursor = this.endOfLine() 903 const killed = this.text.slice(this.offset, endCursor.offset) 904 return { cursor: this.modifyText(endCursor), killed } 905 } 906 907 deleteToLogicalLineEnd(): Cursor { 908 // If cursor is on a newline character, delete just that character 909 if (this.text[this.offset] === '\n') { 910 return this.modifyText(this.right()) 911 } 912 913 return this.modifyText(this.endOfLogicalLine()) 914 } 915 916 deleteWordBefore(): { cursor: Cursor; killed: string } { 917 if (this.isAtStart()) { 918 return { cursor: this, killed: '' } 919 } 920 const target = this.snapOutOfImageRef(this.prevWord().offset, 'start') 921 const prevWordCursor = new Cursor(this.measuredText, target) 922 const killed = this.text.slice(prevWordCursor.offset, this.offset) 923 return { cursor: prevWordCursor.modifyText(this), killed } 924 } 925 926 /** 927 * Deletes a token before the cursor if one exists. 928 * Supports pasted text refs: [Pasted text #1], [Pasted text #1 +10 lines], 929 * [...Truncated text #1 +10 lines...] 930 * 931 * Note: @mentions are NOT tokenized since users may want to correct typos 932 * in file paths. Use Ctrl/Cmd+backspace for word-deletion on mentions. 933 * 934 * Returns null if no token found at cursor position. 935 * Only triggers when cursor is at end of token (followed by whitespace or EOL). 936 */ 937 deleteTokenBefore(): Cursor | null { 938 // Cursor at chip.start is the "selected" state — backspace deletes the 939 // chip forward, not the char before it. 940 const chipAfter = this.imageRefStartingAt(this.offset) 941 if (chipAfter) { 942 const end = 943 this.text[chipAfter.end] === ' ' ? chipAfter.end + 1 : chipAfter.end 944 return this.modifyText(new Cursor(this.measuredText, end)) 945 } 946 947 if (this.isAtStart()) { 948 return null 949 } 950 951 // Only trigger if cursor is at a word boundary (whitespace or end of string after cursor) 952 const charAfter = this.text[this.offset] 953 if (charAfter !== undefined && !/\s/.test(charAfter)) { 954 return null 955 } 956 957 const textBefore = this.text.slice(0, this.offset) 958 959 // Check for pasted/truncated text refs: [Pasted text #1] or [...Truncated text #1 +50 lines...] 960 const pasteMatch = textBefore.match( 961 /(^|\s)\[(Pasted text #\d+(?: \+\d+ lines)?|Image #\d+|\.\.\.Truncated text #\d+ \+\d+ lines\.\.\.)\]$/, 962 ) 963 if (pasteMatch) { 964 const matchStart = pasteMatch.index! + pasteMatch[1]!.length 965 return new Cursor(this.measuredText, matchStart).modifyText(this) 966 } 967 968 return null 969 } 970 971 deleteWordAfter(): Cursor { 972 if (this.isAtEnd()) { 973 return this 974 } 975 976 const target = this.snapOutOfImageRef(this.nextWord().offset, 'end') 977 return this.modifyText(new Cursor(this.measuredText, target)) 978 } 979 980 private graphemeAt(pos: number): string { 981 if (pos >= this.text.length) return '' 982 const nextOff = this.measuredText.nextOffset(pos) 983 return this.text.slice(pos, nextOff) 984 } 985 986 private isOverWhitespace(): boolean { 987 const currentChar = this.text[this.offset] ?? '' 988 return /\s/.test(currentChar) 989 } 990 991 equals(other: Cursor): boolean { 992 return ( 993 this.offset === other.offset && this.measuredText === other.measuredText 994 ) 995 } 996 997 isAtStart(): boolean { 998 return this.offset === 0 999 } 1000 isAtEnd(): boolean { 1001 return this.offset >= this.text.length 1002 } 1003 1004 startOfFirstLine(): Cursor { 1005 // Go to the very beginning of the text (first character of first line) 1006 return new Cursor(this.measuredText, 0, 0) 1007 } 1008 1009 startOfLastLine(): Cursor { 1010 // Go to the beginning of the last line 1011 const lastNewlineIndex = this.text.lastIndexOf('\n') 1012 1013 if (lastNewlineIndex === -1) { 1014 // If there are no newlines, the text is a single line 1015 return this.startOfLine() 1016 } 1017 1018 // Position after the last newline character 1019 return new Cursor(this.measuredText, lastNewlineIndex + 1, 0) 1020 } 1021 1022 goToLine(lineNumber: number): Cursor { 1023 // Go to the beginning of the specified logical line (1-indexed, like vim) 1024 // Uses logical lines (separated by \n), not wrapped display lines 1025 const lines = this.text.split('\n') 1026 const targetLine = Math.min(Math.max(0, lineNumber - 1), lines.length - 1) 1027 let offset = 0 1028 for (let i = 0; i < targetLine; i++) { 1029 offset += (lines[i]?.length ?? 0) + 1 // +1 for newline 1030 } 1031 return new Cursor(this.measuredText, offset, 0) 1032 } 1033 1034 endOfFile(): Cursor { 1035 return new Cursor(this.measuredText, this.text.length, 0) 1036 } 1037 1038 public get text(): string { 1039 return this.measuredText.text 1040 } 1041 1042 private get columns(): number { 1043 return this.measuredText.columns + 1 1044 } 1045 1046 getPosition(): Position { 1047 return this.measuredText.getPositionFromOffset(this.offset) 1048 } 1049 1050 private getOffset(position: Position): number { 1051 return this.measuredText.getOffsetFromPosition(position) 1052 } 1053 1054 /** 1055 * Find a character using vim f/F/t/T semantics. 1056 * 1057 * @param char - The character to find 1058 * @param type - 'f' (forward to), 'F' (backward to), 't' (forward till), 'T' (backward till) 1059 * @param count - Find the Nth occurrence 1060 * @returns The target offset, or null if not found 1061 */ 1062 findCharacter( 1063 char: string, 1064 type: 'f' | 'F' | 't' | 'T', 1065 count: number = 1, 1066 ): number | null { 1067 const text = this.text 1068 const forward = type === 'f' || type === 't' 1069 const till = type === 't' || type === 'T' 1070 let found = 0 1071 1072 if (forward) { 1073 let pos = this.measuredText.nextOffset(this.offset) 1074 while (pos < text.length) { 1075 const grapheme = this.graphemeAt(pos) 1076 if (grapheme === char) { 1077 found++ 1078 if (found === count) { 1079 return till 1080 ? Math.max(this.offset, this.measuredText.prevOffset(pos)) 1081 : pos 1082 } 1083 } 1084 pos = this.measuredText.nextOffset(pos) 1085 } 1086 } else { 1087 if (this.offset === 0) return null 1088 let pos = this.measuredText.prevOffset(this.offset) 1089 while (pos >= 0) { 1090 const grapheme = this.graphemeAt(pos) 1091 if (grapheme === char) { 1092 found++ 1093 if (found === count) { 1094 return till 1095 ? Math.min(this.offset, this.measuredText.nextOffset(pos)) 1096 : pos 1097 } 1098 } 1099 if (pos === 0) break 1100 pos = this.measuredText.prevOffset(pos) 1101 } 1102 } 1103 1104 return null 1105 } 1106} 1107 1108class WrappedLine { 1109 constructor( 1110 public readonly text: string, 1111 public readonly startOffset: number, 1112 public readonly isPrecededByNewline: boolean, 1113 public readonly endsWithNewline: boolean = false, 1114 ) {} 1115 1116 equals(other: WrappedLine): boolean { 1117 return this.text === other.text && this.startOffset === other.startOffset 1118 } 1119 1120 get length(): number { 1121 return this.text.length + (this.endsWithNewline ? 1 : 0) 1122 } 1123} 1124 1125export class MeasuredText { 1126 private _wrappedLines?: WrappedLine[] 1127 public readonly text: string 1128 private navigationCache: Map<string, number> 1129 private graphemeBoundaries?: number[] 1130 1131 constructor( 1132 text: string, 1133 readonly columns: number, 1134 ) { 1135 this.text = text.normalize('NFC') 1136 this.navigationCache = new Map() 1137 } 1138 1139 /** 1140 * Lazily computes and caches wrapped lines. 1141 * This expensive operation is deferred until actually needed. 1142 */ 1143 private get wrappedLines(): WrappedLine[] { 1144 if (!this._wrappedLines) { 1145 this._wrappedLines = this.measureWrappedText() 1146 } 1147 return this._wrappedLines 1148 } 1149 1150 private getGraphemeBoundaries(): number[] { 1151 if (!this.graphemeBoundaries) { 1152 this.graphemeBoundaries = [] 1153 for (const { index } of getGraphemeSegmenter().segment(this.text)) { 1154 this.graphemeBoundaries.push(index) 1155 } 1156 // Add the end of text as a boundary 1157 this.graphemeBoundaries.push(this.text.length) 1158 } 1159 return this.graphemeBoundaries 1160 } 1161 1162 private wordBoundariesCache?: Array<{ 1163 start: number 1164 end: number 1165 isWordLike: boolean 1166 }> 1167 1168 /** 1169 * Get word boundaries using Intl.Segmenter for proper Unicode word segmentation. 1170 * This correctly handles CJK (Chinese, Japanese, Korean) text where each character 1171 * is typically its own word, as well as scripts that use spaces between words. 1172 */ 1173 public getWordBoundaries(): Array<{ 1174 start: number 1175 end: number 1176 isWordLike: boolean 1177 }> { 1178 if (!this.wordBoundariesCache) { 1179 this.wordBoundariesCache = [] 1180 for (const segment of getWordSegmenter().segment(this.text)) { 1181 this.wordBoundariesCache.push({ 1182 start: segment.index, 1183 end: segment.index + segment.segment.length, 1184 isWordLike: segment.isWordLike ?? false, 1185 }) 1186 } 1187 } 1188 return this.wordBoundariesCache 1189 } 1190 1191 /** 1192 * Binary search for boundaries. 1193 * @param boundaries: Sorted array of boundaries 1194 * @param target: Target offset 1195 * @param findNext: If true, finds first boundary > target. If false, finds last boundary < target. 1196 * @returns The found boundary index, or appropriate default 1197 */ 1198 private binarySearchBoundary( 1199 boundaries: number[], 1200 target: number, 1201 findNext: boolean, 1202 ): number { 1203 let left = 0 1204 let right = boundaries.length - 1 1205 let result = findNext ? this.text.length : 0 1206 1207 while (left <= right) { 1208 const mid = Math.floor((left + right) / 2) 1209 const boundary = boundaries[mid] 1210 if (boundary === undefined) break 1211 1212 if (findNext) { 1213 if (boundary > target) { 1214 result = boundary 1215 right = mid - 1 1216 } else { 1217 left = mid + 1 1218 } 1219 } else { 1220 if (boundary < target) { 1221 result = boundary 1222 left = mid + 1 1223 } else { 1224 right = mid - 1 1225 } 1226 } 1227 } 1228 1229 return result 1230 } 1231 1232 // Convert string index to display width 1233 public stringIndexToDisplayWidth(text: string, index: number): number { 1234 if (index <= 0) return 0 1235 if (index >= text.length) return stringWidth(text) 1236 return stringWidth(text.substring(0, index)) 1237 } 1238 1239 // Convert display width to string index 1240 public displayWidthToStringIndex(text: string, targetWidth: number): number { 1241 if (targetWidth <= 0) return 0 1242 if (!text) return 0 1243 1244 // If the text matches our text, use the precomputed graphemes 1245 if (text === this.text) { 1246 return this.offsetAtDisplayWidth(targetWidth) 1247 } 1248 1249 // Otherwise compute on the fly 1250 let currentWidth = 0 1251 let currentOffset = 0 1252 1253 for (const { segment, index } of getGraphemeSegmenter().segment(text)) { 1254 const segmentWidth = stringWidth(segment) 1255 1256 if (currentWidth + segmentWidth > targetWidth) { 1257 break 1258 } 1259 1260 currentWidth += segmentWidth 1261 currentOffset = index + segment.length 1262 } 1263 1264 return currentOffset 1265 } 1266 1267 /** 1268 * Find the string offset that corresponds to a target display width. 1269 */ 1270 private offsetAtDisplayWidth(targetWidth: number): number { 1271 if (targetWidth <= 0) return 0 1272 1273 let currentWidth = 0 1274 const boundaries = this.getGraphemeBoundaries() 1275 1276 // Iterate through grapheme boundaries 1277 for (let i = 0; i < boundaries.length - 1; i++) { 1278 const start = boundaries[i] 1279 const end = boundaries[i + 1] 1280 if (start === undefined || end === undefined) continue 1281 const segment = this.text.substring(start, end) 1282 const segmentWidth = stringWidth(segment) 1283 1284 if (currentWidth + segmentWidth > targetWidth) { 1285 return start 1286 } 1287 currentWidth += segmentWidth 1288 } 1289 1290 return this.text.length 1291 } 1292 1293 private measureWrappedText(): WrappedLine[] { 1294 const wrappedText = wrapAnsi(this.text, this.columns, { 1295 hard: true, 1296 trim: false, 1297 }) 1298 1299 const wrappedLines: WrappedLine[] = [] 1300 let searchOffset = 0 1301 let lastNewLinePos = -1 1302 1303 const lines = wrappedText.split('\n') 1304 for (let i = 0; i < lines.length; i++) { 1305 const text = lines[i]! 1306 const isPrecededByNewline = (startOffset: number) => 1307 i === 0 || (startOffset > 0 && this.text[startOffset - 1] === '\n') 1308 1309 if (text.length === 0) { 1310 // For blank lines, find the next newline character after the last one 1311 lastNewLinePos = this.text.indexOf('\n', lastNewLinePos + 1) 1312 1313 if (lastNewLinePos !== -1) { 1314 const startOffset = lastNewLinePos 1315 const endsWithNewline = true 1316 1317 wrappedLines.push( 1318 new WrappedLine( 1319 text, 1320 startOffset, 1321 isPrecededByNewline(startOffset), 1322 endsWithNewline, 1323 ), 1324 ) 1325 } else { 1326 // If we can't find another newline, this must be the end of text 1327 const startOffset = this.text.length 1328 wrappedLines.push( 1329 new WrappedLine( 1330 text, 1331 startOffset, 1332 isPrecededByNewline(startOffset), 1333 false, 1334 ), 1335 ) 1336 } 1337 } else { 1338 // For non-blank lines, find the text in this.text 1339 const startOffset = this.text.indexOf(text, searchOffset) 1340 1341 if (startOffset === -1) { 1342 throw new Error('Failed to find wrapped line in text') 1343 } 1344 1345 searchOffset = startOffset + text.length 1346 1347 // Check if this line ends with a newline in this.text 1348 const potentialNewlinePos = startOffset + text.length 1349 const endsWithNewline = 1350 potentialNewlinePos < this.text.length && 1351 this.text[potentialNewlinePos] === '\n' 1352 1353 if (endsWithNewline) { 1354 lastNewLinePos = potentialNewlinePos 1355 } 1356 1357 wrappedLines.push( 1358 new WrappedLine( 1359 text, 1360 startOffset, 1361 isPrecededByNewline(startOffset), 1362 endsWithNewline, 1363 ), 1364 ) 1365 } 1366 } 1367 1368 return wrappedLines 1369 } 1370 1371 public getWrappedText(): WrappedText { 1372 return this.wrappedLines.map(line => 1373 line.isPrecededByNewline ? line.text : line.text.trimStart(), 1374 ) 1375 } 1376 1377 public getWrappedLines(): WrappedLine[] { 1378 return this.wrappedLines 1379 } 1380 1381 private getLine(line: number): WrappedLine { 1382 const lines = this.wrappedLines 1383 return lines[Math.max(0, Math.min(line, lines.length - 1))]! 1384 } 1385 1386 public getOffsetFromPosition(position: Position): number { 1387 const wrappedLine = this.getLine(position.line) 1388 1389 // Handle blank lines specially 1390 if (wrappedLine.text.length === 0 && wrappedLine.endsWithNewline) { 1391 return wrappedLine.startOffset 1392 } 1393 1394 // Account for leading whitespace 1395 const leadingWhitespace = wrappedLine.isPrecededByNewline 1396 ? 0 1397 : wrappedLine.text.length - wrappedLine.text.trimStart().length 1398 1399 // Convert display column to string index 1400 const displayColumnWithLeading = position.column + leadingWhitespace 1401 const stringIndex = this.displayWidthToStringIndex( 1402 wrappedLine.text, 1403 displayColumnWithLeading, 1404 ) 1405 1406 // Calculate the actual offset 1407 const offset = wrappedLine.startOffset + stringIndex 1408 1409 // For normal lines 1410 const lineEnd = wrappedLine.startOffset + wrappedLine.text.length 1411 1412 // Don't allow going past the end of the current line into the next line 1413 // unless we're at the very end of the text 1414 let maxOffset = lineEnd 1415 const lineDisplayWidth = stringWidth(wrappedLine.text) 1416 if (wrappedLine.endsWithNewline && position.column > lineDisplayWidth) { 1417 // Allow positioning after the newline 1418 maxOffset = lineEnd + 1 1419 } 1420 1421 return Math.min(offset, maxOffset) 1422 } 1423 1424 public getLineLength(line: number): number { 1425 const wrappedLine = this.getLine(line) 1426 return stringWidth(wrappedLine.text) 1427 } 1428 1429 public getPositionFromOffset(offset: number): Position { 1430 const lines = this.wrappedLines 1431 for (let line = 0; line < lines.length; line++) { 1432 const currentLine = lines[line]! 1433 const nextLine = lines[line + 1] 1434 if ( 1435 offset >= currentLine.startOffset && 1436 (!nextLine || offset < nextLine.startOffset) 1437 ) { 1438 // Calculate string position within the line 1439 const stringPosInLine = offset - currentLine.startOffset 1440 1441 // Handle leading whitespace for wrapped lines 1442 let displayColumn: number 1443 if (currentLine.isPrecededByNewline) { 1444 // For lines preceded by newline, calculate display width directly 1445 displayColumn = this.stringIndexToDisplayWidth( 1446 currentLine.text, 1447 stringPosInLine, 1448 ) 1449 } else { 1450 // For wrapped lines, we need to account for trimmed whitespace 1451 const leadingWhitespace = 1452 currentLine.text.length - currentLine.text.trimStart().length 1453 if (stringPosInLine < leadingWhitespace) { 1454 // Cursor is in the trimmed whitespace area, position at start 1455 displayColumn = 0 1456 } else { 1457 // Calculate display width from the trimmed text 1458 const trimmedText = currentLine.text.trimStart() 1459 const posInTrimmed = stringPosInLine - leadingWhitespace 1460 displayColumn = this.stringIndexToDisplayWidth( 1461 trimmedText, 1462 posInTrimmed, 1463 ) 1464 } 1465 } 1466 1467 return { 1468 line, 1469 column: Math.max(0, displayColumn), 1470 } 1471 } 1472 } 1473 1474 // If we're past the last character, return the end of the last line 1475 const line = lines.length - 1 1476 const lastLine = this.wrappedLines[line]! 1477 return { 1478 line, 1479 column: stringWidth(lastLine.text), 1480 } 1481 } 1482 1483 public get lineCount(): number { 1484 return this.wrappedLines.length 1485 } 1486 1487 private withCache<T>(key: string, compute: () => T): T { 1488 const cached = this.navigationCache.get(key) 1489 if (cached !== undefined) return cached as T 1490 1491 const result = compute() 1492 this.navigationCache.set(key, result as number) 1493 return result 1494 } 1495 1496 nextOffset(offset: number): number { 1497 return this.withCache(`next:${offset}`, () => { 1498 const boundaries = this.getGraphemeBoundaries() 1499 return this.binarySearchBoundary(boundaries, offset, true) 1500 }) 1501 } 1502 1503 prevOffset(offset: number): number { 1504 if (offset <= 0) return 0 1505 1506 return this.withCache(`prev:${offset}`, () => { 1507 const boundaries = this.getGraphemeBoundaries() 1508 return this.binarySearchBoundary(boundaries, offset, false) 1509 }) 1510 } 1511 1512 /** 1513 * Snap an arbitrary code-unit offset to the start of the containing grapheme. 1514 * If offset is already on a boundary, returns it unchanged. 1515 */ 1516 snapToGraphemeBoundary(offset: number): number { 1517 if (offset <= 0) return 0 1518 if (offset >= this.text.length) return this.text.length 1519 const boundaries = this.getGraphemeBoundaries() 1520 // Binary search for largest boundary <= offset 1521 let lo = 0 1522 let hi = boundaries.length - 1 1523 while (lo < hi) { 1524 const mid = (lo + hi + 1) >> 1 1525 if (boundaries[mid]! <= offset) lo = mid 1526 else hi = mid - 1 1527 } 1528 return boundaries[lo]! 1529 } 1530}