diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx index fb34e030095..9985fff02be 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx @@ -298,16 +298,13 @@ describe('Windows IME keyboard ownership', () => { // Windows gives a shifted jamo the same Process/229/shiftKey shape as the committing // Enter, so treating it as Enter injects a Shift+Enter newline into ordinary Hangul. - it.each([ - { code: 'KeyQ', jamo: 'ㅃ' }, - { code: 'KeyE', jamo: 'ㄸ' } - ])('does not read a shifted $jamo as the committing Enter', ({ code, jamo }) => { + it('does not read a shifted jamo as the committing Enter', () => { const harness = createHarness() const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) harness.startComposition() const shiftedJamo = keyboardEvent('keydown', { key: 'Process', - code, + code: 'KeyQ', keyCode: 229, timeStamp: 10, isComposing: true, @@ -315,14 +312,49 @@ describe('Windows IME keyboard ownership', () => { }) harness.terminalInput.dispatchEvent(shiftedJamo) - harness.endComposition(jamo) + harness.endComposition('ㅃ') vi.advanceTimersByTime(250) - expect(harness.ptyWrites).toEqual([jamo]) + expect(harness.ptyWrites).toEqual(['ㅃ']) expect(shiftedJamo.defaultPrevented).toBe(false) hook.unmount() harness.dispose() }) + + // The chord owner keys off the held modifier alone, so a deferred Ctrl+Backspace must not + // claim its single slot — the real Ctrl+Enter behind it would then fail to claim and vanish. + it('leaves the Enter chord slot free for a Ctrl+Enter behind a deferred Ctrl+Backspace', () => { + const harness = createHarness() + const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) + harness.startComposition() + + harness.terminalInput.dispatchEvent( + keyboardEvent('keydown', { + key: 'Backspace', + code: 'Backspace', + keyCode: 8, + timeStamp: 10, + isComposing: true, + ctrlKey: true + }) + ) + harness.terminalInput.dispatchEvent( + keyboardEvent('keydown', { + key: 'Process', + code: 'Enter', + keyCode: 229, + timeStamp: 20, + isComposing: true, + ctrlKey: true + }) + ) + harness.endComposition('녕') + vi.advanceTimersByTime(250) + + expect(harness.ptyWrites).toEqual(['녕', '\x17', '\x1b[13;5u']) + hook.unmount() + harness.dispose() + }) }) // The committing press repeats and the direct shortcut write races xterm's flush for diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index f3c0ecf5505..b51aae16c53 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -444,16 +444,13 @@ export function useTerminalKeyboardShortcuts({ return } - const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null - // Why: the chord owner tracks Enter chords only, while the deferral covers every - // key the IME re-dispatches — Ctrl/Cmd+Backspace, Cmd+Delete and the word-motion - // arrows double the same way Enter does. - const isRedispatchedEnter = e.key === 'Enter' && e.keyCode === 13 + // Why: the chord owner tracks Enter chords only, while the deferral covers every key + // the IME re-dispatches. + const modifiedEnterChord = + isWindows && e.key === 'Enter' && e.keyCode === 13 ? getModifiedEnterChord(e) : null if ( !e.isComposing && - ((isRedispatchedEnter && - modifiedEnterChord && - modifiedEnterChordOwner.absorb(modifiedEnterChord)) || + ((modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) || (isTerminalImeRedispatchableKey(e) && deferredNewlineSender.absorbRedispatchedEnter(e))) ) { // Chromium can drop the modifier when re-dispatching the committing press. @@ -757,6 +754,9 @@ export function useTerminalKeyboardShortcuts({ heldImeEnterModifiers.delete(kind) modifiedEnterChordOwner.release({ kind, code: e.code, timeStamp: e.timeStamp }) } + // Why: only Enter releases its absorb credit here. Releasing on the other IME-owned + // keys risks clearing a credit before the re-dispatch that needs it, and a stale one + // self-heals — clearCreditsForCode drops it on the next press of the same code. if (e.key !== 'Enter') { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts index 4d960cbf34d..ff5098bc210 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts @@ -6,6 +6,7 @@ import { createTerminalImeModifiedEnterChordOwner, isTerminalImeEnterKeyUp, isTerminalImeProcessEnter, + isTerminalImeRedispatchableKey, sendTerminalInputAfterComposition } from './terminal-ime-deferred-newline' import { @@ -334,12 +335,17 @@ describe('isTerminalImeProcessEnter', () => { ...overrides }) as KeyboardEvent - it.each([{ shiftKey: true }, { shiftKey: false, ctrlKey: true }, { code: 'NumpadEnter' }])( - 'recognizes a Windows IME modifier Enter reported as Process', - (modifiers) => { - expect(isTerminalImeProcessEnter(event(modifiers))).toBe(true) - } - ) + it.each([ + { shiftKey: true }, + { shiftKey: false, ctrlKey: true }, + { code: 'NumpadEnter' }, + // Windows sends the Process sequence without a usable code; refusing it would drop a + // real newline, so an undeterminable code keeps the pre-existing verdict. + { code: '' }, + { code: 'Unidentified' } + ])('recognizes a Windows IME modifier Enter reported as Process', (modifiers) => { + expect(isTerminalImeProcessEnter(event(modifiers))).toBe(true) + }) it.each([ { key: 'Enter' }, @@ -349,14 +355,29 @@ describe('isTerminalImeProcessEnter', () => { { altKey: true }, // Windows hands every IME-consumed keydown the same Process/229 shape, so a shifted // jamo (ㅃ on KeyQ) differs from the committing Enter only by `code`. - { code: 'KeyQ' }, - { code: 'Digit3' }, - { code: '' } + { code: 'KeyQ' } ])('rejects a non-IME or ambiguous Process key', (override) => { expect(isTerminalImeProcessEnter(event(override))).toBe(false) }) }) +describe('isTerminalImeRedispatchableKey', () => { + it('accepts an IME-owned key carrying its real keyCode', () => { + expect(isTerminalImeRedispatchableKey({ key: 'Backspace', keyCode: 8 })).toBe(true) + }) + + it.each([ + // The committing press itself: macOS marks it 229 and it must not be read as the + // re-dispatch it is about to trigger. + { key: 'Enter', keyCode: 229 }, + { key: 'Backspace', keyCode: 229 }, + // Not a key the IME consumes mid-composition. + { key: 'a', keyCode: 65 } + ])('rejects the composition marker and non-owned keys', (event) => { + expect(isTerminalImeRedispatchableKey(event)).toBe(false) + }) +}) + describe('isTerminalImeEnterKeyUp', () => { it('recognizes the balancing Enter keyup when Chromium drops its modifier', () => { expect(isTerminalImeEnterKeyUp({ key: 'Enter', keyCode: 13 })).toBe(true) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts index 806371f77ea..44f94342a95 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts @@ -114,11 +114,14 @@ export function isTerminalImeProcessEnter( > ): boolean { // Why: Windows reports every IME-consumed keydown as Process/229, so a shifted jamo - // (ㅃ, ㄲ) matches the modifier check too; only `code` tells it apart from Enter. + // (ㅃ, ㄲ) matches the modifier check too; `code` is the only field telling them apart. + // An absent code stays accepted — Windows sends this sequence with a blank one, per the + // chord owner's "blank codes" case in this module's test. + const { code } = event return ( event.key === 'Process' && event.keyCode === 229 && - (event.code === 'Enter' || event.code === 'NumpadEnter') && + (!code || code === 'Unidentified' || code === 'Enter' || code === 'NumpadEnter') && getTerminalImeModifiedEnterKind(event) !== null ) }