| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { TuiComponent } from './tui/renderer.js' |
| import { KeyDecoder, type KnownKeyName, type KeyEvent } from './keys.js' |
| import { charWidth } from './tui/markdown.js' |
|
|
| |
| |
| |
|
|
| |
| function stepLeft(s: string, i: number): number { |
| if (i <= 0) return 0 |
| const code = s.charCodeAt(i - 1) |
| if (code >= 0xdc00 && code <= 0xdfff && i >= 2) { |
| const prev = s.charCodeAt(i - 2) |
| if (prev >= 0xd800 && prev <= 0xdbff) return i - 2 |
| } |
| return i - 1 |
| } |
|
|
| |
| function stepRight(s: string, i: number): number { |
| if (i >= s.length) return s.length |
| const cp = s.codePointAt(i) ?? 0 |
| return i + (cp > 0xffff ? 2 : 1) |
| } |
|
|
| |
| const isWide = (s: string, i: number): boolean => charWidth(s.codePointAt(i) ?? 0) === 2 |
|
|
| |
| const isWordChar = (ch: string): boolean => /[A-Za-z0-9_]/.test(ch) |
|
|
| |
| |
| |
| |
| |
| const isSep = (s: string, i: number): boolean => !isWide(s, i) && !isWordChar(s[i] ?? '') |
|
|
| |
| export function wordLeft(s: string, caret: number): number { |
| let i = caret |
| while (i > 0 && isSep(s, i - 1)) i = stepLeft(s, i) |
| if (i > 0) { |
| if (isWide(s, i - 1)) { |
| while (i > 0 && isWide(s, i - 1)) i = stepLeft(s, i) |
| } else { |
| while (i > 0 && !isSep(s, i - 1) && !isWide(s, i - 1)) i = stepLeft(s, i) |
| } |
| } |
| return i |
| } |
|
|
| |
| export function wordRight(s: string, caret: number): number { |
| const len = s.length |
| let i = caret |
| while (i < len && isSep(s, i)) i = stepRight(s, i) |
| if (i < len) { |
| if (isWide(s, i)) { |
| while (i < len && isWide(s, i)) i = stepRight(s, i) |
| } else { |
| while (i < len && !isSep(s, i) && !isWide(s, i)) i = stepRight(s, i) |
| } |
| } |
| return i |
| } |
|
|
| |
| |
| |
|
|
| export type EditorSnapshot = { text: string; caret: number } |
|
|
| const UNDO_CAP = 200 |
| const RING_CAP = 10 |
| const PROMPT = '> ' |
|
|
| const reverseVideo = (ch: string): string => `\x1b[7m${ch}\x1b[0m` |
|
|
| export class LineEditor implements TuiComponent { |
| private text = '' |
| private caret = 0 |
| private readonly decoder = new KeyDecoder() |
|
|
| private undoStack: EditorSnapshot[] = [] |
| private redoStack: EditorSnapshot[] = [] |
|
|
| private ring: string[] = [] |
| private yankSpan: { start: number; end: number } | null = null |
| private yankNext = 0 |
|
|
| private historyFn: (() => readonly string[]) | null = null |
| private historyMax = 1000 |
| private historyIndex: number | null = null |
| private historyDraft = '' |
|
|
| private commitCb: ((line: string) => void) | null = null |
| private abortCb: (() => void) | null = null |
|
|
| |
|
|
| getLine(): string { |
| return this.text |
| } |
|
|
| getCaret(): number { |
| return this.caret |
| } |
|
|
| |
| setLine(s: string): void { |
| this.text = s |
| this.caret = s.length |
| this.decoder.dropPending() |
| } |
|
|
| |
| setHistory(getEntries: () => readonly string[], maxEntries?: number): void { |
| this.historyFn = getEntries |
| if (maxEntries !== undefined) this.historyMax = Math.max(1, maxEntries) |
| } |
|
|
| |
| onCommit(cb: (line: string) => void): void { |
| this.commitCb = cb |
| } |
|
|
| |
| onAbort(cb: () => void): void { |
| this.abortCb = cb |
| } |
|
|
| |
| |
| |
| |
| |
| |
| handleKey(data: string | Buffer): boolean { |
| const events = this.decoder.feed(data) |
| if (events.length === 0) return true |
| const before: EditorSnapshot = { text: this.text, caret: this.caret } |
| let mutated = false |
| for (const ev of events) { |
| if (this.applyEvent(ev)) mutated = true |
| } |
| if (mutated) this.pushUndo(before) |
| return true |
| } |
|
|
| |
| handleInput(data: string | Buffer): boolean { |
| return this.handleKey(data) |
| } |
|
|
| |
| invalidate(): void {} |
|
|
| render(width: number): string[] { |
| const w = Math.max(1, width) |
| const promptW = PROMPT.length |
| const rows: string[] = [] |
| let row = PROMPT |
| let rowW = promptW |
|
|
| const emit = (ch: string, isCaret: boolean): void => { |
| const cw = charWidth(ch.codePointAt(0) ?? 0) |
| if (rowW + cw > w) { |
| rows.push(row) |
| row = '' |
| rowW = 0 |
| } |
| row += isCaret ? reverseVideo(ch) : ch |
| rowW += cw |
| } |
|
|
| let i = 0 |
| while (i < this.text.length) { |
| const cp = this.text.codePointAt(i) ?? 0 |
| emit(String.fromCodePoint(cp), i === this.caret) |
| i += cp > 0xffff ? 2 : 1 |
| } |
| if (this.caret >= this.text.length) emit(' ', true) |
| rows.push(row) |
| return rows |
| } |
|
|
| |
|
|
| private applyEvent(ev: KeyEvent): boolean { |
| if (ev.kind === 'text') return this.insert(ev.chars) |
| return this.applyKey(ev.name) |
| } |
|
|
| private applyKey(name: KnownKeyName): boolean { |
| switch (name) { |
| case 'enter': |
| this.commit() |
| return false |
| case 'backspace': { |
| const start = stepLeft(this.text, this.caret) |
| if (start === this.caret) return false |
| this.splice(start, this.caret, '') |
| this.caret = start |
| return true |
| } |
| case 'delete': |
| case 'ctrl+d': { |
| const end = stepRight(this.text, this.caret) |
| if (end === this.caret) return false |
| this.splice(this.caret, end, '') |
| return true |
| } |
| case 'left': |
| this.caret = stepLeft(this.text, this.caret) |
| return false |
| case 'right': |
| this.caret = stepRight(this.text, this.caret) |
| return false |
| case 'home': |
| case 'ctrl+a': |
| this.caret = 0 |
| return false |
| case 'end': |
| case 'ctrl+e': |
| this.caret = this.text.length |
| return false |
| case 'up': |
| this.historyPrev() |
| return false |
| case 'down': |
| this.historyNext() |
| return false |
| case 'word-left': |
| this.caret = wordLeft(this.text, this.caret) |
| return false |
| case 'word-right': |
| this.caret = wordRight(this.text, this.caret) |
| return false |
| case 'kill-word-back': { |
| const start = wordLeft(this.text, this.caret) |
| if (start === this.caret) return false |
| this.pushKill(this.text.slice(start, this.caret)) |
| this.splice(start, this.caret, '') |
| this.caret = start |
| return true |
| } |
| case 'kill-word-fwd': { |
| const end = wordRight(this.text, this.caret) |
| if (end === this.caret) return false |
| this.pushKill(this.text.slice(this.caret, end)) |
| this.splice(this.caret, end, '') |
| return true |
| } |
| case 'ctrl+k': { |
| if (this.caret >= this.text.length) return false |
| this.pushKill(this.text.slice(this.caret)) |
| this.splice(this.caret, this.text.length, '') |
| return true |
| } |
| case 'ctrl+u': { |
| if (this.caret === 0) return false |
| this.pushKill(this.text.slice(0, this.caret)) |
| this.splice(0, this.caret, '') |
| this.caret = 0 |
| return true |
| } |
| case 'ctrl+y': { |
| const y = this.ring[0] |
| if (y === undefined || y === '') return false |
| this.splice(this.caret, this.caret, y) |
| this.caret += y.length |
| this.yankSpan = { start: this.caret - y.length, end: this.caret } |
| this.yankNext = 1 |
| return true |
| } |
| case 'alt+y': { |
| const span = this.yankSpan |
| if (span === null || this.ring.length < 2) return false |
| const next = this.yankNext % this.ring.length |
| const y = this.ring[next] |
| if (y === undefined) return false |
| this.splice(span.start, span.end, y) |
| this.caret = span.start + y.length |
| this.yankSpan = { start: span.start, end: this.caret } |
| this.yankNext = (next + 1) % this.ring.length |
| return true |
| } |
| case 'ctrl+z': |
| this.undo() |
| return false |
| case 'ctrl+r': |
| this.redo() |
| return false |
| case 'ctrl+c': |
| this.abortCb?.() |
| return false |
| case 'esc': |
| case 'tab': |
| case 'alt+b': |
| case 'alt+f': |
| case 'unknown': |
| return false |
| } |
| } |
|
|
| |
|
|
| private insert(s: string): boolean { |
| if (s === '') return false |
| this.splice(this.caret, this.caret, s) |
| this.caret += s.length |
| return true |
| } |
|
|
| |
| private splice(start: number, end: number, s: string): void { |
| this.text = this.text.slice(0, start) + s + this.text.slice(end) |
| this.yankSpan = null |
| this.yankNext = 0 |
| } |
|
|
| private pushKill(killed: string): void { |
| if (killed === '') return |
| this.ring.unshift(killed) |
| if (this.ring.length > RING_CAP) this.ring.pop() |
| } |
|
|
| private pushUndo(before: EditorSnapshot): void { |
| this.undoStack.push(before) |
| if (this.undoStack.length > UNDO_CAP) this.undoStack.shift() |
| this.redoStack.length = 0 |
| } |
|
|
| private undo(): void { |
| const prev = this.undoStack.pop() |
| if (prev === undefined) return |
| this.redoStack.push({ text: this.text, caret: this.caret }) |
| this.text = prev.text |
| this.caret = prev.caret |
| this.yankSpan = null |
| this.yankNext = 0 |
| } |
|
|
| private redo(): void { |
| const next = this.redoStack.pop() |
| if (next === undefined) return |
| this.undoStack.push({ text: this.text, caret: this.caret }) |
| this.text = next.text |
| this.caret = next.caret |
| this.yankSpan = null |
| this.yankNext = 0 |
| } |
|
|
| private commit(): void { |
| const line = this.text |
| this.text = '' |
| this.caret = 0 |
| this.historyIndex = null |
| this.historyDraft = '' |
| this.undoStack.length = 0 |
| this.redoStack.length = 0 |
| this.yankSpan = null |
| this.yankNext = 0 |
| this.commitCb?.(line) |
| } |
|
|
| |
|
|
| private historyPrev(): void { |
| const entries = this.historyFn?.() ?? [] |
| if (entries.length === 0) return |
| const floor = Math.max(0, entries.length - this.historyMax) |
| if (this.historyIndex === null) { |
| this.historyDraft = this.text |
| this.historyIndex = entries.length - 1 |
| } else { |
| this.historyIndex = Math.max(floor, this.historyIndex - 1) |
| } |
| this.text = entries[this.historyIndex] ?? '' |
| this.caret = this.text.length |
| } |
|
|
| private historyNext(): void { |
| if (this.historyIndex === null) return |
| const entries = this.historyFn?.() ?? [] |
| const next = this.historyIndex + 1 |
| if (next >= entries.length) { |
| this.historyIndex = null |
| this.text = this.historyDraft |
| this.caret = this.text.length |
| return |
| } |
| this.historyIndex = next |
| this.text = entries[next] ?? '' |
| this.caret = this.text.length |
| } |
| } |
|
|