@@ -33,6 +33,9 @@ interface MockClipboardEvent {
3333interface 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
81102function 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 (
0 commit comments