import React from "react"; import type { CursorVisualPosition, PaintKeyboardEvent, VisualLineRange } from "paintcannon"; import { useColor } from "../theme.ts"; import { Span } from "paintcannon-react"; import { TerminalFlex } from "./terminal-flex.tsx"; const isWhitespace = (char: string): boolean => /\s/.test(char); const isNewline = (char: string): boolean => char === "\n"; const isWordChar = (char: string): boolean => /[a-zA-Z0-9_]/.test(char); const trimNewlinesFromEnd = (text: string, start: number, end: number): number => { let trimmedEnd = end; for (; trimmedEnd > start; trimmedEnd--) { if (!isNewline(text[trimmedEnd - 1])) break; } return trimmedEnd; }; const clampToVimBounds = (pos: number, textLength: number): number => { return Math.min(Math.max(0, pos), Math.max(0, textLength - 1)); }; const vimCommandResult = (pos: number, textLength: number) => ({ consumed: true, newCursorPosition: clampToVimBounds(pos, textLength), }); const vimEarlyExit = (condition: boolean) => { if (condition) return { consumed: true, }; return null; }; const getLineInfo = ( text: string, position: number, ): { lineIndex: number; columnIndex: number; } => { const lines = text.split("\n"); let currentPos = 0; for (let i = 0; i < lines.length; i++) { const lineLength = lines[i].length; if (position >= currentPos && position <= currentPos + lineLength) { return { lineIndex: i, columnIndex: position - currentPos, }; } currentPos += lineLength + 1; // +1 for the newline } // If position is at the very end (after last newline), return last line return { lineIndex: lines.length - 1, columnIndex: lines[lines.length - 1]?.length || 0, }; }; const getLineStart = (text: string, lineIndex: number): number => { const lines = text.split("\n"); let position = 0; for (let i = 0; i < lineIndex && i < lines.length; i++) { position += lines[i].length + 1; // +1 for the newline } return position; }; const getLineText = (text: string, lineIndex: number): string => { const lines = text.split("\n"); return lines[lineIndex] || ""; }; const getTargetPosition = (text: string, lineIndex: number, columnIndex: number): number => { const line = getLineText(text, lineIndex); const targetCol = line.length === 0 ? 0 : Math.min(columnIndex, line.length - 1); return getLineStart(text, lineIndex) + targetCol; }; const getLogicalLineContentRange = (text: string, cursorPosition: number): VisualLineRange => { const currentLineInfo = getLineInfo(text, cursorPosition); const start = getLineStart(text, currentLineInfo.lineIndex); const line = getLineText(text, currentLineInfo.lineIndex); return { start, end: start + line.length, }; }; const getCurrentLineRange = ( text: string, cursorPosition: number, visualLineRange: VisualLineRange | null, ): VisualLineRange => visualLineRange ?? getLogicalLineContentRange(text, cursorPosition); const getFirstNonWhitespaceInRange = (text: string, range: VisualLineRange): number => { let position = range.start; while (position < range.end && isWhitespace(text[position])) position++; return position < range.end ? position : range.start; }; const getNormalLineEnd = (range: VisualLineRange): number => range.end > range.start ? range.end - 1 : range.start; const includeFollowingNewline = (text: string, range: VisualLineRange): VisualLineRange => { let end = range.end; if (text[end] === "\r" && text[end + 1] === "\n") end += 2; else if (isNewline(text[end])) end += 1; return { start: range.start, end }; }; type Motion = ( text: string, cursorPosition: number, visualLineRange: VisualLineRange | null, ) => { start: number; end: number; }; type Operator = ( text: string, range: { start: number; end: number; }, motionChar?: string, ) => { newText: string; newCursorPosition?: number; enterInsertMode?: boolean; }; type PendingCommand = { operator: Operator; operatorChar: string; }; type TextState = { text: string; cursorPosition: number; }; const motions: Record = { // In vim, a "word" is either: (1) a sequence of letters/digits/underscores, // OR (2) a sequence of other non-blank characters (punctuation). These two // types of words are distinct - "foo-bar" contains 3 words: "foo", "-", "bar". w: (text, cursorPosition) => { const textLength = text.length; if (cursorPosition >= textLength) { return { start: cursorPosition, end: cursorPosition, }; } const currentChar = text[cursorPosition]; let endPosition: number; if (isWhitespace(currentChar)) { // In whitespace: skip to the next non-whitespace endPosition = cursorPosition; while (endPosition < textLength && isWhitespace(text[endPosition])) { endPosition++; } } else { // On a non-whitespace char: skip chars of the same class, then skip whitespace const currentCharIsWord = isWordChar(currentChar); endPosition = cursorPosition; // Skip characters of the same class while (endPosition < textLength && !isWhitespace(text[endPosition])) { const charIsWord = isWordChar(text[endPosition]); if (charIsWord !== currentCharIsWord) { break; } endPosition++; } // Skip trailing whitespace to reach start of next word/WORD while (endPosition < textLength && isWhitespace(text[endPosition])) { endPosition++; } } return { start: cursorPosition, end: endPosition, }; }, // A "WORD" is a sequence of non-blank characters, separated by whitespace. // "foo-bar" is a single WORD, but "foo bar" is two WORDs. W: (text, cursorPosition) => { const textLength = text.length; if (cursorPosition >= textLength) { return { start: cursorPosition, end: cursorPosition, }; } const currentChar = text[cursorPosition]; let endPosition: number; if (isWhitespace(currentChar)) { // In whitespace: skip to the next non-whitespace endPosition = cursorPosition; while (endPosition < textLength && isWhitespace(text[endPosition])) { endPosition++; } } else { // On a non-whitespace char: skip the entire WORD, then skip whitespace endPosition = cursorPosition; while (endPosition < textLength && !isWhitespace(text[endPosition])) { endPosition++; } while (endPosition < textLength && isWhitespace(text[endPosition])) { endPosition++; } } return { start: cursorPosition, end: endPosition, }; }, // In vim, a "word" is either: (1) a sequence of letters/digits/underscores, // OR (2) a sequence of other non-blank characters (punctuation). These two // types of words are distinct - "foo-bar" contains 3 words: "foo", "-", "bar". b: (text, cursorPosition) => { if (cursorPosition === 0) { return { start: 0, end: 0, }; } let start = cursorPosition; // Skip whitespace while (start > 0 && isWhitespace(text[start - 1])) { start--; } // If we're at the start of the text after skipping whitespace, we're done if (start === 0) { return { start: 0, end: cursorPosition, }; } // Determine the character class of the first non-whitespace char we're on const firstNonWsChar = text[start - 1]; const firstCharIsWord = isWordChar(firstNonWsChar); // Continue skipping characters of the same class while (start > 0 && !isWhitespace(text[start - 1])) { const currentChar = text[start - 1]; const currentCharIsWord = isWordChar(currentChar); // Stop when we hit a different character class if (currentCharIsWord !== firstCharIsWord) { break; } start--; } return { start: start, end: cursorPosition, }; }, // A "WORD" is a sequence of non-blank characters, separated by whitespace. // "foo-bar" is a single WORD, but "foo bar" is two WORDs. B: (text, cursorPosition) => { if (cursorPosition === 0) { return { start: 0, end: 0, }; } let start = cursorPosition; // Skip whitespace while (start > 0 && isWhitespace(text[start - 1])) { start--; } // Skip all non-whitespace characters (the entire WORD) while (start > 0 && !isWhitespace(text[start - 1])) { start--; } return { start: start, end: cursorPosition, }; }, e: (text, cursorPosition) => { const textLength = text.length; const currentChar = text[cursorPosition]; const nextChar = cursorPosition + 1 < textLength ? text[cursorPosition + 1] : ""; const atWordEnd = !isWhitespace(currentChar) && (cursorPosition === textLength - 1 || isWhitespace(nextChar)); let endPos: number; if (atWordEnd) { endPos = cursorPosition + 1; while (endPos < textLength && isWhitespace(text[endPos])) { endPos++; } while (endPos < textLength && !isWhitespace(text[endPos])) { endPos++; } } else { endPos = cursorPosition; while (endPos < textLength && isWhitespace(text[endPos])) { endPos++; } while (endPos < textLength && !isWhitespace(text[endPos])) { endPos++; } } const vimEndPos = Math.max(0, endPos - 1); return { start: cursorPosition, end: vimEndPos + 1, }; }, "0": (text, cursorPosition, visualLineRange) => { const lineRange = getCurrentLineRange(text, cursorPosition, visualLineRange); return { start: lineRange.start, end: cursorPosition, }; }, $: (text, cursorPosition, visualLineRange) => { const lineRange = getCurrentLineRange(text, cursorPosition, visualLineRange); return { start: cursorPosition, end: lineRange.end, }; }, "^": (text, cursorPosition, visualLineRange) => { const lineRange = getCurrentLineRange(text, cursorPosition, visualLineRange); const position = getFirstNonWhitespaceInRange(text, lineRange); return { start: cursorPosition, end: position, }; }, }; const operators: Record = { d: (text, { start, end }, motionChar) => { let actualEnd = Math.min(end, text.length); const actualStart = Math.min(start, actualEnd); // Don't delete newlines at the end of the range for motion-based deletions (de, d$, etc.) // But do delete newlines for line-based deletions (dd) if (motionChar !== "d") { actualEnd = trimNewlinesFromEnd(text, actualStart, actualEnd); } const newText = text.slice(0, actualStart) + text.slice(actualEnd); let newCursorPosition = actualStart; if (newText.length === 0) { newCursorPosition = 0; } else if (newCursorPosition >= newText.length) { newCursorPosition = newText.length - 1; } // Don't leave the cursor on a newline character (unless it's line deletion) // Also skip this if the cursor is on an empty line (newline is the only valid position) if (motionChar !== "d") { const cursorLineInfo = getLineInfo(newText, newCursorPosition); const cursorLine = getLineText(newText, cursorLineInfo.lineIndex); while ( newCursorPosition > 0 && isNewline(newText[newCursorPosition]) && cursorLine.length > 0 ) { newCursorPosition--; } } return { newText, newCursorPosition, }; }, c: (text, { start, end }, motionChar) => { let actualEnd = Math.min(end, text.length); const actualStart = Math.min(start, actualEnd); // For change operator with word motions (cw), trim trailing whitespace (like ce behavior in vim) if (motionChar === "w" || motionChar === "e") { let trimmedEnd = actualEnd; for (; trimmedEnd > actualStart; trimmedEnd--) { const char = text[trimmedEnd - 1]; if (char === "\n" || char === "\r") { continue; } if (!isWhitespace(char)) break; } actualEnd = trimmedEnd; } const newText = text.slice(0, actualStart) + text.slice(actualEnd); let newCursorPosition = actualStart; if (newCursorPosition > newText.length) { newCursorPosition = newText.length; } return { newText, newCursorPosition, enterInsertMode: true, }; }, }; export function VimModeIndicator({ vimEnabled, vimMode, }: { vimEnabled: boolean; vimMode: "NORMAL" | "INSERT"; }) { const themeColor = useColor(); if (!vimEnabled) return null; return ( -- INSERT -- ); } export function useVimKeyHandler( vimMode: "NORMAL" | "INSERT", setVimMode: (mode: "NORMAL" | "INSERT") => void, ) { const pendingCommandRef = React.useRef(null); const undoStackRef = React.useRef([]); const redoStackRef = React.useRef([]); const insertStartStateRef = React.useRef(null); const saveState = (text: string, cursorPosition: number) => { undoStackRef.current.push({ text, cursorPosition, }); redoStackRef.current = []; }; const enterInsertMode = (text: string, cursorPosition: number) => { insertStartStateRef.current = { text, cursorPosition, }; setVimMode("INSERT"); }; return { handle( input: string, key: PaintKeyboardEvent, cursorPosition: number, valueLength: number, currentValue: string, cursorVisualPosition: CursorVisualPosition | null, visualLineRange: VisualLineRange | null, ): { consumed: boolean; newCursorPosition?: number; newValue?: string; } { if (vimMode === "INSERT") { if (key.key === "Escape" || (key.ctrlKey && input === "c")) { let newCursorPosition = cursorPosition; if (cursorPosition > 0) { const isAtVisualLineStart = cursorVisualPosition?.column === 0; // Special case: if we're at the start of an empty line, stay there // (empty line = only valid position is column 0) const currentLineInfo = getLineInfo(currentValue, cursorPosition); const lineStart = getLineStart(currentValue, currentLineInfo.lineIndex); const currentLine = getLineText(currentValue, currentLineInfo.lineIndex); if ( !isAtVisualLineStart && !(cursorPosition === lineStart && currentLine.length === 0) ) { newCursorPosition = cursorPosition - 1; } } if (insertStartStateRef.current !== null) { saveState(insertStartStateRef.current.text, insertStartStateRef.current.cursorPosition); insertStartStateRef.current = null; } setVimMode("NORMAL"); return { consumed: true, newCursorPosition, }; } if (insertStartStateRef.current === null) { insertStartStateRef.current = { text: currentValue, cursorPosition, }; } return { consumed: false, }; } if (key.key === "Enter") return { consumed: false, }; if (key.ctrlKey && input === "c") { return { consumed: false, }; } // Check if we have a pending operator waiting for a motion if (pendingCommandRef.current) { const pending = pendingCommandRef.current; // Check if the same operator is pressed again (dd, cc, etc.) - operate on the current line if (input === pending.operatorChar) { const contentRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); const lineRange = input === "d" ? includeFollowingNewline(currentValue, contentRange) : contentRange; const result = pending.operator(currentValue, lineRange, input); pendingCommandRef.current = null; let finalCursorPosition = result.newCursorPosition; if (finalCursorPosition !== undefined) { finalCursorPosition = clampToVimBounds(finalCursorPosition, result.newText.length); } const response: { consumed: boolean; newCursorPosition?: number; newValue?: string; } = { consumed: true, newValue: result.newText, }; if (finalCursorPosition !== undefined) { response.newCursorPosition = finalCursorPosition; } if (result.enterInsertMode) { enterInsertMode(currentValue, cursorPosition); } else { saveState(currentValue, cursorPosition); } return response; } // Check if the input is a motion if (input in motions) { const motion = motions[input]; const range = motion(currentValue, cursorPosition, visualLineRange); const result = pending.operator(currentValue, range, input); pendingCommandRef.current = null; let finalCursorPosition = result.newCursorPosition; if (finalCursorPosition !== undefined) { finalCursorPosition = clampToVimBounds(finalCursorPosition, result.newText.length); } const response: { consumed: boolean; newCursorPosition?: number; newValue?: string; } = { consumed: true, newValue: result.newText, }; if (finalCursorPosition !== undefined) { response.newCursorPosition = finalCursorPosition; } if (result.enterInsertMode) { enterInsertMode(currentValue, cursorPosition); } else { saveState(currentValue, cursorPosition); } return response; } // Not a motion, cancel the pending operator pendingCommandRef.current = null; // Continue to process this key as a normal command } // Handle redo (Ctrl-r) if (key.ctrlKey && input === "r") { if (redoStackRef.current.length === 0) return { consumed: true, }; const state = redoStackRef.current.pop()!; undoStackRef.current.push({ text: currentValue, cursorPosition, }); return { consumed: true, newValue: state.text, newCursorPosition: state.cursorPosition, }; } // Check if input is an operator if (input in operators) { pendingCommandRef.current = { operator: operators[input], operatorChar: input, }; return { consumed: true, }; } const commands: Record< string, () => { consumed: boolean; newCursorPosition?: number; newValue?: string; } > = { u: () => { if (undoStackRef.current.length === 0) return { consumed: true, }; const state = undoStackRef.current.pop()!; redoStackRef.current.push({ text: currentValue, cursorPosition, }); return { consumed: true, newValue: state.text, newCursorPosition: state.cursorPosition, }; }, i: () => { enterInsertMode(currentValue, cursorPosition); return { consumed: true, }; }, a: () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); // If the line is empty (just a newline), "a" should behave like "i" if (lineRange.start === lineRange.end) { enterInsertMode(currentValue, cursorPosition); return { consumed: true, }; } const newCursorPosition = Math.min(valueLength, cursorPosition + 1); enterInsertMode(currentValue, cursorPosition); return { consumed: true, newCursorPosition, }; }, h: () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); if (cursorPosition > lineRange.start) { return vimCommandResult(cursorPosition - 1, valueLength); } return vimCommandResult(cursorPosition, valueLength); }, l: () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); const lineEnd = getNormalLineEnd(lineRange); if (cursorPosition < lineEnd) { return vimCommandResult(cursorPosition + 1, valueLength); } return vimCommandResult(cursorPosition, valueLength); }, k: () => { const currentLineInfo = getLineInfo(currentValue, cursorPosition); if (currentLineInfo.lineIndex > 0) { const targetLineIndex = currentLineInfo.lineIndex - 1; const newCursorPosition = getTargetPosition( currentValue, targetLineIndex, currentLineInfo.columnIndex, ); return vimCommandResult(newCursorPosition, valueLength); } return vimCommandResult(cursorPosition, valueLength); }, j: () => { const lines = currentValue.split("\n"); const currentLineInfo = getLineInfo(currentValue, cursorPosition); if (currentLineInfo.lineIndex < lines.length - 1) { const targetLineIndex = currentLineInfo.lineIndex + 1; const newCursorPosition = getTargetPosition( currentValue, targetLineIndex, currentLineInfo.columnIndex, ); return vimCommandResult(newCursorPosition, valueLength); } return vimCommandResult(cursorPosition, valueLength); }, o: () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); const hasExplicitNewline = isNewline(currentValue[lineRange.end]); const atEndOfInput = lineRange.end >= currentValue.length; const insertPosition = hasExplicitNewline ? lineRange.end + 1 : lineRange.end; const insertedText = hasExplicitNewline || atEndOfInput ? "\n" : "\n\n"; saveState(currentValue, cursorPosition); const newValue = currentValue.slice(0, insertPosition) + insertedText + currentValue.slice(insertPosition); enterInsertMode(currentValue, cursorPosition); return { consumed: true, newCursorPosition: hasExplicitNewline ? insertPosition : Math.min(newValue.length, lineRange.end + 1), newValue, }; }, O: () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); const insertPosition = lineRange.start; const hasExplicitNewlineBefore = insertPosition === 0 || isNewline(currentValue[insertPosition - 1]); const insertedText = hasExplicitNewlineBefore ? "\n" : "\n\n"; saveState(currentValue, cursorPosition); const newValue = currentValue.slice(0, insertPosition) + insertedText + currentValue.slice(insertPosition); enterInsertMode(currentValue, cursorPosition); return { consumed: true, newCursorPosition: hasExplicitNewlineBefore ? insertPosition : insertPosition + 1, newValue, }; }, x: () => { if (valueLength > 0) { saveState(currentValue, cursorPosition); const beforeCursor = currentValue.slice(0, cursorPosition); const afterCursor = currentValue.slice(cursorPosition + 1); const newValue = beforeCursor + afterCursor; let newCursorPosition = cursorPosition; if (newValue.length === 0) { newCursorPosition = 0; } else if (cursorPosition >= newValue.length) { newCursorPosition = newValue.length - 1; } return { consumed: true, newValue, newCursorPosition, }; } return { consumed: true, }; }, // In vim, a "word" is either: (1) a sequence of letters/digits/underscores, // OR (2) a sequence of other non-blank characters (punctuation). These two // types of words are distinct - "foo-bar" contains 3 words: "foo", "-", "bar". w: () => { const earlyExit = vimEarlyExit(cursorPosition >= valueLength - 1); if (earlyExit) return earlyExit; const currentChar = currentValue[cursorPosition]; let newCursorPosition: number; if (isWhitespace(currentChar)) { // In whitespace: skip to the next non-whitespace newCursorPosition = cursorPosition; while ( newCursorPosition < valueLength && isWhitespace(currentValue[newCursorPosition]) ) { newCursorPosition++; } } else { // On a non-whitespace char: skip chars of the same class, then skip whitespace const currentCharIsWord = isWordChar(currentChar); newCursorPosition = cursorPosition; // Skip characters of the same class while ( newCursorPosition < valueLength && !isWhitespace(currentValue[newCursorPosition]) ) { const charIsWord = isWordChar(currentValue[newCursorPosition]); if (charIsWord !== currentCharIsWord) { break; } newCursorPosition++; } // Skip trailing whitespace to reach start of next word while ( newCursorPosition < valueLength && isWhitespace(currentValue[newCursorPosition]) ) { newCursorPosition++; } } return vimCommandResult(newCursorPosition, valueLength); }, // A "WORD" is a sequence of non-blank characters, separated by whitespace. // "foo-bar" is a single WORD, but "foo bar" is two WORDs. W: () => { const earlyExit = vimEarlyExit(cursorPosition >= valueLength - 1); if (earlyExit) return earlyExit; const currentChar = currentValue[cursorPosition]; let newCursorPosition: number; if (isWhitespace(currentChar)) { // In whitespace: skip to the next non-whitespace newCursorPosition = cursorPosition; while ( newCursorPosition < valueLength && isWhitespace(currentValue[newCursorPosition]) ) { newCursorPosition++; } } else { // On a non-whitespace char: skip the entire WORD, then skip whitespace newCursorPosition = cursorPosition; while ( newCursorPosition < valueLength && !isWhitespace(currentValue[newCursorPosition]) ) { newCursorPosition++; } while ( newCursorPosition < valueLength && isWhitespace(currentValue[newCursorPosition]) ) { newCursorPosition++; } } return vimCommandResult(newCursorPosition, valueLength); }, // In vim, a "word" is either: (1) a sequence of letters/digits/underscores, // OR (2) a sequence of other non-blank characters (punctuation). These two // types of words are distinct - "foo-bar" contains 3 words: "foo", "-", "bar". b: () => { const earlyExit = vimEarlyExit(cursorPosition === 0); if (earlyExit) return earlyExit; let wordStart = cursorPosition; // Skip whitespace while (wordStart > 0 && isWhitespace(currentValue[wordStart - 1])) { wordStart--; } // If we're at the start of the text after skipping whitespace, we're done if (wordStart === 0) { return vimCommandResult(0, valueLength); } // Determine the character class of the first non-whitespace char we're on const firstNonWsChar = currentValue[wordStart - 1]; const firstCharIsWord = isWordChar(firstNonWsChar); // Continue skipping characters of the same class while (wordStart > 0 && !isWhitespace(currentValue[wordStart - 1])) { const currentChar = currentValue[wordStart - 1]; const currentCharIsWord = isWordChar(currentChar); // Stop when we hit a different character class if (currentCharIsWord !== firstCharIsWord) { break; } wordStart--; } return vimCommandResult(wordStart, valueLength); }, // A "WORD" is a sequence of non-blank characters, separated by whitespace. // "foo-bar" is a single WORD, but "foo bar" is two WORDs. B: () => { const earlyExit = vimEarlyExit(cursorPosition === 0); if (earlyExit) return earlyExit; let wordStart = cursorPosition; // Skip whitespace while (wordStart > 0 && isWhitespace(currentValue[wordStart - 1])) { wordStart--; } // Skip all non-whitespace characters (the entire WORD) while (wordStart > 0 && !isWhitespace(currentValue[wordStart - 1])) { wordStart--; } return vimCommandResult(wordStart, valueLength); }, e: () => { const earlyExit = vimEarlyExit(cursorPosition >= valueLength - 1); if (earlyExit) return earlyExit; const currentChar = currentValue[cursorPosition]; const nextChar = cursorPosition + 1 < valueLength ? currentValue[cursorPosition + 1] : ""; const atWordEnd = !isWhitespace(currentChar) && (cursorPosition === valueLength - 1 || isWhitespace(nextChar)); let wordEnd: number; if (atWordEnd) { wordEnd = cursorPosition + 1; while (wordEnd < valueLength && isWhitespace(currentValue[wordEnd])) { wordEnd++; } while (wordEnd < valueLength && !isWhitespace(currentValue[wordEnd])) { wordEnd++; } } else { wordEnd = cursorPosition; while (wordEnd < valueLength && isWhitespace(currentValue[wordEnd])) { wordEnd++; } while (wordEnd < valueLength && !isWhitespace(currentValue[wordEnd])) { wordEnd++; } } const vimPos = Math.max(0, wordEnd - 1); return vimCommandResult(vimPos, valueLength); }, "0": () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); return { consumed: true, newCursorPosition: lineRange.start, }; }, $: () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); return { consumed: true, newCursorPosition: getNormalLineEnd(lineRange), }; }, "^": () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); const position = getFirstNonWhitespaceInRange(currentValue, lineRange); return { consumed: true, newCursorPosition: position, }; }, I: () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); const position = getFirstNonWhitespaceInRange(currentValue, lineRange); enterInsertMode(currentValue, cursorPosition); return { consumed: true, newCursorPosition: position, }; }, A: () => { const lineRange = getCurrentLineRange(currentValue, cursorPosition, visualLineRange); enterInsertMode(currentValue, cursorPosition); return { consumed: true, newCursorPosition: lineRange.end, }; }, D: () => { saveState(currentValue, cursorPosition); const range = motions["$"](currentValue, cursorPosition, visualLineRange); const result = operators["d"](currentValue, range, "$"); return { consumed: true, newValue: result.newText, newCursorPosition: clampToVimBounds( result.newCursorPosition ?? cursorPosition, result.newText.length, ), }; }, }; // Ctrl+Arrow keys redirect to vim word navigation (check before regular arrows) if (key.ctrlKey && key.key === "ArrowLeft") { return commands["b"](); } if (key.ctrlKey && key.key === "ArrowRight") { return commands["e"](); } // Arrow keys and Home/End redirect to vim commands if (key.key === "ArrowLeft") { return commands["h"](); } if (key.key === "ArrowRight") { return commands["l"](); } if (key.key === "ArrowUp") { return commands["k"](); } if (key.key === "ArrowDown") { return commands["j"](); } if (key.key === "Home") { return commands["0"](); } if (key.key === "End") { return commands["$"](); } // Check character commands if (input in commands) { return commands[input](); } // NORMAL mode: ignore unhandled keys return { consumed: true, }; }, }; }