From 916d0ee764fe06c62a01ddfa5a1399e6b0c2b7f0 Mon Sep 17 00:00:00 2001 From: tobwen <1864057+tobwen@users.noreply.github.com> Date: Fri, 16 Jan 2026 03:48:59 +0100 Subject: [PATCH] Add ALT+arrow word movement to Input component Implements word-by-word cursor navigation using ALT+Left and ALT+Right: - Adds moveCursorWordLeft and moveCursorWordRight methods - Includes comprehensive test cases for various scenarios - Creates a demo for testing and showing compatibility - Handles multiple spaces and partial word positions The implementation supports both standard terminals and PuTTY's alternative input mode (see additional fix). Hint: Different from moveWordForward/moveWordBackward which are text-direction aware (Forward goes to logical end of text). moveWordLeft/Right are visually direction aware - Left always moves cursor visually left on screen. --- .../src/examples/input-word-movement-demo.ts | 179 ++++++++++++++++++ packages/core/src/renderables/Input.test.ts | 86 +++++++++ packages/core/src/renderables/Input.ts | 41 ++++ 3 files changed, 306 insertions(+) create mode 100644 packages/core/src/examples/input-word-movement-demo.ts diff --git a/packages/core/src/examples/input-word-movement-demo.ts b/packages/core/src/examples/input-word-movement-demo.ts new file mode 100644 index 000000000..851d109e7 --- /dev/null +++ b/packages/core/src/examples/input-word-movement-demo.ts @@ -0,0 +1,179 @@ +import { + createCliRenderer, + InputRenderable, + InputRenderableEvents, + type CliRenderer, + t, + bold, + fg, + BoxRenderable, +} from "../index" +import { setupCommonDemoKeys } from "./lib/standalone-keys" +import { TextRenderable } from "../renderables/Text" + +let testInput: InputRenderable | null = null +let renderer: CliRenderer | null = null +let infoDisplay: TextRenderable | null = null +let cursorPosDisplay: TextRenderable | null = null + +function updateDisplays() { + if (!testInput) return + + const value = testInput.value + const cursorPos = testInput.cursorPosition + + // Show cursor position with visual indicator + const beforeCursor = value.substring(0, cursorPos) + const atCursor = value[cursorPos] || " " + const afterCursor = value.substring(cursorPos + 1) + + const visualText = `${fg("#FFFFFF")(beforeCursor)}${fg("#000000")(bold(`[${atCursor}]`))}${fg("#FFFFFF")(afterCursor)}` + + const cursorText = t`${bold(fg("#FFCC00")("Current Input:"))} + +${visualText} + +${bold(fg("#AAAAAA")(`Cursor Position: ${cursorPos} / ${value.length}`))} +${fg("#666666")(`Character at cursor: "${atCursor === " " ? "SPACE" : atCursor}"`)} +` + + if (cursorPosDisplay) { + cursorPosDisplay.content = cursorText + } + + const infoText = t`${bold(fg("#00FFFF")("ALT+Arrow Word Movement Test"))} + +${bold(fg("#FFFFFF")("Controls:"))} +${fg("#00FF00")("ALT+Left")} - Move cursor one word LEFT +${fg("#00FF00")("ALT+Right")} - Move cursor one word RIGHT +${fg("#FFAA00")("Left/Right")} - Move cursor one character +${fg("#FFAA00")("Home/End")} - Move to start/end +${fg("#FF6666")("Ctrl+Q")} - Quit demo + +${bold(fg("#FFFFFF")("Terminal Compatibility:"))} +${fg("#CCCCCC")("Modern terminals (xterm, kitty, iTerm2):")} + Send ${fg("#AAAAAA")("ESC[1;3C")} for ALT+Right + +${fg("#CCCCCC")("PuTTY (default mode):")} + Send ${fg("#AAAAAA")("ESC ESC [C")} (double ESC) for ALT+Right + +${fg("#FFFF00")("Both modes are supported!")} + +${bold(fg("#FFFFFF")("Try it:"))} +1. Use ALT+Right to jump forward word by word +2. Use ALT+Left to jump backward word by word +3. Notice how it skips over whitespace +4. Works from any cursor position! +` + + if (infoDisplay) { + infoDisplay.content = infoText + } +} + +export function run(rendererInstance: CliRenderer): void { + renderer = rendererInstance + renderer.setBackgroundColor("#000000") + + const container = new BoxRenderable(renderer, { + id: "container", + zIndex: 1, + }) + renderer.root.add(container) + + // Create input with pre-filled text for testing + testInput = new InputRenderable(renderer, { + id: "test-input", + position: "absolute", + left: 5, + top: 3, + width: 80, + height: 3, + zIndex: 100, + backgroundColor: "#1a1a1a", + textColor: "#FFFFFF", + focusedBackgroundColor: "#2a2a2a", + cursorColor: "#00FF00", + value: "The quick brown fox jumps over the lazy dog", + maxLength: 200, + }) + + renderer.root.add(testInput) + + // Info display + infoDisplay = new TextRenderable(renderer, { + id: "info-display", + content: t``, + width: 80, + height: 25, + position: "absolute", + left: 5, + top: 8, + zIndex: 50, + }) + container.add(infoDisplay) + + // Cursor position display + cursorPosDisplay = new TextRenderable(renderer, { + id: "cursor-display", + content: t``, + width: 80, + height: 6, + position: "absolute", + left: 5, + top: 34, + zIndex: 50, + }) + container.add(cursorPosDisplay) + + // Event handlers + testInput.on(InputRenderableEvents.INPUT, () => { + updateDisplays() + }) + + // Update when cursor moves (via arrow keys) + const originalHandleKeyPress = testInput.handleKeyPress.bind(testInput) + testInput.handleKeyPress = (key) => { + const result = originalHandleKeyPress(key) + updateDisplays() + return result + } + + // Global key handler for quit + const keyHandler = (key: any) => { + if (key.ctrl && key.name === "q") { + renderer?.destroy() + process.exit(0) + } + } + + rendererInstance.keyInput.on("keypress", keyHandler) + + // Initial state + testInput.focus() + testInput.cursorPosition = testInput.value.length // Start at end + updateDisplays() +} + +export function destroy(rendererInstance: CliRenderer): void { + if (testInput) { + rendererInstance.root.remove(testInput.id) + testInput.destroy() + testInput = null + } + + rendererInstance.root.remove("container") + infoDisplay = null + cursorPosDisplay = null + renderer = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + }) + + run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} diff --git a/packages/core/src/renderables/Input.test.ts b/packages/core/src/renderables/Input.test.ts index b175f3425..6bf368be9 100644 --- a/packages/core/src/renderables/Input.test.ts +++ b/packages/core/src/renderables/Input.test.ts @@ -211,6 +211,92 @@ describe("InputRenderable", () => { expect(input.cursorPosition).toBe(5) }) + it("should handle ALT+arrow keys for word movement", () => { + const { input } = createInputRenderable({ + value: "hello world test", + }) + + input.focus() + expect(input.cursorPosition).toBe(16) // Should be at end + + // Move word left: should jump to beginning of "test" + mockInput.pressArrow("left", { meta: true }) + expect(input.cursorPosition).toBe(12) + + // Move word left: should jump to beginning of "world" + mockInput.pressArrow("left", { meta: true }) + expect(input.cursorPosition).toBe(6) + + // Move word left: should jump to beginning of "hello" + mockInput.pressArrow("left", { meta: true }) + expect(input.cursorPosition).toBe(0) + + // Move word left at beginning: should stay at 0 + mockInput.pressArrow("left", { meta: true }) + expect(input.cursorPosition).toBe(0) + + // Move word right: should jump to end of "hello" (space after) + mockInput.pressArrow("right", { meta: true }) + expect(input.cursorPosition).toBe(5) + + // Move word right: should jump to end of "world" + mockInput.pressArrow("right", { meta: true }) + expect(input.cursorPosition).toBe(11) + + // Move word right: should jump to end + mockInput.pressArrow("right", { meta: true }) + expect(input.cursorPosition).toBe(16) + + // Move word right at end: should stay at end + mockInput.pressArrow("right", { meta: true }) + expect(input.cursorPosition).toBe(16) + }) + + it("should handle word movement with multiple spaces", () => { + const { input } = createInputRenderable({ + value: "hello world", + }) + + input.focus() + input.cursorPosition = 12 // At end + + // Move word left: should skip multiple spaces + mockInput.pressArrow("left", { meta: true }) + expect(input.cursorPosition).toBe(7) + + // Move word left: should jump to beginning + mockInput.pressArrow("left", { meta: true }) + expect(input.cursorPosition).toBe(0) + + // Move word right: should jump over "hello" + mockInput.pressArrow("right", { meta: true }) + expect(input.cursorPosition).toBe(5) + + // Move word right: should skip spaces and jump over "world" + mockInput.pressArrow("right", { meta: true }) + expect(input.cursorPosition).toBe(12) + }) + + it("should handle word movement from middle of word", () => { + const { input } = createInputRenderable({ + value: "hello world", + }) + + input.focus() + input.cursorPosition = 8 // Middle of "world" + + // Move word left from middle: should jump to beginning of "world" + mockInput.pressArrow("left", { meta: true }) + expect(input.cursorPosition).toBe(6) + + // Set cursor to middle of "hello" + input.cursorPosition = 3 + + // Move word right from middle: should jump to end of "hello" + mockInput.pressArrow("right", { meta: true }) + expect(input.cursorPosition).toBe(5) + }) + it("should handle enter key", () => { const { input } = createInputRenderable({ value: "test input", diff --git a/packages/core/src/renderables/Input.ts b/packages/core/src/renderables/Input.ts index dd0bd6bf0..c729a7d84 100644 --- a/packages/core/src/renderables/Input.ts +++ b/packages/core/src/renderables/Input.ts @@ -16,6 +16,8 @@ import { export type InputAction = | "move-left" | "move-right" + | "move-word-left" + | "move-word-right" | "move-home" | "move-end" | "delete-backward" @@ -39,6 +41,9 @@ const defaultInputKeybindings: InputKeyBinding[] = [ { name: "f", ctrl: true, action: "move-right" }, { name: "b", ctrl: true, action: "move-left" }, { name: "d", ctrl: true, action: "delete-forward" }, + // ALT+Arrow for word movement + { name: "left", meta: true, action: "move-word-left" }, + { name: "right", meta: true, action: "move-word-right" }, ] export interface InputRenderableOptions extends RenderableOptions { @@ -277,6 +282,36 @@ export class InputRenderable extends Renderable { } } + private moveCursorWordLeft(): void { + const text = this._value + let pos = this._cursorPosition + if (pos > 0) { + pos-- + while (pos > 0 && /\s/.test(text[pos])) { + pos-- + } + while (pos > 0 && /\S/.test(text[pos - 1])) { + pos-- + } + this.cursorPosition = pos + } + } + + private moveCursorWordRight(): void { + const text = this._value + let pos = this._cursorPosition + const len = text.length + if (pos < len) { + while (pos < len && /\s/.test(text[pos])) { + pos++ + } + while (pos < len && /\S/.test(text[pos])) { + pos++ + } + this.cursorPosition = pos + } + } + public handleKeyPress(key: KeyEvent): boolean { const bindingKey = getKeyBindingKey({ name: key.name, @@ -297,6 +332,12 @@ export class InputRenderable extends Renderable { case "move-right": this.cursorPosition = this._cursorPosition + 1 return true + case "move-word-left": + this.moveCursorWordLeft() + return true + case "move-word-right": + this.moveCursorWordRight() + return true case "move-home": this.cursorPosition = 0 return true