/**
* 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;
}