437 lines
12 KiB
TypeScript
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
|
|
}
|
|
}
|