diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 112b690d4ff..160002722a5 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -13717,10 +13717,10 @@ "https://github.com/stablyai/orca/pull/17170" ], "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY. A live preedit must stay visible across an IME-resumed transaction, preserve every committed row-tail cell hidden by its opaque overlay, mask only a structurally verified Codex or Claude placeholder and never a row carrying typed text, keep the themed insertion caret and native candidate anchor inside the terminal at the final column, and clean up without stale overlay children or ownership classes. Layout-native Option text must preserve negotiated CSI-u alternate/associated fields and lock modifiers, distinguish repeat from press, and resolve releases with the press's primary identity plus the actual keyup modifiers without exposing a partial or stale layout refresh. An in-focus macOS input-source change must invalidate both renderer authorities before the next key can use the old source. When the snapshot source proves kitty state, macOS Preview commits must use the application's effective flags after the sequenced snapshot and every proven replay/live transition, and a claimed press delivered under report_event_types must produce exactly one matching release regardless of whether keyup arrives before or after insertText and regardless of xterm's defensive internal reset.", - "oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, resumed Windows event traces reactivate the visible preedit, an opaque mid-line overlay reproduces the committed row tail it covers, arbitrary DIM output remains visible, and final-column layout keeps both caret and textarea candidate anchor within the available screen width. The candidate-anchor slice reads back the inline `textarea.style.left` xterm's own listener and Orca's anchor listener both write during one composition event, so the assertion is the position the OS would sample rather than either writer's intent. A Codex frame whose prompt row is bold and whose dimmed model footer sits below a blank gap acquires the mask class while composing; the same placeholder text without that footer, a framed-versus-unframed Claude prompt, a row of arbitrary dimmed output, and a row carrying typed text do not. The class is gone after composition end, after a blur that is not followed by a session end, and after disposal. Rendered Electron/CDP geometry independently proves the overlay reproduces every committed cell it hides, that a real Codex placeholder at column 2 is masked rather than repeated, and that the final-column caret, preedit, and textarea right edges all land inside the screen. Input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. A main/preload/renderer notification chain proves every live window invalidates immediately, the old native read settles before a coalesced refresh signal, and older refreshes are generation-fenced. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation, native candidate-window placement, and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, resumed Windows event traces reactivate the visible preedit, an opaque mid-line overlay reproduces the committed row tail it covers, arbitrary DIM output remains visible, and final-column layout keeps both caret and textarea candidate anchor within the available screen width. The candidate-anchor slice reads back the inline `textarea.style.left` xterm's own listener and Orca's anchor listener both write during one composition event, so the assertion is the position the OS would sample rather than either writer's intent. A Codex frame whose prompt row is bold and whose dimmed model footer sits below a blank gap acquires the mask class while composing; the same placeholder text without that footer, a framed-versus-unframed Claude prompt, a row of arbitrary dimmed output, and a row carrying typed text do not. The class is gone after composition end, after a blur that is not followed by a session end, and after disposal. Rendered Electron/CDP geometry independently proves the overlay reproduces every committed cell it hides, that a real Codex placeholder at column 2 is masked rather than repeated, and that the final-column caret, preedit, and textarea right edges all land inside the screen. Input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and real fcitx4/Sogou captures send only the committed candidate for Space/digit picks while ordinary typing, literal Space and digits stay available. A main/preload/renderer notification chain proves every live window invalidates immediately, the old native read settles before a coalesced refresh signal, and older refreshes are generation-fenced. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text. Real legacy IME commit preservation, native candidate-window placement, and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-fcitx4-trace-replay.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts src/shared/terminal-composer-draft.test.ts src/main/daemon/terminal-cursor-line-context.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts src/main/ipc/macos-keyboard-layout-snapshot.test.ts src/main/ipc/macos-keyboard-layout-change-notifications.test.ts src/preload/app-restart-checkpoint-routing.test.ts src/renderer/src/web/web-preload-api-ui.test.ts", @@ -13736,7 +13736,7 @@ "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", - "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-linux-fcitx4-trace-replay.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts", @@ -13812,11 +13812,11 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "file": "src/renderer/src/components/terminal-pane/terminal-ime-linux-fcitx4-trace-replay.test.ts", "assertions": [ - "an orphaned plain-letter keyup arms exactly the next bare digit guard", - "ordinary, overlapping, shifted, and long-held letter keydowns keep following digits available", - "physical letter tracking survives cross-pane focus handoff and clears on renderer-window blur" + "real fcitx4 Sogou captures (GTK module and XIM) send only the committed candidate for Space and digit picks, under legacy keys and kitty flags 1 and 5", + "a real fcitx4 Google Pinyin in-client preedit still commits through the composition path", + "an XIM pick's code-less keydown pairs with its keyup so kitty event reporting sends one release per pick" ] }, { @@ -14091,22 +14091,13 @@ "summary": "8 test file(s) passed, 143 tests, after adding the OS key-binding substitution replay. Discrimination checked by mutation rather than assumed: suppressing the structural claim, and separately removing the single character the older punctuation table happened to list, each make the replay send the raw layout character while both paired negatives stay green." }, { - "date": "2026-07-11", + "date": "2026-10-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-fcitx4-trace-replay.test.ts", "result": "passed", - "durationSeconds": 0.619, - "summary": "1 state test file and 10 tests passed on macOS, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests." - }, - { - "date": "2026-07-11", - "runner": "local", - "platform": "linux", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", - "result": "passed", - "durationSeconds": 0.747, - "summary": "1 state test file and 10 tests passed in Debian 12 arm64 Docker under Node 24, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests." + "durationSeconds": 1.99, + "summary": "1 replay test file and 37 tests passed on macOS: real fcitx4 Sogou (GTK module and XIM) and Google Pinyin captures through the full Linux pane keyboard stack under legacy keys and kitty flags 1 and 5, plus XIM release pairing under kitty flags 3. The same captures were also replayed with real fcitx4 + Sogou in an Ubuntu 22.04 container, with every scenario sending only the committed text." }, { "date": "2026-07-11", diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 598d11b2beb..f8a9ce0fc21 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -286,7 +286,8 @@ describe('AgentTerminalPreview', () => { expect(writeClipboardText).not.toHaveBeenCalled() }) - it('does not install the IME native-text forwarder off macOS', async () => { + it('does not install the IME native-text forwarder on Windows', async () => { + platformState.value = 'win32' render() await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) await waitFor(() => expect(terminalHarness.instances[0]!.customKeyHandler).not.toBeNull()) @@ -294,6 +295,13 @@ describe('AgentTerminalPreview', () => { expect(imeHarness.trackers).toHaveLength(0) }) + // fcitx4 delivers a single-character pick as the Space/digit keypress's text (#28213). + it('installs the IME native-text forwarder on desktop Linux', async () => { + platformState.value = 'linux' + render() + await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1)) + }) + // The bridge omitted this dependency entirely, so every Preview // commit was evaluated at flags 0. Ordering and provenance live in // preview-terminal-snapshot-replay.test.ts; this pins the wiring. diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts index dcfa851d920..e834f309603 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts @@ -23,7 +23,7 @@ export type PreviewImeBridgeOptions = { * * Why: xterm's kitty encoder can encode+cancel a printable keydown before * Chromium commits IME/native text, silently dropping the glyph. Mirrors - * TerminalPane's forwarder, macOS-only like the pane's install. + * TerminalPane's forwarder, on macOS and desktop Linux like the pane's install. * * Bit policy stays in terminal-ime-kitty-commit-encoding.ts: the getter is * forwarded unchanged, never masked or reduced to a boolean. @@ -32,7 +32,9 @@ export function installPreviewImeBridge( terminal: Terminal, options: PreviewImeBridgeOptions ): PreviewImeBridge | null { - if (getShortcutPlatform() !== 'darwin') { + const platform = getShortcutPlatform() + const isDesktopLinux = platform === 'linux' && !/Android|CrOS/.test(navigator.userAgent) + if (platform !== 'darwin' && !isDesktopLinux) { return null } const compositionTracker = installTerminalImeCompositionTracker(terminal.element) diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-googlepinyin-client-preedit-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-googlepinyin-client-preedit-trace.json new file mode 100644 index 00000000000..54f84477bd4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-googlepinyin-client-preedit-trace.json @@ -0,0 +1,729 @@ +{ + "recordedFrom": "Ubuntu 22.04 container, Xvfb + openbox, Electron 43.7.5 --ozone-platform=x11", + "inputFramework": "fcitx 4.2.9.8 via GTK_IM_MODULE=fcitx", + "engine": "googlepinyin", + "events": [ + { + "scenario": "s1_ni_space", + "type": "keydown", + "key": "Process", + "code": "KeyN", + "keyCode": 229, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "compositionstart", + "data": "", + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "compositionupdate", + "data": "你", + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你", + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你", + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": "n", + "code": "KeyN", + "keyCode": 78, + "charCode": 0, + "isComposing": true, + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "keydown", + "key": "Process", + "code": "KeyI", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": "i", + "code": "KeyI", + "keyCode": 73, + "charCode": 0, + "isComposing": true, + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "keydown", + "key": "Process", + "code": "Space", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "compositionupdate", + "data": "你", + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你", + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你", + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "compositionend", + "data": "你", + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "你" + }, + { + "scenario": "s2_hao_3", + "type": "keydown", + "key": "Process", + "code": "KeyH", + "keyCode": 229, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "compositionstart", + "data": "", + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "compositionupdate", + "data": "和", + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "和", + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "和", + "value": "和" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "h", + "code": "KeyH", + "keyCode": 72, + "charCode": 0, + "isComposing": true, + "value": "和" + }, + { + "scenario": "s2_hao_3", + "type": "keydown", + "key": "Process", + "code": "KeyA", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "和" + }, + { + "scenario": "s2_hao_3", + "type": "compositionupdate", + "data": "哈", + "value": "和" + }, + { + "scenario": "s2_hao_3", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "哈", + "value": "和" + }, + { + "scenario": "s2_hao_3", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "哈", + "value": "哈" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "a", + "code": "KeyA", + "keyCode": 65, + "charCode": 0, + "isComposing": true, + "value": "哈" + }, + { + "scenario": "s2_hao_3", + "type": "keydown", + "key": "Process", + "code": "KeyO", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "哈" + }, + { + "scenario": "s2_hao_3", + "type": "compositionupdate", + "data": "好", + "value": "哈" + }, + { + "scenario": "s2_hao_3", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "好", + "value": "哈" + }, + { + "scenario": "s2_hao_3", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "好", + "value": "好" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "o", + "code": "KeyO", + "keyCode": 79, + "charCode": 0, + "isComposing": true, + "value": "好" + }, + { + "scenario": "s2_hao_3", + "type": "keydown", + "key": "Process", + "code": "Digit3", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "好" + }, + { + "scenario": "s2_hao_3", + "type": "compositionupdate", + "data": "浩", + "value": "好" + }, + { + "scenario": "s2_hao_3", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "浩", + "value": "好" + }, + { + "scenario": "s2_hao_3", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "浩", + "value": "浩" + }, + { + "scenario": "s2_hao_3", + "type": "compositionend", + "data": "浩", + "value": "浩" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "3", + "code": "Digit3", + "keyCode": 51, + "charCode": 0, + "isComposing": false, + "value": "浩" + }, + { + "scenario": "s3_nihao_space", + "type": "keydown", + "key": "Process", + "code": "KeyN", + "keyCode": 229, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "compositionstart", + "data": "", + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "compositionupdate", + "data": "你", + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你", + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你", + "value": "你" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "n", + "code": "KeyN", + "keyCode": 78, + "charCode": 0, + "isComposing": true, + "value": "你" + }, + { + "scenario": "s3_nihao_space", + "type": "keydown", + "key": "Process", + "code": "KeyI", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "你" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "i", + "code": "KeyI", + "keyCode": 73, + "charCode": 0, + "isComposing": true, + "value": "你" + }, + { + "scenario": "s3_nihao_space", + "type": "keydown", + "key": "Process", + "code": "KeyH", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "你" + }, + { + "scenario": "s3_nihao_space", + "type": "compositionupdate", + "data": "你好", + "value": "你" + }, + { + "scenario": "s3_nihao_space", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你好", + "value": "你" + }, + { + "scenario": "s3_nihao_space", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你好", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "h", + "code": "KeyH", + "keyCode": 72, + "charCode": 0, + "isComposing": true, + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "keydown", + "key": "Process", + "code": "KeyA", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "compositionupdate", + "data": "你哈", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你哈", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你哈", + "value": "你哈" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "a", + "code": "KeyA", + "keyCode": 65, + "charCode": 0, + "isComposing": true, + "value": "你哈" + }, + { + "scenario": "s3_nihao_space", + "type": "keydown", + "key": "Process", + "code": "KeyO", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "你哈" + }, + { + "scenario": "s3_nihao_space", + "type": "compositionupdate", + "data": "你好", + "value": "你哈" + }, + { + "scenario": "s3_nihao_space", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你好", + "value": "你哈" + }, + { + "scenario": "s3_nihao_space", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你好", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "o", + "code": "KeyO", + "keyCode": 79, + "charCode": 0, + "isComposing": true, + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "keydown", + "key": "Process", + "code": "Space", + "keyCode": 229, + "charCode": 0, + "isComposing": true, + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "compositionupdate", + "data": "你好", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "beforeinput", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你好", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "input", + "isComposing": true, + "inputType": "insertCompositionText", + "data": "你好", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "compositionend", + "data": "你好", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "你好" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "l", + "code": "KeyL", + "keyCode": 76, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "l", + "code": "KeyL", + "keyCode": 108, + "charCode": 108, + "isComposing": false, + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "l", + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "l", + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "l", + "code": "KeyL", + "keyCode": 76, + "charCode": 0, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "s", + "code": "KeyS", + "keyCode": 83, + "charCode": 0, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "s", + "code": "KeyS", + "keyCode": 115, + "charCode": 115, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "s", + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "s", + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "s", + "code": "KeyS", + "keyCode": 83, + "charCode": 0, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 32, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": " ", + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": " ", + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 0, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 55, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "7", + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "7", + "value": "ls 7" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 0, + "isComposing": false, + "value": "ls 7" + } + ] +} diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-sogou-gtk-im-module-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-sogou-gtk-im-module-trace.json new file mode 100644 index 00000000000..2865dd6c61a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-sogou-gtk-im-module-trace.json @@ -0,0 +1,419 @@ +{ + "recordedFrom": "Ubuntu 22.04 container, Xvfb + openbox, Electron 43.7.5 --ozone-platform=x11", + "inputFramework": "fcitx 4.2.9.8 via GTK_IM_MODULE=fcitx", + "engine": "sogoupinyin", + "events": [ + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": "n", + "code": "KeyN", + "keyCode": 78, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": "i", + "code": "KeyI", + "keyCode": 73, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "keydown", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "keypress", + "key": "你", + "code": "Space", + "keyCode": 20320, + "charCode": 20320, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "你", + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "你", + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "你" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "h", + "code": "KeyH", + "keyCode": 72, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "a", + "code": "KeyA", + "keyCode": 65, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "o", + "code": "KeyO", + "keyCode": 79, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "keydown", + "key": "3", + "code": "Digit3", + "keyCode": 51, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "keypress", + "key": "浩", + "code": "Digit3", + "keyCode": 28009, + "charCode": 28009, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "浩", + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "浩", + "value": "浩" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "3", + "code": "Digit3", + "keyCode": 51, + "charCode": 0, + "isComposing": false, + "value": "浩" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "n", + "code": "KeyN", + "keyCode": 78, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "i", + "code": "KeyI", + "keyCode": 73, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "h", + "code": "KeyH", + "keyCode": 72, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "a", + "code": "KeyA", + "keyCode": 65, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "o", + "code": "KeyO", + "keyCode": 79, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keydown", + "key": "Process", + "code": "Space", + "keyCode": 229, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "你好", + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "你好", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "你好" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "l", + "code": "KeyL", + "keyCode": 76, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "l", + "code": "KeyL", + "keyCode": 108, + "charCode": 108, + "isComposing": false, + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "l", + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "l", + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "l", + "code": "KeyL", + "keyCode": 76, + "charCode": 0, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "s", + "code": "KeyS", + "keyCode": 83, + "charCode": 0, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "s", + "code": "KeyS", + "keyCode": 115, + "charCode": 115, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "s", + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "s", + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "s", + "code": "KeyS", + "keyCode": 83, + "charCode": 0, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 32, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": " ", + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": " ", + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 0, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 55, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "7", + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "7", + "value": "ls 7" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 0, + "isComposing": false, + "value": "ls 7" + } + ] +} diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-sogou-xim-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-sogou-xim-trace.json new file mode 100644 index 00000000000..c79b7c94134 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-sogou-xim-trace.json @@ -0,0 +1,419 @@ +{ + "recordedFrom": "Ubuntu 22.04 container, Xvfb + openbox, Electron 43.7.5 --ozone-platform=x11", + "inputFramework": "fcitx 4.2.9.8 via GTK_IM_MODULE=xim", + "engine": "sogoupinyin", + "events": [ + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": "n", + "code": "KeyN", + "keyCode": 78, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": "i", + "code": "KeyI", + "keyCode": 73, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "keydown", + "key": "\u0000", + "code": "", + "keyCode": 0, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "keypress", + "key": "你", + "code": "", + "keyCode": 20320, + "charCode": 20320, + "isComposing": false, + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "你", + "value": "" + }, + { + "scenario": "s1_ni_space", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "你", + "value": "你" + }, + { + "scenario": "s1_ni_space", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "你" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "h", + "code": "KeyH", + "keyCode": 72, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "a", + "code": "KeyA", + "keyCode": 65, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "o", + "code": "KeyO", + "keyCode": 79, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "keydown", + "key": "\u0000", + "code": "", + "keyCode": 0, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "keypress", + "key": "浩", + "code": "", + "keyCode": 28009, + "charCode": 28009, + "isComposing": false, + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "浩", + "value": "" + }, + { + "scenario": "s2_hao_3", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "浩", + "value": "浩" + }, + { + "scenario": "s2_hao_3", + "type": "keyup", + "key": "3", + "code": "Digit3", + "keyCode": 51, + "charCode": 0, + "isComposing": false, + "value": "浩" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "n", + "code": "KeyN", + "keyCode": 78, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "i", + "code": "KeyI", + "keyCode": 73, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "h", + "code": "KeyH", + "keyCode": 72, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "a", + "code": "KeyA", + "keyCode": 65, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": "o", + "code": "KeyO", + "keyCode": 79, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "你好", + "value": "" + }, + { + "scenario": "s3_nihao_space", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "你好", + "value": "你好" + }, + { + "scenario": "s3_nihao_space", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "你好" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "l", + "code": "KeyL", + "keyCode": 76, + "charCode": 0, + "isComposing": false, + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "l", + "code": "KeyL", + "keyCode": 108, + "charCode": 108, + "isComposing": false, + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "l", + "value": "" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "l", + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "l", + "code": "KeyL", + "keyCode": 76, + "charCode": 0, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "s", + "code": "KeyS", + "keyCode": 83, + "charCode": 0, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "s", + "code": "KeyS", + "keyCode": 115, + "charCode": 115, + "isComposing": false, + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "s", + "value": "l" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "s", + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "s", + "code": "KeyS", + "keyCode": 83, + "charCode": 0, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 32, + "isComposing": false, + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": " ", + "value": "ls" + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": " ", + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": " ", + "code": "Space", + "keyCode": 32, + "charCode": 0, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keydown", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 0, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "keypress", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 55, + "isComposing": false, + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "beforeinput", + "isComposing": false, + "inputType": "insertText", + "data": "7", + "value": "ls " + }, + { + "scenario": "s4_ascii_im_off", + "type": "input", + "isComposing": false, + "inputType": "insertText", + "data": "7", + "value": "ls 7" + }, + { + "scenario": "s4_ascii_im_off", + "type": "keyup", + "key": "7", + "code": "Digit7", + "keyCode": 55, + "charCode": 0, + "isComposing": false, + "value": "ls 7" + } + ] +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts index a9abcf86dfe..c0112fa2c41 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts @@ -3,7 +3,6 @@ import { armTerminalImePendingCandidateKeyRelease, clearTerminalImePendingCandidateKeyRelease, createTerminalImePendingCandidateKeyReleases, - isTerminalImeCandidateDigitKeyEvent, isTerminalImeCandidateSelectionKeyEvent, shouldApplyTerminalImePendingCandidateKeyRelease } from './terminal-ime-candidate-key-release-guard' @@ -20,12 +19,6 @@ describe('terminal IME candidate key release guard', () => { expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', shiftKey: true }))).toBe(false) }) - it('recognizes unmodified digits but not Space as digit candidate selectors', () => { - expect(isTerminalImeCandidateDigitKeyEvent(event({ key: '2' }))).toBe(true) - expect(isTerminalImeCandidateDigitKeyEvent(event({ key: ' ' }))).toBe(false) - expect(isTerminalImeCandidateDigitKeyEvent(event({ key: '2', ctrlKey: true }))).toBe(false) - }) - it('arms a pending release guard from a suppressed candidate keydown', () => { const releases = createTerminalImePendingCandidateKeyReleases() armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts index 337a3c23682..210d8de22d2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts @@ -22,7 +22,6 @@ const TERMINAL_IME_CANDIDATE_SELECTION_KEYS = new Set([ '8', '9' ]) -const TERMINAL_IME_CANDIDATE_DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) function isTerminalImeCandidateSelectionKey(key: string): boolean { return TERMINAL_IME_CANDIDATE_SELECTION_KEYS.has(key) @@ -37,13 +36,6 @@ export function isTerminalImeCandidateSelectionKeyEvent(event: XtermBypassEvent) return isTerminalImeCandidateSelectionKey(event.key) } -/** Returns whether an event is an unmodified IME candidate digit selector. */ -export function isTerminalImeCandidateDigitKeyEvent(event: XtermBypassEvent): boolean { - return ( - isTerminalImeCandidateSelectionKeyEvent(event) && TERMINAL_IME_CANDIDATE_DIGITS.has(event.key) - ) -} - export function createTerminalImePendingCandidateKeyReleases(): TerminalImePendingCandidateKeyReleases { return new Map() } diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts index e648d32c3c5..73f6d83a0b0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts @@ -6,16 +6,8 @@ export type TerminalImeCompositionTracker = IDisposable & { * IME-owned: during a live composition, and briefly after compositionend to * absorb the committing key's trailing press/release. */ isCandidateKeyGuardActive: () => boolean - /** True when the most recent preedit was Hangul, where a bare digit ends the - * syllable as literal text instead of picking a candidate. Expires with the - * same staleness window as the other guards. */ - isHangulPreedit: () => boolean } -// Jamo, compatibility jamo, extended jamo, and precomposed syllables — every -// form a Hangul preedit can take while a syllable is being assembled. -const HANGUL_PREEDIT_PATTERN = /[ᄀ-ᇿ㄰-㆏ꥠ-꥿가-힣]/ - // Why: suppressed candidate keys are preventDefault-ed and fire no input // event, so a stale tracker (missed compositionend) has no natural unstick // path. Expire the guard so Space/digits cannot stay dead indefinitely. @@ -34,24 +26,12 @@ export function installTerminalImeCompositionTracker( let lastCompositionEventAt: number | null = null let compositionEndedAt: number | null = null let sawEmptyCompositionUpdate = false - // Why the preedit and not compositionend data: a Pinyin IME's preedit is the - // Latin spelling it is picking candidates for, while its compositionend data - // is the committed Han text. Reading the commit would misclassify Pinyin. - let hangulPreedit = false const isActiveAt = (at: number): boolean => active && (lastCompositionEventAt === null || at - lastCompositionEventAt <= TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) - // Why time-bound: an engine switch (Hangul -> Pinyin) moves no DOM focus and - // the orphan-digit path emits no composition or input events, so a latched - // flag would disable the Pinyin candidate-digit guard for the whole session. - const isHangulPreeditAt = (at: number): boolean => - hangulPreedit && - lastCompositionEventAt !== null && - at - lastCompositionEventAt <= TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS - const isCandidateKeyGuardActive = (): boolean => { const at = now() if (isActiveAt(at)) { @@ -67,7 +47,6 @@ export function installTerminalImeCompositionTracker( return { isActive: () => active, isCandidateKeyGuardActive, - isHangulPreedit: () => isHangulPreeditAt(now()), dispose: () => undefined } } @@ -77,8 +56,6 @@ export function installTerminalImeCompositionTracker( lastCompositionEventAt = now() compositionEndedAt = null sawEmptyCompositionUpdate = false - // Why safe: the following compositionupdate re-reads the preedit script. - hangulPreedit = false } const updateComposition = (event: Event): void => { lastCompositionEventAt = now() @@ -92,7 +69,6 @@ export function installTerminalImeCompositionTracker( sawEmptyCompositionUpdate = true return } - hangulPreedit = HANGUL_PREEDIT_PATTERN.test(event.data) active = true } const handleCompositionEnd = (): void => { @@ -117,7 +93,6 @@ export function installTerminalImeCompositionTracker( lastCompositionEventAt = null compositionEndedAt = null sawEmptyCompositionUpdate = false - hangulPreedit = false } terminalElement.addEventListener('compositionstart', markActive, true) @@ -129,7 +104,6 @@ export function installTerminalImeCompositionTracker( return { isActive: () => isActiveAt(now()), isCandidateKeyGuardActive, - isHangulPreedit: () => isHangulPreeditAt(now()), dispose: () => { terminalElement.removeEventListener('compositionstart', markActive, true) terminalElement.removeEventListener('compositionupdate', updateComposition, true) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts index b60f1362c7f..2d266da3edd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts @@ -28,7 +28,7 @@ function wonKeydown(): { } } -function dispatchInsertText(target: HTMLElement, data: '`' | '₩' = '`'): void { +function dispatchInsertText(target: HTMLElement, data = '`'): void { target.dispatchEvent(new InputEvent('input', { data, inputType: 'insertText', bubbles: true })) } @@ -80,6 +80,28 @@ describe('xterm composition transaction ownership', () => { expect(textarea.value).toBe('') }) + // A Linux `Process` keydown is never claimed, and xterm dedupes its own echo; stopping the + // input here dropped a commit typed before the transaction settled (#28213). + it('leaves unclaimed input with xterm while a transaction settles', () => { + const sendInput = vi.fn() + const downstream = vi.fn() + installTerminalImeNativeTextForwarder({ + terminalElement: element, + isComposing: () => false, + sendInput + }) + element.addEventListener('input', downstream, true) + + textarea.dispatchEvent( + new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true }) + ) + textarea.value = ',' + dispatchInsertText(textarea, ',') + + expect(sendInput).not.toHaveBeenCalled() + expect(downstream).toHaveBeenCalledOnce() + }) + it('does not let repeated rejected composition ends steal an immediate remap', () => { const sendInput = vi.fn() const forwarder = installTerminalImeNativeTextForwarder({ diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts index 30e8782d0b8..f578f9b2c73 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts @@ -1,203 +1,113 @@ // @vitest-environment happy-dom /** - * A digit that terminates a Hangul composition must reach the pty (#15299: typing `아1` in the - * integrated terminal on Wayland produces `아`). + * A digit typed right after a Hangul syllable commits must reach the pty (#15299: typing `아1` in + * the integrated terminal on Wayland produced `아`). * - * The Linux candidate-digit guards exist for Sogou/fcitx Pinyin, where a bare digit picks a - * numbered candidate and must never be typed (#7543, #8241). Only the orphan-keyup guard is at - * fault here: it arms off a lone letter keyup, cannot see which engine produced it, and spends - * its one suppression on the key that ends a Korean syllable. The composition-window guards stay - * as they are — ibus-hangul's Hanja lookup table does index candidates by digit, over a live - * preedit those guards already own. + * The native text forwarder owns a plain keydown on Linux and sends only what the IME commits, so a + * digit that commits as `1` is sent as `1`. Only a live composition still claims a digit — + * ibus-hangul's Hanja lookup table does index candidates by digit over an open preedit. * - * The composition shape below follows the recorded ibus-hangul trace - * (`__fixtures__/ibus-hangul-mixed-ascii-terminal-trace.json`): non-empty preedit updates, then - * compositionend, then the commit's own `insertText`. Neither recording leaves an empty - * compositionupdate standing before the digit, so the 250ms post-composition window never arms - * for this bug and no test may lean on it. + * Each case replays a recorded Hangul commit (`__fixtures__/*-hangul-mixed-ascii-terminal-trace.json`, + * up to the first syllable's commit) and then the digit as Chromium delivers an uncancelled one. */ import { describe, expect, it } from 'vitest' +import fcitx5Trace from './__fixtures__/fcitx5-hangul-mixed-ascii-terminal-trace.json' +import ibusTrace from './__fixtures__/ibus-hangul-mixed-ascii-terminal-trace.json' +import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' import { - installTerminalImeCompositionTracker, - TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS, - type TerminalImeCompositionTracker -} from './terminal-ime-composition-tracker' -import { createTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' + buildRecordedDomEvent, + replayThroughLinuxPane, + useLinuxPaneTestEnvironment, + type RecordedDomEvent +} from './terminal-ime-linux-pane-replay-fixture' import { shouldPreventDefaultTerminalImeCandidateKey, - shouldSuppressTerminalImeKeyboardEvent, - type XtermImeKeyboardOptions + shouldSuppressTerminalImeKeyboardEvent } from './xterm-bypass-policy' import { event } from './xterm-bypass-event-fixture' -type ImeHarness = { - tracker: TerminalImeCompositionTracker - element: HTMLElement - candidateState: ReturnType - advance: (ms: number) => void - dispose: () => void -} +type RecordedTrace = { dom: RecordedDomEvent[] } -function installImeHarness(): ImeHarness { - let time = 1_000 - const element = document.createElement('div') - document.body.appendChild(element) - const tracker = installTerminalImeCompositionTracker(element, { now: () => time }) - return { - tracker, - element, - candidateState: createTerminalImeLinuxCandidateState(() => time), - advance: (ms) => { - time += ms - }, - dispose: () => { - tracker.dispose() - element.remove() - } - } -} +const TRACES: [string, RecordedTrace][] = [ + ['IBus', ibusTrace], + ['fcitx5', fcitx5Trace] +] -function dispatchComposition(element: HTMLElement, type: string, data: string): void { - const composition = new CompositionEvent(type, { bubbles: true }) - Object.defineProperty(composition, 'data', { value: data }) - element.dispatchEvent(composition) -} +const DIGIT_KEYSTROKE: RecordedDomEvent[] = [ + { type: 'keydown', key: '1', code: 'Digit1', keyCode: 49 }, + { type: 'keypress', key: '1', code: 'Digit1', keyCode: 49, charCode: 49 }, + { type: 'beforeinput', inputType: 'insertText', data: '1' }, + { type: 'input', inputType: 'insertText', data: '1' }, + { type: 'keyup', key: '1', code: 'Digit1', keyCode: 49 } +] -function dispatchInput(element: HTMLElement, inputType: string, data: string | null): void { - const input = new InputEvent('input', { bubbles: true }) - Object.defineProperty(input, 'inputType', { value: inputType }) - Object.defineProperty(input, 'data', { value: data }) - element.dispatchEvent(input) -} - -/** Drives a Hangul syllable into the tracker the way the recorded ibus trace reports one. */ -function composeHangulSyllable(element: HTMLElement): void { - dispatchComposition(element, 'compositionstart', '') - dispatchComposition(element, 'compositionupdate', '아') - dispatchInput(element, 'insertCompositionText', '아') - dispatchComposition(element, 'compositionend', '아') - dispatchInput(element, 'insertText', '아') -} - -/** - * Arms the #8241 orphan-digit window the way the Wayland input-method grab does: it swallows the - * jamo keydown, so only the release reaches the page and reads as a legacy single-letter commit. - */ -function armOrphanCandidateDigitWindow(harness: ImeHarness): void { - const jamoKeyup = event({ type: 'keyup', key: 'k', code: 'KeyK', keyCode: 75 }) - harness.candidateState.observeKeyboardEvent( - jamoKeyup, - harness.candidateState.classifyKeyboardEvent(jamoKeyup) +/** The recorded events through the first syllable's commit, before the ASCII that follows it. */ +function firstSyllableCommit(trace: RecordedTrace): RecordedDomEvent[] { + const firstAscii = trace.dom.findIndex( + (recorded) => recorded.type === 'keydown' && recorded.key === 'a' ) + return trace.dom.slice(0, firstAscii) } -const digitKeydown = event({ type: 'keydown', key: '1', code: 'Digit1', keyCode: 49 }) +describe('a digit typed after a committed Hangul syllable on Linux', () => { + useLinuxPaneTestEnvironment() -/** The gate as `use-terminal-pane-lifecycle` assembles it for a Linux pane. */ -function imeKeyboardOptions(harness: ImeHarness): XtermImeKeyboardOptions { - return { - compositionActive: harness.tracker.isActive(), - candidateKeyGuardActive: harness.tracker.isCandidateKeyGuardActive(), - pendingCandidateKeyReleaseActive: false, - linuxOrphanCandidateDigitGuardActive: - harness.candidateState.classifyKeyboardEvent(digitKeydown).candidateDigitGuardActive, - hangulPreedit: harness.tracker.isHangulPreedit(), - isMac: false, - isLinux: true + it.each(TRACES)('reaches the pty after a %s commit', async (_framework, trace) => { + const { emitted, preventedKeydownCodes } = await replayThroughLinuxPane([ + ...firstSyllableCommit(trace), + ...DIGIT_KEYSTROKE + ]) + expect(emitted).toBe('한1') + expect(preventedKeydownCodes).not.toContain('Digit1') + }) +}) + +describe('the Linux IME policy around a Hangul commit', () => { + const digitKeydown = event({ type: 'keydown', key: '1', code: 'Digit1', keyCode: 49 }) + + function linuxOptions(tracker: ReturnType) { + return { + compositionActive: tracker.isActive(), + candidateKeyGuardActive: tracker.isCandidateKeyGuardActive(), + pendingCandidateKeyReleaseActive: false, + isMac: false, + isLinux: true + } } -} -function suppressesDigit(harness: ImeHarness): boolean { - return shouldSuppressTerminalImeKeyboardEvent(digitKeydown, imeKeyboardOptions(harness)) -} - -describe('a digit that terminates a Hangul composition', () => { - it('survives the orphan-keyup window a compositor-grabbed Hangul keypress opens', () => { - const harness = installImeHarness() - composeHangulSyllable(harness.element) - - // The bug is exclusively after the commit, on the orphan path: no composition window is open. - expect(harness.tracker.isActive()).toBe(false) - expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) - - armOrphanCandidateDigitWindow(harness) - harness.advance(120) - - // The orphan window still arms — it cannot see the composition. The policy is what declines - // to spend it on a digit that a Hangul preedit has already claimed as literal text. - expect(imeKeyboardOptions(harness).linuxOrphanCandidateDigitGuardActive).toBe(true) - expect(suppressesDigit(harness)).toBe(false) - expect( - shouldPreventDefaultTerminalImeCandidateKey(digitKeydown, imeKeyboardOptions(harness)) - ).toBe(false) - - harness.dispose() - }) - - it("stays literal across the commit's own insertText", () => { - const harness = installImeHarness() - composeHangulSyllable(harness.element) + function replayInto(element: HTMLElement, events: RecordedDomEvent[]): void { + for (const recorded of events) { + element.dispatchEvent(buildRecordedDomEvent(recorded)) + } + } - // Both recordings deliver `insertText` for the committed syllable; on Wayland the digit can - // follow it, so the commit must not be read as "ordinary typing resumed". - expect(harness.tracker.isHangulPreedit()).toBe(true) - - harness.dispose() - }) + it.each(TRACES)( + 'leaves the digit alone once a %s syllable has committed', + (_framework, trace) => { + const element = document.createElement('div') + const tracker = installTerminalImeCompositionTracker(element) + replayInto(element, firstSyllableCommit(trace)) + + expect(shouldSuppressTerminalImeKeyboardEvent(digitKeydown, linuxOptions(tracker))).toBe( + false + ) + expect(shouldPreventDefaultTerminalImeCandidateKey(digitKeydown, linuxOptions(tracker))).toBe( + false + ) + tracker.dispose() + } + ) it('keeps the digit IME-owned while the Hangul preedit is still live', () => { - const harness = installImeHarness() - dispatchComposition(harness.element, 'compositionstart', '') - dispatchComposition(harness.element, 'compositionupdate', '아') - dispatchInput(harness.element, 'insertCompositionText', '아') - - // ibus-hangul's Hanja conversion (Hanja key / F9) puts a numbered lookup table over a live - // Hangul preedit and its symbol table behaves the same, so digits are selectors there. - expect(harness.tracker.isHangulPreedit()).toBe(true) - expect(suppressesDigit(harness)).toBe(true) - - harness.dispose() - }) - - it('stops claiming digits once the Hangul preedit has gone stale', () => { - const harness = installImeHarness() - composeHangulSyllable(harness.element) - - // Switching engine (Hangul -> Pinyin) moves no DOM focus, and the #8241 orphan path emits no - // composition or input events at all, so only expiry can retire the Hangul classification. - harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS + 1) - armOrphanCandidateDigitWindow(harness) - - expect(harness.tracker.isHangulPreedit()).toBe(false) - expect(suppressesDigit(harness)).toBe(true) - - harness.dispose() - }) - - it('re-reads the preedit script when the next composition starts', () => { - const harness = installImeHarness() - composeHangulSyllable(harness.element) - dispatchComposition(harness.element, 'compositionstart', '') - - // compositionstart carries no data, so the classification cannot survive into a session that - // may belong to another engine; the following compositionupdate re-establishes it. - expect(harness.tracker.isHangulPreedit()).toBe(false) - - harness.dispose() - }) - - it('still lets a Pinyin preedit claim its numbered candidate digit', () => { - const harness = installImeHarness() - - // Sogou/fcitx Pinyin picks candidates by digit over a Latin preedit (#7543/#8241), and - // delivers the selector as a plain keydown. That must stay IME-owned. - dispatchComposition(harness.element, 'compositionstart', '') - dispatchComposition(harness.element, 'compositionupdate', 'nihao') - dispatchInput(harness.element, 'insertCompositionText', 'nihao') - - expect(harness.tracker.isHangulPreedit()).toBe(false) - expect(suppressesDigit(harness)).toBe(true) - - harness.dispose() + const element = document.createElement('div') + const tracker = installTerminalImeCompositionTracker(element) + const preeditOpen = ibusTrace.dom.findIndex( + (recorded) => recorded.type === 'compositionupdate' && recorded.data === '한' + ) + replayInto(element, ibusTrace.dom.slice(0, preeditOpen + 1)) + + // ibus-hangul's Hanja conversion puts a numbered lookup table over a live preedit. + expect(shouldSuppressTerminalImeKeyboardEvent(digitKeydown, linuxOptions(tracker))).toBe(true) + tracker.dispose() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts deleted file mode 100644 index 2aa8f2a0aff..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createTerminalImeLinuxCandidateState, - installTerminalImeLinuxCandidateState -} from './terminal-ime-linux-candidate-state' -import type { XtermBypassEvent } from './xterm-bypass-policy' - -/** Creates a terminal keyboard event with default modifier state. */ -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} - -describe('createTerminalImeLinuxCandidateState', () => { - it('suppresses the next bare digit after an orphaned plain-letter keyup', () => { - let time = 100 - const state = createTerminalImeLinuxCandidateState(() => time) - - const orphanLetterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) - const orphanClassification = state.classifyKeyboardEvent(orphanLetterKeyup) - expect(orphanClassification.candidateDigitGuardActive).toBe(false) - state.observeKeyboardEvent(orphanLetterKeyup, orphanClassification) - - time += 10 - const digitKeydown = event({ key: '1', code: 'Digit1', keyCode: 49 }) - const digitKeydownClassification = state.classifyKeyboardEvent(digitKeydown) - expect(digitKeydownClassification.candidateDigitGuardActive).toBe(true) - state.observeKeyboardEvent(digitKeydown, digitKeydownClassification) - - const digitKeyup = event({ type: 'keyup', key: '1', code: 'Digit1', keyCode: 49 }) - const digitKeyupClassification = state.classifyKeyboardEvent(digitKeyup) - expect(digitKeyupClassification.candidateDigitGuardActive).toBe(false) - state.observeKeyboardEvent(digitKeyup, digitKeyupClassification) - - time += 10 - const secondDigit = event({ key: '2', code: 'Digit2', keyCode: 50 }) - expect(state.classifyKeyboardEvent(secondDigit).candidateDigitGuardActive).toBe(false) - }) - - it('does not suppress normal letter->digit typing when the letter had a matching keydown', () => { - let time = 100 - const state = createTerminalImeLinuxCandidateState(() => time) - - const letterKeydown = event({ key: 'a', code: 'KeyA', keyCode: 65 }) - const letterKeydownClassification = state.classifyKeyboardEvent(letterKeydown) - expect(letterKeydownClassification.candidateDigitGuardActive).toBe(false) - state.observeKeyboardEvent(letterKeydown, letterKeydownClassification) - - time += 10 - const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) - const letterKeyupClassification = state.classifyKeyboardEvent(letterKeyup) - expect(letterKeyupClassification.candidateDigitGuardActive).toBe(false) - state.observeKeyboardEvent(letterKeyup, letterKeyupClassification) - - time += 10 - const digitKeydown = event({ key: '1', code: 'Digit1', keyCode: 49 }) - expect(state.classifyKeyboardEvent(digitKeydown).candidateDigitGuardActive).toBe(false) - }) - - it('does not mistake a long-held letter release for an orphaned keyup', () => { - let time = 100 - const state = createTerminalImeLinuxCandidateState(() => time) - - const letterKeydown = event({ key: 'a', code: 'KeyA', keyCode: 65 }) - const letterKeydownClassification = state.classifyKeyboardEvent(letterKeydown) - state.observeKeyboardEvent(letterKeydown, letterKeydownClassification) - - time += 2_000 - const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) - const letterKeyupClassification = state.classifyKeyboardEvent(letterKeyup) - state.observeKeyboardEvent(letterKeyup, letterKeyupClassification) - - time += 10 - expect( - state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1', keyCode: 49 })) - .candidateDigitGuardActive - ).toBe(false) - }) - - it('clears a pending physical key when modifiers change before release', () => { - let time = 100 - const state = createTerminalImeLinuxCandidateState(() => time) - const letterKeydown = event({ key: 'a', code: 'KeyA' }) - state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) - const modifiedKeyup = event({ - type: 'keyup', - key: 'A', - code: 'KeyA', - shiftKey: true - }) - state.observeKeyboardEvent(modifiedKeyup, state.classifyKeyboardEvent(modifiedKeyup)) - - time += 10 - const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) - state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) - time += 10 - expect( - state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive - ).toBe(true) - }) - - it('does not mistake a shifted physical letter for an orphan after Shift releases first', () => { - let time = 100 - const state = createTerminalImeLinuxCandidateState(() => time) - for (const keyboardEvent of [ - event({ key: 'A', code: 'KeyA', shiftKey: true }), - event({ type: 'keyup', key: 'Shift', code: 'ShiftLeft' }), - event({ type: 'keyup', key: 'a', code: 'KeyA' }) - ]) { - state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent)) - time += 10 - } - - expect( - state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive - ).toBe(false) - }) - - it('cancels the orphan guard when another non-digit keydown intervenes', () => { - let time = 100 - const state = createTerminalImeLinuxCandidateState(() => time) - for (const keyboardEvent of [ - event({ type: 'keyup', key: 'a', code: 'KeyA' }), - event({ key: 'b', code: 'KeyB' }), - event({ type: 'keyup', key: 'b', code: 'KeyB' }) - ]) { - state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent)) - time += 10 - } - - expect( - state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive - ).toBe(false) - }) - - it('resets missed releases on blur and removes the listener on dispose', () => { - let time = 100 - const terminalElement = new EventTarget() - const removeEventListener = vi.spyOn(terminalElement, 'removeEventListener') - const state = installTerminalImeLinuxCandidateState(terminalElement, () => time) - const letterKeydown = event({ key: 'a', code: 'KeyA' }) - state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) - - terminalElement.dispatchEvent(new Event('blur')) - time += 10 - const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) - state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) - time += 10 - expect( - state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive - ).toBe(true) - - state.dispose() - expect(removeEventListener).toHaveBeenCalledWith('blur', state.resetCandidateGuard, true) - }) - - it('shares pressed letters across pane focus handoffs and disposes the tracker once', () => { - let time = 100 - const rendererWindow = new EventTarget() - const removeWindowListener = vi.spyOn(rendererWindow, 'removeEventListener') - const firstTerminal = new EventTarget() - const secondTerminal = new EventTarget() - const firstState = installTerminalImeLinuxCandidateState( - firstTerminal, - () => time, - rendererWindow - ) - const secondState = installTerminalImeLinuxCandidateState( - secondTerminal, - () => time, - rendererWindow - ) - - const letterKeydown = event({ key: 'a', code: 'KeyA' }) - firstState.observeKeyboardEvent(letterKeydown, firstState.classifyKeyboardEvent(letterKeydown)) - firstTerminal.dispatchEvent(new Event('blur')) - const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) - secondState.observeKeyboardEvent(letterKeyup, secondState.classifyKeyboardEvent(letterKeyup)) - time += 10 - expect( - secondState.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })) - .candidateDigitGuardActive - ).toBe(false) - - firstState.dispose() - expect(removeWindowListener).not.toHaveBeenCalled() - secondState.dispose() - expect(removeWindowListener).toHaveBeenCalledWith('keyup', expect.any(Function)) - }) - - it('clears renderer-wide pressed letters when the window blurs', () => { - let time = 100 - const rendererWindow = new EventTarget() - const state = installTerminalImeLinuxCandidateState( - new EventTarget(), - () => time, - rendererWindow - ) - const letterKeydown = event({ key: 'a', code: 'KeyA' }) - state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) - - rendererWindow.dispatchEvent(new Event('blur')) - const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) - state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) - time += 10 - expect( - state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive - ).toBe(true) - state.dispose() - }) - - it('does not suppress a digit after overlapping ordinary letter key presses', () => { - let time = 100 - const state = createTerminalImeLinuxCandidateState(() => time) - - for (const keyboardEvent of [ - event({ key: 'a', code: 'KeyA', keyCode: 65 }), - event({ key: 'b', code: 'KeyB', keyCode: 66 }), - event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }), - event({ type: 'keyup', key: 'b', code: 'KeyB', keyCode: 66 }) - ]) { - const classification = state.classifyKeyboardEvent(keyboardEvent) - state.observeKeyboardEvent(keyboardEvent, classification) - time += 10 - } - - expect( - state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1', keyCode: 49 })) - .candidateDigitGuardActive - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts deleted file mode 100644 index 42cb90e0e56..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts +++ /dev/null @@ -1,324 +0,0 @@ -import type { XtermBypassEvent } from './xterm-bypass-policy' -import { isTerminalImeCandidateSelectionKeyEvent } from './terminal-ime-candidate-key-release-guard' - -export type TerminalImeLinuxCandidateClassification = { - candidateDigitGuardActive: boolean - /** True for a selector the IME owns because it is still picking a preedit it - * never opened a composition session for. */ - imeOwnedPreeditGuardActive: boolean -} - -type TerminalImeLinuxCandidateState = { - /** Classifies an event before the state observes it. */ - classifyKeyboardEvent: (event: XtermBypassEvent) => TerminalImeLinuxCandidateClassification - /** Advances the state after the caller consumes an event classification. */ - observeKeyboardEvent: ( - event: XtermBypassEvent, - classification: TerminalImeLinuxCandidateClassification - ) => void - /** Drops all candidate and physical-key state. */ - reset: () => void - /** Drops only this pane's candidate window. */ - resetCandidateGuard: () => void - /** Drops only the window armed by IME-claimed letter keydowns. */ - resetImeOwnedPreeditGuard: () => void -} - -type TerminalImeLinuxPhysicalKeyTracker = { - pressedCodes: Set - dispose: () => void -} - -const CANDIDATE_DIGIT_WINDOW_MS = 1500 -// A stuck-state valve, not an attribution window: every normal path out of a -// pick is event-driven — the session's start or end, a non-preedit commit, a -// claimed commit or cancel key, an unclaimed key, or blur. Claimed keys refresh -// it, so this only expires when the IME stops reporting entirely. -const IME_OWNED_PREEDIT_WINDOW_MS = 1500 -const ASCII_LOWERCASE_LETTER = /^[a-z]$/ -const ASCII_DIGIT = /^[0-9]$/ -const PHYSICAL_ASCII_LETTER_CODE = /^Key[A-Z]$/ -// Why these and not the navigation keys: an engine commits or cancels with -// Space, Enter or Escape and pages with the arrows, `-`/`=` and PageUp/PageDown. -// Physical codes rather than `key`, because `key` reads `Process` on all of them -// and because the digit row moves on AZERTY, Dvorak and Colemak while `Digit*` -// does not. -const PHYSICAL_PICK_ENDING_CODE = /^(?:Space|Enter|NumpadEnter|Escape|Digit[0-9]|Numpad[0-9])$/ -const physicalKeyTrackers = new WeakMap< - EventTarget, - TerminalImeLinuxPhysicalKeyTracker & { users: number } ->() - -function acquirePhysicalKeyTracker( - eventTarget: EventTarget | null -): TerminalImeLinuxPhysicalKeyTracker { - if (!eventTarget) { - return { pressedCodes: new Set(), dispose: () => undefined } - } - const existing = physicalKeyTrackers.get(eventTarget) - if (existing) { - existing.users += 1 - return { - pressedCodes: existing.pressedCodes, - dispose: () => releasePhysicalKeyTracker(eventTarget, existing) - } - } - - const pressedCodes = new Set() - const observeKeyboardEvent = (event: Event): void => { - const code = (event as Event & { code?: string }).code - if (!code || !PHYSICAL_ASCII_LETTER_CODE.test(code)) { - return - } - if (event.type === 'keydown') { - pressedCodes.add(code) - } else { - pressedCodes.delete(code) - } - } - const reset = (): void => pressedCodes.clear() - // Why: bubble-phase keyup cleanup runs after xterm's target handler, so the - // pane can still classify that release against the shared pressed-key set. - eventTarget.addEventListener('keydown', observeKeyboardEvent) - eventTarget.addEventListener('keyup', observeKeyboardEvent) - eventTarget.addEventListener('blur', reset) - const tracker = { - pressedCodes, - users: 1, - dispose: () => releasePhysicalKeyTracker(eventTarget, tracker), - observeKeyboardEvent, - reset - } - physicalKeyTrackers.set(eventTarget, tracker) - return tracker -} - -function releasePhysicalKeyTracker( - eventTarget: EventTarget, - tracker: TerminalImeLinuxPhysicalKeyTracker & { - users: number - observeKeyboardEvent?: EventListener - reset?: EventListener - } -): void { - tracker.users -= 1 - if (tracker.users > 0) { - return - } - if (tracker.observeKeyboardEvent && tracker.reset) { - eventTarget.removeEventListener('keydown', tracker.observeKeyboardEvent) - eventTarget.removeEventListener('keyup', tracker.observeKeyboardEvent) - eventTarget.removeEventListener('blur', tracker.reset) - } - tracker.pressedCodes.clear() - physicalKeyTrackers.delete(eventTarget) -} - -/** - * A keydown the input framework has already claimed. - * - * Why this is the signal: Sogou on fcitx draws its preedit and candidate list in - * its own window and opens no Chromium composition session at all, so every - * composition-scoped guard is inactive when the user presses the selector. What - * it does still deliver is the claimed keydown — `keyCode 229` / `key 'Process'` - * over the original physical key — and that is positive evidence that some - * preedit the renderer cannot see is open. fcitx5 on Wayland omits the marker - * instead, which is why this complements the orphan-keyup window rather than - * replacing it. - */ -function isImeClaimedKeydown(event: XtermBypassEvent): boolean { - return ( - event.type === 'keydown' && - (event.keyCode === 229 || event.key === 'Process') && - !event.ctrlKey && - !event.altKey && - !event.metaKey - ) -} - -/** A claimed keydown over a letter, which is a preedit being spelled out. */ -function isImeOwnedLetterKeydown(event: XtermBypassEvent): boolean { - return ( - isImeClaimedKeydown(event) && - event.code !== undefined && - PHYSICAL_ASCII_LETTER_CODE.test(event.code) - ) -} - -/** Returns whether an event is an unmodified lowercase Latin letter. */ -function isPlainAsciiLetterKey(event: XtermBypassEvent): boolean { - return ( - ASCII_LOWERCASE_LETTER.test(event.key) && - !event.ctrlKey && - !event.altKey && - !event.metaKey && - !event.shiftKey - ) -} - -/** Returns whether an event is an unmodified ASCII digit. */ -function isPlainAsciiDigitKey(event: XtermBypassEvent): boolean { - return ( - ASCII_DIGIT.test(event.key) && - !event.ctrlKey && - !event.altKey && - !event.metaKey && - !event.shiftKey - ) -} - -/** Tracks legacy desktop Linux IME candidate-selection event sequences. */ -export function createTerminalImeLinuxCandidateState( - now: () => number = () => Date.now(), - pendingPlainLetterKeydownsByCode = new Set() -): TerminalImeLinuxCandidateState { - let candidateDigitUntil = 0 - let imeOwnedPreeditUntil = 0 - - const resetCandidateGuard = (): void => { - candidateDigitUntil = 0 - imeOwnedPreeditUntil = 0 - } - - const reset = (): void => { - pendingPlainLetterKeydownsByCode.clear() - resetCandidateGuard() - } - - return { - reset, - resetCandidateGuard, - resetImeOwnedPreeditGuard: () => { - imeOwnedPreeditUntil = 0 - }, - /** Classifies the current event before state is advanced for it. */ - classifyKeyboardEvent: (event) => { - const at = now() - return { - candidateDigitGuardActive: - event.type === 'keydown' && isPlainAsciiDigitKey(event) && candidateDigitUntil > at, - imeOwnedPreeditGuardActive: - event.type === 'keydown' && - isTerminalImeCandidateSelectionKeyEvent(event) && - imeOwnedPreeditUntil > at - } - }, - /** Records the current event after its classification is consumed. */ - observeKeyboardEvent: (event, classification) => { - const at = now() - if (imeOwnedPreeditUntil <= at) { - imeOwnedPreeditUntil = 0 - } - if (classification.imeOwnedPreeditGuardActive) { - // One selector per claimed preedit: the next letter the IME claims arms - // the window again, so a stale window cannot eat later literal input. - imeOwnedPreeditUntil = 0 - candidateDigitUntil = 0 - return - } - if (event.type === 'keydown') { - if (isImeOwnedLetterKeydown(event)) { - imeOwnedPreeditUntil = at + IME_OWNED_PREEDIT_WINDOW_MS - } else if (isImeClaimedKeydown(event)) { - // A claimed commit or cancel means the engine ended the round itself. - // Ending the window here is what keeps a literal Space safe when an - // engine opens a composition session and never closes it. - imeOwnedPreeditUntil = - event.code !== undefined && PHYSICAL_PICK_ENDING_CODE.test(event.code) - ? 0 - : // Why refresh rather than arm: the IME also claims the keys that - // edit and page the preedit — Backspace, the arrows, `-`/`=` for - // the next candidate page. Treating those as unclaimed disarmed - // the window mid-pick and let the following selector through, - // but claiming them from cold would arm on a bare navigation key - // with no preedit behind it. - imeOwnedPreeditUntil > at - ? at + IME_OWNED_PREEDIT_WINDOW_MS - : imeOwnedPreeditUntil - } else if (!isTerminalImeCandidateSelectionKeyEvent(event)) { - imeOwnedPreeditUntil = 0 - } - } - if (classification.candidateDigitGuardActive) { - candidateDigitUntil = 0 - return - } - - if (candidateDigitUntil <= at) { - candidateDigitUntil = 0 - } - - if (event.type === 'keydown') { - if (!isPlainAsciiDigitKey(event)) { - candidateDigitUntil = 0 - } - const physicalCode = event.code - if (physicalCode && PHYSICAL_ASCII_LETTER_CODE.test(physicalCode)) { - pendingPlainLetterKeydownsByCode.add(physicalCode) - } - return - } - - if (event.type === 'keyup') { - const matchingPlainLetterKeydown = event.code - ? pendingPlainLetterKeydownsByCode.delete(event.code) - : false - if (isPlainAsciiLetterKey(event) && event.code) { - if (!matchingPlainLetterKeydown) { - // Why: some legacy Linux IME paths commit a single-letter preedit - // without composition/input events. The orphaned keyup is a narrow - // hint that the next bare digit belongs to the candidate picker. - candidateDigitUntil = at + CANDIDATE_DIGIT_WINDOW_MS - } - } - } - } - } -} - -/** Installs Linux IME candidate state with focus-loss cleanup. */ -export function installTerminalImeLinuxCandidateState( - terminalElement: EventTarget | null | undefined, - now: () => number = () => Date.now(), - rendererKeyboardEventTarget: EventTarget | null = typeof window === 'undefined' - ? (terminalElement ?? null) - : window -): TerminalImeLinuxCandidateState & { dispose: () => void } { - const physicalKeyTracker = acquirePhysicalKeyTracker(rendererKeyboardEventTarget) - const state = createTerminalImeLinuxCandidateState(now, physicalKeyTracker.pressedCodes) - // Why: an input source that does open a composition session is already owned - // by the composition-scoped guards, so the claimed-keydown window must stand - // down at both boundaries rather than claim the same selector twice. - const releaseToCompositionSession = (): void => state.resetImeOwnedPreeditGuard() - // Why: a commit that is not preedit text means the picking round is over and - // the next Space or digit is literal terminal input again. A session-running - // IME commits through `insertCompositionText` instead and is released by - // `compositionend` below, so this stays narrow rather than matching every - // input event. - const releaseOnCommit = (event: Event): void => { - if (event instanceof InputEvent && event.inputType === 'insertCompositionText') { - return - } - state.resetImeOwnedPreeditGuard() - } - terminalElement?.addEventListener('blur', state.resetCandidateGuard, true) - terminalElement?.addEventListener('compositionstart', releaseToCompositionSession, true) - // Why both ends of the session: the claimed keydowns an engine that DOES run a - // composition session emits still arm this window, and its commit and its - // cancel both travel as `insertCompositionText`, which `releaseOnCommit` - // deliberately ignores. Without this the window outlived a cancelled preedit - // and swallowed the next literal Space. The engines this guard exists for emit - // no `compositionend` at all, so releasing here cannot reach them. - terminalElement?.addEventListener('compositionend', releaseToCompositionSession, true) - terminalElement?.addEventListener('input', releaseOnCommit, true) - return { - ...state, - dispose: () => { - terminalElement?.removeEventListener('blur', state.resetCandidateGuard, true) - terminalElement?.removeEventListener('compositionstart', releaseToCompositionSession, true) - terminalElement?.removeEventListener('compositionend', releaseToCompositionSession, true) - terminalElement?.removeEventListener('input', releaseOnCommit, true) - physicalKeyTracker.dispose() - } - } -} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-release.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-release.test.ts deleted file mode 100644 index 9a982d7a709..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-release.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it } from 'vitest' -import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' -import type { XtermBypassEvent } from './xterm-bypass-policy' - -/** - * The sibling selector suite drives the state object directly, so it cannot see a - * missing listener. These drive the installed DOM wiring. - * - * `compositionend` matters on its own: an engine that DOES run a composition - * session still emits the claimed `keyCode 229` keydowns that arm this window, - * and both its commit and its cancel travel as `insertCompositionText`, which the - * commit release deliberately ignores. Without a release at the session's end the - * window outlived a cancelled preedit and swallowed the next literal Space. - */ -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} - -function installOnElement(): { - element: HTMLElement - state: ReturnType - advance: (ms: number) => void -} { - const element = document.createElement('div') - let time = 1_000 - const state = installTerminalImeLinuxCandidateState(element, () => time, element) - return { element, state, advance: (ms) => void (time += ms) } -} - -function armWithClaimedLetter( - state: ReturnType -): void { - const keydown = event({ key: 'Process', code: 'KeyN', keyCode: 229 }) - state.observeKeyboardEvent(keydown, state.classifyKeyboardEvent(keydown)) -} - -function claimsSpace(state: ReturnType): boolean { - return state.classifyKeyboardEvent(event({ key: ' ', code: 'Space', keyCode: 32 })) - .imeOwnedPreeditGuardActive -} - -describe('claimed-preedit window release wiring', () => { - it.each(['compositionstart', 'compositionend'])('releases the window on %s', (eventType) => { - const { element, state, advance } = installOnElement() - armWithClaimedLetter(state) - advance(40) - expect(claimsSpace(state)).toBe(true) - element.dispatchEvent(new CompositionEvent(eventType, { data: '你' })) - expect(claimsSpace(state)).toBe(false) - state.dispose() - }) - - it('releases the window on a commit that is not preedit text', () => { - const { element, state, advance } = installOnElement() - armWithClaimedLetter(state) - advance(40) - element.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: '你' })) - expect(claimsSpace(state)).toBe(false) - state.dispose() - }) - - it('keeps the window across a preedit-text input event', () => { - const { element, state, advance } = installOnElement() - armWithClaimedLetter(state) - advance(40) - element.dispatchEvent( - new InputEvent('input', { inputType: 'insertCompositionText', data: 'ni' }) - ) - expect(claimsSpace(state)).toBe(true) - state.dispose() - }) - - it('stops listening once disposed', () => { - const { element, state, advance } = installOnElement() - state.dispose() - armWithClaimedLetter(state) - advance(40) - element.dispatchEvent(new CompositionEvent('compositionend', { data: '你' })) - expect(claimsSpace(state)).toBe(true) - }) -}) - -/** - * The full session shape for a Pinyin/Mozc/Anthy/Zhuyin engine that commits on - * Space: every letter after the first re-arms the window, the commit travels as - * preedit text, and the committing Space itself arrives claimed. Only the end of - * the session can close the window before the user's next literal Space. - */ -describe('a composition session that commits on Space', () => { - it('leaves the following literal Space alone', () => { - const { element, state, advance } = installOnElement() - const feed = (keyboardEvent: XtermBypassEvent): boolean => { - const classification = state.classifyKeyboardEvent(keyboardEvent) - state.observeKeyboardEvent(keyboardEvent, classification) - return classification.imeOwnedPreeditGuardActive - } - - feed(event({ key: 'Process', code: 'KeyN', keyCode: 229 })) - element.dispatchEvent(new CompositionEvent('compositionstart', { data: '' })) - advance(40) - // A second claimed letter re-arms; there is no second compositionstart. - feed(event({ key: 'Process', code: 'KeyI', keyCode: 229 })) - element.dispatchEvent( - new InputEvent('input', { inputType: 'insertCompositionText', data: 'ni' }) - ) - advance(40) - - // The committing Space is claimed by the engine, so it never reaches the guard. - expect(feed(event({ key: 'Process', code: 'Space', keyCode: 229 }))).toBe(false) - element.dispatchEvent( - new InputEvent('input', { inputType: 'insertCompositionText', data: '你' }) - ) - element.dispatchEvent(new CompositionEvent('compositionend', { data: '你' })) - advance(40) - - expect(claimsSpace(state)).toBe(false) - state.dispose() - }) -}) - -/** - * Belt and braces for an engine that opens a composition session and never closes - * it — a documented defect in at least one shipped input framework. Without a - * `compositionend` the session-end release never runs, so the claimed selector - * itself has to end the window. - */ -describe('a composition session that never ends', () => { - it.each(['Space', 'Enter', 'NumpadEnter', 'Escape', 'Digit2', 'Numpad2'])( - 'still frees the next literal Space after a claimed %s', - (selectorCode) => { - const { element, state, advance } = installOnElement() - const feed = (keyboardEvent: XtermBypassEvent): void => { - state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent)) - } - - feed(event({ key: 'Process', code: 'KeyN', keyCode: 229 })) - element.dispatchEvent(new CompositionEvent('compositionstart', { data: '' })) - advance(40) - feed(event({ key: 'Process', code: 'KeyI', keyCode: 229 })) - element.dispatchEvent( - new InputEvent('input', { inputType: 'insertCompositionText', data: 'ni' }) - ) - advance(40) - feed(event({ key: 'Process', code: selectorCode, keyCode: 229 })) - element.dispatchEvent( - new InputEvent('input', { inputType: 'insertCompositionText', data: '你' }) - ) - // No compositionend arrives. - advance(40) - - expect(claimsSpace(state)).toBe(false) - state.dispose() - } - ) - - it('still lets the IME page its candidate list', () => { - const { element, state, advance } = installOnElement() - const feed = (keyboardEvent: XtermBypassEvent): void => { - state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent)) - } - feed(event({ key: 'Process', code: 'KeyN', keyCode: 229 })) - advance(40) - feed(event({ key: 'Process', code: 'Equal', keyCode: 229 })) - advance(40) - expect(claimsSpace(state)).toBe(true) - state.dispose() - element.remove() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-selector.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-selector.test.ts deleted file mode 100644 index ec23c4a2b00..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-selector.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' -import { - shouldPreventDefaultTerminalImeCandidateKey, - shouldSuppressTerminalImeKeyboardEvent, - type XtermBypassEvent, - type XtermImeKeyboardOptions -} from './xterm-bypass-policy' - -/** - * Sogou on fcitx draws its preedit and candidate list in its own window and opens no Chromium - * composition session, so every composition-scoped guard is idle when the user presses Space or a - * candidate digit. The selector then reached the PTY as literal text while the commit arrived - * afterwards — a space or a digit in front of every phrase (#22442). - */ -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} - -/** The keydown shape fcitx delivers for a letter it has claimed for a preedit. */ -function claimedLetterKeydown(code: string): XtermBypassEvent { - return event({ key: 'Process', code, keyCode: 229 }) -} - -function linuxOptions(overrides: Partial = {}): XtermImeKeyboardOptions { - return { - compositionActive: false, - candidateKeyGuardActive: false, - pendingCandidateKeyReleaseActive: false, - isMac: false, - isLinux: true, - ...overrides - } -} - -/** Feeds the state one event and returns its classification. */ -function observe( - state: ReturnType, - keyboardEvent: XtermBypassEvent -): { imeOwnedPreeditGuardActive: boolean; candidateDigitGuardActive: boolean } { - const classification = state.classifyKeyboardEvent(keyboardEvent) - state.observeKeyboardEvent(keyboardEvent, classification) - return classification -} - -describe('claimed-preedit candidate selectors on Linux', () => { - it('claims Space and a candidate digit after the IME claims the letter keydowns', () => { - for (const selector of [ - event({ key: ' ', code: 'Space', keyCode: 32 }), - event({ key: '3', code: 'Digit3', keyCode: 51 }) - ]) { - let time = 1_000 - const state = createTerminalImeLinuxCandidateState(() => time) - observe(state, claimedLetterKeydown('KeyN')) - observe(state, event({ type: 'keyup', key: 'n', code: 'KeyN', keyCode: 78 })) - time += 40 - - const classification = state.classifyKeyboardEvent(selector) - expect(classification.imeOwnedPreeditGuardActive).toBe(true) - - const options = linuxOptions({ linuxImeOwnedPreeditGuardActive: true }) - expect(shouldSuppressTerminalImeKeyboardEvent(selector, options)).toBe(true) - // Returning false from the custom handler does not preventDefault, so the - // selector would still reach the helper textarea and be flushed later. - expect(shouldPreventDefaultTerminalImeCandidateKey(selector, options)).toBe(true) - } - }) - - it('claims only one selector per claimed preedit', () => { - let time = 1_000 - const state = createTerminalImeLinuxCandidateState(() => time) - observe(state, claimedLetterKeydown('KeyN')) - time += 40 - expect( - observe(state, event({ key: ' ', code: 'Space', keyCode: 32 })).imeOwnedPreeditGuardActive - ).toBe(true) - time += 40 - expect( - state.classifyKeyboardEvent(event({ key: ' ', code: 'Space', keyCode: 32 })) - .imeOwnedPreeditGuardActive - ).toBe(false) - }) - - // Chromium preserves the original `code` on the 229/Process keydown the IME - // consumed, so the keys that edit and page a preedit arrive claimed too. - it.each([ - ['Backspace', 'Backspace'], - ['candidate navigation', 'ArrowDown'], - ['next candidate page', 'Equal'], - ['previous candidate page', 'Minus'], - ['page down', 'PageDown'] - ])('keeps the window open across a claimed %s keydown', (_label, code) => { - let time = 1_000 - const state = createTerminalImeLinuxCandidateState(() => time) - observe(state, claimedLetterKeydown('KeyN')) - time += 40 - observe(state, event({ key: 'Process', code, keyCode: 229 })) - time += 40 - expect( - state.classifyKeyboardEvent(event({ key: '2', code: 'Digit2', keyCode: 50 })) - .imeOwnedPreeditGuardActive - ).toBe(true) - }) - - it('refreshes the deadline while the IME keeps claiming keys, without arming from cold', () => { - let time = 1_000 - const state = createTerminalImeLinuxCandidateState(() => time) - observe(state, claimedLetterKeydown('KeyN')) - // Browsing candidate pages for longer than one window still picks correctly. - for (let page = 0; page < 3; page += 1) { - time += 1_000 - observe(state, event({ key: 'Process', code: 'Equal', keyCode: 229 })) - } - time += 40 - expect( - state.classifyKeyboardEvent(event({ key: '2', code: 'Digit2', keyCode: 50 })) - .imeOwnedPreeditGuardActive - ).toBe(true) - - // A claimed navigation key on its own is not evidence of an open preedit. - const cold = createTerminalImeLinuxCandidateState(() => time) - observe(cold, event({ key: 'Process', code: 'ArrowDown', keyCode: 229 })) - time += 40 - expect( - cold.classifyKeyboardEvent(event({ key: ' ', code: 'Space', keyCode: 32 })) - .imeOwnedPreeditGuardActive - ).toBe(false) - }) - - it('leaves ordinary Latin typing alone', () => { - let time = 1_000 - const state = createTerminalImeLinuxCandidateState(() => time) - for (const [key, code, keyCode] of [ - ['l', 'KeyL', 76], - ['s', 'KeyS', 83] - ] as const) { - observe(state, event({ key, code, keyCode })) - observe(state, event({ type: 'keyup', key, code, keyCode })) - time += 20 - } - for (const selector of [ - event({ key: ' ', code: 'Space', keyCode: 32 }), - event({ key: '7', code: 'Digit7', keyCode: 55 }) - ]) { - expect(state.classifyKeyboardEvent(selector).imeOwnedPreeditGuardActive).toBe(false) - } - }) - - it('releases the window once the picking round is over', () => { - let time = 1_000 - const state = createTerminalImeLinuxCandidateState(() => time) - observe(state, claimedLetterKeydown('KeyM')) - state.resetImeOwnedPreeditGuard() - time += 40 - expect( - state.classifyKeyboardEvent(event({ key: ' ', code: 'Space', keyCode: 32 })) - .imeOwnedPreeditGuardActive - ).toBe(false) - }) - - it('expires the window rather than holding Space hostage', () => { - let time = 1_000 - const state = createTerminalImeLinuxCandidateState(() => time) - observe(state, claimedLetterKeydown('KeyM')) - time += 1_600 - expect( - state.classifyKeyboardEvent(event({ key: ' ', code: 'Space', keyCode: 32 })) - .imeOwnedPreeditGuardActive - ).toBe(false) - }) - - it('leaves a Hangul syllable its terminating Space and digit', () => { - const options = linuxOptions({ - linuxImeOwnedPreeditGuardActive: true, - hangulPreedit: true - }) - for (const selector of [ - event({ key: ' ', code: 'Space', keyCode: 32 }), - event({ key: '3', code: 'Digit3', keyCode: 51 }) - ]) { - expect(shouldSuppressTerminalImeKeyboardEvent(selector, options)).toBe(false) - } - }) - - it('does not claim modified or non-Linux selectors', () => { - const selector = event({ key: ' ', code: 'Space', keyCode: 32, ctrlKey: true }) - expect( - shouldSuppressTerminalImeKeyboardEvent( - selector, - linuxOptions({ linuxImeOwnedPreeditGuardActive: true }) - ) - ).toBe(false) - const plainSpace = event({ key: ' ', code: 'Space', keyCode: 32 }) - expect( - shouldSuppressTerminalImeKeyboardEvent( - plainSpace, - linuxOptions({ isLinux: false, isMac: true, linuxImeOwnedPreeditGuardActive: true }) - ) - ).toBe(false) - }) - - it('does not arm from a control chord the IME never claimed', () => { - let time = 1_000 - const state = createTerminalImeLinuxCandidateState(() => time) - observe(state, event({ key: 'Process', code: 'KeyN', keyCode: 229, ctrlKey: true })) - time += 40 - expect( - state.classifyKeyboardEvent(event({ key: ' ', code: 'Space', keyCode: 32 })) - .imeOwnedPreeditGuardActive - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-fcitx4-trace-replay.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-fcitx4-trace-replay.test.ts new file mode 100644 index 00000000000..16baac1208d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-linux-fcitx4-trace-replay.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom +// Replays real fcitx4 captures (#28213) through the pane's full keyboard stack and a real xterm. +// Recorded in an Ubuntu 22.04 container on Xvfb with Electron 43.7.5 on X11, keys driven by +// xdotool. Sogou draws its preedit in its own window: the letters arrive as keyups only, and a +// single-character pick is a plain Space/digit keydown whose keypress and insertText carry the +// candidate. Google Pinyin with an in-client preedit runs a composition session instead and is +// kept as the regression check for that path. +import { describe, expect, it } from 'vitest' +import sogouGtkTrace from './__fixtures__/fcitx4-sogou-gtk-im-module-trace.json' +import sogouXimTrace from './__fixtures__/fcitx4-sogou-xim-trace.json' +import googlePinyinTrace from './__fixtures__/fcitx4-googlepinyin-client-preedit-trace.json' +import { + replayThroughLinuxPane, + useLinuxPaneTestEnvironment, + type RecordedDomEvent +} from './terminal-ime-linux-pane-replay-fixture' + +type RecordedTrace = { events: (RecordedDomEvent & { scenario: string })[] } + +const EXPECTED_BY_SCENARIO = { + s1_ni_space: '你', + s2_hao_3: '浩', + s3_nihao_space: '你好', + s4_ascii_im_off: 'ls 7' +} + +const TRACES: [string, RecordedTrace][] = [ + ['Sogou via the fcitx GTK module', sogouGtkTrace], + ['Sogou via XIM', sogouXimTrace], + ['Google Pinyin with an in-client preedit', googlePinyinTrace] +] + +describe.each(TRACES)('fcitx4 %s', (_label, trace) => { + useLinuxPaneTestEnvironment() + + // Why the kitty pushes: Claude Code negotiates flags 1 or 5, and xterm's kitty encoder sends a + // printable key from its keydown and cancels it, so the keypress carrying the pick never fires. + describe.each([ + ['legacy keyboard', null], + ['kitty flags 1', '\x1b[>1u'], + ['kitty flags 5', '\x1b[>5u'] + ])('%s', (_mode, kittyPush) => { + it.each(Object.entries(EXPECTED_BY_SCENARIO))( + '%s sends exactly the committed text', + async (scenario, expected) => { + const events = trace.events.filter((event) => event.scenario === scenario) + const { emitted } = await replayThroughLinuxPane(events, kittyPush) + expect(emitted).toBe(expected) + } + ) + }) +}) + +// A kitty release report ends in `;:3u`; no modifiers are held in these recordings. +const KITTY_RELEASE_SUFFIX = ';1:3u' + +describe('fcitx4 Sogou via XIM under kitty event reporting', () => { + useLinuxPaneTestEnvironment() + + // Why: the XIM pick keydown carries `code: ''` while its keyup is the real key, so the unpaired + // keyup reached xterm and the next pick settled the stale claim too — two releases per pick. + it('reports one release per pick', async () => { + const events = sogouXimTrace.events.filter( + (event) => event.scenario === 's1_ni_space' || event.scenario === 's2_hao_3' + ) + const { emitted } = await replayThroughLinuxPane(events, '\x1b[>3u') + const releasesAfter = (glyph: string, until: number): number => + emitted.slice(emitted.indexOf(glyph) + 1, until).split(KITTY_RELEASE_SUFFIX).length - 1 + // The releases between the picks are the first pick's Space plus the second round's letters. + expect(releasesAfter('你', emitted.indexOf('浩'))).toBe(1 + 'hao'.length) + expect(releasesAfter('浩', emitted.length)).toBe(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-pane-replay-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-pane-replay-fixture.ts new file mode 100644 index 00000000000..fb4083b9585 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-linux-pane-replay-fixture.ts @@ -0,0 +1,153 @@ +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, vi } from 'vitest' +import type { IDisposable } from '@xterm/xterm' +import { installTerminalPaneInputHandling } from './terminal-pane-pane-input' +import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' + +const LINUX_UA = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Orca Chrome/146.0.0.0 Electron/43.7.5 Safari/537.36' + +/** One DOM event as recorded off the terminal's helper textarea. */ +export type RecordedDomEvent = { + type: string + key?: string + code?: string + keyCode?: number + charCode?: number + isComposing?: boolean + inputType?: string + data?: string + value?: string +} + +export type LinuxPaneReplayResult = { + emitted: string + /** `code` of every keydown the pane cancelled, in order. */ + preventedKeydownCodes: string[] +} + +const disposables: IDisposable[] = [] + +/** Pins a desktop Linux UA, stubs the canvas xterm measures with, and tears panes down. */ +export function useLinuxPaneTestEnvironment(): void { + let originalUserAgent: PropertyDescriptor | undefined + + beforeEach(() => { + originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent') + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: LINUX_UA }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm only calls measureText while measuring cells in happy-dom. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + for (const disposable of disposables.splice(0)) { + disposable.dispose() + } + if (originalUserAgent) { + Object.defineProperty(navigator, 'userAgent', originalUserAgent) + } else { + Reflect.deleteProperty(navigator, 'userAgent') + } + vi.restoreAllMocks() + document.body.replaceChildren() + }) +} + +function nextEventLoop(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)) +} + +export function buildRecordedDomEvent(recorded: RecordedDomEvent): Event { + if (recorded.type === 'keydown' || recorded.type === 'keyup' || recorded.type === 'keypress') { + const keyboard = new KeyboardEvent(recorded.type, { + key: recorded.key, + code: recorded.code, + isComposing: recorded.isComposing, + bubbles: true, + cancelable: true + }) + // happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm reads them. + Object.defineProperty(keyboard, 'keyCode', { value: recorded.keyCode ?? 0 }) + Object.defineProperty(keyboard, 'charCode', { value: recorded.charCode ?? 0 }) + Object.defineProperty(keyboard, 'which', { value: recorded.charCode || recorded.keyCode || 0 }) + return keyboard + } + if (recorded.type === 'input' || recorded.type === 'beforeinput') { + const input = new InputEvent(recorded.type, { + isComposing: recorded.isComposing, + bubbles: true, + cancelable: recorded.type === 'beforeinput' + }) + Object.defineProperty(input, 'inputType', { value: recorded.inputType ?? '' }) + Object.defineProperty(input, 'data', { value: recorded.data ?? null }) + Object.defineProperty(input, 'composed', { value: true }) + return input + } + const composition = new CompositionEvent(recorded.type, { bubbles: true }) + Object.defineProperty(composition, 'data', { value: recorded.data ?? '' }) + return composition +} + +/** + * Replays recorded events through a real xterm wired with the pane's full keyboard stack. + * `kittyPush` is written as PTY output first, the way a TUI negotiates the protocol. + */ +export async function replayThroughLinuxPane( + events: readonly RecordedDomEvent[], + kittyPush: string | null = null +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + // Matches the pane defaults that advertise kitty support (pane-terminal-options.ts). + const terminal = new Terminal({ cols: 40, rows: 8, vtExtensions: { kittyKeyboard: true } }) + disposables.push(terminal) + terminal.open(container) + const kittyModes = new TerminalKittyKeyboardModeTracker() + installTerminalPaneInputHandling({ + pane: { id: 1, terminal }, + managerRef: { current: null }, + paneKittyKeyboardModesRef: { current: new Map([[1, kittyModes]]) }, + settingsRef: { current: {} }, + imeCompositionDisposablesRef: { current: new Map() }, + imeNativeTextForwarderDisposablesRef: { current: new Map() } + }) + if (kittyPush) { + kittyModes.scan(kittyPush) + await new Promise((resolve) => terminal.write(kittyPush, resolve)) + } + const emitted: string[] = [] + disposables.push(terminal.onData((data) => emitted.push(data))) + const textarea = terminal.textarea! + const preventedKeydownCodes: string[] = [] + let keystrokeCancelled = false + for (const recorded of events) { + if (recorded.type === 'keydown' || recorded.type === 'keyup') { + await nextEventLoop() + } + // Why: Chromium skips a keydown's keypress and text insertion once it is preventDefault-ed. + if ( + keystrokeCancelled && + (recorded.type === 'keypress' || recorded.type === 'beforeinput' || recorded.type === 'input') + ) { + continue + } + if (recorded.value !== undefined) { + textarea.value = recorded.value + } + const dispatched = buildRecordedDomEvent(recorded) + textarea.dispatchEvent(dispatched) + if (recorded.type === 'keydown') { + keystrokeCancelled = dispatched.defaultPrevented + if (dispatched.defaultPrevented) { + preventedKeydownCodes.push(recorded.code ?? '') + } + } else if (recorded.type === 'keyup') { + keystrokeCancelled = false + } + } + await nextEventLoop() + await nextEventLoop() + return { emitted: emitted.join(''), preventedKeydownCodes } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts index 8ef896b8e45..7204c730e9e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts @@ -13,6 +13,8 @@ import { getLayoutCharacterForCode } from '../../lib/keyboard-layout/layout-base // actually produced (`,` for `,`, `、` for `\`, `——` for a single press). // Xterm would otherwise send the raw layout character from the keydown and then // preventDefault, destroying the committed text before Chromium can deliver it. +// Linux fcitx4 has the same shape: a single-character candidate pick is a plain +// Space/digit keydown whose insertText is the candidate (#28213). // // The claim is structural, so it holds for input sources that do not exist yet: // no input-source identity is read, and `key` is only ever measured for length. @@ -135,7 +137,7 @@ function matchesClaimedPress( /** Different held keys coexist, so records are keyed by physical identity. */ function claimedKeyId(press: { key: string; code?: string }): string { - return press.code ?? press.key + return press.code || press.key } export function installTerminalImeNativeTextForwarder(args: { @@ -174,6 +176,15 @@ export function installTerminalImeNativeTextForwarder(args: { return id } } + if (event.type === 'keyup') { + // XIM's pick keydown carries `code: ''` and key NUL while its keyup is the real key, so + // only elimination can pair them (#28213). + for (const [id, record] of claimedKeyRecords) { + if (!record.code) { + return id + } + } + } return null } @@ -320,13 +331,13 @@ export function installTerminalImeNativeTextForwarder(args: { if (!(event instanceof InputEvent)) { return } - // Why: an accepted composition transaction already owns its commit; letting - // it through here would send the text a second time. - if (compositionTransactionPending && event.inputType === 'insertText') { - if (pendingCommit) { - settleCommit(pendingCommit, null) - pendingCommit = null - } + // Why: an accepted composition transaction already owns its commit, so a + // claimed press must not send that text a second time. Unclaimed input stays + // with xterm, which dedupes its own echo — stopping it dropped a Linux + // `Process` commit that landed before the transaction settled. + if (compositionTransactionPending && event.inputType === 'insertText' && pendingCommit) { + settleCommit(pendingCommit, null) + pendingCommit = null event.stopImmediatePropagation() return } diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts index 7bf337c196b..d71c6365c60 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts @@ -95,7 +95,6 @@ export function openIosTerminal( compositionActive: tracker.isActive(), candidateKeyGuardActive: tracker.isCandidateKeyGuardActive(), pendingCandidateKeyReleaseActive: false, - hangulPreedit: tracker.isHangulPreedit(), isMac: true, isLinux: false }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts index 32ee2639029..cce0788df68 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts @@ -9,7 +9,6 @@ import { } from './terminal-ime-candidate-key-release-guard' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' import { installTerminalImeComposerPlaceholderMask } from './terminal-ime-composer-placeholder-mask' -import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder' import { installTerminalIosHangulPreedit } from './terminal-ios-hangul-preedit' import { createTerminalIosHangulPreeditRenderer } from './terminal-ios-hangul-preedit-overlay' @@ -58,9 +57,6 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi const isLinux = !isMac && navigator.userAgent.includes('Linux') && !/Android|CrOS/.test(navigator.userAgent) const isIosWeb = isCurrentPlatformIosWeb() - const linuxImeCandidateState = isLinux - ? installTerminalImeLinuxCandidateState(pane.terminal.element) - : null const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element) const imeComposerPlaceholderMask = installTerminalImeComposerPlaceholderMask(pane.terminal) const iosHangulPreedit = isIosWeb @@ -76,12 +72,12 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi dispose: () => { imeComposerPlaceholderMask.dispose() imeCompositionTracker.dispose() - linuxImeCandidateState?.dispose() iosHangulPreedit?.dispose() } }) + // Why Linux too: fcitx4 commits a candidate pick as the Space/digit keydown's insertText (#28213). const imeNativeTextForwarder = - isMac && !isIosWeb + (isMac || isLinux) && !isIosWeb ? installTerminalImeNativeTextForwarder({ terminalElement: pane.terminal.element, isComposing: () => imeCompositionTracker.isActive(), @@ -92,13 +88,6 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((event) => { - const linuxCandidateClassification = linuxImeCandidateState?.classifyKeyboardEvent(event) ?? { - candidateDigitGuardActive: false, - imeOwnedPreeditGuardActive: false - } - const observeLinuxCandidateEvent = (): void => { - linuxImeCandidateState?.observeKeyboardEvent(event, linuxCandidateClassification) - } const now = Date.now() const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( event, @@ -110,9 +99,6 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi candidateKeyGuardActive: imeCompositionTracker.isCandidateKeyGuardActive() || pendingCandidateReleaseGuardActive, pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive, - linuxOrphanCandidateDigitGuardActive: linuxCandidateClassification.candidateDigitGuardActive, - linuxImeOwnedPreeditGuardActive: linuxCandidateClassification.imeOwnedPreeditGuardActive, - hangulPreedit: imeCompositionTracker.isHangulPreedit(), isMac, isLinux } @@ -122,13 +108,11 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi event.preventDefault() armTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, event, now) } - observeLinuxCandidateEvent() return false } clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, event) if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(event)) { pendingTerminalInterruptKeyup = false - observeLinuxCandidateEvent() return false } if ( @@ -143,23 +127,19 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi } else { pendingTerminalInterruptKeyup = false } - observeLinuxCandidateEvent() return false } if (isNonLatinControlChordKeyup(event, claimedNonLatinControlChordCode)) { claimedNonLatinControlChordCode = null - observeLinuxCandidateEvent() return false } const nonLatinControlChord = resolveNonLatinControlChordInput(event) if (nonLatinControlChord) { claimedNonLatinControlChordCode = event.code pane.terminal.input(nonLatinControlChord) - observeLinuxCandidateEvent() return false } if (shouldSuppressTerminalModifierKeyboardEvent(event)) { - observeLinuxCandidateEvent() return false } const jisYenInput = resolveTerminalJisYenInput(event, { @@ -170,7 +150,6 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi if (jisYenInput.type === 'input') { pane.terminal.input(jisYenInput.data) } - observeLinuxCandidateEvent() return false } if (event.type === 'keydown') { @@ -188,7 +167,6 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi } } if (imeNativeTextForwarder.claimKeyEvent(event)) { - observeLinuxCandidateEvent() return false } const shouldBypass = shouldBypassXtermKeyboardEvent(event, { @@ -197,7 +175,6 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi hasSelection: pane.terminal.hasSelection(), kittyKeyboardFlags: paneKittyKeyboardModesRef.current.get(pane.id)?.flags ?? 0 }) - observeLinuxCandidateEvent() return !shouldBypass }) } diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index 2124ce1ad05..a8b5069798b 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -172,10 +172,6 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { // Post-compositionend guard: the tracker is already inactive but the // committing key's trailing press/release must still be absorbed. const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true } - const linuxOrphanCandidateDigitGuard = { - ...linuxIdle, - linuxOrphanCandidateDigitGuardActive: true - } const windowsComposing = { ...windowsIdle, compositionActive: true, @@ -324,21 +320,6 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { } }) - it('guards only digits for the orphaned-keyup fallback', () => { - expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ key: '2', code: 'Digit2' }), - linuxOrphanCandidateDigitGuard - ) - ).toBe(true) - expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ key: ' ', code: 'Space' }), - linuxOrphanCandidateDigitGuard - ) - ).toBe(false) - }) - it('does not treat modified chords such as Ctrl+Space (IME toggle) as candidate keys', () => { expect( shouldSuppressTerminalImeKeyboardEvent( @@ -431,20 +412,5 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { ) ).toBe(false) }) - - it('prevents the default only for fallback digit keydowns', () => { - expect( - shouldPreventDefaultTerminalImeCandidateKey( - event({ key: '2', code: 'Digit2' }), - linuxOrphanCandidateDigitGuard - ) - ).toBe(true) - expect( - shouldPreventDefaultTerminalImeCandidateKey( - event({ key: ' ', code: 'Space' }), - linuxOrphanCandidateDigitGuard - ) - ).toBe(false) - }) }) }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 977a1731e1e..071ee1e4e71 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,10 +1,7 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' import { isHangulJamoKeyText } from './hangul-jamo-key' import { getLayoutBaseCharacterForCode } from '../../lib/keyboard-layout/layout-base-character' -import { - isTerminalImeCandidateDigitKeyEvent, - isTerminalImeCandidateSelectionKeyEvent -} from './terminal-ime-candidate-key-release-guard' +import { isTerminalImeCandidateSelectionKeyEvent } from './terminal-ime-candidate-key-release-guard' // Why: when a CLI activates kitty progressive enhancement (CSI > N u), xterm's // KittyKeyboard encoder turns every modifier chord — including plain Cmd+C — @@ -52,18 +49,6 @@ export type XtermImeKeyboardOptions = { candidateKeyGuardActive: boolean /** True when the pending-release guard already matched this specific event. */ pendingCandidateKeyReleaseActive: boolean - /** True for the narrow Linux path where the IME emits an orphaned letter - * keyup but no composition/input events before its candidate digit. */ - linuxOrphanCandidateDigitGuardActive?: boolean - /** True when the IME claimed the preceding letter keydowns for a preedit it - * never opened a composition session for, so this Space or digit is picking - * a candidate rather than typing (#22442). */ - linuxImeOwnedPreeditGuardActive?: boolean - /** True when the most recent preedit was Hangul, where a digit ends the - * syllable and is literal text. Only the orphan-keyup guard is barred from - * claiming it (#15299): ibus-hangul's Hanja lookup table does index by digit, - * but only over a live preedit the composition guards already own. */ - hangulPreedit?: boolean // Required so no caller silently falls back to non-mac 229 suppression, // which re-swallows the first key after a macOS IME input-source switch. isMac: boolean @@ -133,41 +118,6 @@ export function shouldBypassXtermForIosTextEdit( return isHangulJamoKeyText(event.key) } -/** Returns whether the Linux orphan-keyup window may claim this digit. */ -function claimsOrphanCandidateDigit( - event: XtermBypassEvent, - options: XtermImeKeyboardOptions -): boolean { - return ( - options.linuxOrphanCandidateDigitGuardActive === true && - isTerminalImeCandidateDigitKeyEvent(event) && - // Why: the orphan window arms off a bare keyup and cannot see which engine - // produced it, so a Hangul syllable's terminating digit must opt out. - options.hangulPreedit !== true - ) -} - -/** - * Returns whether the claimed-keydown window may claim this selector. - * - * Space is included where the orphan-keyup window deliberately excludes it: this - * window arms from the IME marking the letter keydowns as its own, which is far - * stronger evidence of an open preedit than a bare orphaned keyup, and Space is - * the selector Sogou users reach for first. - */ -function claimsImeOwnedPreeditSelector( - event: XtermBypassEvent, - options: XtermImeKeyboardOptions -): boolean { - return ( - options.linuxImeOwnedPreeditGuardActive === true && - isTerminalImeCandidateSelectionKeyEvent(event) && - // Why: 2-Set Hangul commits its syllable on Space and still owes the literal - // space, so the syllable-terminating selectors stay with the caller. - options.hangulPreedit !== true - ) -} - /** Returns whether xterm must not process an IME-owned keyboard event. */ export function shouldSuppressTerminalImeKeyboardEvent( event: XtermBypassEvent, @@ -183,9 +133,7 @@ export function shouldSuppressTerminalImeKeyboardEvent( const suppressCandidateKey = isLinux && (pendingCandidateKeyReleaseActive || - (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) || - claimsOrphanCandidateDigit(event, options) || - claimsImeOwnedPreeditSelector(event, options)) + (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event))) if (event.type === 'keypress') { // Why: a suppressed candidate keydown is not preventDefault-ed by xterm, // so its native keypress still fires and _keyPress would forward the @@ -229,9 +177,8 @@ export function shouldPreventDefaultTerminalImeCandidateKey( return ( event.type === 'keydown' && options.isLinux && - ((options.candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) || - claimsOrphanCandidateDigit(event, options) || - claimsImeOwnedPreeditSelector(event, options)) + options.candidateKeyGuardActive && + isTerminalImeCandidateSelectionKeyEvent(event) ) } diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index 9c72e8cdd4d..bb1eadd6698 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -401,18 +401,6 @@ async function dispatchCandidateSelectionKey( }) } -async function dispatchOrphanLetterKeyup(session: CDPSession): Promise { - // Why: legacy Sogou/fcitx can emit only the release after committing a - // single-letter preedit, leaving no composition or input event to classify. - await session.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: 'a', - code: 'KeyA', - windowsVirtualKeyCode: 65, - nativeVirtualKeyCode: 65 - }) -} - async function dispatchSogouEmptyCompositionUpdate(page: Page): Promise { // Why: Sogou/fcitx emits empty compositionupdate data while its candidate // popup is still open (#6765); Orca's tracker must not flip inactive on it. @@ -745,24 +733,11 @@ test.describe('Chinese IME terminal chat input repro', () => { }) .toBe('再见') - // Some legacy desktop Linux paths omit every composition/input event and - // expose only an orphaned Latin release before the candidate digit. - await dispatchOrphanLetterKeyup(session) - await dispatchCandidateSelectionKey(session, { key: '4', code: 'Digit4', keyCode: 52 }) - await orcaPage.keyboard.press('Enter') - await expect - .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { - timeout: 5_000, - message: 'orphan-keyup candidate digit leaked into the submitted terminal input' - }) - .toBe('') - await attachImeEvidence(orcaPage, testInfo, 'sogou-after-orphan-keyup-digit-suppression') - const promptState = await readPromptState(orcaPage) expect( promptState?.submitted, 'candidate Space/digit selectors and pinyin preedit must not leak into the PTY' - ).toEqual(['你', '你好', '再见', '']) + ).toEqual(['你', '你好', '再见']) } finally { await attachImeEvidence(orcaPage, testInfo, 'sogou-final-ime-evidence').catch(() => undefined) await session?.detach().catch(() => undefined) diff --git a/tests/e2e/terminal-cjk-ime-committed-text.spec.ts b/tests/e2e/terminal-cjk-ime-committed-text.spec.ts index 056929de8bb..f4861b1754e 100644 --- a/tests/e2e/terminal-cjk-ime-committed-text.spec.ts +++ b/tests/e2e/terminal-cjk-ime-committed-text.spec.ts @@ -115,13 +115,11 @@ const SUBSTITUTION_ROUTES: readonly { ] /** - * The two substitution shapes above are macOS-only, and deliberately so: the keydown bypass that - * owns single-keystroke IME commits installs only when the renderer reports macOS, because only - * the macOS text system delivers a substituted glyph with the plain layout key still on the - * keydown. Windows and Linux frameworks claim the same keystroke as VK_PROCESSKEY and route the - * glyph through a composition session instead, which the separate composition-session test below - * covers. Running the macOS shapes under a Linux policy would assert a sequence no Linux input - * framework produces. + * The two substitution shapes above run under the macOS policy: they are the macOS text system's + * shapes for punctuation. Windows and most Linux frameworks claim the same keystroke as + * VK_PROCESSKEY and route the glyph through a composition session instead, which the separate + * composition-session test below covers. The Linux plain-keydown commit shape (fcitx4 Sogou's + * candidate picks, #28213) is covered by terminal-linux-claimed-preedit-selector.spec.ts. */ const FULL_WIDTH_SESSION_PUNCTUATION = [ { key: ',', code: 'Comma', keyCode: 188, glyph: ',', ascii: ',' }, diff --git a/tests/e2e/terminal-ime-observed-event-sequences.ts b/tests/e2e/terminal-ime-observed-event-sequences.ts index 6574cfdc23e..eb8aafbf8c7 100644 --- a/tests/e2e/terminal-ime-observed-event-sequences.ts +++ b/tests/e2e/terminal-ime-observed-event-sequences.ts @@ -28,10 +28,24 @@ async function dispatchObservedIbusHangulSequence( textarea.value = value input('input', inputType, data) } - const keydown = (key: string, code: string, keyCode: number, isComposing = false): void => { - const event = new KeyboardEvent('keydown', { bubbles: true, code, isComposing, key }) + const keydown = (key: string, code: string, keyCode: number, isComposing = false): boolean => { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code, + isComposing, + key + }) Object.defineProperty(event, 'keyCode', { value: keyCode }) textarea.dispatchEvent(event) + return !event.defaultPrevented + } + // Why: a synthetic keydown has no default action, so supply the text insertion Chromium makes + // for a printable key nobody cancelled; the recording lacks it only because xterm cancelled it. + const typeCharacter = (character: string): void => { + if (keydown(character, `Key${character.toUpperCase()}`, character.charCodeAt(0))) { + replaceAndInput(`${textarea.value}${character}`, 'insertText', character) + } } const update = (prefix: string, text: string): void => { composition('compositionupdate', text) @@ -59,8 +73,7 @@ async function dispatchObservedIbusHangulSequence( let prefix = begin('한') commit(prefix, '한') for (const character of 'abc') { - keydown(character, `Key${character.toUpperCase()}`, character.charCodeAt(0)) - replaceAndInput(`${textarea.value}${character}`, 'insertText', character) + typeCharacter(character) } prefix = begin('글') commit(prefix, '글') @@ -68,13 +81,15 @@ async function dispatchObservedIbusHangulSequence( return } - const prefix = begin('테') + begin('테') composition('compositionend', '테') - keydown('a', 'KeyA', 65) + typeCharacter('a') keydown('Process', 'KeyR', 229, true) - textarea.setSelectionRange(prefix.length + 1, prefix.length + 1) + // The next session opens at the caret, which follows whatever `a` inserted. + const nextPrefix = textarea.value + textarea.setSelectionRange(nextPrefix.length, nextPrefix.length) composition('compositionstart') - update(`${prefix}테`, '스') + update(nextPrefix, '스') composition('compositionend', '스') keydown('Enter', 'Enter', 13) }, variant) diff --git a/tests/e2e/terminal-ime-platform-policy.ts b/tests/e2e/terminal-ime-platform-policy.ts index 04adb66f0ea..30845237d88 100644 --- a/tests/e2e/terminal-ime-platform-policy.ts +++ b/tests/e2e/terminal-ime-platform-policy.ts @@ -4,8 +4,8 @@ import type { Page } from '@stablyai/playwright-test' * Pins the renderer's IME ownership policy to one platform, from any runner. * * Every platform-dependent IME decision in the terminal reads `navigator.userAgent` and nothing - * else — the forwarder that owns printable keydowns installs only when the UA reports macOS, the - * candidate-key guards only when it reports desktop Linux, and the standalone `keyCode 229` + * else — the forwarder that owns printable keydowns installs when the UA reports macOS or desktop + * Linux, the candidate-key guards only on desktop Linux, and the standalone `keyCode 229` * keydown reaches xterm on macOS and Linux but not on Windows. Overriding the UA is therefore the * whole platform decision, which is what lets the Windows and Linux shapes run headless on the * Linux CI shards instead of needing three runners. diff --git a/tests/e2e/terminal-linux-claimed-preedit-selector.spec.ts b/tests/e2e/terminal-linux-claimed-preedit-selector.spec.ts index df051b69670..9c58db51774 100644 --- a/tests/e2e/terminal-linux-claimed-preedit-selector.spec.ts +++ b/tests/e2e/terminal-linux-claimed-preedit-selector.spec.ts @@ -11,42 +11,36 @@ import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal- import { applyImePlatformPolicy } from './terminal-ime-platform-policy' /** - * Sogou on fcitx keeps its preedit and candidate list in its own window and opens no Chromium - * composition session, so the composition-scoped candidate guards are idle when the user presses - * the selector. Space and candidate digits then reached the PTY as literal text ahead of the - * commit (#22442). What the IME does still deliver is the claimed `keyCode 229` letter keydown, - * and that is what this spec replays. + * Sogou on fcitx4 keeps its preedit and candidate list in its own window and opens no Chromium + * composition session (#22442, #28213). The recorded shape + * (src/renderer/src/components/terminal-pane/__fixtures__/fcitx4-sogou-gtk-im-module-trace.json): + * the IME swallows the letter keydowns so only their keyups reach the page, and a single-character + * pick is a plain Space/digit keydown whose keypress and `insertText` carry the candidate instead + * of the selector. CDP's `keyDown` with `text` set to the candidate produces exactly that sequence. */ -/** The letter keydown shape fcitx delivers once it has claimed the key for a preedit. */ -async function dispatchClaimedLetter( +async function dispatchLetterKeyupOnly( session: CDPSession, + key: string, code: string, - letter: string, keyCode: number ): Promise { - await session.send('Input.dispatchKeyEvent', { - type: 'rawKeyDown', - key: 'Process', - code, - windowsVirtualKeyCode: 229, - nativeVirtualKeyCode: keyCode - }) await session.send('Input.dispatchKeyEvent', { type: 'keyUp', - key: letter, + key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode }) } -/** A selector pressed as a plain key, which is how it arrives with no composition session. */ -async function dispatchPlainKey( +/** A key press whose keydown names `key` but whose keypress and text insertion carry `text`. */ +async function dispatchKeyCommitting( session: CDPSession, key: string, code: string, - keyCode: number + keyCode: number, + text: string ): Promise { await session.send('Input.dispatchKeyEvent', { type: 'keyDown', @@ -54,8 +48,8 @@ async function dispatchPlainKey( code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, - text: key, - unmodifiedText: key + text, + unmodifiedText: text }) await session.send('Input.dispatchKeyEvent', { type: 'keyUp', @@ -66,8 +60,8 @@ async function dispatchPlainKey( }) } -// CDP exercises Linux renderer policy and PTY bytes; it does not simulate native fcitx5/Sogou. -test('Linux candidate selectors reach the IME, not the PTY, for a preedit with no composition session', async ({ +// CDP exercises Linux renderer policy and PTY bytes; it does not run native fcitx4/Sogou. +test('Linux Sogou candidate picks send the candidate, not the selector, to the PTY', async ({ orcaPage, testRepoPath }, testInfo) => { @@ -79,31 +73,29 @@ test('Linux candidate selectors reach the IME, not the PTY, for a preedit with n await startTerminalImeByteReader(orcaPage, arena.ptyId, reader) // Space picks the first candidate. - await dispatchClaimedLetter(arena.session, 'KeyN', 'n', 78) - await dispatchClaimedLetter(arena.session, 'KeyI', 'i', 73) - await dispatchPlainKey(arena.session, ' ', 'Space', 32) - await arena.session.send('Input.insertText', { text: '你' }) + await dispatchLetterKeyupOnly(arena.session, 'n', 'KeyN', 78) + await dispatchLetterKeyupOnly(arena.session, 'i', 'KeyI', 73) + await dispatchKeyCommitting(arena.session, ' ', 'Space', 32, '你') // A digit picks a later candidate. - await dispatchClaimedLetter(arena.session, 'KeyH', 'h', 72) - await dispatchClaimedLetter(arena.session, 'KeyA', 'a', 65) - await dispatchPlainKey(arena.session, '3', 'Digit3', 51) - await arena.session.send('Input.insertText', { text: '好' }) + await dispatchLetterKeyupOnly(arena.session, 'h', 'KeyH', 72) + await dispatchLetterKeyupOnly(arena.session, 'a', 'KeyA', 65) + await dispatchLetterKeyupOnly(arena.session, 'o', 'KeyO', 79) + await dispatchKeyCommitting(arena.session, '3', 'Digit3', 51, '浩') - // The round is over: the next Space is ordinary terminal input again. - await dispatchPlainKey(arena.session, ' ', 'Space', 32) - // And so is ordinary Latin typing that the IME never claimed. + // With the IME off, Space, letters and digits are ordinary terminal input. for (const [key, code, keyCode] of [ + [' ', 'Space', 32], ['l', 'KeyL', 76], - ['s', 'KeyS', 83] + ['s', 'KeyS', 83], + ['7', 'Digit7', 55] ] as const) { - await dispatchPlainKey(arena.session, key, code, keyCode) + await dispatchKeyCommitting(arena.session, key, code, keyCode, key) } - await dispatchPlainKey(arena.session, '7', 'Digit7', 55) await dispatchPlainEnter(arena.session) expect(await waitForTerminalImeBytes(orcaPage, reader)).toEqual([ - Buffer.from('你好 ls7\n').toString('hex') + Buffer.from('你浩 ls7\n').toString('hex') ]) completed = true } finally {