1531 lines
46 KiB
TypeScript
1531 lines
46 KiB
TypeScript
import { stringWidth } from '../ink/stringWidth.js'
|
|
import { wrapAnsi } from '../ink/wrapAnsi.js'
|
|
import {
|
|
firstGrapheme,
|
|
getGraphemeSegmenter,
|
|
getWordSegmenter,
|
|
} from './intl.js'
|
|
|
|
/**
|
|
* Kill ring for storing killed (cut) text that can be yanked (pasted) with Ctrl+Y.
|
|
* This is global state that shares one kill ring across all input fields.
|
|
*
|
|
* Consecutive kills accumulate in the kill ring until the user types some
|
|
* other key. Alt+Y cycles through previous kills after a yank.
|
|
*/
|
|
const KILL_RING_MAX_SIZE = 10
|
|
let killRing: string[] = []
|
|
let killRingIndex = 0
|
|
let lastActionWasKill = false
|
|
|
|
// Track yank state for yank-pop (alt-y)
|
|
let lastYankStart = 0
|
|
let lastYankLength = 0
|
|
let lastActionWasYank = false
|
|
|
|
export function pushToKillRing(
|
|
text: string,
|
|
direction: 'prepend' | 'append' = 'append',
|
|
): void {
|
|
if (text.length > 0) {
|
|
if (lastActionWasKill && killRing.length > 0) {
|
|
// Accumulate with the most recent kill
|
|
if (direction === 'prepend') {
|
|
killRing[0] = text + killRing[0]
|
|
} else {
|
|
killRing[0] = killRing[0] + text
|
|
}
|
|
} else {
|
|
// Add new entry to front of ring
|
|
killRing.unshift(text)
|
|
if (killRing.length > KILL_RING_MAX_SIZE) {
|
|
killRing.pop()
|
|
}
|
|
}
|
|
lastActionWasKill = true
|
|
// Reset yank state when killing new text
|
|
lastActionWasYank = false
|
|
}
|
|
}
|
|
|
|
export function getLastKill(): string {
|
|
return killRing[0] ?? ''
|
|
}
|
|
|
|
export function getKillRingItem(index: number): string {
|
|
if (killRing.length === 0) return ''
|
|
const normalizedIndex =
|
|
((index % killRing.length) + killRing.length) % killRing.length
|
|
return killRing[normalizedIndex] ?? ''
|
|
}
|
|
|
|
export function getKillRingSize(): number {
|
|
return killRing.length
|
|
}
|
|
|
|
export function clearKillRing(): void {
|
|
killRing = []
|
|
killRingIndex = 0
|
|
lastActionWasKill = false
|
|
lastActionWasYank = false
|
|
lastYankStart = 0
|
|
lastYankLength = 0
|
|
}
|
|
|
|
export function resetKillAccumulation(): void {
|
|
lastActionWasKill = false
|
|
}
|
|
|
|
// Yank tracking for yank-pop
|
|
export function recordYank(start: number, length: number): void {
|
|
lastYankStart = start
|
|
lastYankLength = length
|
|
lastActionWasYank = true
|
|
killRingIndex = 0
|
|
}
|
|
|
|
export function canYankPop(): boolean {
|
|
return lastActionWasYank && killRing.length > 1
|
|
}
|
|
|
|
export function yankPop(): {
|
|
text: string
|
|
start: number
|
|
length: number
|
|
} | null {
|
|
if (!lastActionWasYank || killRing.length <= 1) {
|
|
return null
|
|
}
|
|
// Cycle to next item in kill ring
|
|
killRingIndex = (killRingIndex + 1) % killRing.length
|
|
const text = killRing[killRingIndex] ?? ''
|
|
return { text, start: lastYankStart, length: lastYankLength }
|
|
}
|
|
|
|
export function updateYankLength(length: number): void {
|
|
lastYankLength = length
|
|
}
|
|
|
|
export function resetYankState(): void {
|
|
lastActionWasYank = false
|
|
}
|
|
|
|
/**
|
|
* Text Processing Flow for Unicode Normalization:
|
|
*
|
|
* User Input (raw text, potentially mixed NFD/NFC)
|
|
* ↓
|
|
* MeasuredText (normalizes to NFC + builds grapheme info)
|
|
* ↓
|
|
* All cursor operations use normalized text/offsets
|
|
* ↓
|
|
* Display uses normalized text from wrappedLines
|
|
*
|
|
* This flow ensures consistent Unicode handling:
|
|
* - NFD/NFC normalization differences don't break cursor movement
|
|
* - Grapheme clusters (like 👨👩👧👦) are treated as single units
|
|
* - Display width calculations are accurate for CJK characters
|
|
*
|
|
* RULE: Once text enters MeasuredText, all operations
|
|
* work on the normalized version.
|
|
*/
|
|
|
|
// Pre-compiled regex patterns for Vim word detection (avoid creating in hot loops)
|
|
export const VIM_WORD_CHAR_REGEX = /^[\p{L}\p{N}\p{M}_]$/u
|
|
export const WHITESPACE_REGEX = /\s/
|
|
|
|
// Exported helper functions for Vim character classification
|
|
export const isVimWordChar = (ch: string): boolean =>
|
|
VIM_WORD_CHAR_REGEX.test(ch)
|
|
export const isVimWhitespace = (ch: string): boolean =>
|
|
WHITESPACE_REGEX.test(ch)
|
|
export const isVimPunctuation = (ch: string): boolean =>
|
|
ch.length > 0 && !isVimWhitespace(ch) && !isVimWordChar(ch)
|
|
|
|
type WrappedText = string[]
|
|
type Position = {
|
|
line: number
|
|
column: number
|
|
}
|
|
|
|
export class Cursor {
|
|
readonly offset: number
|
|
constructor(
|
|
readonly measuredText: MeasuredText,
|
|
offset: number = 0,
|
|
readonly selection: number = 0,
|
|
) {
|
|
// it's ok for the cursor to be 1 char beyond the end of the string
|
|
this.offset = Math.max(0, Math.min(this.text.length, offset))
|
|
}
|
|
|
|
static fromText(
|
|
text: string,
|
|
columns: number,
|
|
offset: number = 0,
|
|
selection: number = 0,
|
|
): Cursor {
|
|
// make MeasuredText on less than columns width, to account for cursor
|
|
return new Cursor(new MeasuredText(text, columns - 1), offset, selection)
|
|
}
|
|
|
|
getViewportStartLine(maxVisibleLines?: number): number {
|
|
if (maxVisibleLines === undefined || maxVisibleLines <= 0) return 0
|
|
const { line } = this.getPosition()
|
|
const allLines = this.measuredText.getWrappedText()
|
|
if (allLines.length <= maxVisibleLines) return 0
|
|
const half = Math.floor(maxVisibleLines / 2)
|
|
let startLine = Math.max(0, line - half)
|
|
const endLine = Math.min(allLines.length, startLine + maxVisibleLines)
|
|
if (endLine - startLine < maxVisibleLines) {
|
|
startLine = Math.max(0, endLine - maxVisibleLines)
|
|
}
|
|
return startLine
|
|
}
|
|
|
|
getViewportCharOffset(maxVisibleLines?: number): number {
|
|
const startLine = this.getViewportStartLine(maxVisibleLines)
|
|
if (startLine === 0) return 0
|
|
const wrappedLines = this.measuredText.getWrappedLines()
|
|
return wrappedLines[startLine]?.startOffset ?? 0
|
|
}
|
|
|
|
getViewportCharEnd(maxVisibleLines?: number): number {
|
|
const startLine = this.getViewportStartLine(maxVisibleLines)
|
|
const allLines = this.measuredText.getWrappedLines()
|
|
if (maxVisibleLines === undefined || maxVisibleLines <= 0)
|
|
return this.text.length
|
|
const endLine = Math.min(allLines.length, startLine + maxVisibleLines)
|
|
if (endLine >= allLines.length) return this.text.length
|
|
return allLines[endLine]?.startOffset ?? this.text.length
|
|
}
|
|
|
|
render(
|
|
cursorChar: string,
|
|
mask: string,
|
|
invert: (text: string) => string,
|
|
ghostText?: { text: string; dim: (text: string) => string },
|
|
maxVisibleLines?: number,
|
|
) {
|
|
const { line, column } = this.getPosition()
|
|
const allLines = this.measuredText.getWrappedText()
|
|
|
|
const startLine = this.getViewportStartLine(maxVisibleLines)
|
|
const endLine =
|
|
maxVisibleLines !== undefined && maxVisibleLines > 0
|
|
? Math.min(allLines.length, startLine + maxVisibleLines)
|
|
: allLines.length
|
|
|
|
return allLines
|
|
.slice(startLine, endLine)
|
|
.map((text, i) => {
|
|
const currentLine = i + startLine
|
|
let displayText = text
|
|
if (mask) {
|
|
const graphemes = Array.from(getGraphemeSegmenter().segment(text))
|
|
if (currentLine === allLines.length - 1) {
|
|
// Last line: mask all but the trailing 6 chars so the user can
|
|
// confirm they pasted the right thing without exposing the full token
|
|
const visibleCount = Math.min(6, graphemes.length)
|
|
const maskCount = graphemes.length - visibleCount
|
|
const splitOffset =
|
|
graphemes.length > visibleCount ? graphemes[maskCount]!.index : 0
|
|
displayText = mask.repeat(maskCount) + text.slice(splitOffset)
|
|
} else {
|
|
// Earlier wrapped lines: fully mask. Previously only the last line
|
|
// was masked, leaking the start of the token on narrow terminals
|
|
// where the pasted OAuth code wraps across multiple lines.
|
|
displayText = mask.repeat(graphemes.length)
|
|
}
|
|
}
|
|
// looking for the line with the cursor
|
|
if (line !== currentLine) return displayText.trimEnd()
|
|
|
|
// Split the line into before/at/after cursor in a single pass over the
|
|
// graphemes, accumulating display width until we reach the cursor column.
|
|
// This replaces a two-pass approach (displayWidthToStringIndex + a second
|
|
// segmenter pass) — the intermediate stringIndex from that approach is
|
|
// always a grapheme boundary, so the "cursor in the middle of a
|
|
// multi-codepoint character" branch was unreachable.
|
|
let beforeCursor = ''
|
|
let atCursor = cursorChar
|
|
let afterCursor = ''
|
|
let currentWidth = 0
|
|
let cursorFound = false
|
|
|
|
for (const { segment } of getGraphemeSegmenter().segment(displayText)) {
|
|
if (cursorFound) {
|
|
afterCursor += segment
|
|
continue
|
|
}
|
|
const nextWidth = currentWidth + stringWidth(segment)
|
|
if (nextWidth > column) {
|
|
atCursor = segment
|
|
cursorFound = true
|
|
} else {
|
|
currentWidth = nextWidth
|
|
beforeCursor += segment
|
|
}
|
|
}
|
|
|
|
// Only invert the cursor if we have a cursor character to show
|
|
// When ghost text is present and cursor is at end, show first ghost char in cursor
|
|
let renderedCursor: string
|
|
let ghostSuffix = ''
|
|
if (
|
|
ghostText &&
|
|
currentLine === allLines.length - 1 &&
|
|
this.isAtEnd() &&
|
|
ghostText.text.length > 0
|
|
) {
|
|
// First ghost character goes in the inverted cursor (grapheme-safe)
|
|
const firstGhostChar =
|
|
firstGrapheme(ghostText.text) || ghostText.text[0]!
|
|
renderedCursor = cursorChar ? invert(firstGhostChar) : firstGhostChar
|
|
// Rest of ghost text is dimmed after cursor
|
|
const ghostRest = ghostText.text.slice(firstGhostChar.length)
|
|
if (ghostRest.length > 0) {
|
|
ghostSuffix = ghostText.dim(ghostRest)
|
|
}
|
|
} else {
|
|
renderedCursor = cursorChar ? invert(atCursor) : atCursor
|
|
}
|
|
|
|
return (
|
|
beforeCursor + renderedCursor + ghostSuffix + afterCursor.trimEnd()
|
|
)
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
left(): Cursor {
|
|
if (this.offset === 0) return this
|
|
|
|
const chip = this.imageRefEndingAt(this.offset)
|
|
if (chip) return new Cursor(this.measuredText, chip.start)
|
|
|
|
const prevOffset = this.measuredText.prevOffset(this.offset)
|
|
return new Cursor(this.measuredText, prevOffset)
|
|
}
|
|
|
|
right(): Cursor {
|
|
if (this.offset >= this.text.length) return this
|
|
|
|
const chip = this.imageRefStartingAt(this.offset)
|
|
if (chip) return new Cursor(this.measuredText, chip.end)
|
|
|
|
const nextOffset = this.measuredText.nextOffset(this.offset)
|
|
return new Cursor(this.measuredText, Math.min(nextOffset, this.text.length))
|
|
}
|
|
|
|
/**
|
|
* If an [Image #N] chip ends at `offset`, return its bounds. Used by left()
|
|
* to hop the cursor over the chip instead of stepping into it.
|
|
*/
|
|
imageRefEndingAt(offset: number): { start: number; end: number } | null {
|
|
const m = this.text.slice(0, offset).match(/\[Image #\d+\]$/)
|
|
return m ? { start: offset - m[0].length, end: offset } : null
|
|
}
|
|
|
|
imageRefStartingAt(offset: number): { start: number; end: number } | null {
|
|
const m = this.text.slice(offset).match(/^\[Image #\d+\]/)
|
|
return m ? { start: offset, end: offset + m[0].length } : null
|
|
}
|
|
|
|
/**
|
|
* If offset lands strictly inside an [Image #N] chip, snap it to the given
|
|
* boundary. Used by word-movement methods so Ctrl+W / Alt+D never leave a
|
|
* partial chip.
|
|
*/
|
|
snapOutOfImageRef(offset: number, toward: 'start' | 'end'): number {
|
|
const re = /\[Image #\d+\]/g
|
|
let m
|
|
while ((m = re.exec(this.text)) !== null) {
|
|
const start = m.index
|
|
const end = start + m[0].length
|
|
if (offset > start && offset < end) {
|
|
return toward === 'start' ? start : end
|
|
}
|
|
}
|
|
return offset
|
|
}
|
|
|
|
up(): Cursor {
|
|
const { line, column } = this.getPosition()
|
|
if (line === 0) {
|
|
return this
|
|
}
|
|
|
|
const prevLine = this.measuredText.getWrappedText()[line - 1]
|
|
if (prevLine === undefined) {
|
|
return this
|
|
}
|
|
|
|
const prevLineDisplayWidth = stringWidth(prevLine)
|
|
if (column > prevLineDisplayWidth) {
|
|
const newOffset = this.getOffset({
|
|
line: line - 1,
|
|
column: prevLineDisplayWidth,
|
|
})
|
|
return new Cursor(this.measuredText, newOffset, 0)
|
|
}
|
|
|
|
const newOffset = this.getOffset({ line: line - 1, column })
|
|
return new Cursor(this.measuredText, newOffset, 0)
|
|
}
|
|
|
|
down(): Cursor {
|
|
const { line, column } = this.getPosition()
|
|
if (line >= this.measuredText.lineCount - 1) {
|
|
return this
|
|
}
|
|
|
|
// If there is no next line, stay on the current line,
|
|
// and let the caller handle it (e.g. for prompt input,
|
|
// we move to the next history entry)
|
|
const nextLine = this.measuredText.getWrappedText()[line + 1]
|
|
if (nextLine === undefined) {
|
|
return this
|
|
}
|
|
|
|
// If the current column is past the end of the next line,
|
|
// move to the end of the next line
|
|
const nextLineDisplayWidth = stringWidth(nextLine)
|
|
if (column > nextLineDisplayWidth) {
|
|
const newOffset = this.getOffset({
|
|
line: line + 1,
|
|
column: nextLineDisplayWidth,
|
|
})
|
|
return new Cursor(this.measuredText, newOffset, 0)
|
|
}
|
|
|
|
// Otherwise, move to the same column on the next line
|
|
const newOffset = this.getOffset({
|
|
line: line + 1,
|
|
column,
|
|
})
|
|
return new Cursor(this.measuredText, newOffset, 0)
|
|
}
|
|
|
|
/**
|
|
* Move to the start of the current line (column 0).
|
|
* This is the raw version used internally by startOfLine.
|
|
*/
|
|
private startOfCurrentLine(): Cursor {
|
|
const { line } = this.getPosition()
|
|
return new Cursor(
|
|
this.measuredText,
|
|
this.getOffset({
|
|
line,
|
|
column: 0,
|
|
}),
|
|
0,
|
|
)
|
|
}
|
|
|
|
startOfLine(): Cursor {
|
|
const { line, column } = this.getPosition()
|
|
|
|
// If already at start of line and not at first line, move to previous line
|
|
if (column === 0 && line > 0) {
|
|
return new Cursor(
|
|
this.measuredText,
|
|
this.getOffset({
|
|
line: line - 1,
|
|
column: 0,
|
|
}),
|
|
0,
|
|
)
|
|
}
|
|
|
|
return this.startOfCurrentLine()
|
|
}
|
|
|
|
firstNonBlankInLine(): Cursor {
|
|
const { line } = this.getPosition()
|
|
const lineText = this.measuredText.getWrappedText()[line] || ''
|
|
|
|
const match = lineText.match(/^\s*\S/)
|
|
const column = match?.index ? match.index + match[0].length - 1 : 0
|
|
const offset = this.getOffset({ line, column })
|
|
|
|
return new Cursor(this.measuredText, offset, 0)
|
|
}
|
|
|
|
endOfLine(): Cursor {
|
|
const { line } = this.getPosition()
|
|
const column = this.measuredText.getLineLength(line)
|
|
const offset = this.getOffset({ line, column })
|
|
return new Cursor(this.measuredText, offset, 0)
|
|
}
|
|
|
|
// Helper methods for finding logical line boundaries
|
|
private findLogicalLineStart(fromOffset: number = this.offset): number {
|
|
const prevNewline = this.text.lastIndexOf('\n', fromOffset - 1)
|
|
return prevNewline === -1 ? 0 : prevNewline + 1
|
|
}
|
|
|
|
private findLogicalLineEnd(fromOffset: number = this.offset): number {
|
|
const nextNewline = this.text.indexOf('\n', fromOffset)
|
|
return nextNewline === -1 ? this.text.length : nextNewline
|
|
}
|
|
|
|
// Helper to get logical line bounds for current position
|
|
private getLogicalLineBounds(): { start: number; end: number } {
|
|
return {
|
|
start: this.findLogicalLineStart(),
|
|
end: this.findLogicalLineEnd(),
|
|
}
|
|
}
|
|
|
|
// Helper to create cursor with preserved column, clamped to line length
|
|
// Snaps to grapheme boundary to avoid landing mid-grapheme
|
|
private createCursorWithColumn(
|
|
lineStart: number,
|
|
lineEnd: number,
|
|
targetColumn: number,
|
|
): Cursor {
|
|
const lineLength = lineEnd - lineStart
|
|
const clampedColumn = Math.min(targetColumn, lineLength)
|
|
const rawOffset = lineStart + clampedColumn
|
|
const offset = this.measuredText.snapToGraphemeBoundary(rawOffset)
|
|
return new Cursor(this.measuredText, offset, 0)
|
|
}
|
|
|
|
endOfLogicalLine(): Cursor {
|
|
return new Cursor(this.measuredText, this.findLogicalLineEnd(), 0)
|
|
}
|
|
|
|
startOfLogicalLine(): Cursor {
|
|
return new Cursor(this.measuredText, this.findLogicalLineStart(), 0)
|
|
}
|
|
|
|
firstNonBlankInLogicalLine(): Cursor {
|
|
const { start, end } = this.getLogicalLineBounds()
|
|
const lineText = this.text.slice(start, end)
|
|
const match = lineText.match(/\S/)
|
|
const offset = start + (match?.index ?? 0)
|
|
return new Cursor(this.measuredText, offset, 0)
|
|
}
|
|
|
|
upLogicalLine(): Cursor {
|
|
const { start: currentStart } = this.getLogicalLineBounds()
|
|
|
|
// At first line - stay at beginning
|
|
if (currentStart === 0) {
|
|
return new Cursor(this.measuredText, 0, 0)
|
|
}
|
|
|
|
// Calculate target column position
|
|
const currentColumn = this.offset - currentStart
|
|
|
|
// Find previous line bounds
|
|
const prevLineEnd = currentStart - 1
|
|
const prevLineStart = this.findLogicalLineStart(prevLineEnd)
|
|
|
|
return this.createCursorWithColumn(
|
|
prevLineStart,
|
|
prevLineEnd,
|
|
currentColumn,
|
|
)
|
|
}
|
|
|
|
downLogicalLine(): Cursor {
|
|
const { start: currentStart, end: currentEnd } = this.getLogicalLineBounds()
|
|
|
|
// At last line - stay at end
|
|
if (currentEnd >= this.text.length) {
|
|
return new Cursor(this.measuredText, this.text.length, 0)
|
|
}
|
|
|
|
// Calculate target column position
|
|
const currentColumn = this.offset - currentStart
|
|
|
|
// Find next line bounds
|
|
const nextLineStart = currentEnd + 1
|
|
const nextLineEnd = this.findLogicalLineEnd(nextLineStart)
|
|
|
|
return this.createCursorWithColumn(
|
|
nextLineStart,
|
|
nextLineEnd,
|
|
currentColumn,
|
|
)
|
|
}
|
|
|
|
// Vim word vs WORD movements:
|
|
// - word (lowercase w/b/e): sequences of letters, digits, and underscores
|
|
// - WORD (uppercase W/B/E): sequences of non-whitespace characters
|
|
// For example, in "hello-world!", word movements see 3 words: "hello", "world", and nothing
|
|
// But WORD movements see 1 WORD: "hello-world!"
|
|
|
|
nextWord(): Cursor {
|
|
if (this.isAtEnd()) {
|
|
return this
|
|
}
|
|
|
|
// Use Intl.Segmenter for proper word boundary detection (including CJK)
|
|
const wordBoundaries = this.measuredText.getWordBoundaries()
|
|
|
|
// Find the next word start boundary after current position
|
|
for (const boundary of wordBoundaries) {
|
|
if (boundary.isWordLike && boundary.start > this.offset) {
|
|
return new Cursor(this.measuredText, boundary.start)
|
|
}
|
|
}
|
|
|
|
// If no next word found, go to end
|
|
return new Cursor(this.measuredText, this.text.length)
|
|
}
|
|
|
|
endOfWord(): Cursor {
|
|
if (this.isAtEnd()) {
|
|
return this
|
|
}
|
|
|
|
// Use Intl.Segmenter for proper word boundary detection (including CJK)
|
|
const wordBoundaries = this.measuredText.getWordBoundaries()
|
|
|
|
// Find the current word boundary we're in
|
|
for (const boundary of wordBoundaries) {
|
|
if (!boundary.isWordLike) continue
|
|
|
|
// If we're inside this word but NOT at the last character
|
|
if (this.offset >= boundary.start && this.offset < boundary.end - 1) {
|
|
// Move to end of this word (last character position)
|
|
return new Cursor(this.measuredText, boundary.end - 1)
|
|
}
|
|
|
|
// If we're at the last character of a word (end - 1), find the next word's end
|
|
if (this.offset === boundary.end - 1) {
|
|
// Find next word
|
|
for (const nextBoundary of wordBoundaries) {
|
|
if (nextBoundary.isWordLike && nextBoundary.start > this.offset) {
|
|
return new Cursor(this.measuredText, nextBoundary.end - 1)
|
|
}
|
|
}
|
|
return this
|
|
}
|
|
}
|
|
|
|
// If not in a word, find the next word and go to its end
|
|
for (const boundary of wordBoundaries) {
|
|
if (boundary.isWordLike && boundary.start > this.offset) {
|
|
return new Cursor(this.measuredText, boundary.end - 1)
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
prevWord(): Cursor {
|
|
if (this.isAtStart()) {
|
|
return this
|
|
}
|
|
|
|
// Use Intl.Segmenter for proper word boundary detection (including CJK)
|
|
const wordBoundaries = this.measuredText.getWordBoundaries()
|
|
|
|
// Find the previous word start boundary before current position
|
|
// We need to iterate in reverse to find the previous word
|
|
let prevWordStart: number | null = null
|
|
|
|
for (const boundary of wordBoundaries) {
|
|
if (!boundary.isWordLike) continue
|
|
|
|
// If we're at or after the start of this word, but this word starts before us
|
|
if (boundary.start < this.offset) {
|
|
// If we're inside this word (not at the start), go to its start
|
|
if (this.offset > boundary.start && this.offset <= boundary.end) {
|
|
return new Cursor(this.measuredText, boundary.start)
|
|
}
|
|
// Otherwise, remember this as a candidate for previous word
|
|
prevWordStart = boundary.start
|
|
}
|
|
}
|
|
|
|
if (prevWordStart !== null) {
|
|
return new Cursor(this.measuredText, prevWordStart)
|
|
}
|
|
|
|
return new Cursor(this.measuredText, 0)
|
|
}
|
|
|
|
// Vim-specific word methods
|
|
// In Vim, a "word" is either:
|
|
// 1. A sequence of word characters (letters, digits, underscore) - including Unicode
|
|
// 2. A sequence of non-blank, non-word characters (punctuation/symbols)
|
|
|
|
nextVimWord(): Cursor {
|
|
if (this.isAtEnd()) {
|
|
return this
|
|
}
|
|
|
|
let pos = this.offset
|
|
const advance = (p: number): number => this.measuredText.nextOffset(p)
|
|
|
|
const currentGrapheme = this.graphemeAt(pos)
|
|
if (!currentGrapheme) {
|
|
return this
|
|
}
|
|
|
|
if (isVimWordChar(currentGrapheme)) {
|
|
while (pos < this.text.length && isVimWordChar(this.graphemeAt(pos))) {
|
|
pos = advance(pos)
|
|
}
|
|
} else if (isVimPunctuation(currentGrapheme)) {
|
|
while (pos < this.text.length && isVimPunctuation(this.graphemeAt(pos))) {
|
|
pos = advance(pos)
|
|
}
|
|
}
|
|
|
|
while (
|
|
pos < this.text.length &&
|
|
WHITESPACE_REGEX.test(this.graphemeAt(pos))
|
|
) {
|
|
pos = advance(pos)
|
|
}
|
|
|
|
return new Cursor(this.measuredText, pos)
|
|
}
|
|
|
|
endOfVimWord(): Cursor {
|
|
if (this.isAtEnd()) {
|
|
return this
|
|
}
|
|
|
|
const text = this.text
|
|
let pos = this.offset
|
|
const advance = (p: number): number => this.measuredText.nextOffset(p)
|
|
|
|
if (this.graphemeAt(pos) === '') {
|
|
return this
|
|
}
|
|
|
|
pos = advance(pos)
|
|
|
|
while (pos < text.length && WHITESPACE_REGEX.test(this.graphemeAt(pos))) {
|
|
pos = advance(pos)
|
|
}
|
|
|
|
if (pos >= text.length) {
|
|
return new Cursor(this.measuredText, text.length)
|
|
}
|
|
|
|
const charAtPos = this.graphemeAt(pos)
|
|
if (isVimWordChar(charAtPos)) {
|
|
while (pos < text.length) {
|
|
const nextPos = advance(pos)
|
|
if (nextPos >= text.length || !isVimWordChar(this.graphemeAt(nextPos)))
|
|
break
|
|
pos = nextPos
|
|
}
|
|
} else if (isVimPunctuation(charAtPos)) {
|
|
while (pos < text.length) {
|
|
const nextPos = advance(pos)
|
|
if (
|
|
nextPos >= text.length ||
|
|
!isVimPunctuation(this.graphemeAt(nextPos))
|
|
)
|
|
break
|
|
pos = nextPos
|
|
}
|
|
}
|
|
|
|
return new Cursor(this.measuredText, pos)
|
|
}
|
|
|
|
prevVimWord(): Cursor {
|
|
if (this.isAtStart()) {
|
|
return this
|
|
}
|
|
|
|
let pos = this.offset
|
|
const retreat = (p: number): number => this.measuredText.prevOffset(p)
|
|
|
|
pos = retreat(pos)
|
|
|
|
while (pos > 0 && WHITESPACE_REGEX.test(this.graphemeAt(pos))) {
|
|
pos = retreat(pos)
|
|
}
|
|
|
|
// At position 0 with whitespace means no previous word exists, go to start
|
|
if (pos === 0 && WHITESPACE_REGEX.test(this.graphemeAt(0))) {
|
|
return new Cursor(this.measuredText, 0)
|
|
}
|
|
|
|
const charAtPos = this.graphemeAt(pos)
|
|
if (isVimWordChar(charAtPos)) {
|
|
while (pos > 0) {
|
|
const prevPos = retreat(pos)
|
|
if (!isVimWordChar(this.graphemeAt(prevPos))) break
|
|
pos = prevPos
|
|
}
|
|
} else if (isVimPunctuation(charAtPos)) {
|
|
while (pos > 0) {
|
|
const prevPos = retreat(pos)
|
|
if (!isVimPunctuation(this.graphemeAt(prevPos))) break
|
|
pos = prevPos
|
|
}
|
|
}
|
|
|
|
return new Cursor(this.measuredText, pos)
|
|
}
|
|
|
|
nextWORD(): Cursor {
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
let nextCursor: Cursor = this
|
|
// If we're on a non-whitespace character, move to the next whitespace
|
|
while (!nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) {
|
|
nextCursor = nextCursor.right()
|
|
}
|
|
// now move to the next non-whitespace character
|
|
while (nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) {
|
|
nextCursor = nextCursor.right()
|
|
}
|
|
return nextCursor
|
|
}
|
|
|
|
endOfWORD(): Cursor {
|
|
if (this.isAtEnd()) {
|
|
return this
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
let cursor: Cursor = this
|
|
|
|
// Check if we're already at the end of a WORD
|
|
// (current character is non-whitespace, but next character is whitespace or we're at the end)
|
|
const atEndOfWORD =
|
|
!cursor.isOverWhitespace() &&
|
|
(cursor.right().isOverWhitespace() || cursor.right().isAtEnd())
|
|
|
|
if (atEndOfWORD) {
|
|
// We're already at the end of a WORD, move to the next WORD
|
|
cursor = cursor.right()
|
|
return cursor.endOfWORD()
|
|
}
|
|
|
|
// If we're on a whitespace character, find the next WORD
|
|
if (cursor.isOverWhitespace()) {
|
|
cursor = cursor.nextWORD()
|
|
}
|
|
|
|
// Now move to the end of the current WORD
|
|
while (!cursor.right().isOverWhitespace() && !cursor.isAtEnd()) {
|
|
cursor = cursor.right()
|
|
}
|
|
|
|
return cursor
|
|
}
|
|
|
|
prevWORD(): Cursor {
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
let cursor: Cursor = this
|
|
|
|
// if we are already at the beginning of a WORD, step off it
|
|
if (cursor.left().isOverWhitespace()) {
|
|
cursor = cursor.left()
|
|
}
|
|
|
|
// Move left over any whitespace characters
|
|
while (cursor.isOverWhitespace() && !cursor.isAtStart()) {
|
|
cursor = cursor.left()
|
|
}
|
|
|
|
// If we're over a non-whitespace character, move to the start of this WORD
|
|
if (!cursor.isOverWhitespace()) {
|
|
while (!cursor.left().isOverWhitespace() && !cursor.isAtStart()) {
|
|
cursor = cursor.left()
|
|
}
|
|
}
|
|
|
|
return cursor
|
|
}
|
|
|
|
modifyText(end: Cursor, insertString: string = ''): Cursor {
|
|
const startOffset = this.offset
|
|
const endOffset = end.offset
|
|
|
|
const newText =
|
|
this.text.slice(0, startOffset) +
|
|
insertString +
|
|
this.text.slice(endOffset)
|
|
|
|
return Cursor.fromText(
|
|
newText,
|
|
this.columns,
|
|
startOffset + insertString.normalize('NFC').length,
|
|
)
|
|
}
|
|
|
|
insert(insertString: string): Cursor {
|
|
const newCursor = this.modifyText(this, insertString)
|
|
return newCursor
|
|
}
|
|
|
|
del(): Cursor {
|
|
if (this.isAtEnd()) {
|
|
return this
|
|
}
|
|
return this.modifyText(this.right())
|
|
}
|
|
|
|
backspace(): Cursor {
|
|
if (this.isAtStart()) {
|
|
return this
|
|
}
|
|
return this.left().modifyText(this)
|
|
}
|
|
|
|
deleteToLineStart(): { cursor: Cursor; killed: string } {
|
|
// If cursor is right after a newline (at start of line), delete just that
|
|
// newline — symmetric with deleteToLineEnd's newline handling. This lets
|
|
// repeated ctrl+u clear across lines.
|
|
if (this.offset > 0 && this.text[this.offset - 1] === '\n') {
|
|
return { cursor: this.left().modifyText(this), killed: '\n' }
|
|
}
|
|
|
|
// Use startOfLine() so that at column 0 of a wrapped visual line,
|
|
// the cursor moves to the previous visual line's start instead of
|
|
// getting stuck.
|
|
const startCursor = this.startOfLine()
|
|
const killed = this.text.slice(startCursor.offset, this.offset)
|
|
return { cursor: startCursor.modifyText(this), killed }
|
|
}
|
|
|
|
deleteToLineEnd(): { cursor: Cursor; killed: string } {
|
|
// If cursor is on a newline character, delete just that character
|
|
if (this.text[this.offset] === '\n') {
|
|
return { cursor: this.modifyText(this.right()), killed: '\n' }
|
|
}
|
|
|
|
const endCursor = this.endOfLine()
|
|
const killed = this.text.slice(this.offset, endCursor.offset)
|
|
return { cursor: this.modifyText(endCursor), killed }
|
|
}
|
|
|
|
deleteToLogicalLineEnd(): Cursor {
|
|
// If cursor is on a newline character, delete just that character
|
|
if (this.text[this.offset] === '\n') {
|
|
return this.modifyText(this.right())
|
|
}
|
|
|
|
return this.modifyText(this.endOfLogicalLine())
|
|
}
|
|
|
|
deleteWordBefore(): { cursor: Cursor; killed: string } {
|
|
if (this.isAtStart()) {
|
|
return { cursor: this, killed: '' }
|
|
}
|
|
const target = this.snapOutOfImageRef(this.prevWord().offset, 'start')
|
|
const prevWordCursor = new Cursor(this.measuredText, target)
|
|
const killed = this.text.slice(prevWordCursor.offset, this.offset)
|
|
return { cursor: prevWordCursor.modifyText(this), killed }
|
|
}
|
|
|
|
/**
|
|
* Deletes a token before the cursor if one exists.
|
|
* Supports pasted text refs: [Pasted text #1], [Pasted text #1 +10 lines],
|
|
* [...Truncated text #1 +10 lines...]
|
|
*
|
|
* Note: @mentions are NOT tokenized since users may want to correct typos
|
|
* in file paths. Use Ctrl/Cmd+backspace for word-deletion on mentions.
|
|
*
|
|
* Returns null if no token found at cursor position.
|
|
* Only triggers when cursor is at end of token (followed by whitespace or EOL).
|
|
*/
|
|
deleteTokenBefore(): Cursor | null {
|
|
// Cursor at chip.start is the "selected" state — backspace deletes the
|
|
// chip forward, not the char before it.
|
|
const chipAfter = this.imageRefStartingAt(this.offset)
|
|
if (chipAfter) {
|
|
const end =
|
|
this.text[chipAfter.end] === ' ' ? chipAfter.end + 1 : chipAfter.end
|
|
return this.modifyText(new Cursor(this.measuredText, end))
|
|
}
|
|
|
|
if (this.isAtStart()) {
|
|
return null
|
|
}
|
|
|
|
// Only trigger if cursor is at a word boundary (whitespace or end of string after cursor)
|
|
const charAfter = this.text[this.offset]
|
|
if (charAfter !== undefined && !/\s/.test(charAfter)) {
|
|
return null
|
|
}
|
|
|
|
const textBefore = this.text.slice(0, this.offset)
|
|
|
|
// Check for pasted/truncated text refs: [Pasted text #1] or [...Truncated text #1 +50 lines...]
|
|
const pasteMatch = textBefore.match(
|
|
/(^|\s)\[(Pasted text #\d+(?: \+\d+ lines)?|Image #\d+|\.\.\.Truncated text #\d+ \+\d+ lines\.\.\.)\]$/,
|
|
)
|
|
if (pasteMatch) {
|
|
const matchStart = pasteMatch.index! + pasteMatch[1]!.length
|
|
return new Cursor(this.measuredText, matchStart).modifyText(this)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
deleteWordAfter(): Cursor {
|
|
if (this.isAtEnd()) {
|
|
return this
|
|
}
|
|
|
|
const target = this.snapOutOfImageRef(this.nextWord().offset, 'end')
|
|
return this.modifyText(new Cursor(this.measuredText, target))
|
|
}
|
|
|
|
private graphemeAt(pos: number): string {
|
|
if (pos >= this.text.length) return ''
|
|
const nextOff = this.measuredText.nextOffset(pos)
|
|
return this.text.slice(pos, nextOff)
|
|
}
|
|
|
|
private isOverWhitespace(): boolean {
|
|
const currentChar = this.text[this.offset] ?? ''
|
|
return /\s/.test(currentChar)
|
|
}
|
|
|
|
equals(other: Cursor): boolean {
|
|
return (
|
|
this.offset === other.offset && this.measuredText === other.measuredText
|
|
)
|
|
}
|
|
|
|
isAtStart(): boolean {
|
|
return this.offset === 0
|
|
}
|
|
isAtEnd(): boolean {
|
|
return this.offset >= this.text.length
|
|
}
|
|
|
|
startOfFirstLine(): Cursor {
|
|
// Go to the very beginning of the text (first character of first line)
|
|
return new Cursor(this.measuredText, 0, 0)
|
|
}
|
|
|
|
startOfLastLine(): Cursor {
|
|
// Go to the beginning of the last line
|
|
const lastNewlineIndex = this.text.lastIndexOf('\n')
|
|
|
|
if (lastNewlineIndex === -1) {
|
|
// If there are no newlines, the text is a single line
|
|
return this.startOfLine()
|
|
}
|
|
|
|
// Position after the last newline character
|
|
return new Cursor(this.measuredText, lastNewlineIndex + 1, 0)
|
|
}
|
|
|
|
goToLine(lineNumber: number): Cursor {
|
|
// Go to the beginning of the specified logical line (1-indexed, like vim)
|
|
// Uses logical lines (separated by \n), not wrapped display lines
|
|
const lines = this.text.split('\n')
|
|
const targetLine = Math.min(Math.max(0, lineNumber - 1), lines.length - 1)
|
|
let offset = 0
|
|
for (let i = 0; i < targetLine; i++) {
|
|
offset += (lines[i]?.length ?? 0) + 1 // +1 for newline
|
|
}
|
|
return new Cursor(this.measuredText, offset, 0)
|
|
}
|
|
|
|
endOfFile(): Cursor {
|
|
return new Cursor(this.measuredText, this.text.length, 0)
|
|
}
|
|
|
|
public get text(): string {
|
|
return this.measuredText.text
|
|
}
|
|
|
|
private get columns(): number {
|
|
return this.measuredText.columns + 1
|
|
}
|
|
|
|
getPosition(): Position {
|
|
return this.measuredText.getPositionFromOffset(this.offset)
|
|
}
|
|
|
|
private getOffset(position: Position): number {
|
|
return this.measuredText.getOffsetFromPosition(position)
|
|
}
|
|
|
|
/**
|
|
* Find a character using vim f/F/t/T semantics.
|
|
*
|
|
* @param char - The character to find
|
|
* @param type - 'f' (forward to), 'F' (backward to), 't' (forward till), 'T' (backward till)
|
|
* @param count - Find the Nth occurrence
|
|
* @returns The target offset, or null if not found
|
|
*/
|
|
findCharacter(
|
|
char: string,
|
|
type: 'f' | 'F' | 't' | 'T',
|
|
count: number = 1,
|
|
): number | null {
|
|
const text = this.text
|
|
const forward = type === 'f' || type === 't'
|
|
const till = type === 't' || type === 'T'
|
|
let found = 0
|
|
|
|
if (forward) {
|
|
let pos = this.measuredText.nextOffset(this.offset)
|
|
while (pos < text.length) {
|
|
const grapheme = this.graphemeAt(pos)
|
|
if (grapheme === char) {
|
|
found++
|
|
if (found === count) {
|
|
return till
|
|
? Math.max(this.offset, this.measuredText.prevOffset(pos))
|
|
: pos
|
|
}
|
|
}
|
|
pos = this.measuredText.nextOffset(pos)
|
|
}
|
|
} else {
|
|
if (this.offset === 0) return null
|
|
let pos = this.measuredText.prevOffset(this.offset)
|
|
while (pos >= 0) {
|
|
const grapheme = this.graphemeAt(pos)
|
|
if (grapheme === char) {
|
|
found++
|
|
if (found === count) {
|
|
return till
|
|
? Math.min(this.offset, this.measuredText.nextOffset(pos))
|
|
: pos
|
|
}
|
|
}
|
|
if (pos === 0) break
|
|
pos = this.measuredText.prevOffset(pos)
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
}
|
|
|
|
class WrappedLine {
|
|
constructor(
|
|
public readonly text: string,
|
|
public readonly startOffset: number,
|
|
public readonly isPrecededByNewline: boolean,
|
|
public readonly endsWithNewline: boolean = false,
|
|
) {}
|
|
|
|
equals(other: WrappedLine): boolean {
|
|
return this.text === other.text && this.startOffset === other.startOffset
|
|
}
|
|
|
|
get length(): number {
|
|
return this.text.length + (this.endsWithNewline ? 1 : 0)
|
|
}
|
|
}
|
|
|
|
export class MeasuredText {
|
|
private _wrappedLines?: WrappedLine[]
|
|
public readonly text: string
|
|
private navigationCache: Map<string, number>
|
|
private graphemeBoundaries?: number[]
|
|
|
|
constructor(
|
|
text: string,
|
|
readonly columns: number,
|
|
) {
|
|
this.text = text.normalize('NFC')
|
|
this.navigationCache = new Map()
|
|
}
|
|
|
|
/**
|
|
* Lazily computes and caches wrapped lines.
|
|
* This expensive operation is deferred until actually needed.
|
|
*/
|
|
private get wrappedLines(): WrappedLine[] {
|
|
if (!this._wrappedLines) {
|
|
this._wrappedLines = this.measureWrappedText()
|
|
}
|
|
return this._wrappedLines
|
|
}
|
|
|
|
private getGraphemeBoundaries(): number[] {
|
|
if (!this.graphemeBoundaries) {
|
|
this.graphemeBoundaries = []
|
|
for (const { index } of getGraphemeSegmenter().segment(this.text)) {
|
|
this.graphemeBoundaries.push(index)
|
|
}
|
|
// Add the end of text as a boundary
|
|
this.graphemeBoundaries.push(this.text.length)
|
|
}
|
|
return this.graphemeBoundaries
|
|
}
|
|
|
|
private wordBoundariesCache?: Array<{
|
|
start: number
|
|
end: number
|
|
isWordLike: boolean
|
|
}>
|
|
|
|
/**
|
|
* Get word boundaries using Intl.Segmenter for proper Unicode word segmentation.
|
|
* This correctly handles CJK (Chinese, Japanese, Korean) text where each character
|
|
* is typically its own word, as well as scripts that use spaces between words.
|
|
*/
|
|
public getWordBoundaries(): Array<{
|
|
start: number
|
|
end: number
|
|
isWordLike: boolean
|
|
}> {
|
|
if (!this.wordBoundariesCache) {
|
|
this.wordBoundariesCache = []
|
|
for (const segment of getWordSegmenter().segment(this.text)) {
|
|
this.wordBoundariesCache.push({
|
|
start: segment.index,
|
|
end: segment.index + segment.segment.length,
|
|
isWordLike: segment.isWordLike ?? false,
|
|
})
|
|
}
|
|
}
|
|
return this.wordBoundariesCache
|
|
}
|
|
|
|
/**
|
|
* Binary search for boundaries.
|
|
* @param boundaries: Sorted array of boundaries
|
|
* @param target: Target offset
|
|
* @param findNext: If true, finds first boundary > target. If false, finds last boundary < target.
|
|
* @returns The found boundary index, or appropriate default
|
|
*/
|
|
private binarySearchBoundary(
|
|
boundaries: number[],
|
|
target: number,
|
|
findNext: boolean,
|
|
): number {
|
|
let left = 0
|
|
let right = boundaries.length - 1
|
|
let result = findNext ? this.text.length : 0
|
|
|
|
while (left <= right) {
|
|
const mid = Math.floor((left + right) / 2)
|
|
const boundary = boundaries[mid]
|
|
if (boundary === undefined) break
|
|
|
|
if (findNext) {
|
|
if (boundary > target) {
|
|
result = boundary
|
|
right = mid - 1
|
|
} else {
|
|
left = mid + 1
|
|
}
|
|
} else {
|
|
if (boundary < target) {
|
|
result = boundary
|
|
left = mid + 1
|
|
} else {
|
|
right = mid - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Convert string index to display width
|
|
public stringIndexToDisplayWidth(text: string, index: number): number {
|
|
if (index <= 0) return 0
|
|
if (index >= text.length) return stringWidth(text)
|
|
return stringWidth(text.substring(0, index))
|
|
}
|
|
|
|
// Convert display width to string index
|
|
public displayWidthToStringIndex(text: string, targetWidth: number): number {
|
|
if (targetWidth <= 0) return 0
|
|
if (!text) return 0
|
|
|
|
// If the text matches our text, use the precomputed graphemes
|
|
if (text === this.text) {
|
|
return this.offsetAtDisplayWidth(targetWidth)
|
|
}
|
|
|
|
// Otherwise compute on the fly
|
|
let currentWidth = 0
|
|
let currentOffset = 0
|
|
|
|
for (const { segment, index } of getGraphemeSegmenter().segment(text)) {
|
|
const segmentWidth = stringWidth(segment)
|
|
|
|
if (currentWidth + segmentWidth > targetWidth) {
|
|
break
|
|
}
|
|
|
|
currentWidth += segmentWidth
|
|
currentOffset = index + segment.length
|
|
}
|
|
|
|
return currentOffset
|
|
}
|
|
|
|
/**
|
|
* Find the string offset that corresponds to a target display width.
|
|
*/
|
|
private offsetAtDisplayWidth(targetWidth: number): number {
|
|
if (targetWidth <= 0) return 0
|
|
|
|
let currentWidth = 0
|
|
const boundaries = this.getGraphemeBoundaries()
|
|
|
|
// Iterate through grapheme boundaries
|
|
for (let i = 0; i < boundaries.length - 1; i++) {
|
|
const start = boundaries[i]
|
|
const end = boundaries[i + 1]
|
|
if (start === undefined || end === undefined) continue
|
|
const segment = this.text.substring(start, end)
|
|
const segmentWidth = stringWidth(segment)
|
|
|
|
if (currentWidth + segmentWidth > targetWidth) {
|
|
return start
|
|
}
|
|
currentWidth += segmentWidth
|
|
}
|
|
|
|
return this.text.length
|
|
}
|
|
|
|
private measureWrappedText(): WrappedLine[] {
|
|
const wrappedText = wrapAnsi(this.text, this.columns, {
|
|
hard: true,
|
|
trim: false,
|
|
})
|
|
|
|
const wrappedLines: WrappedLine[] = []
|
|
let searchOffset = 0
|
|
let lastNewLinePos = -1
|
|
|
|
const lines = wrappedText.split('\n')
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const text = lines[i]!
|
|
const isPrecededByNewline = (startOffset: number) =>
|
|
i === 0 || (startOffset > 0 && this.text[startOffset - 1] === '\n')
|
|
|
|
if (text.length === 0) {
|
|
// For blank lines, find the next newline character after the last one
|
|
lastNewLinePos = this.text.indexOf('\n', lastNewLinePos + 1)
|
|
|
|
if (lastNewLinePos !== -1) {
|
|
const startOffset = lastNewLinePos
|
|
const endsWithNewline = true
|
|
|
|
wrappedLines.push(
|
|
new WrappedLine(
|
|
text,
|
|
startOffset,
|
|
isPrecededByNewline(startOffset),
|
|
endsWithNewline,
|
|
),
|
|
)
|
|
} else {
|
|
// If we can't find another newline, this must be the end of text
|
|
const startOffset = this.text.length
|
|
wrappedLines.push(
|
|
new WrappedLine(
|
|
text,
|
|
startOffset,
|
|
isPrecededByNewline(startOffset),
|
|
false,
|
|
),
|
|
)
|
|
}
|
|
} else {
|
|
// For non-blank lines, find the text in this.text
|
|
const startOffset = this.text.indexOf(text, searchOffset)
|
|
|
|
if (startOffset === -1) {
|
|
throw new Error('Failed to find wrapped line in text')
|
|
}
|
|
|
|
searchOffset = startOffset + text.length
|
|
|
|
// Check if this line ends with a newline in this.text
|
|
const potentialNewlinePos = startOffset + text.length
|
|
const endsWithNewline =
|
|
potentialNewlinePos < this.text.length &&
|
|
this.text[potentialNewlinePos] === '\n'
|
|
|
|
if (endsWithNewline) {
|
|
lastNewLinePos = potentialNewlinePos
|
|
}
|
|
|
|
wrappedLines.push(
|
|
new WrappedLine(
|
|
text,
|
|
startOffset,
|
|
isPrecededByNewline(startOffset),
|
|
endsWithNewline,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
return wrappedLines
|
|
}
|
|
|
|
public getWrappedText(): WrappedText {
|
|
return this.wrappedLines.map(line =>
|
|
line.isPrecededByNewline ? line.text : line.text.trimStart(),
|
|
)
|
|
}
|
|
|
|
public getWrappedLines(): WrappedLine[] {
|
|
return this.wrappedLines
|
|
}
|
|
|
|
private getLine(line: number): WrappedLine {
|
|
const lines = this.wrappedLines
|
|
return lines[Math.max(0, Math.min(line, lines.length - 1))]!
|
|
}
|
|
|
|
public getOffsetFromPosition(position: Position): number {
|
|
const wrappedLine = this.getLine(position.line)
|
|
|
|
// Handle blank lines specially
|
|
if (wrappedLine.text.length === 0 && wrappedLine.endsWithNewline) {
|
|
return wrappedLine.startOffset
|
|
}
|
|
|
|
// Account for leading whitespace
|
|
const leadingWhitespace = wrappedLine.isPrecededByNewline
|
|
? 0
|
|
: wrappedLine.text.length - wrappedLine.text.trimStart().length
|
|
|
|
// Convert display column to string index
|
|
const displayColumnWithLeading = position.column + leadingWhitespace
|
|
const stringIndex = this.displayWidthToStringIndex(
|
|
wrappedLine.text,
|
|
displayColumnWithLeading,
|
|
)
|
|
|
|
// Calculate the actual offset
|
|
const offset = wrappedLine.startOffset + stringIndex
|
|
|
|
// For normal lines
|
|
const lineEnd = wrappedLine.startOffset + wrappedLine.text.length
|
|
|
|
// Don't allow going past the end of the current line into the next line
|
|
// unless we're at the very end of the text
|
|
let maxOffset = lineEnd
|
|
const lineDisplayWidth = stringWidth(wrappedLine.text)
|
|
if (wrappedLine.endsWithNewline && position.column > lineDisplayWidth) {
|
|
// Allow positioning after the newline
|
|
maxOffset = lineEnd + 1
|
|
}
|
|
|
|
return Math.min(offset, maxOffset)
|
|
}
|
|
|
|
public getLineLength(line: number): number {
|
|
const wrappedLine = this.getLine(line)
|
|
return stringWidth(wrappedLine.text)
|
|
}
|
|
|
|
public getPositionFromOffset(offset: number): Position {
|
|
const lines = this.wrappedLines
|
|
for (let line = 0; line < lines.length; line++) {
|
|
const currentLine = lines[line]!
|
|
const nextLine = lines[line + 1]
|
|
if (
|
|
offset >= currentLine.startOffset &&
|
|
(!nextLine || offset < nextLine.startOffset)
|
|
) {
|
|
// Calculate string position within the line
|
|
const stringPosInLine = offset - currentLine.startOffset
|
|
|
|
// Handle leading whitespace for wrapped lines
|
|
let displayColumn: number
|
|
if (currentLine.isPrecededByNewline) {
|
|
// For lines preceded by newline, calculate display width directly
|
|
displayColumn = this.stringIndexToDisplayWidth(
|
|
currentLine.text,
|
|
stringPosInLine,
|
|
)
|
|
} else {
|
|
// For wrapped lines, we need to account for trimmed whitespace
|
|
const leadingWhitespace =
|
|
currentLine.text.length - currentLine.text.trimStart().length
|
|
if (stringPosInLine < leadingWhitespace) {
|
|
// Cursor is in the trimmed whitespace area, position at start
|
|
displayColumn = 0
|
|
} else {
|
|
// Calculate display width from the trimmed text
|
|
const trimmedText = currentLine.text.trimStart()
|
|
const posInTrimmed = stringPosInLine - leadingWhitespace
|
|
displayColumn = this.stringIndexToDisplayWidth(
|
|
trimmedText,
|
|
posInTrimmed,
|
|
)
|
|
}
|
|
}
|
|
|
|
return {
|
|
line,
|
|
column: Math.max(0, displayColumn),
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we're past the last character, return the end of the last line
|
|
const line = lines.length - 1
|
|
const lastLine = this.wrappedLines[line]!
|
|
return {
|
|
line,
|
|
column: stringWidth(lastLine.text),
|
|
}
|
|
}
|
|
|
|
public get lineCount(): number {
|
|
return this.wrappedLines.length
|
|
}
|
|
|
|
private withCache<T>(key: string, compute: () => T): T {
|
|
const cached = this.navigationCache.get(key)
|
|
if (cached !== undefined) return cached as T
|
|
|
|
const result = compute()
|
|
this.navigationCache.set(key, result as number)
|
|
return result
|
|
}
|
|
|
|
nextOffset(offset: number): number {
|
|
return this.withCache(`next:${offset}`, () => {
|
|
const boundaries = this.getGraphemeBoundaries()
|
|
return this.binarySearchBoundary(boundaries, offset, true)
|
|
})
|
|
}
|
|
|
|
prevOffset(offset: number): number {
|
|
if (offset <= 0) return 0
|
|
|
|
return this.withCache(`prev:${offset}`, () => {
|
|
const boundaries = this.getGraphemeBoundaries()
|
|
return this.binarySearchBoundary(boundaries, offset, false)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Snap an arbitrary code-unit offset to the start of the containing grapheme.
|
|
* If offset is already on a boundary, returns it unchanged.
|
|
*/
|
|
snapToGraphemeBoundary(offset: number): number {
|
|
if (offset <= 0) return 0
|
|
if (offset >= this.text.length) return this.text.length
|
|
const boundaries = this.getGraphemeBoundaries()
|
|
// Binary search for largest boundary <= offset
|
|
let lo = 0
|
|
let hi = boundaries.length - 1
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1
|
|
if (boundaries[mid]! <= offset) lo = mid
|
|
else hi = mid - 1
|
|
}
|
|
return boundaries[lo]!
|
|
}
|
|
}
|