Skip to content

Commit ce706c1

Browse files
authored
Added support for IME input for languages such as Chinese and Japanese. (#90)
* working on IME Composition, step 1 * fixed double line issue * reformat with fmt
1 parent d9174e8 commit ce706c1

File tree

2 files changed

+210
-0
lines changed

2 files changed

+210
-0
lines changed

lib/input-handler.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ interface MockClipboardEvent {
3333
interface MockHTMLElement {
3434
addEventListener: (event: string, handler: (e: any) => void) => void;
3535
removeEventListener: (event: string, handler: (e: any) => void) => void;
36+
childNodes: Node[];
37+
removeChild: (node: Node) => Node;
38+
appendChild: (node: Node) => Node;
3639
}
3740

3841
// Helper to create mock keyboard event
@@ -76,7 +79,25 @@ function createClipboardEvent(text: string | null): MockClipboardEvent {
7679
stopPropagation: mock(() => {}),
7780
};
7881
}
82+
interface MockCompositionEvent {
83+
type: string;
84+
data: string | null;
85+
preventDefault: () => void;
86+
stopPropagation: () => void;
87+
}
7988

89+
// Helper to create mock composition event
90+
function createCompositionEvent(
91+
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
92+
data: string | null
93+
): MockCompositionEvent {
94+
return {
95+
type,
96+
data,
97+
preventDefault: mock(() => {}),
98+
stopPropagation: mock(() => {}),
99+
};
100+
}
80101
// Helper to create mock container
81102
function createMockContainer(): MockHTMLElement & {
82103
_listeners: Map<string, ((e: any) => void)[]>;
@@ -107,6 +128,19 @@ function createMockContainer(): MockHTMLElement & {
107128
handler(event);
108129
}
109130
},
131+
// Mock childNodes and removeChild for text node cleanup test
132+
childNodes: [] as Node[],
133+
removeChild(node: Node) {
134+
const index = this.childNodes.indexOf(node);
135+
if (index >= 0) {
136+
this.childNodes.splice(index, 1);
137+
}
138+
return node;
139+
},
140+
appendChild(node: Node) {
141+
this.childNodes.push(node);
142+
return node;
143+
},
110144
};
111145
}
112146

@@ -269,6 +303,104 @@ describe('InputHandler', () => {
269303
});
270304
});
271305

306+
describe('IME Composition', () => {
307+
test('handles composition sequence', () => {
308+
const handler = new InputHandler(
309+
ghostty,
310+
container as any,
311+
(data) => dataReceived.push(data),
312+
() => {
313+
bellCalled = true;
314+
}
315+
);
316+
317+
// Start composition
318+
const startEvent = createCompositionEvent('compositionstart', '');
319+
container.dispatchEvent(startEvent);
320+
321+
// Update composition (typing)
322+
const updateEvent1 = createCompositionEvent('compositionupdate', 'n');
323+
container.dispatchEvent(updateEvent1);
324+
325+
// Keydown events during composition should be ignored
326+
const keyEvent1 = createKeyEvent('KeyN', 'n');
327+
Object.defineProperty(keyEvent1, 'isComposing', { value: true });
328+
simulateKey(container, keyEvent1);
329+
330+
// Update composition (more typing)
331+
const updateEvent2 = createCompositionEvent('compositionupdate', 'ni');
332+
container.dispatchEvent(updateEvent2);
333+
334+
// End composition (commit)
335+
const endEvent = createCompositionEvent('compositionend', '你好');
336+
container.dispatchEvent(endEvent);
337+
338+
// Should only receive the final committed text
339+
expect(dataReceived).toEqual(['你好']);
340+
});
341+
342+
test('ignores keydown during composition', () => {
343+
const handler = new InputHandler(
344+
ghostty,
345+
container as any,
346+
(data) => dataReceived.push(data),
347+
() => {
348+
bellCalled = true;
349+
}
350+
);
351+
352+
// Start composition
353+
container.dispatchEvent(createCompositionEvent('compositionstart', ''));
354+
355+
// Simulate keydown with isComposing=true
356+
const keyEvent = createKeyEvent('KeyA', 'a');
357+
Object.defineProperty(keyEvent, 'isComposing', { value: true });
358+
simulateKey(container, keyEvent);
359+
360+
// Simulate keydown with keyCode 229
361+
const keyEvent229 = createKeyEvent('KeyB', 'b');
362+
Object.defineProperty(keyEvent229, 'keyCode', { value: 229 });
363+
simulateKey(container, keyEvent229);
364+
365+
// Should not receive any data
366+
expect(dataReceived.length).toBe(0);
367+
368+
// End composition
369+
container.dispatchEvent(createCompositionEvent('compositionend', 'a'));
370+
expect(dataReceived).toEqual(['a']);
371+
});
372+
373+
test('cleans up text nodes in container after composition', () => {
374+
const handler = new InputHandler(
375+
ghostty,
376+
container as any,
377+
(data) => dataReceived.push(data),
378+
() => {
379+
bellCalled = true;
380+
}
381+
);
382+
383+
// Simulate browser inserting text node during composition
384+
const textNode = { nodeType: 3, textContent: '你好' } as Node;
385+
container.appendChild(textNode);
386+
387+
// Also add a non-text node (e.g. canvas) to ensure it's not removed
388+
const elementNode = { nodeType: 1, nodeName: 'CANVAS' } as Node;
389+
container.appendChild(elementNode);
390+
391+
expect(container.childNodes.length).toBe(2);
392+
393+
// End composition
394+
const endEvent = createCompositionEvent('compositionend', '你好');
395+
container.dispatchEvent(endEvent);
396+
397+
// Should have removed the text node but kept the element node
398+
expect(container.childNodes.length).toBe(1);
399+
expect(container.childNodes[0]).toBe(elementNode);
400+
expect(dataReceived).toEqual(['你好']);
401+
});
402+
});
403+
272404
describe('Control Characters', () => {
273405
test('encodes Ctrl+A', () => {
274406
const handler = new InputHandler(

lib/input-handler.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ export class InputHandler {
169169
private keydownListener: ((e: KeyboardEvent) => void) | null = null;
170170
private keypressListener: ((e: KeyboardEvent) => void) | null = null;
171171
private pasteListener: ((e: ClipboardEvent) => void) | null = null;
172+
private compositionStartListener: ((e: CompositionEvent) => void) | null = null;
173+
private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null;
174+
private compositionEndListener: ((e: CompositionEvent) => void) | null = null;
175+
private isComposing = false;
172176
private isDisposed = false;
173177

174178
/**
@@ -233,6 +237,15 @@ export class InputHandler {
233237

234238
this.pasteListener = this.handlePaste.bind(this);
235239
this.container.addEventListener('paste', this.pasteListener);
240+
241+
this.compositionStartListener = this.handleCompositionStart.bind(this);
242+
this.container.addEventListener('compositionstart', this.compositionStartListener);
243+
244+
this.compositionUpdateListener = this.handleCompositionUpdate.bind(this);
245+
this.container.addEventListener('compositionupdate', this.compositionUpdateListener);
246+
247+
this.compositionEndListener = this.handleCompositionEnd.bind(this);
248+
this.container.addEventListener('compositionend', this.compositionEndListener);
236249
}
237250

238251
/**
@@ -287,6 +300,12 @@ export class InputHandler {
287300
private handleKeyDown(event: KeyboardEvent): void {
288301
if (this.isDisposed) return;
289302

303+
// Ignore keydown events during composition
304+
// Note: Some browsers send keyCode 229 for all keys during composition
305+
if (this.isComposing || event.isComposing || event.keyCode === 229) {
306+
return;
307+
}
308+
290309
// Emit onKey event first (before any processing)
291310
if (this.onKeyCallback) {
292311
this.onKeyCallback({ key: event.key, domEvent: event });
@@ -493,6 +512,50 @@ export class InputHandler {
493512
this.onDataCallback(text);
494513
}
495514

515+
/**
516+
* Handle compositionstart event
517+
*/
518+
private handleCompositionStart(_event: CompositionEvent): void {
519+
if (this.isDisposed) return;
520+
this.isComposing = true;
521+
}
522+
523+
/**
524+
* Handle compositionupdate event
525+
*/
526+
private handleCompositionUpdate(_event: CompositionEvent): void {
527+
if (this.isDisposed) return;
528+
// We could track the current composition string here if we wanted to
529+
// display it in a custom way, but for now we rely on the browser's
530+
// input method editor UI.
531+
}
532+
533+
/**
534+
* Handle compositionend event
535+
*/
536+
private handleCompositionEnd(event: CompositionEvent): void {
537+
if (this.isDisposed) return;
538+
this.isComposing = false;
539+
540+
const data = event.data;
541+
if (data && data.length > 0) {
542+
this.onDataCallback(data);
543+
}
544+
545+
// Cleanup text nodes in container (fix for duplicate text display)
546+
// When the container is contenteditable, the browser might insert text nodes
547+
// upon composition end. We need to remove them to prevent duplicate display.
548+
if (this.container && this.container.childNodes) {
549+
for (let i = this.container.childNodes.length - 1; i >= 0; i--) {
550+
const node = this.container.childNodes[i];
551+
// Node.TEXT_NODE === 3
552+
if (node.nodeType === 3) {
553+
this.container.removeChild(node);
554+
}
555+
}
556+
}
557+
}
558+
496559
/**
497560
* Dispose the InputHandler and remove event listeners
498561
*/
@@ -514,6 +577,21 @@ export class InputHandler {
514577
this.pasteListener = null;
515578
}
516579

580+
if (this.compositionStartListener) {
581+
this.container.removeEventListener('compositionstart', this.compositionStartListener);
582+
this.compositionStartListener = null;
583+
}
584+
585+
if (this.compositionUpdateListener) {
586+
this.container.removeEventListener('compositionupdate', this.compositionUpdateListener);
587+
this.compositionUpdateListener = null;
588+
}
589+
590+
if (this.compositionEndListener) {
591+
this.container.removeEventListener('compositionend', this.compositionEndListener);
592+
this.compositionEndListener = null;
593+
}
594+
517595
this.isDisposed = true;
518596
}
519597

0 commit comments

Comments
 (0)