/** * Input element key handler — manages text editing operations for . * * Given a key event and the current input state (value + cursor position), * produces the next state. The screen wires this into the key dispatch loop * for any focused node. */ import type { ParsedKey } from "./parse-keypress.ts"; export interface InputState { cursorPos: number; value: string; } export interface InputResult { /** Whether onChange should be called (value changed) */ changed: boolean; /** New cursor position */ cursorPos: number; /** Whether onSubmit should be called (Enter without shift in single-line, or ctrl+enter) */ submit: boolean; /** New value after the key operation */ value: string; } /** * Process a key event against input state, returning the next state. * Pure function — no side effects. */ export function handleInputKey( key: ParsedKey, state: InputState, options?: { multiline?: boolean }, ): InputResult { const multiline = options?.multiline ?? false; const { value, cursorPos } = state; const result: InputResult = { value, cursorPos, changed: false, submit: false, }; // Modifier-only keys (shift, ctrl, etc.) — ignore if ( key.name === "leftshift" || key.name === "rightshift" || key.name === "leftctrl" || key.name === "rightctrl" || key.name === "leftalt" || key.name === "rightalt" || key.name === "leftsuper" || key.name === "rightsuper" || key.name === "capslock" || key.name === "numlock" ) { return result; } // Release events — ignore if (key.eventType === "release") return result; // Navigation if (key.name === "left") { result.cursorPos = Math.max(0, cursorPos - 1); return result; } if (key.name === "right") { result.cursorPos = Math.min(value.length, cursorPos + 1); return result; } if (key.name === "home" || (key.ctrl && key.name === "a")) { // Move to start of current line const lineStart = value.lastIndexOf("\n", cursorPos - 1) + 1; result.cursorPos = lineStart; return result; } if (key.name === "end" || (key.ctrl && key.name === "e")) { // Move to end of current line const lineEnd = value.indexOf("\n", cursorPos); result.cursorPos = lineEnd === -1 ? value.length : lineEnd; return result; } if (key.name === "up") { result.cursorPos = moveCursorVertically(value, cursorPos, -1); return result; } if (key.name === "down") { result.cursorPos = moveCursorVertically(value, cursorPos, 1); return result; } // Deletion if (key.name === "backspace") { if (cursorPos > 0) { result.value = value.slice(0, cursorPos - 1) + value.slice(cursorPos); result.cursorPos = cursorPos - 1; result.changed = true; } return result; } if (key.name === "delete") { if (cursorPos < value.length) { result.value = value.slice(0, cursorPos) + value.slice(cursorPos + 1); result.changed = true; } return result; } // Kill line (ctrl+k) — delete from cursor to end of line if (key.ctrl && key.name === "k") { const lineEnd = value.indexOf("\n", cursorPos); const end = lineEnd === -1 ? value.length : lineEnd; if (cursorPos < end) { result.value = value.slice(0, cursorPos) + value.slice(end); result.changed = true; } return result; } // Kill word backward (ctrl+w / ctrl+backspace) if (key.ctrl && (key.name === "w" || key.name === "backspace")) { if (cursorPos > 0) { const before = value.slice(0, cursorPos); const trimmed = before.replace(/\S+\s*$|^\s+$/, ""); result.value = trimmed + value.slice(cursorPos); result.cursorPos = trimmed.length; result.changed = true; } return result; } // Submit / newline — behavior depends on multiline prop if (key.name === "return" || key.name === "linefeed") { if (multiline) { // Multiline: Enter inserts newline, Ctrl+Enter submits if (key.ctrl || key.meta) { result.submit = true; return result; } result.value = `${value.slice(0, cursorPos)}\n${value.slice(cursorPos)}`; result.cursorPos = cursorPos + 1; result.changed = true; return result; } // Single-line: Enter submits result.submit = true; return result; } // Tab — insert tab (or could be handled by focus system later) if (key.name === "tab" && !key.shift) { result.value = `${value.slice(0, cursorPos)}\t${value.slice(cursorPos)}`; result.cursorPos = cursorPos + 1; result.changed = true; return result; } // Ignore other control sequences if (key.ctrl || key.meta) return result; // Printable character insertion if (key.sequence && key.sequence.length > 0 && !key.ctrl && !key.meta) { const char = key.sequence; // Filter out non-printable sequences (escape, function keys, etc.) if ( key.name === "escape" || key.name === "tab" || key.name === "space" || (key.name.startsWith("f") && key.name.length <= 3 && /^f\d+$/.test(key.name)) ) { if (key.name === "space") { result.value = `${value.slice(0, cursorPos)} ${value.slice(cursorPos)}`; result.cursorPos = cursorPos + 1; result.changed = true; } return result; } result.value = value.slice(0, cursorPos) + char + value.slice(cursorPos); result.cursorPos = cursorPos + char.length; result.changed = true; return result; } return result; } /** * Move cursor up or down by `delta` lines within a multi-line string. * Tries to maintain the column position. */ function moveCursorVertically( value: string, cursorPos: number, delta: number, ): number { const lines = value.split("\n"); // Find which line and column the cursor is on let charCount = 0; let currentLine = 0; let currentCol = 0; for (let i = 0; i < lines.length; i++) { const lineLen = (lines[i] ?? "").length; if (cursorPos <= charCount + lineLen) { currentLine = i; currentCol = cursorPos - charCount; break; } charCount += lineLen + 1; // +1 for \n } const targetLine = Math.max( 0, Math.min(lines.length - 1, currentLine + delta), ); if (targetLine === currentLine) return cursorPos; // Calculate new cursor position maintaining column const targetLineLen = (lines[targetLine] ?? "").length; const targetCol = Math.min(currentCol, targetLineLen); let newPos = 0; for (let i = 0; i < targetLine; i++) { newPos += (lines[i] ?? "").length + 1; } return newPos + targetCol; }