Skip to content
Open
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
158 changes: 158 additions & 0 deletions lib/ime.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
188 changes: 188 additions & 0 deletions lib/ime.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
Loading