fix: correct IME candidate window position when typing Japanese in terminal (#798)

* fix: correct IME candidate window position when typing Japanese in terminal

Two fixes for the IME candidate window appearing offset from the cursor:

1. CSS: Add \left: 0\ to \.xterm .xterm-helpers\ in terminal.css.
   xterm.css sets position:absolute;top:0 but omits left, leaving
   left:auto. In Electron's Blink this can resolve to a non-zero value
   and shift the IME window away from the cursor.

2. JS: Add a capture-phase compositionstart listener in openTerminal().
   xterm.js repositions the textarea on compositionupdate but not on
   compositionstart. The OS reads the textarea's screen rect at
   compositionstart to place the IME candidate window, so the window
   can appear at a stale position. The listener force-syncs the textarea
   to the exact cursor pixel position before the OS opens the window.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: remove compositionstart listener on pane disposal to prevent memory leak

Store the handler reference in ManagedPaneInternal.compositionHandler so
disposePane() can call removeEventListener with the exact same function
reference, preventing the closure from holding the terminal alive after
the pane is closed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(terminal): derive IME cell dimensions from public xterm API

Computes cell width/height from the .xterm-screen element's bounding
rect and uses terminal.textarea (public) instead of reaching into
terminal._core. Avoids the `any`-cast access to xterm internals so
future xterm.js upgrades don't silently regress the IME position fix.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Drakontia 2026-04-19 13:24:37 +09:00 committed by GitHub
parent ab81f04fae
commit 82fe59fc23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 50 additions and 1 deletions

View file

@ -29,6 +29,14 @@
overflow-y: auto;
}
/* xterm.css sets position:absolute;top:0 on .xterm-helpers but omits left,
leaving left:auto. In Electron's Blink, left:auto can resolve to a non-zero
value and offset the IME candidate window away from the cursor. Anchoring it
explicitly to 0 matches the intended layout and fixes the IME position. */
.pane-manager-root .xterm .xterm-helpers {
left: 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,

View file

@ -132,7 +132,8 @@ export function createPaneDOM(
serializeAddon,
unicode11Addon,
webLinksAddon,
webglAddon: null
webglAddon: null,
compositionHandler: null
}
// Focus handler: clicking a pane makes it active and explicitly focuses
@ -182,6 +183,40 @@ export function openTerminal(pane: ManagedPaneInternal): void {
// Activate unicode 11
terminal.unicode.activeVersion = '11'
// Why: the OS reads the focused textarea's screen rect at compositionstart to
// decide where to display the IME candidate window. xterm.js only repositions
// the textarea on compositionupdate (via updateCompositionElements), not on
// compositionstart, so the window can appear at a stale cursor position. We
// force-sync the textarea position in a capture-phase listener so the OS sees
// the correct location before it opens the candidate window.
//
// Cell dimensions are derived from the public .xterm-screen element's bounds
// (xterm sizes that element to cols*cellWidth × rows*cellHeight) rather than
// poking `_core._renderService.dimensions` — keeps us on the public API
// surface so upgrades don't silently regress the fix.
if (terminal.element && terminal.textarea) {
const screenElement = terminal.element.querySelector<HTMLElement>('.xterm-screen')
const textarea = terminal.textarea
const handler = (): void => {
if (!screenElement) {
return
}
const rect = screenElement.getBoundingClientRect()
const cellWidth = rect.width / terminal.cols
const cellHeight = rect.height / terminal.rows
if (!(cellWidth > 0) || !(cellHeight > 0)) {
return
}
const buf = terminal.buffer.active
const x = Math.min(buf.cursorX, terminal.cols - 1)
textarea.style.top = `${buf.cursorY * cellHeight}px`
textarea.style.left = `${x * cellWidth}px`
}
terminal.element.addEventListener('compositionstart', handler, true)
// Store so disposePane() can remove it and avoid a memory leak.
pane.compositionHandler = handler
}
if (pane.gpuRenderingEnabled) {
attachWebgl(pane)
}
@ -243,6 +278,10 @@ export function disposePane(
pane: ManagedPaneInternal,
panes: Map<number, ManagedPaneInternal>
): void {
if (pane.compositionHandler) {
pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true)
pane.compositionHandler = null
}
try {
pane.webglAddon?.dispose()
} catch {

View file

@ -56,6 +56,8 @@ export type ManagedPaneInternal = {
serializeAddon: SerializeAddon
unicode11Addon: Unicode11Addon
webLinksAddon: WebLinksAddon
// Stored so disposePane() can remove it and avoid a memory leak.
compositionHandler: (() => void) | null
} & ManagedPane
export type DropZone = 'top' | 'bottom' | 'left' | 'right'