Files
gemini-cli/packages/cli/src/ui/components/shared/text-buffer.ts
T
Jacob Richman 78ec631acb Multiline editor (#302)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
2025-05-13 11:24:04 -07:00

1050 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import pathMod from 'path';
export type Direction =
| 'left'
| 'right'
| 'up'
| 'down'
| 'wordLeft'
| 'wordRight'
| 'home'
| 'end';
// Simple helper for wordwise ops.
function isWordChar(ch: string | undefined): boolean {
if (ch === undefined) {
return false;
}
return !/[\s,.;!?]/.test(ch);
}
export interface Viewport {
height: number;
width: number;
}
function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
/*
* -------------------------------------------------------------------------
* Unicodeaware helpers (work at the codepoint level rather than UTF16
* code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
function toCodePoints(str: string): string[] {
// [...str] or Array.from both iterate by UTF32 code point, handling
// surrogate pairs correctly.
return Array.from(str);
}
function cpLen(str: string): number {
return toCodePoints(str).length;
}
function cpSlice(str: string, start: number, end?: number): string {
// Slice by codepoint indices and rejoin.
const arr = toCodePoints(str).slice(start, end);
return arr.join('');
}
/* -------------------------------------------------------------------------
* Debug helper enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
* ---------------------------------------------------------------------- */
// Enable verbose logging only when requested via env var.
const DEBUG =
process.env['TEXTBUFFER_DEBUG'] === '1' ||
process.env['TEXTBUFFER_DEBUG'] === 'true';
function dbg(...args: unknown[]): void {
if (DEBUG) {
console.log('[TextBuffer]', ...args);
}
}
/* ────────────────────────────────────────────────────────────────────────── */
export class TextBuffer {
private lines: string[];
private cursorRow = 0;
private cursorCol = 0;
private scrollRow = 0;
private scrollCol = 0;
/**
* When the user moves the caret vertically we try to keep their original
* horizontal column even when passing through shorter lines. We remember
* that *preferred* column in this field while the user is still travelling
* vertically. Any explicit horizontal movement resets the preference.
*/
private preferredCol: number | null = null;
/* a single integer that bumps every time text changes */
private version = 0;
/* ------------------------------------------------------------------
* History & clipboard
* ---------------------------------------------------------------- */
private undoStack: Array<{ lines: string[]; row: number; col: number }> = [];
private redoStack: Array<{ lines: string[]; row: number; col: number }> = [];
private historyLimit = 100;
private clipboard: string | null = null;
private selectionAnchor: [number, number] | null = null;
/**
* Creates a new TextBuffer with the given text
*
* @param text Initial text content for the buffer
* @param initialCursorOffset Initial cursor position as character offset
*/
constructor(text: string = '', initialCursorOffset = 0) {
this.lines = text.split('\n');
if (this.lines.length === 0) {
this.lines = [''];
}
this.setCursorOffset(initialCursorOffset);
}
/**
* Creates a new TextBuffer that is a copy of an existing one
*
* @param source The source TextBuffer to copy
* @returns A new TextBuffer instance with the same content and state
*/
static fromBuffer(source: TextBuffer): TextBuffer {
const buffer = new TextBuffer('');
// Copy all properties
buffer.lines = source.lines.slice();
buffer.cursorRow = source.cursorRow;
buffer.cursorCol = source.cursorCol;
buffer.scrollRow = source.scrollRow;
buffer.scrollCol = source.scrollCol;
buffer.preferredCol = source.preferredCol;
buffer.version = source.version + 1;
// Deep copy history stacks
buffer.undoStack = source.undoStack.slice();
buffer.redoStack = source.redoStack.slice();
buffer.historyLimit = source.historyLimit;
buffer.clipboard = source.clipboard;
buffer.selectionAnchor = source.selectionAnchor
? [...source.selectionAnchor]
: null;
return buffer;
}
/* =====================================================================
* External editor integration (gitstyle $EDITOR workflow)
* =================================================================== */
/**
* Opens the current buffer contents in the users preferred terminal text
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
* until the editor exits, then reloads the file and replaces the inmemory
* buffer with whatever the user saved.
*
* The operation is treated as a single undoable edit we snapshot the
* previous state *once* before launching the editor so one `undo()` will
* revert the entire change set.
*
* Note: We purposefully rely on the *synchronous* spawn API so that the
* calling process genuinely waits for the editor to close before
* continuing. This mirrors Gits behaviour and simplifies downstream
* controlflow (callers can simply `await` the Promise).
*/
async openInExternalEditor(opts: { editor?: string } = {}): Promise<void> {
const editor =
opts.editor ??
process.env['VISUAL'] ??
process.env['EDITOR'] ??
(process.platform === 'win32' ? 'notepad' : 'vi');
// Prepare a temporary file with the current contents. We use mkdtempSync
// to obtain an isolated directory and avoid name collisions.
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'codex-edit-'));
const filePath = pathMod.join(tmpDir, 'buffer.txt');
fs.writeFileSync(filePath, this.getText(), 'utf8');
// One snapshot for undo semantics *before* we mutate anything.
this.pushUndo();
// The child inherits stdio so the user can interact with the editor as if
// they had launched it directly.
const { status, error } = spawnSync(editor, [filePath], {
stdio: 'inherit',
});
if (error) {
throw error;
}
if (typeof status === 'number' && status !== 0) {
throw new Error(`External editor exited with status ${status}`);
}
// Read the edited contents back in normalise line endings to \n.
let newText = fs.readFileSync(filePath, 'utf8');
newText = newText.replace(/\r\n?/g, '\n');
// Update buffer.
this.lines = newText.split('\n');
if (this.lines.length === 0) {
this.lines = [''];
}
// Position the caret at EOF.
this.cursorRow = this.lines.length - 1;
this.cursorCol = cpLen(this.line(this.cursorRow));
// Reset scroll offsets so the new end is visible.
this.scrollRow = Math.max(0, this.cursorRow - 1);
this.scrollCol = 0;
this.version++;
}
/* =======================================================================
* Geometry helpers
* ===================================================================== */
private line(r: number): string {
return this.lines[r] ?? '';
}
private lineLen(r: number): number {
return cpLen(this.line(r));
}
private ensureCursorInRange(): void {
this.cursorRow = clamp(this.cursorRow, 0, this.lines.length - 1);
this.cursorCol = clamp(this.cursorCol, 0, this.lineLen(this.cursorRow));
}
/**
* Sets the cursor position based on a character offset from the start of the document.
*/
private setCursorOffset(offset: number): boolean {
// Reset preferred column since this is an explicit horizontal movement
this.preferredCol = null;
let remainingChars = offset;
let row = 0;
// Count characters line by line until we find the right position
while (row < this.lines.length) {
const lineLength = this.lineLen(row);
// Add 1 for the newline character (except for the last line)
const totalChars = lineLength + (row < this.lines.length - 1 ? 1 : 0);
if (remainingChars <= lineLength) {
this.cursorRow = row;
this.cursorCol = remainingChars;
return true;
}
// Move to next line, subtract this line's characters plus newline
remainingChars -= totalChars;
row++;
}
// If we get here, the index was too large
return false;
}
/* =====================================================================
* History helpers
* =================================================================== */
private snapshot() {
return {
lines: this.lines.slice(),
row: this.cursorRow,
col: this.cursorCol,
};
}
private pushUndo() {
dbg('pushUndo', { cursor: this.getCursor(), text: this.getText() });
this.undoStack.push(this.snapshot());
if (this.undoStack.length > this.historyLimit) {
this.undoStack.shift();
}
// once we mutate we clear redo
this.redoStack.length = 0;
}
/**
* Restore a snapshot and return true if restoration happened.
*/
private restore(
state: { lines: string[]; row: number; col: number } | undefined,
): boolean {
if (!state) {
return false;
}
this.lines = state.lines.slice();
this.cursorRow = state.row;
this.cursorCol = state.col;
this.ensureCursorInRange();
return true;
}
/* =======================================================================
* Scrolling helpers
* ===================================================================== */
private ensureCursorVisible(vp: Viewport) {
const { height, width } = vp;
if (this.cursorRow < this.scrollRow) {
this.scrollRow = this.cursorRow;
} else if (this.cursorRow >= this.scrollRow + height) {
this.scrollRow = this.cursorRow - height + 1;
}
if (this.cursorCol < this.scrollCol) {
this.scrollCol = this.cursorCol;
} else if (this.cursorCol >= this.scrollCol + width) {
this.scrollCol = this.cursorCol - width + 1;
}
}
/* =======================================================================
* Public readonly accessors
* ===================================================================== */
getVersion(): number {
return this.version;
}
getCursor(): [number, number] {
return [this.cursorRow, this.cursorCol];
}
getScrollRow(): number {
return this.scrollRow;
}
getScrollCol(): number {
return this.scrollCol;
}
getVisibleLines(vp: Viewport): string[] {
// Whenever the viewport dimensions change (e.g. on a terminal resize) we
// need to reevaluate whether the current scroll offset still keeps the
// caret visible. Calling `ensureCursorVisible` here guarantees that mere
// rerenders even when not triggered by user input will adjust the
// horizontal and vertical scroll positions so the cursor remains in view.
this.ensureCursorVisible(vp);
return this.lines.slice(this.scrollRow, this.scrollRow + vp.height);
}
getText(): string {
return this.lines.join('\n');
}
getLines(): string[] {
return this.lines.slice();
}
/* =====================================================================
* History public API undo / redo
* =================================================================== */
undo(): boolean {
const state = this.undoStack.pop();
if (!state) {
return false;
}
// push current to redo before restore
this.redoStack.push(this.snapshot());
this.restore(state);
this.version++;
return true;
}
redo(): boolean {
const state = this.redoStack.pop();
if (!state) {
return false;
}
// push current to undo before restore
this.undoStack.push(this.snapshot());
this.restore(state);
this.version++;
return true;
}
/* =======================================================================
* Editing operations
* ===================================================================== */
/**
* Insert a single character or string without newlines. If the string
* contains a newline we delegate to insertStr so that line splitting
* logic is shared.
*/
insert(ch: string): void {
// Handle pasted blocks that may contain newline sequences (\n, \r or
// Windowsstyle \r\n). Delegate to `insertStr` so the splitting logic is
// centralised.
if (/[\n\r]/.test(ch)) {
this.insertStr(ch);
return;
}
dbg('insert', { ch, beforeCursor: this.getCursor() });
this.pushUndo();
const line = this.line(this.cursorRow);
this.lines[this.cursorRow] =
cpSlice(line, 0, this.cursorCol) + ch + cpSlice(line, this.cursorCol);
this.cursorCol += ch.length;
this.version++;
dbg('insert:after', {
cursor: this.getCursor(),
line: this.line(this.cursorRow),
});
}
newline(): void {
dbg('newline', { beforeCursor: this.getCursor() });
this.pushUndo();
const l = this.line(this.cursorRow);
const before = cpSlice(l, 0, this.cursorCol);
const after = cpSlice(l, this.cursorCol);
this.lines[this.cursorRow] = before;
this.lines.splice(this.cursorRow + 1, 0, after);
this.cursorRow += 1;
this.cursorCol = 0;
this.version++;
dbg('newline:after', {
cursor: this.getCursor(),
lines: [this.line(this.cursorRow - 1), this.line(this.cursorRow)],
});
}
backspace(): void {
dbg('backspace', { beforeCursor: this.getCursor() });
if (this.cursorCol === 0 && this.cursorRow === 0) {
return;
} // nothing to delete
this.pushUndo();
if (this.cursorCol > 0) {
const line = this.line(this.cursorRow);
this.lines[this.cursorRow] =
cpSlice(line, 0, this.cursorCol - 1) + cpSlice(line, this.cursorCol);
this.cursorCol--;
} else if (this.cursorRow > 0) {
// merge with previous
const prev = this.line(this.cursorRow - 1);
const cur = this.line(this.cursorRow);
const newCol = cpLen(prev);
this.lines[this.cursorRow - 1] = prev + cur;
this.lines.splice(this.cursorRow, 1);
this.cursorRow--;
this.cursorCol = newCol;
}
this.version++;
dbg('backspace:after', {
cursor: this.getCursor(),
line: this.line(this.cursorRow),
});
}
del(): void {
dbg('delete', { beforeCursor: this.getCursor() });
const line = this.line(this.cursorRow);
if (this.cursorCol < this.lineLen(this.cursorRow)) {
this.pushUndo();
this.lines[this.cursorRow] =
cpSlice(line, 0, this.cursorCol) + cpSlice(line, this.cursorCol + 1);
} else if (this.cursorRow < this.lines.length - 1) {
this.pushUndo();
const next = this.line(this.cursorRow + 1);
this.lines[this.cursorRow] = line + next;
this.lines.splice(this.cursorRow + 1, 1);
}
this.version++;
dbg('delete:after', {
cursor: this.getCursor(),
line: this.line(this.cursorRow),
});
}
/**
* Replaces the entire buffer content with the provided text.
* The operation is undoable.
*
* @param text The new text content for the buffer.
*/
setText(text: string): void {
dbg('setText', { text });
this.pushUndo(); // Snapshot before replacing everything
// Normalize line endings and split into lines
this.lines = text.replace(/\r\n?/g, '\n').split('\n');
if (this.lines.length === 0) {
// Ensure there's always at least one line, even if empty
this.lines = [''];
}
// Reset cursor to the end of the new text
this.cursorRow = this.lines.length - 1;
this.cursorCol = this.lineLen(this.cursorRow);
// Reset scroll positions and preferred column
this.scrollRow = 0;
this.scrollCol = 0;
this.preferredCol = null;
this.version++; // Bump version to indicate change
this.ensureCursorInRange(); // Ensure cursor is valid after replacement
// ensureCursorVisible will be called on next render via getVisibleLines
dbg('setText:after', { cursor: this.getCursor(), text: this.getText() });
}
/**
* Replaces the text within the specified range with new text.
* Handles both single-line and multi-line ranges. asdf jas
*
* @param startRow The starting row index (inclusive).
* @param startCol The starting column index (inclusive, code-point based).
* @param endRow The ending row index (inclusive).
* @param endCol The ending column index (exclusive, code-point based).
* @param text The new text to insert.
* @returns True if the buffer was modified, false otherwise.
*/
replaceRange(
startRow: number,
startCol: number,
endRow: number,
endCol: number,
text: string,
): boolean {
// Ensure range is valid and ordered (start <= end)
// Basic validation, more robust checks might be needed
if (
startRow > endRow ||
(startRow === endRow && startCol > endCol) ||
startRow < 0 ||
startCol < 0 ||
endRow >= this.lines.length
// endCol check needs line length, done below
) {
console.error('Invalid range provided to replaceRange');
return false; // Or throw an error
}
dbg('replaceRange', {
start: [startRow, startCol],
end: [endRow, endCol],
text,
});
this.pushUndo(); // Snapshot before modification
const startLine = this.line(startRow);
const endLine = this.line(endRow);
// Clamp columns to valid positions within their respective lines
startCol = clamp(startCol, 0, this.lineLen(startRow));
endCol = clamp(endCol, 0, this.lineLen(endRow));
// 1. Perform the deletion part
const prefix = cpSlice(startLine, 0, startCol);
const suffix = cpSlice(endLine, endCol);
// Remove lines between startRow (exclusive) and endRow (inclusive)
if (startRow < endRow) {
this.lines.splice(startRow + 1, endRow - startRow);
}
// Replace the startRow line with the combined prefix and suffix
this.lines[startRow] = prefix + suffix;
// 2. Position cursor at the start of the replaced range
this.cursorRow = startRow;
this.cursorCol = startCol;
this.preferredCol = null; // Reset preferred column after modification
// 3. Insert the new text
const inserted = this.insertStr(text); // insertStr handles cursor update & version++
// Ensure version is bumped even if inserted text was empty
if (!inserted && text === '') {
this.version++;
}
this.ensureCursorInRange(); // Ensure cursor is valid after potential deletion/insertion
// ensureCursorVisible will be called on next render via getVisibleLines
dbg('replaceRange:after', {
cursor: this.getCursor(),
text: this.getText(),
});
return true; // Assume modification happened (pushUndo was called)
}
/* ------------------------------------------------------------------
* Wordwise deletion helpers exposed publicly so tests (and future
* keybindings) can invoke them directly.
* ---------------------------------------------------------------- */
/** Delete the word to the *left* of the caret, mirroring common
* Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent
* whitespace *and* the word characters immediately preceding the caret are
* removed. If the caret is already at column0 this becomes a no-op. */
deleteWordLeft(): void {
dbg('deleteWordLeft', { beforeCursor: this.getCursor() });
if (this.cursorCol === 0 && this.cursorRow === 0) {
return;
} // Nothing to delete
// When at column0 but *not* on the first row we merge with the previous
// line matching the behaviour of `backspace` for uniform UX.
if (this.cursorCol === 0) {
this.backspace();
return;
}
this.pushUndo();
const line = this.line(this.cursorRow);
const arr = toCodePoints(line);
// If the cursor is just after a space (or several spaces), we only delete the separators
// then, on the next call, the previous word. We should never delete the entire line.
let start = this.cursorCol;
let onlySpaces = true;
for (let i = 0; i < start; i++) {
if (isWordChar(arr[i])) {
onlySpaces = false;
break;
}
}
// If the line contains only spaces up to the cursor, delete just one space
if (onlySpaces && start > 0) {
start--;
} else {
// Step 1 skip over any separators sitting *immediately* to the left of the caret
while (start > 0 && !isWordChar(arr[start - 1])) {
start--;
}
// Step 2 skip the word characters themselves
while (start > 0 && isWordChar(arr[start - 1])) {
start--;
}
}
this.lines[this.cursorRow] =
cpSlice(line, 0, start) + cpSlice(line, this.cursorCol);
this.cursorCol = start;
this.version++;
dbg('deleteWordLeft:after', {
cursor: this.getCursor(),
line: this.line(this.cursorRow),
});
}
/** Delete the word to the *right* of the caret, akin to many editors'
* Ctrl/Alt+Delete shortcut. Removes any whitespace/punctuation that
* follows the caret and the next contiguous run of word characters. */
deleteWordRight(): void {
dbg('deleteWordRight', { beforeCursor: this.getCursor() });
const line = this.line(this.cursorRow);
const arr = toCodePoints(line);
if (
this.cursorCol >= arr.length &&
this.cursorRow === this.lines.length - 1
) {
return;
} // nothing to delete
// At endofline ➜ merge with next row (mirrors `del` behaviour).
if (this.cursorCol >= arr.length) {
this.del();
return;
}
this.pushUndo();
let end = this.cursorCol;
// Skip separators *first* so that consecutive calls gradually chew
// through whitespace then whole words.
while (end < arr.length && !isWordChar(arr[end])) {
end++;
}
// Skip the word characters.
while (end < arr.length && isWordChar(arr[end])) {
end++;
}
this.lines[this.cursorRow] =
cpSlice(line, 0, this.cursorCol) + cpSlice(line, end);
// caret stays in place
this.version++;
dbg('deleteWordRight:after', {
cursor: this.getCursor(),
line: this.line(this.cursorRow),
});
}
move(dir: Direction): void {
const before = this.getCursor();
switch (dir) {
case 'left':
this.preferredCol = null;
if (this.cursorCol > 0) {
this.cursorCol--;
} else if (this.cursorRow > 0) {
this.cursorRow--;
this.cursorCol = this.lineLen(this.cursorRow);
}
break;
case 'right':
this.preferredCol = null;
if (this.cursorCol < this.lineLen(this.cursorRow)) {
this.cursorCol++;
} else if (this.cursorRow < this.lines.length - 1) {
this.cursorRow++;
this.cursorCol = 0;
}
break;
case 'up':
if (this.cursorRow > 0) {
if (this.preferredCol == null) {
this.preferredCol = this.cursorCol;
}
this.cursorRow--;
this.cursorCol = clamp(
this.preferredCol,
0,
this.lineLen(this.cursorRow),
);
}
break;
case 'down':
if (this.cursorRow < this.lines.length - 1) {
if (this.preferredCol == null) {
this.preferredCol = this.cursorCol;
}
this.cursorRow++;
this.cursorCol = clamp(
this.preferredCol,
0,
this.lineLen(this.cursorRow),
);
}
break;
case 'home':
this.preferredCol = null;
this.cursorCol = 0;
break;
case 'end':
this.preferredCol = null;
this.cursorCol = this.lineLen(this.cursorRow);
break;
case 'wordLeft': {
this.preferredCol = null;
const regex = /[\s,.;!?]+/g;
const slice = cpSlice(
this.line(this.cursorRow),
0,
this.cursorCol,
).replace(/[\s,.;!?]+$/, '');
let lastIdx = 0;
let m;
while ((m = regex.exec(slice)) != null) {
lastIdx = m.index;
}
const last = cpLen(slice.slice(0, lastIdx));
this.cursorCol = last === 0 ? 0 : last + 1;
break;
}
case 'wordRight': {
this.preferredCol = null;
const regex = /[\s,.;!?]+/g;
const l = this.line(this.cursorRow);
let moved = false;
let m;
while ((m = regex.exec(l)) != null) {
const cpIdx = cpLen(l.slice(0, m.index));
if (cpIdx > this.cursorCol) {
// We want to land *at the beginning* of the separator run so that a
// subsequent move("right") behaves naturally.
this.cursorCol = cpIdx;
moved = true;
break;
}
}
if (!moved) {
// No boundary to the right jump to EOL.
this.cursorCol = this.lineLen(this.cursorRow);
}
break;
}
default:
break;
}
if (DEBUG) {
dbg('move', { dir, before, after: this.getCursor() });
}
/*
* If the user performed any movement other than a consecutive vertical
* traversal we clear the preferred column so the next vertical run starts
* afresh. The cases that keep the preference already returned earlier.
*/
if (dir !== 'up' && dir !== 'down') {
this.preferredCol = null;
}
}
/* =====================================================================
* Higherlevel helpers
* =================================================================== */
/**
* Insert an arbitrary string, possibly containing internal newlines.
* Returns true if the buffer was modified.
*/
insertStr(str: string): boolean {
dbg('insertStr', { str, beforeCursor: this.getCursor() });
if (str === '') {
return false;
}
// Normalise all newline conventions (\r, \n, \r\n) to a single '\n'.
const normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Fast path: resulted in singleline string ➜ delegate back to insert
if (!normalised.includes('\n')) {
this.insert(normalised);
return true;
}
this.pushUndo();
const parts = normalised.split('\n');
const before = cpSlice(this.line(this.cursorRow), 0, this.cursorCol);
const after = cpSlice(this.line(this.cursorRow), this.cursorCol);
// Replace current line with first part combined with before text
this.lines[this.cursorRow] = before + parts[0];
// Middle lines (if any) are inserted verbatim after current row
if (parts.length > 2) {
const middle = parts.slice(1, -1);
this.lines.splice(this.cursorRow + 1, 0, ...middle);
}
// Smart handling of the *final* inserted part:
// • When the caret is midline we preserve existing behaviour merge
// the last part with the text to the **right** of the caret so that
// inserting in the middle of a line keeps the remainder on the same
// row (e.g. "he|llo" → paste "x\ny" ⇒ "he x", "y llo").
// • When the caret is at column0 we instead treat the current line as
// a *separate* row that follows the inserted block. This mirrors
// common editor behaviour and avoids the unintuitive merge that led
// to "cd"+"ef" → "cdef" in the failing tests.
// Append the last part combined with original after text as a new line
const last = parts[parts.length - 1] + after;
this.lines.splice(this.cursorRow + (parts.length - 1), 0, last);
// Update cursor position to end of last inserted part (before 'after')
this.cursorRow += parts.length - 1;
// `parts` is guaranteed to have at least one element here because
// `split("\n")` always returns an array with ≥1 entry. Tell the
// compiler so we can pass a plain `string` to `cpLen`.
this.cursorCol = cpLen(parts[parts.length - 1]!);
this.version++;
return true;
}
/* =====================================================================
* Selection & clipboard helpers (minimal)
* =================================================================== */
startSelection(): void {
this.selectionAnchor = [this.cursorRow, this.cursorCol];
}
endSelection(): void {
// no-op for now, kept for API symmetry
// we rely on anchor + current cursor to compute selection
}
/** Extract selected text. Returns null if no valid selection. */
private getSelectedText(): string | null {
if (!this.selectionAnchor) {
return null;
}
const [ar, ac] = this.selectionAnchor;
const [br, bc] = [this.cursorRow, this.cursorCol];
// Determine ordering
if (ar === br && ac === bc) {
return null;
} // empty selection
const topBefore = ar < br || (ar === br && ac < bc);
const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];
if (sr === er) {
return cpSlice(this.line(sr), sc, ec);
}
const parts: string[] = [];
parts.push(cpSlice(this.line(sr), sc));
for (let r = sr + 1; r < er; r++) {
parts.push(this.line(r));
}
parts.push(cpSlice(this.line(er), 0, ec));
return parts.join('\n');
}
copy(): string | null {
const txt = this.getSelectedText();
if (txt == null) {
return null;
}
this.clipboard = txt;
return txt;
}
paste(): boolean {
if (this.clipboard == null) {
return false;
}
return this.insertStr(this.clipboard);
}
/* =======================================================================
* High level "handleInput" receives what Ink gives us
* Returns true when buffer mutated (=> rerender)
* ===================================================================== */
handleInput(
input: string | undefined,
key: Record<string, boolean>,
vp: Viewport,
): boolean {
if (DEBUG) {
dbg('handleInput', { input, key, cursor: this.getCursor() });
}
const beforeVer = this.version;
const [beforeRow, beforeCol] = this.getCursor();
if (key['escape']) {
return false;
}
/* new line — Ink sets either `key.return` *or* passes a literal "\n" */
if (key['return'] || input === '\r' || input === '\n') {
this.newline();
} else if (
key['leftArrow'] &&
!key['meta'] &&
!key['ctrl'] &&
!key['alt']
) {
/* navigation */
this.move('left');
} else if (
key['rightArrow'] &&
!key['meta'] &&
!key['ctrl'] &&
!key['alt']
) {
this.move('right');
} else if (key['upArrow']) {
this.move('up');
} else if (key['downArrow']) {
this.move('down');
} else if ((key['meta'] || key['ctrl'] || key['alt']) && key['leftArrow']) {
this.move('wordLeft');
} else if (
(key['meta'] || key['ctrl'] || key['alt']) &&
key['rightArrow']
) {
this.move('wordRight');
} else if (key['home']) {
this.move('home');
} else if (key['end']) {
this.move('end');
}
/* delete */
// In raw terminal mode many frameworks (Ink included) surface a physical
// Backspace keypress as the single DEL (0x7f) byte placed in `input` with
// no `key.backspace` flag set. Treat that byte exactly like an ordinary
// Backspace for parity with textarea.rs and to make interactive tests
// feedable through the simpler `(ch, {}, vp)` path.
else if (
(key['meta'] || key['ctrl'] || key['alt']) &&
(key['backspace'] || input === '\x7f')
) {
this.deleteWordLeft();
} else if ((key['meta'] || key['ctrl'] || key['alt']) && key['delete']) {
this.deleteWordRight();
} else if (
key['backspace'] ||
input === '\x7f' ||
(key['delete'] && !key['shift'])
) {
// Treat unmodified "delete" (the common Mac backspace key) as a
// standard backspace. Holding Shift+Delete continues to perform a
// forward deletion so we don't lose that capability on keyboards that
// expose both behaviours.
this.backspace();
}
// Forward deletion (Fn+Delete on macOS, or Delete key with Shift held after
// the branch above) remove the character *under / to the right* of the
// caret, merging lines when at EOL similar to many editors.
else if (key['delete']) {
this.del();
} else if (input && !key['ctrl'] && !key['meta']) {
this.insert(input);
}
/* printable */
/* clamp + scroll */
this.ensureCursorInRange();
this.ensureCursorVisible(vp);
const cursorMoved =
this.cursorRow !== beforeRow || this.cursorCol !== beforeCol;
if (DEBUG) {
dbg('handleInput:after', {
cursor: this.getCursor(),
text: this.getText(),
});
}
return this.version !== beforeVer || cursorMoved;
}
}