Cursor.ts
1 import wrapAnsi from 'wrap-ansi' 2 3 type WrappedText = string[] 4 type Position = { 5 line: number 6 column: number 7 } 8 9 export class Cursor { 10 readonly offset: number 11 constructor( 12 readonly measuredText: MeasuredText, 13 offset: number = 0, 14 readonly selection: number = 0, 15 ) { 16 // it's ok for the cursor to be 1 char beyond the end of the string 17 this.offset = Math.max(0, Math.min(this.measuredText.text.length, offset)) 18 } 19 20 static fromText( 21 text: string, 22 columns: number, 23 offset: number = 0, 24 selection: number = 0, 25 ): Cursor { 26 // make MeasuredText on less than columns width, to account for cursor 27 return new Cursor(new MeasuredText(text, columns - 1), offset, selection) 28 } 29 30 render(cursorChar: string, mask: string, invert: (text: string) => string) { 31 const { line, column } = this.getPosition() 32 return this.measuredText 33 .getWrappedText() 34 .map((text, currentLine, allLines) => { 35 let displayText = text 36 if (mask && currentLine === allLines.length - 1) { 37 const lastSixStart = Math.max(0, text.length - 6) 38 displayText = mask.repeat(lastSixStart) + text.slice(lastSixStart) 39 } 40 // looking for the line with the cursor 41 if (line != currentLine) return displayText.trimEnd() 42 43 return ( 44 displayText.slice(0, column) + 45 invert(displayText[column] || cursorChar) + 46 displayText.trimEnd().slice(column + 1) 47 ) 48 }) 49 .join('\n') 50 } 51 52 left(): Cursor { 53 return new Cursor(this.measuredText, this.offset - 1) 54 } 55 56 right(): Cursor { 57 return new Cursor(this.measuredText, this.offset + 1) 58 } 59 60 up(): Cursor { 61 const { line, column } = this.getPosition() 62 if (line == 0) { 63 return new Cursor(this.measuredText, 0, 0) 64 } 65 66 const newOffset = this.getOffset({ line: line - 1, column }) 67 return new Cursor(this.measuredText, newOffset, 0) 68 } 69 70 down(): Cursor { 71 const { line, column } = this.getPosition() 72 if (line >= this.measuredText.lineCount - 1) { 73 return new Cursor(this.measuredText, this.text.length, 0) 74 } 75 76 const newOffset = this.getOffset({ line: line + 1, column }) 77 return new Cursor(this.measuredText, newOffset, 0) 78 } 79 80 startOfLine(): Cursor { 81 const { line } = this.getPosition() 82 return new Cursor( 83 this.measuredText, 84 this.getOffset({ 85 line, 86 column: 0, 87 }), 88 0, 89 ) 90 } 91 92 endOfLine(): Cursor { 93 const { line } = this.getPosition() 94 const column = this.measuredText.getLineLength(line) 95 const offset = this.getOffset({ line, column }) 96 return new Cursor(this.measuredText, offset, 0) 97 } 98 99 nextWord(): Cursor { 100 // eslint-disable-next-line @typescript-eslint/no-this-alias 101 let nextCursor: Cursor = this 102 // If we're on a word, move to the next non-word 103 while (nextCursor.isOverWordChar() && !nextCursor.isAtEnd()) { 104 nextCursor = nextCursor.right() 105 } 106 // now move to the next word char 107 while (!nextCursor.isOverWordChar() && !nextCursor.isAtEnd()) { 108 nextCursor = nextCursor.right() 109 } 110 return nextCursor 111 } 112 113 prevWord(): Cursor { 114 // eslint-disable-next-line @typescript-eslint/no-this-alias 115 let cursor: Cursor = this 116 117 // if we are already at the beginning of a word, step off it 118 if (!cursor.left().isOverWordChar()) { 119 cursor = cursor.left() 120 } 121 122 // Move left over any non-word characters 123 while (!cursor.isOverWordChar() && !cursor.isAtStart()) { 124 cursor = cursor.left() 125 } 126 127 // If we're over a word character, move to the start of this word 128 if (cursor.isOverWordChar()) { 129 while (cursor.left().isOverWordChar() && !cursor.isAtStart()) { 130 cursor = cursor.left() 131 } 132 } 133 134 return cursor 135 } 136 137 private modifyText(end: Cursor, insertString: string = ''): Cursor { 138 const startOffset = this.offset 139 const endOffset = end.offset 140 141 const newText = 142 this.text.slice(0, startOffset) + 143 insertString + 144 this.text.slice(endOffset) 145 146 return Cursor.fromText( 147 newText, 148 this.columns, 149 startOffset + insertString.length, 150 ) 151 } 152 153 insert(insertString: string): Cursor { 154 const newCursor = this.modifyText(this, insertString) 155 return newCursor 156 } 157 158 del(): Cursor { 159 if (this.isAtEnd()) { 160 return this 161 } 162 return this.modifyText(this.right()) 163 } 164 165 backspace(): Cursor { 166 if (this.isAtStart()) { 167 return this 168 } 169 return this.left().modifyText(this) 170 } 171 172 deleteToLineStart(): Cursor { 173 return this.startOfLine().modifyText(this) 174 } 175 176 deleteToLineEnd(): Cursor { 177 // If cursor is on a newline character, delete just that character 178 if (this.text[this.offset] === '\n') { 179 return this.modifyText(this.right()) 180 } 181 182 return this.modifyText(this.endOfLine()) 183 } 184 185 deleteWordBefore(): Cursor { 186 if (this.isAtStart()) { 187 return this 188 } 189 return this.prevWord().modifyText(this) 190 } 191 192 deleteWordAfter(): Cursor { 193 if (this.isAtEnd()) { 194 return this 195 } 196 197 return this.modifyText(this.nextWord()) 198 } 199 200 private isOverWordChar(): boolean { 201 const currentChar = this.text[this.offset] ?? '' 202 return /\w/.test(currentChar) 203 } 204 205 equals(other: Cursor): boolean { 206 return ( 207 this.offset === other.offset && this.measuredText == other.measuredText 208 ) 209 } 210 211 private isAtStart(): boolean { 212 return this.offset == 0 213 } 214 private isAtEnd(): boolean { 215 return this.offset == this.text.length 216 } 217 218 public get text(): string { 219 return this.measuredText.text 220 } 221 222 private get columns(): number { 223 return this.measuredText.columns + 1 224 } 225 226 private getPosition(): Position { 227 return this.measuredText.getPositionFromOffset(this.offset) 228 } 229 230 private getOffset(position: Position): number { 231 return this.measuredText.getOffsetFromPosition(position) 232 } 233 } 234 235 class WrappedLine { 236 constructor( 237 public readonly text: string, 238 public readonly startOffset: number, 239 public readonly isPrecededByNewline: boolean, 240 public readonly endsWithNewline: boolean = false, 241 ) {} 242 243 equals(other: WrappedLine): boolean { 244 return this.text === other.text && this.startOffset === other.startOffset 245 } 246 247 get length(): number { 248 return this.text.length + (this.endsWithNewline ? 1 : 0) 249 } 250 } 251 252 export class MeasuredText { 253 private wrappedLines: WrappedLine[] 254 255 constructor( 256 readonly text: string, 257 readonly columns: number, 258 ) { 259 this.wrappedLines = this.measureWrappedText() 260 } 261 262 private measureWrappedText(): WrappedLine[] { 263 const wrappedText = wrapAnsi(this.text, this.columns, { 264 hard: true, 265 trim: false, 266 }) 267 268 const wrappedLines: WrappedLine[] = [] 269 let searchOffset = 0 270 let lastNewLinePos = -1 271 272 const lines = wrappedText.split('\n') 273 for (let i = 0; i < lines.length; i++) { 274 const text = lines[i]! 275 const isPrecededByNewline = (startOffset: number) => 276 i == 0 || (startOffset > 0 && this.text[startOffset - 1] === '\n') 277 278 if (text.length === 0) { 279 // For blank lines, find the next newline character after the last one 280 lastNewLinePos = this.text.indexOf('\n', lastNewLinePos + 1) 281 282 if (lastNewLinePos !== -1) { 283 const startOffset = lastNewLinePos 284 const endsWithNewline = true 285 286 wrappedLines.push( 287 new WrappedLine( 288 text, 289 startOffset, 290 isPrecededByNewline(startOffset), 291 endsWithNewline, 292 ), 293 ) 294 } else { 295 // If we can't find another newline, this must be the end of text 296 const startOffset = this.text.length 297 wrappedLines.push( 298 new WrappedLine( 299 text, 300 startOffset, 301 isPrecededByNewline(startOffset), 302 false, 303 ), 304 ) 305 } 306 } else { 307 // For non-blank lines 308 const startOffset = this.text.indexOf(text, searchOffset) 309 if (startOffset === -1) { 310 console.log('Debug: Failed to find wrapped line in original text') 311 console.log('Debug: Current text:', text) 312 console.log('Debug: Full original text:', this.text) 313 console.log('Debug: Search offset:', searchOffset) 314 console.log('Debug: Wrapped text:', wrappedText) 315 throw new Error('Failed to find wrapped line in original text') 316 } 317 318 searchOffset = startOffset + text.length 319 320 // Check if this line ends with a newline in the original text 321 const potentialNewlinePos = startOffset + text.length 322 const endsWithNewline = 323 potentialNewlinePos < this.text.length && 324 this.text[potentialNewlinePos] === '\n' 325 326 if (endsWithNewline) { 327 lastNewLinePos = potentialNewlinePos 328 } 329 330 wrappedLines.push( 331 new WrappedLine( 332 text, 333 startOffset, 334 isPrecededByNewline(startOffset), 335 endsWithNewline, 336 ), 337 ) 338 } 339 } 340 341 return wrappedLines 342 } 343 344 public getWrappedText(): WrappedText { 345 return this.wrappedLines.map(line => 346 line.isPrecededByNewline ? line.text : line.text.trimStart(), 347 ) 348 } 349 350 private getLine(line: number): WrappedLine { 351 return this.wrappedLines[ 352 Math.max(0, Math.min(line, this.wrappedLines.length - 1)) 353 ]! 354 } 355 356 public getOffsetFromPosition(position: Position): number { 357 const wrappedLine = this.getLine(position.line) 358 const startOffsetPlusColumn = wrappedLine.startOffset + position.column 359 360 // Handle blank lines specially 361 if (wrappedLine.text.length === 0 && wrappedLine.endsWithNewline) { 362 return wrappedLine.startOffset 363 } 364 365 // For normal lines 366 const lineEnd = wrappedLine.startOffset + wrappedLine.text.length 367 // Add 1 only if this line ends with a newline 368 const maxOffset = wrappedLine.endsWithNewline ? lineEnd + 1 : lineEnd 369 370 return Math.min(startOffsetPlusColumn, maxOffset) 371 } 372 373 public getLineLength(line: number): number { 374 const currentLine = this.getLine(line) 375 const nextLine = this.getLine(line + 1) 376 if (nextLine.equals(currentLine)) { 377 return this.text.length - currentLine.startOffset 378 } 379 380 return nextLine.startOffset - currentLine.startOffset - 1 381 } 382 383 public getPositionFromOffset(offset: number): Position { 384 const lines = this.wrappedLines 385 for (let line = 0; line < lines.length; line++) { 386 const currentLine = lines[line]! 387 const nextLine = lines[line + 1] 388 if ( 389 offset >= currentLine.startOffset && 390 (!nextLine || offset < nextLine.startOffset) 391 ) { 392 const leadingWhitepace = currentLine.isPrecededByNewline 393 ? 0 394 : currentLine.text.length - currentLine.text.trimStart().length 395 const column = Math.max( 396 0, 397 Math.min( 398 offset - currentLine.startOffset - leadingWhitepace, 399 currentLine.text.length, 400 ), 401 ) 402 return { 403 line, 404 column, 405 } 406 } 407 } 408 409 // If we're past the last character, return the end of the last line 410 const line = lines.length - 1 411 return { 412 line, 413 column: this.wrappedLines[line]!.text.length, 414 } 415 } 416 417 public get lineCount(): number { 418 return this.wrappedLines.length 419 } 420 equals(other: MeasuredText): boolean { 421 return this.text === other.text && this.columns === other.columns 422 } 423 }