/** * Text selection: track anchor/focus, overlay selected cells, extract text, copy. * * Selection operates in screen-space on the FrameBuffer — it doesn't know * about nodes or scroll offsets. The overlay is applied as a post-paint * pass before diffing, using Attr.Inverse XOR to highlight. */ import { Attr, type FrameBuffer } from "./frame-buffer.ts"; export interface SelectionPos { col: number; row: number; } export interface SelectionState { anchor: SelectionPos | null; focus: SelectionPos | null; } export function createSelection(): SelectionState { return { anchor: null, focus: null }; } export function clearSelection(state: SelectionState): void { state.anchor = null; state.focus = null; } /** Normalize anchor/focus into reading-order start/end. */ export function selectionRange(state: SelectionState): { endCol: number; endRow: number; startCol: number; startRow: number; } | null { if (!state.anchor || !state.focus) return null; const a = state.anchor; const b = state.focus; // Reading order: top-to-bottom, left-to-right if (a.row < b.row || (a.row === b.row && a.col <= b.col)) { return { startRow: a.row, startCol: a.col, endRow: b.row, endCol: b.col }; } return { startRow: b.row, startCol: b.col, endRow: a.row, endCol: a.col }; } /** Toggle Attr.Inverse on selected cells in the buffer. */ export function applySelectionOverlay( buf: FrameBuffer, state: SelectionState, ): void { const range = selectionRange(state); if (!range) return; const { startRow, startCol, endRow, endCol } = range; for (let row = startRow; row <= endRow && row < buf.rows; row++) { let colStart = row === startRow ? Math.max(0, startCol) : 0; let colEnd = row === endRow ? Math.min(buf.cols - 1, endCol) : buf.cols - 1; // Clip leading empty cells on all rows while (colStart < colEnd && buf.get(colStart, row).char === " ") { colStart++; } // Clip trailing empty cells on non-end rows (end row follows cursor) if (row !== endRow) { while (colEnd > colStart && buf.get(colEnd, row).char === " ") { colEnd--; } } // Entirely blank row — skip it if (colStart === colEnd && buf.get(colStart, row).char === " ") continue; for (let col = colStart; col <= colEnd; col++) { // getMutable materializes blank slots so highlighted gaps invert too // (and never mutates the shared frozen blank). const cell = buf.getMutable(col, row); // XOR inverse for visual highlight cell.attrs ^= Attr.Inverse; } } } /** Extract text from selected cells, joining rows with newlines. */ export function getSelectedText( buf: FrameBuffer, state: SelectionState, ): string { const range = selectionRange(state); if (!range) return ""; const { startRow, startCol, endRow, endCol } = range; const lines: string[] = []; for (let row = startRow; row <= endRow && row < buf.rows; row++) { const colStart = row === startRow ? Math.max(0, startCol) : 0; const colEnd = row === endRow ? Math.min(buf.cols - 1, endCol) : buf.cols - 1; let line = ""; for (let col = colStart; col <= colEnd; col++) { const cell = buf.get(col, row); if (cell.width === 0) continue; // skip continuation cells line += cell.char; } lines.push(line.trimEnd()); } // Trim trailing empty lines while (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } return lines.join("\n"); } /** Copy text to system clipboard via OSC 52. */ export function copyToClipboard( text: string, write: (s: string) => void, ): void { if (!text) return; const encoded = Buffer.from(text, "utf-8").toString("base64"); // OSC 52: set clipboard 'c' (system clipboard), BEL-terminated for compatibility write(`\x1b]52;c;${encoded}\x07`); }