mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 08:04:44 +00:00
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:
parent
d9464383ec
commit
07fad1362c
8 changed files with 193 additions and 17 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||
import { getEditorKeybindings } from "../keybindings.js";
|
||||
import { matchesKey } from "../keys.js";
|
||||
import type { Component, TUI } from "../tui.js";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js";
|
||||
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
|
||||
import { SelectList, type SelectListTheme } from "./select-list.js";
|
||||
|
||||
|
|
@ -204,13 +204,16 @@ export interface EditorTheme {
|
|||
selectList: SelectListTheme;
|
||||
}
|
||||
|
||||
export class Editor implements Component {
|
||||
export class Editor implements Component, Focusable {
|
||||
private state: EditorState = {
|
||||
lines: [""],
|
||||
cursorLine: 0,
|
||||
cursorCol: 0,
|
||||
};
|
||||
|
||||
/** Focusable interface - set by TUI when focus changes */
|
||||
focused: boolean = false;
|
||||
|
||||
protected tui: TUI;
|
||||
private theme: EditorTheme;
|
||||
|
||||
|
|
@ -365,6 +368,9 @@ export class Editor implements Component {
|
|||
}
|
||||
|
||||
// Render each visible layout line
|
||||
// Emit hardware cursor marker only when focused and not showing autocomplete
|
||||
const emitCursorMarker = this.focused && !this.isAutocompleting;
|
||||
|
||||
for (const layoutLine of visibleLines) {
|
||||
let displayText = layoutLine.text;
|
||||
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
||||
|
|
@ -374,6 +380,9 @@ export class Editor implements Component {
|
|||
const before = displayText.slice(0, layoutLine.cursorPos);
|
||||
const after = displayText.slice(layoutLine.cursorPos);
|
||||
|
||||
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
||||
const marker = emitCursorMarker ? CURSOR_MARKER : "";
|
||||
|
||||
if (after.length > 0) {
|
||||
// Cursor is on a character (grapheme) - replace it with highlighted version
|
||||
// Get the first grapheme from 'after'
|
||||
|
|
@ -381,14 +390,14 @@ export class Editor implements Component {
|
|||
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
||||
const restAfter = after.slice(firstGrapheme.length);
|
||||
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
||||
displayText = before + cursor + restAfter;
|
||||
displayText = before + marker + cursor + restAfter;
|
||||
// lineVisibleWidth stays the same - we're replacing, not adding
|
||||
} else {
|
||||
// Cursor is at the end - check if we have room for the space
|
||||
if (lineVisibleWidth < width) {
|
||||
// We have room - add highlighted space
|
||||
const cursor = "\x1b[7m \x1b[0m";
|
||||
displayText = before + cursor;
|
||||
displayText = before + marker + cursor;
|
||||
// lineVisibleWidth increases by 1 - we're adding a space
|
||||
lineVisibleWidth = lineVisibleWidth + 1;
|
||||
} else {
|
||||
|
|
@ -403,7 +412,7 @@ export class Editor implements Component {
|
|||
.slice(0, -1)
|
||||
.map((g) => g.segment)
|
||||
.join("");
|
||||
displayText = beforeWithoutLast + cursor;
|
||||
displayText = beforeWithoutLast + marker + cursor;
|
||||
}
|
||||
// lineVisibleWidth stays the same
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue