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');