From d642d438a39c1c679fa0b962d57bd8acc11000f9 Mon Sep 17 00:00:00 2001 From: midagedev Date: Wed, 26 Aug 2026 07:13:07 +0900 Subject: [PATCH] fix(ime): draw the composition on the cursor, not in the corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser draws an IME's preedit inside the focused element, at that element's caret. The terminal draws its cursor on a canvas, and a canvas has no caret, so nothing lines the two up on its own: the preedit and the platform's candidate window land wherever the focused input happens to sit. Three things put that input away from the cursor. `focus()` focused the contenteditable parent rather than the helper textarea, so the browser inserted the preedit as text nodes in the parent's own content flow — beside the canvas, not on the cursor. (handleCompositionEnd already swept those nodes back up afterwards, which is how visible the leak was.) The textarea was created at left:0/top:0 and never moved. And the parent was left `position: static`, so `position: absolute` on the textarea resolved against whatever ancestor happened to be positioned; in a host app whose panel was `relative`, that put the IME target outside the terminal entirely. So: the textarea becomes the focused input and is tracked to the cursor cell from the render loop that already reads getCursor() every frame, and the parent becomes a containing block so a cell offset means what it says. The parent keeps `contenteditable` — extensions like Vimium look for it — it just no longer holds focus; a focus handler forwards, and dispose() takes it back off. Tracking follows the renderer's own rule for drawing the cursor: only while the viewport is at the bottom. The textarea also keeps a real box instead of `clip-path: inset(50%)`, because the platform anchors the candidate window to it, with a transparent caret and text in place of the clip. That caret is the ghost cursor that shows up at 0,0 beside the canvas cursor. Since the textarea is invisible, the preedit inside it is invisible too, so ImeOverlay draws the composing text at the cursor cell in the terminal's own font and colors, underlined the way platforms mark uncommitted text. handleCompositionUpdate had been a deliberate no-op that deferred to "the browser's input method editor UI"; there is no such UI to defer to when the input it would draw into is invisible. That view is not a nicety for CJK. Hangul composes *within* a syllable — ㄱ becomes 가 becomes 각 as you type — so without a visible preedit there is nothing on screen between pressing a key and committing a syllable. Chinese and Japanese at least have a candidate window to read. Tests: lib/ime.test.ts. The four Terminal-level cases were confirmed failing against the parent commit; the existing suite is unchanged at 372 pass. --- lib/ime.test.ts | 158 ++++++++++++++++++++++++++++++++++++ lib/ime.ts | 188 +++++++++++++++++++++++++++++++++++++++++++ lib/input-handler.ts | 26 +++++- lib/terminal.ts | 90 +++++++++++++++++++-- 4 files changed, 453 insertions(+), 9 deletions(-) create mode 100644 lib/ime.test.ts create mode 100644 lib/ime.ts diff --git a/lib/ime.test.ts b/lib/ime.test.ts new file mode 100644 index 00000000..35d5bbe0 --- /dev/null +++ b/lib/ime.test.ts @@ -0,0 +1,158 @@ +/** + * IME target tracking. + * + * The defect these cover: the helper textarea was created at left:0/top:0 and + * never moved, and `focus()` focused the contenteditable parent instead of it. + * A browser draws an IME's preedit inside the focused element at its caret, so + * both paths put the composing text — and the platform's candidate window — in + * the terminal's top-left corner instead of on the cursor. Korean is the worst + * case: a syllable composes in place (ㄱ → 가 → 각), so a preedit that is + * mispositioned *and* invisible leaves nothing on screen to read. + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { ImeOverlay } from './ime'; +import type { Terminal } from './terminal'; +import { createIsolatedTerminal } from './test-helpers'; + +function cell() { + return { width: 8, height: 16 }; +} + +function style() { + return { + fontFamily: 'monospace', + fontSize: 13, + foreground: '#ffffff', + background: '#000000', + }; +} + +function overlay(): { + ime: ImeOverlay; + parent: HTMLElement; + textarea: HTMLTextAreaElement; +} { + const parent = document.createElement('div'); + const textarea = document.createElement('textarea'); + parent.appendChild(textarea); + document.body.appendChild(parent); + const ime = new ImeOverlay({ parent, textarea, metrics: cell, style }); + return { ime, parent, textarea }; +} + +function compositionView(parent: HTMLElement): HTMLElement | null { + return parent.querySelector('[data-ghostty-composition]'); +} + +describe('ImeOverlay', () => { + test('moves the IME target onto the cursor cell', () => { + const { ime, textarea } = overlay(); + ime.moveTo(12, 5); + expect(textarea.style.left).toBe('96px'); // 12 * 8 + expect(textarea.style.top).toBe('80px'); // 5 * 16 + // One cell tall, so the platform hangs the candidate window under the + // cursor's line rather than under the top of the terminal. + expect(textarea.style.height).toBe('16px'); + ime.dispose(); + }); + + test('draws the composing text at that cell and clears it on commit', () => { + const { ime, parent } = overlay(); + ime.moveTo(3, 2); + ime.start(); + ime.update('하'); + const view = compositionView(parent); + expect(view).not.toBeNull(); + expect(view!.textContent).toBe('하'); + expect(view!.style.left).toBe('24px'); + expect(view!.style.top).toBe('32px'); + expect(view!.style.display).toBe('block'); + + // Hangul composes in place: the same preedit becomes a fuller syllable + // before it is ever committed. + ime.update('한'); + expect(view!.textContent).toBe('한'); + + ime.end(); + expect(view!.textContent).toBe(''); + expect(view!.style.display).toBe('none'); + ime.dispose(); + }); + + test('follows the cursor while a composition is open', () => { + const { ime, parent, textarea } = overlay(); + ime.start(); + ime.update('ㅎ'); + ime.moveTo(1, 7); + expect(textarea.style.top).toBe('112px'); + expect(compositionView(parent)!.style.top).toBe('112px'); + ime.dispose(); + }); + + test('dispose removes the view', () => { + const { ime, parent } = overlay(); + ime.start(); + ime.update('가'); + expect(compositionView(parent)).not.toBeNull(); + ime.dispose(); + expect(compositionView(parent)).toBeNull(); + }); +}); + +describe('Terminal IME wiring', () => { + let term: Terminal; + let container: HTMLElement; + + beforeEach(async () => { + term = await createIsolatedTerminal({ cols: 20, rows: 6 }); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + term.dispose(); + container.remove(); + }); + + test('focus() focuses the textarea, not the contenteditable parent', () => { + term.open(container); + term.focus(); + // The parent stays contenteditable for the extensions that look for it — + // it just must not be what holds focus, because that is where the browser + // would draw the preedit. + expect(container.getAttribute('contenteditable')).toBe('true'); + expect(document.activeElement).toBe(term.textarea!); + }); + + test('the parent is a containing block, so cell offsets mean what they say', () => { + term.open(container); + // Left static, `position: absolute` on the textarea resolves against + // whatever ancestor happens to be positioned — measured in a host app + // whose panel was `relative`, which put the IME target above the terminal. + expect(container.style.position).toBe('relative'); + }); + + test('the textarea keeps a real box and a transparent caret', () => { + term.open(container); + const ta = term.textarea!; + // clip-path: inset(50%) collapsed the box the platform anchors to. + expect(ta.style.clipPath).toBeFalsy(); + // The caret drawn here is the "ghost cursor at 0,0" seen beside the + // canvas cursor. + expect(ta.style.caretColor).toBe('transparent'); + }); + + test('the IME target follows the terminal cursor', async () => { + term.open(container); + const ta = term.textarea!; + term.write('abc'); + // Tracking rides the render loop, which is a requestAnimationFrame chain. + await new Promise((resolve) => setTimeout(resolve, 50)); + const left = Number.parseFloat(ta.style.left); + expect(Number.isFinite(left)).toBe(true); + // Three columns in, so the target is no longer parked at the origin. + expect(left).toBeGreaterThan(0); + expect(ta.style.top).toBe('0px'); + }); +}); diff --git a/lib/ime.ts b/lib/ime.ts new file mode 100644 index 00000000..e6ad3750 --- /dev/null +++ b/lib/ime.ts @@ -0,0 +1,188 @@ +/** + * IME (input method) support: put the composition where the cursor is. + * + * A browser draws an IME's preedit — the text being composed, before the user + * commits it — inside whatever element holds focus, at that element's caret. + * The terminal draws its own cursor on a canvas, and a canvas has no caret, so + * nothing lines the two up on its own. Unless the focused input is moved to the + * cursor cell on every frame, the preedit and the candidate window appear + * wherever that input happens to sit — for a helper textarea parked at the + * origin, that is the top-left corner of the terminal. + * + * Two elements, one cell: + * + * - the helper textarea, which is what the browser and the platform IME treat + * as the input. It stays invisible (opacity 0, transparent caret) but must + * be *positioned* correctly, because the platform anchors the candidate + * window to it. + * - the composition view, which is what a person reads. The textarea is + * invisible, so the preedit inside it is invisible too; this element draws + * that text in the terminal's own font and colors, at the cursor cell. + * + * The view is not decoration for CJK. Korean composes *inside* a syllable — ㄱ + * becomes 가 becomes 각 as you type — so a user who cannot see the preedit + * cannot tell what they are about to commit. Chinese and Japanese at least have + * a candidate window to read; Korean has nothing but the preedit itself. + */ + +export interface ImeCellMetrics { + /** Cell width in CSS pixels. */ + width: number; + /** Cell height in CSS pixels. */ + height: number; +} + +export interface ImeViewStyle { + fontFamily: string; + /** Font size in CSS pixels. */ + fontSize: number; + /** CSS color for the composing text. */ + foreground: string; + /** CSS color painted behind it, so the canvas underneath does not show. */ + background: string; +} + +export interface ImeOverlayOptions { + /** The terminal's parent element. Both elements are positioned inside it. */ + parent: HTMLElement; + /** The helper textarea the browser treats as the input. */ + textarea: HTMLTextAreaElement; + /** Current cell size. Read per move: a resize or a font change moves cells. */ + metrics: () => ImeCellMetrics; + /** Current view styling. Read per composition, for the same reason. */ + style: () => ImeViewStyle; +} + +/** + * Keeps the IME target and the composition view on the terminal's cursor cell. + */ +export class ImeOverlay { + private readonly parent: HTMLElement; + private readonly textarea: HTMLTextAreaElement; + private readonly metrics: () => ImeCellMetrics; + private readonly style: () => ImeViewStyle; + private view: HTMLElement | null = null; + private col = 0; + private row = 0; + private composing = false; + private disposed = false; + + constructor(options: ImeOverlayOptions) { + this.parent = options.parent; + this.textarea = options.textarea; + this.metrics = options.metrics; + this.style = options.style; + } + + /** True while a composition is open. */ + get isComposing(): boolean { + return this.composing; + } + + /** + * Move the IME target to a cell. Called every frame the cursor is drawn, so + * it must stay cheap: bail out when the cell has not changed. + */ + moveTo(col: number, row: number): void { + if (this.disposed) return; + if (col === this.col && row === this.row) return; + this.col = col; + this.row = row; + this.applyPosition(); + } + + /** compositionstart: show the view (empty until the first update). */ + start(): void { + if (this.disposed) return; + this.composing = true; + this.render(''); + } + + /** compositionupdate: draw what the user has composed so far. */ + update(text: string): void { + if (this.disposed || !this.composing) return; + this.render(text); + } + + /** compositionend: the text has been committed (or cancelled) — hide. */ + end(): void { + this.composing = false; + this.hide(); + } + + dispose(): void { + this.disposed = true; + this.composing = false; + this.view?.remove(); + this.view = null; + } + + private applyPosition(): void { + const { width, height } = this.metrics(); + if (!(width > 0) || !(height > 0)) return; + const left = `${this.col * width}px`; + const top = `${this.row * height}px`; + // The textarea is one cell tall so the platform anchors the candidate + // window under the cursor line rather than under the top of the terminal. + if (this.textarea.style) { + this.textarea.style.left = left; + this.textarea.style.top = top; + this.textarea.style.height = `${height}px`; + } + if (this.view) { + this.view.style.left = left; + this.view.style.top = top; + } + } + + private ensureView(): HTMLElement | null { + if (this.view) return this.view; + if (typeof document === 'undefined' || !document.createElement) return null; + const view = document.createElement('div'); + view.setAttribute('aria-hidden', 'true'); + view.dataset.ghosttyComposition = ''; + view.style.position = 'absolute'; + view.style.zIndex = '10'; + view.style.pointerEvents = 'none'; + view.style.whiteSpace = 'pre'; + view.style.lineHeight = '1'; + // An underline is the convention every platform IME uses for "not + // committed yet", and it is the one signal that survives a theme whose + // preedit colors match ordinary text. + view.style.textDecoration = 'underline'; + this.parent.appendChild(view); + this.view = view; + return view; + } + + private render(text: string): void { + const view = this.ensureView(); + if (!view) return; + const { fontFamily, fontSize, foreground, background } = this.style(); + view.style.fontFamily = fontFamily; + view.style.fontSize = `${fontSize}px`; + view.style.color = foreground; + view.style.background = background; + const { height } = this.metrics(); + if (height > 0) { + view.style.height = `${height}px`; + view.style.lineHeight = `${height}px`; + } + view.textContent = text; + view.style.display = text.length > 0 ? 'block' : 'none'; + this.applyPositionTo(view); + } + + private applyPositionTo(view: HTMLElement): void { + const { width, height } = this.metrics(); + if (!(width > 0) || !(height > 0)) return; + view.style.left = `${this.col * width}px`; + view.style.top = `${this.row * height}px`; + } + + private hide(): void { + if (!this.view) return; + this.view.textContent = ''; + this.view.style.display = 'none'; + } +} diff --git a/lib/input-handler.ts b/lib/input-handler.ts index 83d6f3f2..810c9607 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -15,6 +15,7 @@ import type { Ghostty } from './ghostty'; import type { KeyEncoder } from './ghostty'; +import type { ImeOverlay } from './ime'; import type { IKeyEvent } from './interfaces'; import { Key, KeyAction, KeyEncoderOption, Mods } from './types'; @@ -196,6 +197,7 @@ export class InputHandler { private wheelListener: ((e: WheelEvent) => void) | null = null; private isComposing = false; private isDisposed = false; + private ime: ImeOverlay | null = null; private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting private lastKeyDownData: string | null = null; private lastKeyDownTime = 0; @@ -255,6 +257,14 @@ export class InputHandler { this.customKeyEventHandler = handler; } + /** + * Give the handler the overlay that tracks the cursor cell, so composition + * can be drawn there. Null detaches it. + */ + setImeOverlay(ime: ImeOverlay | null): void { + this.ime = ime; + } + /** * Attach keyboard event listeners to container */ @@ -666,16 +676,23 @@ export class InputHandler { private handleCompositionStart(_event: CompositionEvent): void { if (this.isDisposed) return; this.isComposing = true; + this.ime?.start(); } /** * Handle compositionupdate event */ - private handleCompositionUpdate(_event: CompositionEvent): void { + private handleCompositionUpdate(event: CompositionEvent): void { if (this.isDisposed) return; - // We could track the current composition string here if we wanted to - // display it in a custom way, but for now we rely on the browser's - // input method editor UI. + // The browser's own IME UI cannot be relied on here: it draws the preedit + // inside the focused element, and the focused element is an invisible + // textarea. Draw it ourselves, at the cursor cell. + // + // This is what makes Korean usable at all. Hangul composes within a + // syllable — ㄱ, 가, 각 are three states of one character — so without a + // visible preedit there is nothing on screen between pressing a key and + // committing a syllable. + this.ime?.update(event.data ?? ''); } /** @@ -684,6 +701,7 @@ export class InputHandler { private handleCompositionEnd(event: CompositionEvent): void { if (this.isDisposed) return; this.isComposing = false; + this.ime?.end(); const data = event.data; if (data && data.length > 0) { diff --git a/lib/terminal.ts b/lib/terminal.ts index 92d5f3d0..774330f7 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -18,6 +18,7 @@ import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; +import { ImeOverlay } from './ime'; import { getGhostty } from './index'; import { InputHandler, type MouseTrackingConfig } from './input-handler'; import type { @@ -48,6 +49,10 @@ export class Terminal implements ITerminalCore { public rows: number; public element?: HTMLElement; public textarea?: HTMLTextAreaElement; + /** Keeps the IME target and the composition view on the cursor cell. */ + private ime?: ImeOverlay; + /** Kept so dispose() can take it off the caller's element again. */ + private parentFocusListener?: () => void; // Buffer API (xterm.js compatibility) public readonly buffer: IBufferNamespace; @@ -355,9 +360,35 @@ export class Terminal implements ITerminalCore { parent.setAttribute('tabindex', '0'); } + // The canvas and the helper textarea are positioned against this + // element, so it has to be a containing block. Left static, they anchor + // to whatever ancestor happens to be positioned — measured in a host app + // whose panel was `relative`, which put the textarea (and with it the + // IME's preedit and candidate window) above the terminal entirely. + if (parent.style) { + const position = + parent.style.position || + (typeof getComputedStyle === 'function' ? getComputedStyle(parent).position : ''); + if (!position || position === 'static') { + parent.style.position = 'relative'; + } + } + // Mark as contenteditable so browser extensions (Vimium, etc.) recognize // this as an input element and don't intercept keyboard events. + // + // It must never hold focus: a focused contenteditable is where the + // browser puts an IME's preedit, and this one's content origin is the + // top-left corner of the terminal. Focus belongs to the textarea, which + // is tracked to the cursor cell. focus() and the handler below enforce + // that; the attribute stays for the extensions that look for it. parent.setAttribute('contenteditable', 'true'); + this.parentFocusListener = () => { + if (this.textarea && document.activeElement !== this.textarea) { + this.textarea.focus(); + } + }; + parent.addEventListener('focus', this.parentFocusListener); // Prevent actual content editing - we handle input ourselves parent.addEventListener('beforeinput', (e) => { if (e.target === parent) { @@ -388,7 +419,12 @@ export class Terminal implements ITerminalCore { this.textarea.setAttribute('spellcheck', 'false'); this.textarea.setAttribute('tabindex', '0'); // Allow focus for mobile keyboard this.textarea.setAttribute('aria-label', 'Terminal input'); - // Use clip-path to completely hide the textarea and its caret + // Invisible, but not clipped away: the platform anchors the IME's + // candidate window to this element's box, so it has to keep a real one. + // (clip-path: inset(50%) collapsed it, which is part of why the preedit + // and the candidate window turned up in the corner.) Transparent caret + // and text instead — the caret drawn here is the "ghost cursor at 0,0" + // reported against the canvas cursor. this.textarea.style.position = 'absolute'; this.textarea.style.left = '0'; this.textarea.style.top = '0'; @@ -398,7 +434,10 @@ export class Terminal implements ITerminalCore { this.textarea.style.border = 'none'; this.textarea.style.margin = '0'; this.textarea.style.opacity = '0'; - this.textarea.style.clipPath = 'inset(50%)'; // Clip everything including caret + this.textarea.style.caretColor = 'transparent'; + this.textarea.style.color = 'transparent'; + this.textarea.style.background = 'transparent'; + this.textarea.style.outline = 'none'; this.textarea.style.overflow = 'hidden'; this.textarea.style.whiteSpace = 'nowrap'; this.textarea.style.resize = 'none'; @@ -429,6 +468,23 @@ export class Terminal implements ITerminalCore { // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) this.renderer.resize(this.cols, this.rows); + // Keep the IME target on the cursor cell. Without this the preedit and + // the candidate window sit wherever the textarea was parked. + this.ime = new ImeOverlay({ + parent, + textarea: this.textarea, + metrics: () => ({ + width: this.renderer?.charWidth ?? 0, + height: this.renderer?.charHeight ?? 0, + }), + style: () => ({ + fontFamily: this.options.fontFamily, + fontSize: this.options.fontSize, + foreground: this.options.theme?.foreground ?? '#ffffff', + background: this.options.theme?.background ?? '#000000', + }), + }); + // Create mouse tracking configuration const canvas = this.canvas; const renderer = this.renderer; @@ -482,6 +538,8 @@ export class Terminal implements ITerminalCore { mouseConfig ); + this.inputHandler.setImeOverlay(this.ime ?? null); + // Create selection manager (pass textarea for context menu positioning) this.selectionManager = new SelectionManager( this, @@ -749,13 +807,18 @@ export class Terminal implements ITerminalCore { */ focus(): void { if (this.isOpen && this.element) { - // Focus immediately for immediate keyboard/wheel event handling - this.element.focus(); + // The textarea, not the element: focus decides where the browser draws + // an IME's preedit, and the element is a contenteditable whose content + // origin is the terminal's top-left corner. Keyboard, wheel and paste + // all still arrive — their listeners are on the element and the textarea + // is inside it, so the events bubble. + const target = this.textarea ?? this.element; + target.focus(); // Also schedule a delayed focus as backup to ensure it sticks // (some browsers may need this if DOM isn't fully settled) setTimeout(() => { - this.element?.focus(); + (this.textarea ?? this.element)?.focus(); }, 0); } } @@ -1178,6 +1241,12 @@ export class Terminal implements ITerminalCore { // Check for cursor movement (Phase 2: onCursorMove event) // Note: getCursor() reads from already-updated render state (from render() above) const cursor = this.wasmTerm!.getCursor(); + // Keep the IME target on that cell. The renderer only draws the + // cursor when the viewport is at the bottom, so track it on the same + // condition rather than parking the preedit over scrollback. + if (this.viewportY === 0) { + this.ime?.moveTo(cursor.x, cursor.y); + } if (cursor.y !== this.lastCursorY) { this.lastCursorY = cursor.y; this.cursorMoveEmitter.fire(); @@ -1215,6 +1284,12 @@ export class Terminal implements ITerminalCore { * Clean up components (called on dispose or error) */ private cleanupComponents(): void { + // Drop the IME overlay first: it owns an element inside the parent and a + // listener on the renderer, and both outlive a failed open() otherwise. + this.inputHandler?.setImeOverlay(null); + this.ime?.dispose(); + this.ime = undefined; + // Dispose selection manager if (this.selectionManager) { this.selectionManager.dispose(); @@ -1253,6 +1328,11 @@ export class Terminal implements ITerminalCore { this.element.removeEventListener('mouseleave', this.handleMouseLeave); this.element.removeEventListener('click', this.handleClick); + if (this.parentFocusListener) { + this.element.removeEventListener('focus', this.parentFocusListener); + this.parentFocusListener = undefined; + } + // Remove contenteditable and accessibility attributes added in open() this.element.removeAttribute('contenteditable'); this.element.removeAttribute('role');