diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index ee5e0af01f54..1b97c1289dad 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -67,6 +67,33 @@ caret-color: transparent; } +/* The preedit caret. While a CJK IME composes, this opaque overlay covers the + cell xterm draws its cursor in and the helper textarea's caret is transparent + (above), so a syllable had no caret anywhere until it committed. Geometry and + blink come from terminal-ime-preedit-caret.ts, which knows the pane's cell + size and cursor settings; this only paints. currentcolor tracks the overlay's + own foreground, which xterm keeps legible against its ground. Scoped to + .xterm-container, not .pane-manager-root: panes mount as + `.xterm-container > .xterm` and nothing carries that older class today, so a + rule written under it never matches. */ +.xterm-container .xterm .composition-view .orca-ime-preedit-caret { + display: inline-block; + vertical-align: top; + background: currentcolor; +} + +/* Matches xterm own step blink so the preedit caret and the real cursor cannot + be told apart when one replaces the other. */ +.xterm-container .xterm .composition-view .orca-ime-preedit-caret-blink { + animation: orca-ime-preedit-caret-blink 1.2s step-end infinite; +} + +@keyframes orca-ime-preedit-caret-blink { + 50% { + opacity: 0; + } +} + /* Divider: the element is a wide transparent hit area; the visible line is drawn by ::after so that setting `background` on the element never hides it. */ .pane-divider.is-vertical, diff --git a/src/renderer/src/lib/pane-manager/pane-active-focus.test.ts b/src/renderer/src/lib/pane-manager/pane-active-focus.test.ts new file mode 100644 index 000000000000..b05f1d4efacd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-active-focus.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { shouldFocusPaneTerminal } from './pane-active-focus' + +// Sibling pane-manager suites run on the node environment, so stand-ins replace +// real DOM nodes; the decision only ever compares identity. +const textarea = (): HTMLTextAreaElement => ({}) as HTMLTextAreaElement +const element = (): Element => ({}) as Element + +describe('shouldFocusPaneTerminal', () => { + it('focuses a terminal whose textarea does not hold focus', () => { + expect(shouldFocusPaneTerminal(textarea(), element())).toBe(true) + }) + + it('focuses when nothing on the page holds focus', () => { + expect(shouldFocusPaneTerminal(textarea(), null)).toBe(true) + }) + + // The regression this guards: re-activating the already-focused pane fired a + // redundant textarea.focus() on every churned React effect, which can drop an + // in-flight IME preedit. + it('skips the redundant call when the textarea already holds focus', () => { + const focused = textarea() + expect(shouldFocusPaneTerminal(focused, focused)).toBe(false) + }) + + it('still focuses a terminal that has not opened its textarea yet', () => { + expect(shouldFocusPaneTerminal(undefined, element())).toBe(true) + expect(shouldFocusPaneTerminal(null, null)).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-active-focus.ts b/src/renderer/src/lib/pane-manager/pane-active-focus.ts new file mode 100644 index 000000000000..25cc814b9c9e --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-active-focus.ts @@ -0,0 +1,32 @@ +import type { Terminal } from '@xterm/xterm' + +/** + * Decides whether activating a pane still owes it a terminal focus call. + * + * Why: `setActivePane` runs from React effects whose deps churn on unrelated + * renders, so the already-active pane gets re-activated many times while the + * user types. xterm's `focus()` forwards to its helper textarea, and re-focusing + * an element that is mid-IME-composition can drop the preedit — the cost lands + * on CJK input specifically. Skipping the call when focus is already there keeps + * the click-to-refocus path intact while removing the redundant ones. + */ +export function shouldFocusPaneTerminal( + textarea: HTMLTextAreaElement | undefined | null, + activeElement: Element | null +): boolean { + // An unopened terminal has no textarea yet; focusing is how it acquires one. + if (!textarea) { + return true + } + return activeElement !== textarea +} + +/** Applies the decision above; `focus === false` opts the caller out entirely. */ +export function focusPaneTerminalOnActivate(terminal: Terminal, focus: boolean | undefined): void { + if (focus === false) { + return + } + if (shouldFocusPaneTerminal(terminal.textarea, document.activeElement)) { + terminal.focus() + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index bc05069bdfe7..716f2426ed79 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -17,6 +17,7 @@ import { applyRootBackground, disposeDividersIn } from './pane-divider' +import { focusPaneTerminalOnActivate } from './pane-active-focus' import { cancelActivePaneDrag, createDragReorderState, handlePaneDrop } from './pane-drag-reorder' import { beginPaneDragFromPointerDown } from './pane-drag-pointer' import { setLigaturesEnabled, disposePane } from './pane-lifecycle' @@ -234,9 +235,7 @@ export class PaneManager { this.activePaneId = paneId applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions) - if (opts?.focus !== false) { - pane.terminal.focus() - } + focusPaneTerminalOnActivate(pane.terminal, opts?.focus) if (changed) { this.options.onActivePaneChange?.(toPublicPane(pane)) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts index 8b0ef503ded6..e6406f114994 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts @@ -14,17 +14,18 @@ type AnchorHarness = { element: HTMLElement style: { top: string; left: string } compositionStyle: CSSStyleDeclaration - counts: { rectReads: number; styleWrites: number } + counts: { rectReads: number; styleWrites: number; cellReads: number } setCursor: (cursorX: number, cursorY: number) => void setLines: (lines: string[]) => void } -function makeLine(text: string): IBufferLine { +function makeLine(text: string, counts: { cellReads: number }): IBufferLine { const chars = Array.from(text) while (chars.length < COLS) { chars.push(' ') } const cellAt = (column: number): IBufferCell | undefined => { + counts.cellReads++ const char = chars[column] return char === undefined ? undefined @@ -35,6 +36,7 @@ function makeLine(text: string): IBufferLine { length: chars.length, getCell: cellAt, translateToString: (trimRight = false, start = 0, end = chars.length) => { + counts.cellReads++ const result = chars.slice(start, end).join('') return trimRight ? result.replace(/\s+$/, '') : result } @@ -42,7 +44,7 @@ function makeLine(text: string): IBufferLine { } function createHarness(): AnchorHarness { - const counts = { rectReads: 0, styleWrites: 0 } + const counts = { rectReads: 0, styleWrites: 0, cellReads: 0 } const element = document.createElement('div') const screen = document.createElement('div') screen.className = 'xterm-screen' @@ -75,15 +77,34 @@ function createHarness(): AnchorHarness { cursorX: 0, cursorY: 0, length: ROWS, - getLine: (row: number) => (row < lines.length ? makeLine(lines[row] ?? '') : makeLine('')) + getLine: (row: number) => + row < lines.length ? makeLine(lines[row] ?? '', counts) : makeLine('', counts) } as unknown as IBuffer + // Why: a real terminal cannot change its buffer without a parsed write or a + // cursor move, and the anchor cache keys off exactly those. Modelling them + // keeps the fake honest — mutating `lines`/`cursorX` behind the emulator's back + // is a back door xterm does not have. + const writeParsedListeners: (() => void)[] = [] + const cursorMoveListeners: (() => void)[] = [] + const emit = (listeners: readonly (() => void)[]): void => { + for (const listener of listeners) { + listener() + } + } + const subscribe = (listeners: (() => void)[]) => (listener: () => void) => { + listeners.push(listener) + return { dispose: () => undefined } + } + const terminal = { element, textarea, cols: COLS, rows: ROWS, - buffer: { active: buffer } + buffer: { active: buffer }, + onWriteParsed: subscribe(writeParsedListeners), + onCursorMove: subscribe(cursorMoveListeners) } as unknown as Terminal return { @@ -94,9 +115,11 @@ function createHarness(): AnchorHarness { counts, setCursor: (cursorX: number, cursorY: number) => { Object.assign(buffer, { cursorX, cursorY }) + emit(cursorMoveListeners) }, setLines: (next: string[]) => { lines = next + emit(writeParsedListeners) } } } @@ -155,7 +178,7 @@ describe('installTerminalImeCandidateAnchor', () => { fire(harness.element, 'keydown') fire(harness.element, 'input') - expect(harness.counts).toEqual({ rectReads: 0, styleWrites: 0 }) + expect(harness.counts).toEqual({ rectReads: 0, styleWrites: 0, cellReads: 0 }) }) it('re-corrects the anchor mid-composition after xterm rewrites the textarea', () => { @@ -185,6 +208,41 @@ describe('installTerminalImeCandidateAnchor', () => { expect(harness.counts.rectReads).toBe(2) }) + it('resolves the Cursor Agent anchor once per composition, not once per jamo', () => { + const harness = createHarness() + harness.setLines(['Cursor Agent', '', '→ hello']) + installTerminalImeCandidateAnchor(harness.terminal) + harness.setCursor(0, 1) + + fire(harness.element, 'compositionstart') + const afterStart = harness.counts.cellReads + // The fallback scan is the expensive path this cache exists for. + expect(afterStart).toBeGreaterThan(0) + + // Three jamo for one syllable; the cursor has not moved, so the resolve + // that walks the viewport must not run again. + fire(harness.element, 'compositionupdate') + fire(harness.element, 'compositionupdate') + fire(harness.element, 'compositionupdate') + + expect(harness.counts.cellReads).toBe(afterStart) + }) + + it('re-resolves mid-composition once the cursor moves off the cached cell', () => { + const harness = createHarness() + harness.setLines(['Cursor Agent', '', '→ hello']) + installTerminalImeCandidateAnchor(harness.terminal) + harness.setCursor(0, 1) + + fire(harness.element, 'compositionstart') + const afterStart = harness.counts.cellReads + + harness.setCursor(0, 3) + fire(harness.element, 'compositionupdate') + + expect(harness.counts.cellReads).toBeGreaterThan(afterStart) + }) + it('coalesces the deferred Cursor Agent re-apply to one timer per burst', () => { const harness = createHarness() harness.setLines(['Cursor Agent', '', '→ hello']) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts index 2d4349d67d29..4548a6dcc83b 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts @@ -1,5 +1,6 @@ import type { Terminal } from '@xterm/xterm' import { resolveCursorAgentImeAnchor, type TerminalImeAnchor } from './terminal-ime-anchor' +import { applyTerminalImePreeditCaret } from './terminal-ime-preedit-caret' type ImeAnchorCellMetrics = { cellWidth: number @@ -10,6 +11,11 @@ type ImeAnchorCellMetrics = { type ImeAnchorStyleProperty = 'top' | 'left' | 'height' | 'lineHeight' +type ResolvedImeAnchor = { + anchor: TerminalImeAnchor + isCursorAgent: boolean +} + /** * Keep the OS IME candidate window anchored to the cell the user is typing in. * @@ -44,6 +50,7 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo let metrics: ImeAnchorCellMetrics | null = null let deferredApply: number | null = null let cursorAgentSeen = false + let cachedAnchor: { key: string; value: ResolvedImeAnchor } | null = null const measureCells = (): ImeAnchorCellMetrics | null => { if (!screenElement) { @@ -81,6 +88,16 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo const left = `${column * cells.cellWidth}px` writeStyle(textarea, 'top', top) writeStyle(textarea, 'left', left) + if (compositionView) { + // Independent of the Cursor Agent branch: every pane's preedit covers the + // cursor cell, so every pane needs the caret mirrored. + applyTerminalImePreeditCaret( + compositionView, + terminal.options, + cells.cellWidth, + cells.cellHeight + ) + } if (isCursorAgent && compositionView) { const height = `${cells.cellHeight}px` writeStyle(compositionView, 'top', top) @@ -90,8 +107,23 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo } } - const resolveAnchor = (): { anchor: TerminalImeAnchor; isCursorAgent: boolean } => { + // Why: the Cursor Agent fallback scans every visible row bottom-up and reads + // up to `cols` cells per row. A Hangul IME fires compositionupdate per jamo, so + // an uncached resolve repeated that O(rows x cols) buffer walk two to three + // times per typed character, and for a TUI that has no `->` input row it + // repeated the full walk to return nothing. + // + // The result is derived from the cursor and from the viewport's contents. The + // cursor is cheap to compare, so it is the key; the contents cannot be compared + // without doing the walk, so they invalidate by event instead (see the + // onWriteParsed/onCursorMove hookup below). A preedit sends no bytes to the pty, + // so nothing repaints mid-composition unless the agent itself writes. + const resolveAnchor = (): ResolvedImeAnchor => { const buf = terminal.buffer.active + const key = `${buf.cursorX}:${buf.cursorY}:${buf.baseY}:${terminal.cols}:${terminal.rows}` + if (cachedAnchor?.key === key) { + return cachedAnchor.value + } // Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor // on a blank row, so the OS IME anchor needs the rendered prompt row instead. const cursorAgentAnchor = resolveCursorAgentImeAnchor({ @@ -103,13 +135,15 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo knownCursorAgent: cursorAgentSeen }) cursorAgentSeen ||= cursorAgentAnchor !== null - return { + const resolved: ResolvedImeAnchor = { anchor: cursorAgentAnchor ?? { row: buf.cursorY, column: Math.min(buf.cursorX, terminal.cols - 1) }, isCursorAgent: cursorAgentAnchor !== null } + cachedAnchor = { key, value: resolved } + return resolved } const handler = (event?: Event): void => { @@ -122,6 +156,9 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo !metrics || metrics.cols !== terminal.cols || metrics.rows !== terminal.rows if (event?.type !== 'compositionupdate' || staleMetrics) { metrics = measureCells() + // A new composition re-reads the buffer rather than trusting a resolve + // cached under the previous one. + cachedAnchor = null } const cells = metrics if (!cells) { @@ -158,7 +195,21 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo }, 0) } + // Why: anything that can change what the scan would find goes through one of + // these two — a repaint is always a parsed write, and a bare cursor move can + // relocate the anchor without one. Not disposed here on purpose: this function's + // contract is to return the single DOM handler its callers remove, and both call + // sites drop the terminal right after, which disposes its own emitters with it. + const invalidate = (): void => { + cachedAnchor = null + } + terminal.onWriteParsed(invalidate) + terminal.onCursorMove(invalidate) + + // Same non-disposal rationale as the emitters above: both call sites drop the + // terminal right after removing the returned handler. terminal.element.addEventListener('compositionstart', handler) terminal.element.addEventListener('compositionupdate', handler) + return handler } diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-preedit-caret.ts b/src/renderer/src/lib/pane-manager/terminal-ime-preedit-caret.ts new file mode 100644 index 000000000000..016bd4bd2014 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-preedit-caret.ts @@ -0,0 +1,84 @@ +/** + * Draws the terminal's own cursor at the end of an in-flight IME preedit. + * + * Why: xterm's `.composition-view` is opaque and sits on the cell the renderer + * draws the cursor in, so while a CJK syllable composes there is no cursor + * anywhere — it reappears only once the syllable commits, or when the pane + * loses focus and the overlay closes. The overlay is DOM and the cursor is + * canvas, so the real one cannot show through; this mirrors it in DOM, matching + * the pane's configured style, blink, and cell size. + * + * A real element rather than a `::after`: the pseudo-element computed correctly + * but never painted inside this overlay, and a node we own is verifiable. + */ + +const CARET_CLASS = 'orca-ime-preedit-caret' +const BLINK_CLASS = 'orca-ime-preedit-caret-blink' + +type CaretStyle = 'block' | 'bar' | 'underline' + +/** Only the cursor settings this needs, so a pane double does not have to be a + * whole Terminal to exercise the caret. */ +export type PreeditCaretCursorOptions = { + cursorStyle?: string + cursorBlink?: boolean + /** xterm's resolved theme; `cursor` already carries the configured opacity. */ + theme?: { cursor?: string } +} + +function resolveCaretStyle(options: PreeditCaretCursorOptions | undefined): CaretStyle { + // Not cursorInactiveStyle: a composing pane is focused by definition, so the + // active style is the one the user would otherwise be looking at. + switch (options?.cursorStyle) { + case 'bar': + return 'bar' + case 'underline': + return 'underline' + default: + return 'block' + } +} + +function ensureCaret(compositionView: HTMLElement): HTMLElement { + const caret = + compositionView.querySelector(`.${CARET_CLASS}`) ?? document.createElement('span') + caret.className = CARET_CLASS + caret.setAttribute('aria-hidden', 'true') + // Why the direction check, and why in flow at all: the overlay does not paint + // absolutely-positioned children that fall outside its box, so the caret has + // to be a normal inline box — and xterm gives the view `direction: rtl` with + // an isolate, under which the *first* child is the rightmost one. Both calls + // move an existing node, which is what re-seats the caret after xterm rewrites + // the view's text on every update. + if (getComputedStyle(compositionView).direction === 'rtl') { + compositionView.prepend(caret) + } else { + compositionView.append(caret) + } + return caret +} + +/** + * Applies caret geometry to the composition view. Cell metrics come from the + * caller so both IME overlays measure the screen once per composition. + */ +export function applyTerminalImePreeditCaret( + compositionView: HTMLElement, + options: PreeditCaretCursorOptions | undefined, + cellWidth: number, + cellHeight: number +): void { + const caret = ensureCaret(compositionView) + const style = resolveCaretStyle(options) + const width = style === 'bar' ? Math.max(1, Math.round(cellWidth / 8)) : cellWidth + const height = style === 'underline' ? Math.max(1, Math.round(cellHeight / 8)) : cellHeight + + caret.classList.toggle(BLINK_CLASS, options?.cursorBlink === true) + // The pane's own cursor colour, not the overlay's foreground: the two happen + // to match in the default theme and diverge in every other one. Empty falls + // back to the stylesheet's currentcolor for a pane with no theme. + caret.style.background = options?.theme?.cursor ?? '' + caret.style.width = `${width}px` + caret.style.height = `${height}px` + caret.style.marginTop = style === 'underline' ? `${cellHeight - height}px` : '0px' +}