/ src / utils / Cursor.ts
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  }