diff --git a/cli/src/components/__tests__/multiline-input.test.tsx b/cli/src/components/__tests__/multiline-input.test.tsx index 7fcf7eaa17..9d5bb95cba 100644 --- a/cli/src/components/__tests__/multiline-input.test.tsx +++ b/cli/src/components/__tests__/multiline-input.test.tsx @@ -1,9 +1,21 @@ -import { describe, test, expect } from 'bun:test' +import { beforeAll, describe, expect, test } from 'bun:test' +import { createTestRenderer } from '@opentui/core/testing' +import { createRoot, flushSync } from '@opentui/react' +import React from 'react' import { getKeypadPrintableSequence, isKeypadEnter, } from '../../utils/keypad-keys' +import { initializeThemeStore } from '../../hooks/use-theme' +import { + calculateMultilineInputCursorPosition, + MultilineInput, +} from '../multiline-input' + +beforeAll(() => { + initializeThemeStore() +}) /** * Tests for tab character cursor rendering in MultilineInput component. @@ -242,6 +254,157 @@ describe('MultilineInput - tab character handling', () => { }) }) +describe('MultilineInput - hardware cursor coordinates', () => { + const viewportX = 10 + const viewportY = 20 + const lineInfo = (lineStartCols: number[]) => ({ lineStartCols }) + + test('places the ASCII caret using terminal cell columns', () => { + expect( + calculateMultilineInputCursorPosition({ + text: 'hello', + cursorPosition: 2, + lineInfo: lineInfo([0]), + viewportX, + viewportY, + verticalScrollPosition: 0, + }), + ).toEqual({ x: 13, y: 21 }) + }) + + test('advances two terminal cells after a CJK wide character', () => { + expect( + calculateMultilineInputCursorPosition({ + text: '你a', + cursorPosition: 1, + lineInfo: lineInfo([0]), + viewportX, + viewportY, + verticalScrollPosition: 0, + }), + ).toEqual({ x: 13, y: 21 }) + }) + + test('handles mixed ASCII and CJK widths', () => { + expect( + calculateMultilineInputCursorPosition({ + text: 'a你b', + cursorPosition: 2, + lineInfo: lineInfo([0]), + viewportX, + viewportY, + verticalScrollPosition: 0, + }), + ).toEqual({ x: 14, y: 21 }) + }) + + test('uses the existing four-cell tab expansion', () => { + expect( + calculateMultilineInputCursorPosition({ + text: '\ta', + cursorPosition: 1, + lineInfo: lineInfo([0]), + viewportX, + viewportY, + verticalScrollPosition: 0, + }), + ).toEqual({ x: 15, y: 21 }) + }) + + test('uses cumulative visual-line offsets for wrapped text', () => { + expect( + calculateMultilineInputCursorPosition({ + text: 'abcdefghij', + cursorPosition: 7, + lineInfo: lineInfo([0, 5]), + viewportX, + viewportY, + verticalScrollPosition: 0, + }), + ).toEqual({ x: 13, y: 22 }) + }) + + test('applies the viewport offset and vertical scroll position', () => { + expect( + calculateMultilineInputCursorPosition({ + text: 'a\nb\nc', + cursorPosition: 4, + lineInfo: lineInfo([0, 2, 4]), + viewportX, + viewportY, + verticalScrollPosition: 1, + }), + ).toEqual({ x: 11, y: 22 }) + }) +}) + +describe('MultilineInput - hardware cursor lifecycle', () => { + test('moves the renderer cursor two cells after a CJK character', async () => { + const setup = await createTestRenderer({ width: 30, height: 8 }) + const root = createRoot(setup.renderer) + + try { + flushSync(() => + root.render( + {}} + onSubmit={() => {}} + onPaste={() => {}} + cursorPosition={1} + maxHeight={3} + shouldBlinkCursor={false} + focused + />, + ), + ) + await setup.renderOnce() + + expect(setup.renderer.getCursorState()).toMatchObject({ + x: 4, + y: 1, + visible: true, + }) + } finally { + flushSync(() => root.unmount()) + setup.renderer.destroy() + } + }) + + test('hides the hardware cursor when unfocused and on unmount', async () => { + const setup = await createTestRenderer({ width: 30, height: 8 }) + const root = createRoot(setup.renderer) + const props = { + value: 'input', + onChange: () => {}, + onSubmit: () => {}, + onPaste: () => {}, + cursorPosition: 2, + maxHeight: 3, + shouldBlinkCursor: false, + } + + try { + flushSync(() => root.render()) + await setup.renderOnce() + expect(setup.renderer.getCursorState().visible).toBe(true) + + flushSync(() => root.render()) + await setup.renderOnce() + expect(setup.renderer.getCursorState().visible).toBe(false) + + flushSync(() => root.render()) + await setup.renderOnce() + expect(setup.renderer.getCursorState().visible).toBe(true) + + flushSync(() => root.unmount()) + expect(setup.renderer.getCursorState().visible).toBe(false) + } finally { + setup.renderer.destroy() + } + }) +}) + /** * Tests for Chinese/IME character input handling in MultilineInput component. * diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index c5db1c51fd..51b5ae7d26 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -12,6 +12,7 @@ import { useRef, useState, } from 'react' +import stringWidth from 'string-width' import { InputCursor } from './input-cursor' import { useTheme } from '../hooks/use-theme' @@ -31,6 +32,7 @@ import { calculateNewCursorPosition } from '../utils/word-wrap-utils' import type { InputValue } from '../types/store' import type { KeyEvent, + LineInfo, MouseEvent, PasteEvent, ScrollBoxRenderable, @@ -95,6 +97,45 @@ export const CURSOR_CHAR = '▍' const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b-\u000c\u000e-\u001f\u007f]/ const TAB_WIDTH = 4 +export function calculateMultilineInputCursorPosition({ + text, + cursorPosition, + lineInfo, + viewportX, + viewportY, + verticalScrollPosition, +}: { + text: string + cursorPosition: number + lineInfo: Pick | null + viewportX: number + viewportY: number + verticalScrollPosition: number +}): { x: number; y: number } { + const safeCursorPosition = Math.max(0, Math.min(cursorPosition, text.length)) + const renderedPrefix = text + .slice(0, safeCursorPosition) + .replace(/\t/g, ' '.repeat(TAB_WIDTH)) + const displayColumn = + stringWidth(renderedPrefix) + (renderedPrefix.match(/\n/g)?.length ?? 0) + + // OpenTUI reports lineStartCols as cumulative display-column offsets for + // visual lines. Compare against the rendered width, not the source index. + const lineStarts = lineInfo?.lineStartCols ?? [0] + const visualRow = Math.max( + 0, + lineStarts.findLastIndex((lineStart) => lineStart <= displayColumn), + ) + const visualLineStart = lineStarts[visualRow] ?? 0 + + // OpenTUI's renderer cursor coordinates are 1-based terminal coordinates; + // viewport x/y are 0-based screen layout coordinates. + return { + x: viewportX + displayColumn - visualLineStart + 1, + y: viewportY + visualRow - verticalScrollPosition + 1, + } +} + /** * Check if a key event represents printable character input (not a special key). * Uses a positive heuristic based on key.name length rather than a brittle deny-list. @@ -236,10 +277,12 @@ export const MultilineInput = forwardRef< // updated synchronously to ensure each keystroke builds on the previous one. const valueRef = useRef(value) const cursorPositionRef = useRef(cursorPosition) + const focusedRef = useRef(focused) // Keep refs current on every render (synchronous assignment avoids useEffect timing issues) valueRef.current = value cursorPositionRef.current = cursorPosition + focusedRef.current = focused // Helper to get or set the sticky column for vertical navigation. // When stickyColumnRef.current is set, we return it (preserving column across @@ -513,6 +556,60 @@ export const MultilineInput = forwardRef< /\t/g, ' '.repeat(TAB_WIDTH), ) + const hardwareCursorTextRef = useRef(displayValue) + hardwareCursorTextRef.current = displayValue + + // Keep the terminal's hardware cursor at the same screen-cell position as + // the existing visual caret so terminal IMEs can anchor their UI correctly. + const syncHardwareCursor = useCallback(() => { + if (!focusedRef.current) { + renderer.setCursorPosition(0, 0, false) + return + } + + const scrollBox = scrollBoxRef.current + const textBufferView = textRef.current + ? ((textRef.current as any).textBufferView as TextBufferView) + : null + + if (!scrollBox || !textBufferView) { + renderer.setCursorPosition(0, 0, false) + return + } + + const viewport = scrollBox.viewport + const cursor = calculateMultilineInputCursorPosition({ + text: hardwareCursorTextRef.current, + cursorPosition: cursorPositionRef.current, + lineInfo: textBufferView.lineInfo, + viewportX: Number(viewport.x), + viewportY: Number(viewport.y), + verticalScrollPosition: scrollBox.verticalScrollBar.scrollPosition, + }) + + renderer.setCursorPosition(cursor.x, cursor.y, true) + }, [renderer]) + + useEffect(() => { + syncHardwareCursor() + + if (!focused) return + + // React effects can run before OpenTUI has completed the layout pass that + // establishes wrapping and viewport coordinates. TextRenderable emits + // this event after its line information is recalculated. + const textRenderable = textRef.current + textRenderable?.on('line-info-change', syncHardwareCursor) + return () => { + textRenderable?.off('line-info-change', syncHardwareCursor) + } + }, [focused, displayValue, cursorPosition, lineInfo, syncHardwareCursor]) + + useEffect(() => { + return () => { + renderer.setCursorPosition(0, 0, false) + } + }, [renderer]) // Calculate cursor position in the expanded string (accounting for tabs) let renderCursorPosition = 0