feat(tui): hardware cursor positioning for IME support

- Add Focusable interface for components that need hardware cursor positioning
- Add CURSOR_MARKER (APC escape sequence) for marking cursor position in render output
- Editor and Input components implement Focusable and emit marker when focused
- TUI extracts cursor position from rendered output and positions hardware cursor
- Track hardwareCursorRow separately from cursorRow for differential rendering
- visibleWidth() and extractAnsiCode() now handle APC sequences
- Update overlay-test.ts example to demonstrate Focusable usage
- Add documentation for Focusable interface in docs/tui.md

Closes #719, closes #525
This commit is contained in:
Mario Zechner 2026-01-16 04:30:07 +01:00
parent d9464383ec
commit 07fad1362c
8 changed files with 193 additions and 17 deletions

View file

@ -1,5 +1,5 @@
import { getEditorKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
const segmenter = getSegmenter();
@ -7,12 +7,15 @@ const segmenter = getSegmenter();
/**
* Input component - single-line text input with horizontal scrolling
*/
export class Input implements Component {
export class Input implements Component, Focusable {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
@ -325,9 +328,12 @@ export class Input implements Component {
const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + 1);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = this.focused ? CURSOR_MARKER : "";
// Use inverse video to show cursor
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
const textWithCursor = beforeCursor + cursorChar + afterCursor;
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
// Calculate visual width
const visualLength = visibleWidth(textWithCursor);