Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions packages/core/src/examples/input-word-movement-demo.ts
Original file line number Diff line number Diff line change
@@ -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()
}
86 changes: 86 additions & 0 deletions packages/core/src/renderables/Input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/renderables/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
export type InputAction =
| "move-left"
| "move-right"
| "move-word-left"
| "move-word-right"
| "move-home"
| "move-end"
| "delete-backward"
Expand All @@ -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<InputRenderable> {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down