Skip to content
Closed
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
27 changes: 27 additions & 0 deletions src/renderer/src/assets/terminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/renderer/src/lib/pane-manager/pane-active-focus.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
32 changes: 32 additions & 0 deletions src/renderer/src/lib/pane-manager/pane-active-focus.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
5 changes: 2 additions & 3 deletions src/renderer/src/lib/pane-manager/pane-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,14 +36,15 @@ 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
}
} as 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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'])
Expand Down
55 changes: 53 additions & 2 deletions src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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({
Expand All @@ -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 => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Loading