Kode-cli/src/utils/Cursor.ts
2025-08-10 19:57:17 +08:00

437 lines
12 KiB
TypeScript

import wrapAnsi from 'wrap-ansi'
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.measuredText.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)
}
render(cursorChar: string, mask: string, invert: (text: string) => string) {
const { line, column } = this.getPosition()
return this.measuredText
.getWrappedText()
.map((text, currentLine, allLines) => {
let displayText = text
if (mask && currentLine === allLines.length - 1) {
const lastSixStart = Math.max(0, text.length - 6)
displayText = mask.repeat(lastSixStart) + text.slice(lastSixStart)
}
// looking for the line with the cursor
if (line != currentLine) return displayText.trimEnd()
return (
displayText.slice(0, column) +
invert(displayText[column] || cursorChar) +
displayText.trimEnd().slice(column + 1)
)
})
.join('\n')
}
left(): Cursor {
return new Cursor(this.measuredText, this.offset - 1)
}
right(): Cursor {
return new Cursor(this.measuredText, this.offset + 1)
}
up(): Cursor {
const { line, column } = this.getPosition()
if (line == 0) {
return new Cursor(this.measuredText, 0, 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 new Cursor(this.measuredText, this.text.length, 0)
}
const newOffset = this.getOffset({ line: line + 1, column })
return new Cursor(this.measuredText, newOffset, 0)
}
startOfLine(): Cursor {
const { line } = this.getPosition()
return new Cursor(
this.measuredText,
this.getOffset({
line,
column: 0,
}),
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)
}
nextWord(): Cursor {
// eslint-disable-next-line @typescript-eslint/no-this-alias
let nextCursor: Cursor = this
// If we're on a word, move to the next non-word
while (nextCursor.isOverWordChar() && !nextCursor.isAtEnd()) {
nextCursor = nextCursor.right()
}
// now move to the next word char
while (!nextCursor.isOverWordChar() && !nextCursor.isAtEnd()) {
nextCursor = nextCursor.right()
}
return nextCursor
}
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().isOverWordChar()) {
cursor = cursor.left()
}
// Move left over any non-word characters
while (!cursor.isOverWordChar() && !cursor.isAtStart()) {
cursor = cursor.left()
}
// If we're over a word character, move to the start of this word
if (cursor.isOverWordChar()) {
while (cursor.left().isOverWordChar() && !cursor.isAtStart()) {
cursor = cursor.left()
}
}
return cursor
}
private 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.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
}
// Get the current position
const currentOffset = this.offset
// Create a new cursor at the position before the current one
const leftCursor = this.left()
const leftOffset = leftCursor.offset
// Create the new text by removing one character
const newText =
this.text.slice(0, leftOffset) + this.text.slice(currentOffset)
// Return a new cursor with the updated text and position
return Cursor.fromText(newText, this.columns, leftOffset)
}
deleteToLineStart(): Cursor {
return this.startOfLine().modifyText(this)
}
deleteToLineEnd(): 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.endOfLine())
}
deleteWordBefore(): Cursor {
if (this.isAtStart()) {
return this
}
return this.prevWord().modifyText(this)
}
deleteWordAfter(): Cursor {
if (this.isAtEnd()) {
return this
}
return this.modifyText(this.nextWord())
}
private isOverWordChar(): boolean {
const currentChar = this.text[this.offset] ?? ''
return /\w/.test(currentChar)
}
equals(other: Cursor): boolean {
return (
this.offset === other.offset && this.measuredText == other.measuredText
)
}
private isAtStart(): boolean {
return this.offset == 0
}
private isAtEnd(): boolean {
return this.offset == this.text.length
}
public get text(): string {
return this.measuredText.text
}
private get columns(): number {
return this.measuredText.columns + 1
}
private getPosition(): Position {
return this.measuredText.getPositionFromOffset(this.offset)
}
private getOffset(position: Position): number {
return this.measuredText.getOffsetFromPosition(position)
}
}
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[]
constructor(
readonly text: string,
readonly columns: number,
) {
this.wrappedLines = this.measureWrappedText()
}
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
const startOffset = this.text.indexOf(text, searchOffset)
if (startOffset === -1) {
console.log('Debug: Failed to find wrapped line in original text')
console.log('Debug: Current text:', text)
console.log('Debug: Full original text:', this.text)
console.log('Debug: Search offset:', searchOffset)
console.log('Debug: Wrapped text:', wrappedText)
throw new Error('Failed to find wrapped line in original text')
}
searchOffset = startOffset + text.length
// Check if this line ends with a newline in the original 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(),
)
}
private getLine(line: number): WrappedLine {
return this.wrappedLines[
Math.max(0, Math.min(line, this.wrappedLines.length - 1))
]!
}
public getOffsetFromPosition(position: Position): number {
const wrappedLine = this.getLine(position.line)
const startOffsetPlusColumn = wrappedLine.startOffset + position.column
// Handle blank lines specially
if (wrappedLine.text.length === 0 && wrappedLine.endsWithNewline) {
return wrappedLine.startOffset
}
// For normal lines
const lineEnd = wrappedLine.startOffset + wrappedLine.text.length
// Add 1 only if this line ends with a newline
const maxOffset = wrappedLine.endsWithNewline ? lineEnd + 1 : lineEnd
return Math.min(startOffsetPlusColumn, maxOffset)
}
public getLineLength(line: number): number {
const currentLine = this.getLine(line)
const nextLine = this.getLine(line + 1)
if (nextLine.equals(currentLine)) {
return this.text.length - currentLine.startOffset
}
return nextLine.startOffset - currentLine.startOffset - 1
}
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)
) {
const leadingWhitepace = currentLine.isPrecededByNewline
? 0
: currentLine.text.length - currentLine.text.trimStart().length
const column = Math.max(
0,
Math.min(
offset - currentLine.startOffset - leadingWhitepace,
currentLine.text.length,
),
)
return {
line,
column,
}
}
}
// If we're past the last character, return the end of the last line
const line = lines.length - 1
return {
line,
column: this.wrappedLines[line]!.text.length,
}
}
public get lineCount(): number {
return this.wrappedLines.length
}
equals(other: MeasuredText): boolean {
return this.text === other.text && this.columns === other.columns
}
}