fix(terminal): claim printable keydowns structurally so committed IME text survives (#13288)
* fix(terminal): show a preedit the IME resumes without a compositionstart Typing 2-Set Korean shows committed syllables but not the in-progress jamo, so the user composes each syllable blind. Long-standing hole in the vendored terminal library, not a regression: the same test fails identically against the bundle this branch starts from. The `.active` class that CSS keys `display: block` off is added only in `compositionstart` and dropped in `_finalizeComposition`. Some IMEs (observed on Windows/WSL Korean) resume a composition with a bare `compositionupdate` and no second `compositionstart`, by which point `compositionend` has already hidden the overlay, so the resumed preedit is written into a hidden element and never positioned. `updateCompositionElements` also early-returned on `!_isComposing`, so it would not lay the overlay out either. Re-show the overlay on an update that carries data, and key the layout guard on the shown overlay instead. `_isComposing` is deliberately left alone, so no commit bookkeeping changes and `onData` stays byte-identical. The two guards are equivalent on every pre-existing path: `compositionstart` sets both, `_finalizeComposition` clears both. The bundle hunks are the same two edits applied to the shipped minified output; the sourcemaps are carried through unchanged. * test(terminal): prove the resumed-preedit fix against a recorded Windows capture The synthetic test pins the shape; this replays events a real Microsoft Korean IME emitted on Windows/WSL. The capture holds three compositionupdates that resume a composition with no second compositionstart — the exact ordering that wrote the preedit into a hidden overlay. Without the fix all three report shown:false; with it all three are visible. Fixture derived from the sealed 11919-windows-wsl-current capture, which is read-only and unmodified. Co-authored-by: Orca <help@stably.ai> * test(terminal): stop the recorded Hangul fixture pinning a derivation artifact The capture logs each event twice — a dispatch record and a batched next-frame re-log. Deriving from both replayed every event twice, which made three compositionupdates appear to land after a session had ended. Filtered to dispatch records the capture holds zero resumes and 11 balanced sessions, so the previous toHaveLength(3) was pinning an artifact of the derivation. Re-scoped to what the capture does prove: the preedit stays visible across all 37 real updates. Verified by reverting the patch that this passes either way, so it is coverage and the synthetic test remains the discriminator. Both facts are now stated in the file. Co-authored-by: Orca <help@stably.ai> * fix(terminal): restore the preedit visibility patch onto its own branch The previous commit accidentally reverted it: checking main's patch and lockfile into the worktree to test whether a test discriminates also stages them, so the commit that followed swept them up. Co-authored-by: Orca <help@stably.ai> * fix(terminal): claim printable keydowns structurally so committed text survives Co-authored-by: Orca <help@stably.ai> * chore(reliability-gates): retarget the IME forwarding gate after the allowlist removal The gate listed terminal-ime-input-source.test.ts, which went with the input-source allowlist. Points at the substituted-text commit test instead, which covers what the gate is actually protecting: text committed outside a composition session reaching the pty exactly once. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record why withholding a claimed keydown needs no timer The predicate withholds a keydown's byte until the commit arrives, so a key the IME eats without committing would be dropped. Measured across the recorded corpus that case does not occur, and the browser marks IME-owned presses on the keydown itself. Both facts belong next to the predicate rather than only in a handoff note, since the obvious fix for the imagined gap is a timer, and a timer here once wrote a newline the user never typed. Co-authored-by: Orca <help@stably.ai> * test(terminal): pin the kitty all-keys-as-escape-codes hole explicitly Flag 8 asks for every printable key as an escape code; this path sends the committed text raw instead. That is a deliberate trade, not an oversight, but it was untested — the suite only covered the disambiguate flag. Pinning it makes the choice visible and records the gate to use if it ever needs closing. Co-authored-by: Orca <help@stably.ai> * fix(terminal): keep the kitty key-release report for presses that reached the pty Claiming the keyup unconditionally suppressed xterm's release report. That was sized for the old design, which claimed only a short punctuation list; the structural claim takes every printable keydown, so on macOS an app that negotiated kitty report_event_types stopped seeing releases for ordinary typing and would treat every printable key as held down. Suppress the release only when the press put nothing on the wire — swallowed by the input source, or owned by a composition transaction. xterm emits nothing from keyup unless kitty report_event_types (or win32 input mode) is on, so letting it through is inert everywhere else. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a47b9d1167
commit
01bcc8dca2
|
|
@ -6658,13 +6658,13 @@
|
|||
"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.",
|
||||
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, 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. 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 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-input-source.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-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-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 run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.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",
|
||||
|
|
@ -6682,7 +6682,7 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
|
||||
"assertions": [
|
||||
"synthetic and native input-source paths are classified for covered fixtures"
|
||||
]
|
||||
|
|
@ -6770,7 +6770,7 @@
|
|||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "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-input-source.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",
|
||||
"command": "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-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",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.7,
|
||||
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ const imeHarness = vi.hoisted(() => ({
|
|||
sendInput: (data: string) => void
|
||||
}[],
|
||||
trackers: [] as { dispose: ReturnType<typeof vi.fn> }[],
|
||||
claimResult: false,
|
||||
inputSourceTrackerRequests: 0
|
||||
claimResult: false
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
|
|
@ -131,12 +130,6 @@ vi.mock('@/components/terminal-pane/terminal-ime-composition-tracker', () => ({
|
|||
return tracker
|
||||
}
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/terminal-ime-input-source', () => ({
|
||||
getMacNativeTextInputSourceTracker: () => {
|
||||
imeHarness.inputSourceTrackerRequests++
|
||||
return { getFeatures: () => ({}) }
|
||||
}
|
||||
}))
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = (selector: (s: typeof storeState) => unknown): unknown => selector(storeState)
|
||||
useAppStore.getState = (): typeof storeState => storeState
|
||||
|
|
@ -165,7 +158,6 @@ describe('AgentTerminalPreview', () => {
|
|||
imeHarness.forwarders.length = 0
|
||||
imeHarness.trackers.length = 0
|
||||
imeHarness.claimResult = false
|
||||
imeHarness.inputSourceTrackerRequests = 0
|
||||
emitData = null
|
||||
emitAppMenuPaste = null
|
||||
connect.mockResolvedValue({
|
||||
|
|
@ -235,7 +227,6 @@ describe('AgentTerminalPreview', () => {
|
|||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
expect(imeHarness.forwarders).toHaveLength(1)
|
||||
expect(imeHarness.trackers).toHaveLength(1)
|
||||
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
|
||||
|
||||
imeHarness.forwarders[0]!.sendInput('。')
|
||||
expect(terminal.input).toHaveBeenCalledOnce()
|
||||
|
|
@ -260,7 +251,6 @@ describe('AgentTerminalPreview', () => {
|
|||
expect(copied).toBe(false)
|
||||
expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
|
||||
})
|
||||
|
||||
it('does not install the IME native-text forwarder off macOS', async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { Terminal } from '@xterm/xterm'
|
|||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { installTerminalImeCompositionTracker } from '@/components/terminal-pane/terminal-ime-composition-tracker'
|
||||
import { installTerminalImeNativeTextForwarder } from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
|
||||
import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
|
||||
|
||||
export type PreviewImeBridge = {
|
||||
/** True when the forwarder owns this keydown, so xterm must not encode it. */
|
||||
|
|
@ -21,14 +20,11 @@ export function installPreviewImeBridge(terminal: Terminal): PreviewImeBridge |
|
|||
if (getShortcutPlatform() !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
// Why: prewarm the async input-source lookup before the first native-text key needs classification.
|
||||
const inputSourceTracker = getMacNativeTextInputSourceTracker()
|
||||
const compositionTracker = installTerminalImeCompositionTracker(terminal.element)
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: terminal.element,
|
||||
isComposing: () => compositionTracker?.isActive() ?? false,
|
||||
sendInput: (data) => terminal.input(data),
|
||||
getInputSourceFeatures: () => inputSourceTracker.getFeatures()
|
||||
sendInput: (data) => terminal.input(data)
|
||||
})
|
||||
return {
|
||||
claimKeyEvent: (event) => forwarder?.claimKeyEvent(event) ?? false,
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
_resetMacNativeTextInputSourceTrackerForTests,
|
||||
createMacNativeTextInputSourceTracker,
|
||||
getMacNativeTextInputSourceFeatures,
|
||||
getMacNativeTextInputSourceTracker
|
||||
} from './terminal-ime-input-source'
|
||||
|
||||
describe('getMacNativeTextInputSourceFeatures', () => {
|
||||
it('enables punctuation forwarding for Apple Chinese, Japanese and Korean input methods', () => {
|
||||
for (const sourceId of [
|
||||
'com.apple.inputmethod.SCIM.ITABC',
|
||||
'com.apple.inputmethod.TCIM.Pinyin',
|
||||
'com.apple.keylayout.PinyinKeyboard',
|
||||
'com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese',
|
||||
'com.apple.inputmethod.Korean.2SetKorean'
|
||||
]) {
|
||||
expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({
|
||||
forwardHangulJamo: sourceId.includes('Korean'),
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts common third-party CJK input source IDs', () => {
|
||||
expect(getMacNativeTextInputSourceFeatures('com.google.inputmethod.Japanese.base')).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
expect(getMacNativeTextInputSourceFeatures('com.sogou.inputmethod.sogou.pinyin')).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
expect(getMacNativeTextInputSourceFeatures('com.bytedance.inputmethod.Doubao')).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
expect(getMacNativeTextInputSourceFeatures('im.rime.inputmethod.Squirrel.Rime')).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
})
|
||||
|
||||
it('enables short native replacement forwarding for Vietnamese input methods', () => {
|
||||
for (const sourceId of [
|
||||
'com.apple.inputmethod.Vietnamese',
|
||||
'com.apple.inputmethod.Vietnamese.Telex',
|
||||
'com.apple.inputmethod.Vietnamese.VNI',
|
||||
'org.unikey.inputmethod.Unikey'
|
||||
]) {
|
||||
expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects plain keyboard layouts and unrelated input methods', () => {
|
||||
const disabled = {
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
}
|
||||
expect(getMacNativeTextInputSourceFeatures(null)).toEqual(disabled)
|
||||
expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.US')).toEqual(disabled)
|
||||
expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.ABC')).toEqual(disabled)
|
||||
expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.PolishPro')).toEqual(disabled)
|
||||
expect(getMacNativeTextInputSourceFeatures('com.apple.inputmethod.CharacterPaletteIM')).toEqual(
|
||||
disabled
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMacNativeTextInputSourceTracker', () => {
|
||||
beforeEach(() => {
|
||||
_resetMacNativeTextInputSourceTrackerForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetMacNativeTextInputSourceTrackerForTests()
|
||||
})
|
||||
|
||||
it('starts disabled and refreshes from the current input source', async () => {
|
||||
let sourceId = 'com.apple.keylayout.US'
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, {
|
||||
readInputSourceId: async () => sourceId
|
||||
})
|
||||
|
||||
expect(tracker.isActive()).toBe(false)
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(false)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
|
||||
sourceId = 'com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese'
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
|
||||
sourceId = 'com.apple.inputmethod.Vietnamese.Telex'
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
})
|
||||
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('refreshes on window focus so language switches are picked up', async () => {
|
||||
let sourceId = 'com.apple.keylayout.US'
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, {
|
||||
readInputSourceId: async () => sourceId
|
||||
})
|
||||
await tracker.refresh()
|
||||
|
||||
sourceId = 'com.apple.inputmethod.TCIM.Pinyin'
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
|
||||
await vi.waitFor(() => expect(tracker.isActive()).toBe(true))
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('refreshes on keydown so focused input source switches are picked up', async () => {
|
||||
let sourceId = 'com.apple.keylayout.ABC'
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, {
|
||||
readInputSourceId: async () => sourceId
|
||||
})
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(false)
|
||||
|
||||
sourceId = 'com.apple.inputmethod.SCIM.ITABC'
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
)
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('throttles ordinary key refreshes while input-source features are disabled', async () => {
|
||||
let sourceId = 'com.apple.keylayout.ABC'
|
||||
const readInputSourceId = vi.fn(async () => sourceId)
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, { readInputSourceId })
|
||||
await tracker.refresh()
|
||||
readInputSourceId.mockClear()
|
||||
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1000)
|
||||
try {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
|
||||
await vi.waitFor(() => expect(readInputSourceId).toHaveBeenCalledTimes(1))
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b' }))
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'b' }))
|
||||
await Promise.resolve()
|
||||
expect(readInputSourceId).toHaveBeenCalledTimes(1)
|
||||
|
||||
sourceId = 'com.apple.inputmethod.SCIM.ITABC'
|
||||
now.mockReturnValue(2001)
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' }))
|
||||
await vi.waitFor(() => expect(readInputSourceId).toHaveBeenCalledTimes(2))
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
tracker.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('refreshes on modifier keyup so active sources can become disabled while focused', async () => {
|
||||
let sourceId = 'com.apple.inputmethod.SCIM.ITABC'
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, {
|
||||
readInputSourceId: async () => sourceId
|
||||
})
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
|
||||
sourceId = 'com.apple.keylayout.ABC'
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', ctrlKey: true }))
|
||||
|
||||
await vi.waitFor(() => expect(tracker.isActive()).toBe(false))
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('keeps the singleton reusable for terminal lifecycle code', () => {
|
||||
const first = getMacNativeTextInputSourceTracker()
|
||||
const second = getMacNativeTextInputSourceTracker()
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
import type { IDisposable } from '@xterm/xterm'
|
||||
|
||||
export type MacNativeTextInputSourceFeatures = Readonly<{
|
||||
forwardHangulJamo: boolean
|
||||
forwardAsciiPunctuation: boolean
|
||||
forwardShortTextReplacements: boolean
|
||||
}>
|
||||
|
||||
export type MacNativeTextInputSourceTracker = IDisposable & {
|
||||
isActive: () => boolean
|
||||
getFeatures: () => MacNativeTextInputSourceFeatures
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
type KeyboardInputSourceReader = () => Promise<string | null>
|
||||
|
||||
export const DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
}) satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const CJK_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
}) satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const KOREAN_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({
|
||||
forwardHangulJamo: true,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
}) satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const VIETNAMESE_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
}) satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const CJK_INPUT_SOURCE_TERMS = [
|
||||
'bytedance',
|
||||
'cangjie',
|
||||
'chinese',
|
||||
'doubao',
|
||||
'hangul',
|
||||
'hanin',
|
||||
'hiragana',
|
||||
'itabc',
|
||||
'japanese',
|
||||
'kana',
|
||||
'katakana',
|
||||
'korean',
|
||||
'kotoeri',
|
||||
'pinyin',
|
||||
'rime',
|
||||
'romaji',
|
||||
'scim',
|
||||
'shuangpin',
|
||||
'stroke',
|
||||
'tcim',
|
||||
'wubi',
|
||||
'wubihua',
|
||||
'zhuyin'
|
||||
] as const
|
||||
|
||||
const VIETNAMESE_INPUT_SOURCE_TERMS = ['telex', 'unikey', 'vietnam', 'vni'] as const
|
||||
const KOREAN_INPUT_SOURCE_TERMS = ['hangul', 'korean'] as const
|
||||
|
||||
const KEYBOARD_ACTIVITY_REFRESH_COOLDOWN_MS = 1000
|
||||
|
||||
function defaultKeyboardInputSourceReader(): KeyboardInputSourceReader {
|
||||
return async () => {
|
||||
const api = (
|
||||
globalThis as {
|
||||
window?: {
|
||||
api?: {
|
||||
app?: {
|
||||
getKeyboardInputSourceId?: () => Promise<string | null>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
).window?.api
|
||||
const reader = api?.app?.getKeyboardInputSourceId
|
||||
if (!reader) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await reader()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getMacNativeTextInputSourceFeatures(
|
||||
id: string | null | undefined
|
||||
): MacNativeTextInputSourceFeatures {
|
||||
const normalized = id?.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
if (KOREAN_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) {
|
||||
return KOREAN_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
if (CJK_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) {
|
||||
return CJK_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
if (VIETNAMESE_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) {
|
||||
return VIETNAMESE_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
return DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
|
||||
export function createMacNativeTextInputSourceTracker(
|
||||
win: Window = window,
|
||||
options: { readInputSourceId?: KeyboardInputSourceReader } = {}
|
||||
): MacNativeTextInputSourceTracker {
|
||||
const readInputSourceId = options.readInputSourceId ?? defaultKeyboardInputSourceReader()
|
||||
let features: MacNativeTextInputSourceFeatures = DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
let disposed = false
|
||||
let refreshGeneration = 0
|
||||
let refreshInFlight = false
|
||||
let refreshQueued = false
|
||||
let lastKeyboardActivityRefreshAt: number | null = null
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const generation = ++refreshGeneration
|
||||
let inputSourceId: string | null = null
|
||||
try {
|
||||
inputSourceId = await readInputSourceId()
|
||||
} catch {
|
||||
inputSourceId = null
|
||||
}
|
||||
if (disposed || generation !== refreshGeneration) {
|
||||
return
|
||||
}
|
||||
features = getMacNativeTextInputSourceFeatures(inputSourceId)
|
||||
}
|
||||
|
||||
const requestRefresh = (): void => {
|
||||
if (refreshInFlight) {
|
||||
refreshQueued = true
|
||||
return
|
||||
}
|
||||
refreshInFlight = true
|
||||
void refresh().finally(() => {
|
||||
refreshInFlight = false
|
||||
if (!disposed && refreshQueued) {
|
||||
refreshQueued = false
|
||||
requestRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onFocus = (): void => {
|
||||
requestRefresh()
|
||||
}
|
||||
|
||||
const requestKeyboardActivityRefresh = (force: boolean): void => {
|
||||
const now = Date.now()
|
||||
if (
|
||||
!force &&
|
||||
lastKeyboardActivityRefreshAt !== null &&
|
||||
now - lastKeyboardActivityRefreshAt < KEYBOARD_ACTIVITY_REFRESH_COOLDOWN_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastKeyboardActivityRefreshAt = now
|
||||
requestRefresh()
|
||||
}
|
||||
|
||||
const onKeyboardActivity = (event: KeyboardEvent): void => {
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
requestKeyboardActivityRefresh(true)
|
||||
return
|
||||
}
|
||||
if (
|
||||
!features.forwardHangulJamo &&
|
||||
!features.forwardAsciiPunctuation &&
|
||||
!features.forwardShortTextReplacements
|
||||
) {
|
||||
requestKeyboardActivityRefresh(false)
|
||||
}
|
||||
}
|
||||
|
||||
win.addEventListener('focus', onFocus)
|
||||
// Why: macOS input-source changes can happen while the terminal keeps focus;
|
||||
// refresh from keyboard activity so CJK punctuation gates do not stay stale.
|
||||
win.addEventListener('keydown', onKeyboardActivity, true)
|
||||
win.addEventListener('keyup', onKeyboardActivity, true)
|
||||
requestRefresh()
|
||||
|
||||
return {
|
||||
isActive: () =>
|
||||
features.forwardHangulJamo ||
|
||||
features.forwardAsciiPunctuation ||
|
||||
features.forwardShortTextReplacements,
|
||||
getFeatures: () => features,
|
||||
refresh,
|
||||
dispose: () => {
|
||||
disposed = true
|
||||
win.removeEventListener('focus', onFocus)
|
||||
win.removeEventListener('keydown', onKeyboardActivity, true)
|
||||
win.removeEventListener('keyup', onKeyboardActivity, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: MacNativeTextInputSourceTracker | null = null
|
||||
|
||||
export function getMacNativeTextInputSourceTracker(): MacNativeTextInputSourceTracker {
|
||||
singleton ??= createMacNativeTextInputSourceTracker()
|
||||
return singleton
|
||||
}
|
||||
|
||||
export function _resetMacNativeTextInputSourceTrackerForTests(): void {
|
||||
singleton?.dispose()
|
||||
singleton = null
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import {
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES,
|
||||
type MacNativeTextInputSourceFeatures
|
||||
} from './terminal-ime-input-source'
|
||||
|
||||
export type ImeNativeTextKeyEvent = {
|
||||
type: string
|
||||
key: string
|
||||
code?: string
|
||||
keyCode?: number
|
||||
which?: number
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
altKey: boolean
|
||||
isComposing?: boolean
|
||||
}
|
||||
|
||||
const CJK_DIRECT_PUNCTUATION_KEYS = new Set<string>([
|
||||
'、',
|
||||
'。',
|
||||
',',
|
||||
'.',
|
||||
'!',
|
||||
'?',
|
||||
';',
|
||||
':',
|
||||
'“',
|
||||
'”',
|
||||
'‘',
|
||||
'’',
|
||||
'(',
|
||||
')',
|
||||
'【',
|
||||
'】',
|
||||
'《',
|
||||
'》',
|
||||
'〈',
|
||||
'〉',
|
||||
'「',
|
||||
'」',
|
||||
'『',
|
||||
'』',
|
||||
'¥',
|
||||
// Korean sources put ₩ on Backquote; its committed text can differ from
|
||||
// `key` when DefaultKeyBinding.dict rewrites it (typically back to `).
|
||||
'₩',
|
||||
'~',
|
||||
'·',
|
||||
'…'
|
||||
])
|
||||
|
||||
function isSingleAsciiKey(key: string): number | null {
|
||||
// Reject multi-codepoint keys ("Enter", "ArrowLeft", emoji, ...).
|
||||
if (Array.from(key).length !== 1) {
|
||||
return null
|
||||
}
|
||||
return key.codePointAt(0) ?? null
|
||||
}
|
||||
|
||||
function isAsciiDigitCode(code: number): boolean {
|
||||
return code >= 0x30 && code <= 0x39
|
||||
}
|
||||
|
||||
function isUpperAsciiLetterCode(code: number): boolean {
|
||||
return code >= 0x41 && code <= 0x5a
|
||||
}
|
||||
|
||||
function isLowerAsciiLetterCode(code: number): boolean {
|
||||
return code >= 0x61 && code <= 0x7a
|
||||
}
|
||||
|
||||
function isAsciiPunctuationKey(key: string): boolean {
|
||||
const code = isSingleAsciiKey(key)
|
||||
if (code === null) {
|
||||
return false
|
||||
}
|
||||
const isDigit = isAsciiDigitCode(code)
|
||||
const isUpperAlpha = isUpperAsciiLetterCode(code)
|
||||
const isLowerAlpha = isLowerAsciiLetterCode(code)
|
||||
// Printable ASCII excluding space (0x20), digits and letters: the keys an
|
||||
// IME may swap for a full-width or CJK glyph.
|
||||
return code > 0x20 && code <= 0x7e && !isDigit && !isUpperAlpha && !isLowerAlpha
|
||||
}
|
||||
|
||||
function isCjkDirectPunctuationKey(key: string): boolean {
|
||||
return Array.from(key).length === 1 && CJK_DIRECT_PUNCTUATION_KEYS.has(key)
|
||||
}
|
||||
|
||||
function isHangulJamoKey(key: string): boolean {
|
||||
const chars = Array.from(key)
|
||||
if (chars.length !== 1) {
|
||||
return false
|
||||
}
|
||||
const codePoint = chars[0].codePointAt(0)!
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
|
||||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
|
||||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
|
||||
(codePoint >= 0xd7b0 && codePoint <= 0xd7ff) ||
|
||||
(codePoint >= 0xffa0 && codePoint <= 0xffdc)
|
||||
)
|
||||
}
|
||||
|
||||
function isAsciiShortTextReplacementKey(key: string): boolean {
|
||||
const code = isSingleAsciiKey(key)
|
||||
if (code === null) {
|
||||
return false
|
||||
}
|
||||
return isAsciiDigitCode(code) || isUpperAsciiLetterCode(code) || isLowerAsciiLetterCode(code)
|
||||
}
|
||||
|
||||
export function isSinglePrintableTextKey(key: string): boolean {
|
||||
const chars = Array.from(key)
|
||||
if (chars.length !== 1) {
|
||||
return false
|
||||
}
|
||||
const codePoint = chars[0].codePointAt(0)
|
||||
return codePoint !== undefined && codePoint >= 0x20 && codePoint !== 0x7f
|
||||
}
|
||||
|
||||
function hasUnreliablePhysicalKeyIdentity(event: ImeNativeTextKeyEvent): boolean {
|
||||
const code = event.code?.trim()
|
||||
const legacyKeyCode = event.keyCode ?? event.which
|
||||
return !code || code === 'Unidentified' || legacyKeyCode === 0
|
||||
}
|
||||
|
||||
function isSyntheticUnicodeTextKey(event: ImeNativeTextKeyEvent): boolean {
|
||||
if (!hasUnreliablePhysicalKeyIdentity(event)) {
|
||||
return false
|
||||
}
|
||||
// CGEventKeyboardSetUnicodeString-style injectors can surface as
|
||||
// `Unidentified` keydowns; the following `input` event carries the text.
|
||||
if (event.key === 'Unidentified') {
|
||||
return true
|
||||
}
|
||||
return isSinglePrintableTextKey(event.key)
|
||||
}
|
||||
|
||||
export function isImeNativeTextKeydownCandidate(
|
||||
event: ImeNativeTextKeyEvent,
|
||||
compositionActive: boolean,
|
||||
inputSourceFeatures: MacNativeTextInputSourceFeatures = DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
): boolean {
|
||||
if (event.type !== 'keydown') {
|
||||
return false
|
||||
}
|
||||
// Modifier chords are real shortcuts (Ctrl+C, Cmd+V, Alt+...); never a plain
|
||||
// native text commit. Shift is allowed since punctuation and uppercase
|
||||
// Vietnamese text can legitimately need it.
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
// Composing keystrokes belong to the IME preedit and xterm's CompositionHelper
|
||||
// (which already forwards the committed text), so leave them alone.
|
||||
if (event.isComposing === true || compositionActive) {
|
||||
return false
|
||||
}
|
||||
if (isSyntheticUnicodeTextKey(event)) {
|
||||
return true
|
||||
}
|
||||
if (inputSourceFeatures.forwardHangulJamo && isHangulJamoKey(event.key)) {
|
||||
return true
|
||||
}
|
||||
if (isCjkDirectPunctuationKey(event.key)) {
|
||||
return true
|
||||
}
|
||||
if (inputSourceFeatures.forwardAsciiPunctuation && isAsciiPunctuationKey(event.key)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
inputSourceFeatures.forwardShortTextReplacements && isAsciiShortTextReplacementKey(event.key)
|
||||
)
|
||||
}
|
||||
|
|
@ -1,35 +1,11 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
|
||||
import {
|
||||
isImeNativeTextKeydownCandidate,
|
||||
installTerminalImeNativeTextForwarder,
|
||||
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
|
||||
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT,
|
||||
type ImeNativeTextKeyEvent
|
||||
} from './terminal-ime-native-text-candidates'
|
||||
import type { MacNativeTextInputSourceFeatures } from './terminal-ime-input-source'
|
||||
|
||||
const CJK_FEATURES = {
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
} satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const KOREAN_FEATURES = {
|
||||
forwardHangulJamo: true,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
} satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const VIETNAMESE_FEATURES = {
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
} satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const DISABLED_FEATURES = {
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
} satisfies MacNativeTextInputSourceFeatures
|
||||
} from './terminal-ime-native-text-forwarder'
|
||||
|
||||
function keyEvent(overrides: Partial<ImeNativeTextKeyEvent>): ImeNativeTextKeyEvent {
|
||||
return {
|
||||
|
|
@ -48,197 +24,6 @@ function dispatchInsertText(target: HTMLElement, data: string | null): void {
|
|||
target.dispatchEvent(new InputEvent('input', { data, inputType: 'insertText', bubbles: true }))
|
||||
}
|
||||
|
||||
describe('isImeNativeTextKeydownCandidate', () => {
|
||||
it('accepts unmodified ASCII punctuation keydown outside composition for CJK sources', () => {
|
||||
for (const key of [',', '.', '?', '!', ';', ':', '"', "'", '\\', '<', '>', '~', '@', '#']) {
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key }), false, CJK_FEATURES)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unmodified ASCII punctuation keydown without an input source gate', () => {
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '.' }), false, DISABLED_FEATURES)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts direct CJK punctuation keydown outside composition for CJK sources', () => {
|
||||
for (const key of [',', '。', '、', '?', '!', ':', ';', '…']) {
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key }), false, CJK_FEATURES)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts direct CJK punctuation keydown when the input source probe is stale', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: ',', code: 'Comma' }),
|
||||
false,
|
||||
DISABLED_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns initial Korean jamo keydowns to the native IME', () => {
|
||||
for (const key of ['ㅎ', 'ㅏ', 'ㄴ', 'ᄀ', 'ᄀ']) {
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key }), false, KOREAN_FEATURES)).toBe(true)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key }), false, CJK_FEATURES)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Korean jamo modifier chords in terminal shortcut handling', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: 'ㅎ', metaKey: true }),
|
||||
false,
|
||||
KOREAN_FEATURES
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
// Why: macOS Korean sources emit ₩ from Backquote, and a DefaultKeyBinding.dict
|
||||
// entry can rewrite it to ` — but only in the keypress/input events.
|
||||
it('accepts the Korean won-sign key even when the input source probe is stale', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }),
|
||||
false,
|
||||
DISABLED_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts Vietnamese short replacement keys without enabling punctuation', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: 'a', code: 'KeyA' }),
|
||||
false,
|
||||
VIETNAMESE_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: 'D', code: 'KeyD' }),
|
||||
false,
|
||||
VIETNAMESE_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: '9', code: 'Digit9' }),
|
||||
false,
|
||||
VIETNAMESE_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(keyEvent({ key: ',' }), false, VIETNAMESE_FEATURES)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts synthesized Unicode text keydowns without an input source gate', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: 'h', code: 'KeyA', keyCode: 0 }),
|
||||
false,
|
||||
DISABLED_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: 'é', code: '', keyCode: 0 }),
|
||||
false,
|
||||
DISABLED_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: 'Unidentified', code: 'Unidentified' }),
|
||||
false,
|
||||
DISABLED_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects letters, digits and whitespace keys for CJK punctuation forwarding', () => {
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: 'a' }), false, CJK_FEATURES)).toBe(false)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: 'Z' }), false, CJK_FEATURES)).toBe(false)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '5' }), false, CJK_FEATURES)).toBe(false)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: ' ' }), false, CJK_FEATURES)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects named keys and multi-codepoint keys', () => {
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: 'Enter' }), false, CJK_FEATURES)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(keyEvent({ key: 'ArrowLeft' }), false, CJK_FEATURES)
|
||||
).toBe(false)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '👩💻' }), false, CJK_FEATURES)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: 'Enter', code: 'Enter', keyCode: 0 }),
|
||||
false,
|
||||
DISABLED_FEATURES
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects Ctrl/Alt/Meta chords but accepts shifted punctuation like "!"', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(keyEvent({ key: ',', ctrlKey: true }), false, CJK_FEATURES)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(keyEvent({ key: ',', metaKey: true }), false, CJK_FEATURES)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(keyEvent({ key: ',', altKey: true }), false, CJK_FEATURES)
|
||||
).toBe(false)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '!' }), false, CJK_FEATURES)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects keystrokes that belong to an active composition', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: ',', isComposing: true }),
|
||||
false,
|
||||
CJK_FEATURES
|
||||
)
|
||||
).toBe(false)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: ',' }), true, CJK_FEATURES)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non keyboard event types', () => {
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ type: 'input' }), false, CJK_FEATURES)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(keyEvent({ type: 'keypress' }), false, CJK_FEATURES)
|
||||
).toBe(false)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ type: 'keyup' }), false, CJK_FEATURES)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat Japanese text keys as punctuation candidates', () => {
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: 'あ' }), false, CJK_FEATURES)).toBe(
|
||||
false
|
||||
)
|
||||
expect(isImeNativeTextKeydownCandidate(keyEvent({ key: '語' }), false, CJK_FEATURES)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects physical printable candidates when no native text source feature is active', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: 'a', code: 'KeyA' }),
|
||||
false,
|
||||
DISABLED_FEATURES
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installTerminalImeNativeTextForwarder', () => {
|
||||
let element: HTMLDivElement
|
||||
let textarea: HTMLTextAreaElement
|
||||
|
|
@ -252,408 +37,300 @@ describe('installTerminalImeNativeTextForwarder', () => {
|
|||
document.body.appendChild(element)
|
||||
})
|
||||
|
||||
it('forwards the IME-committed full-width glyph from the input event', () => {
|
||||
const sendInput = vi.fn()
|
||||
const laterInputListener = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
element.addEventListener('input', laterInputListener, true)
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
textarea.value = ','
|
||||
dispatchInsertText(textarea, ',')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
expect(laterInputListener).not.toHaveBeenCalled()
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
|
||||
it('forwards Japanese direct punctuation committed from a punctuation key', () => {
|
||||
function install(isComposing: () => boolean = () => false): {
|
||||
forwarder: ReturnType<typeof installTerminalImeNativeTextForwarder>
|
||||
sendInput: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
isComposing,
|
||||
sendInput
|
||||
})
|
||||
return { forwarder, sendInput }
|
||||
}
|
||||
|
||||
describe('the claim is structural', () => {
|
||||
it('claims every single printable key regardless of which character it is', () => {
|
||||
const { forwarder } = install()
|
||||
// Punctuation, letters, digits, already-substituted CJK glyphs, Hangul
|
||||
// jamo and a won sign are all the same case: one printable character.
|
||||
for (const key of [
|
||||
',',
|
||||
'.',
|
||||
'?',
|
||||
'\\',
|
||||
'!',
|
||||
'a',
|
||||
'Z',
|
||||
'9',
|
||||
',',
|
||||
'。',
|
||||
'、',
|
||||
'ᄒ',
|
||||
'₩',
|
||||
'1'
|
||||
]) {
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key }))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '.' }))).toBe(true)
|
||||
dispatchInsertText(textarea, '。')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('。')
|
||||
})
|
||||
|
||||
it('forwards CJK punctuation when the keydown already carries the transformed glyph', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
it('claims without reading any input source, so unknown IMEs work too', () => {
|
||||
// Regression: the previous forwarder gated on a 23-term input-source
|
||||
// allowlist, so third-party IMEs off that list were never claimed.
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '\\', code: 'Backslash' }))).toBe(true)
|
||||
dispatchInsertText(textarea, '、')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('、')
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '。', code: 'Period' }))).toBe(true)
|
||||
dispatchInsertText(textarea, '。')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('。')
|
||||
})
|
||||
|
||||
it('forwards the key binding substitution for the Korean won-sign key', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
it('rejects Ctrl/Alt/Meta chords but accepts shifted punctuation', () => {
|
||||
const { forwarder } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'c', ctrlKey: true }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'v', metaKey: true }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', altKey: true }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '!', code: 'Digit1' }))).toBe(true)
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }))).toBe(
|
||||
true
|
||||
)
|
||||
dispatchInsertText(textarea, '`')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
|
||||
})
|
||||
|
||||
it('forwards the won sign unchanged when no key binding remaps it', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
it('rejects named keys and multi-code-unit keys on length alone', () => {
|
||||
const { forwarder } = install()
|
||||
// No enumeration needed: none of these is one code unit long.
|
||||
for (const key of [
|
||||
'Enter',
|
||||
'Tab',
|
||||
'ArrowLeft',
|
||||
'Escape',
|
||||
'Dead',
|
||||
'F3',
|
||||
'Unidentified',
|
||||
'😀'
|
||||
]) {
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key }))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }))).toBe(
|
||||
true
|
||||
)
|
||||
dispatchInsertText(textarea, '₩')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('₩')
|
||||
})
|
||||
|
||||
it('forwards a plain ASCII symbol unchanged when the IME does not convert it', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
it('rejects keystrokes that belong to an active composition', () => {
|
||||
const { forwarder } = install(() => true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false)
|
||||
const { forwarder: other } = install()
|
||||
expect(other.claimKeyEvent(keyEvent({ key: ',', isComposing: true }))).toBe(false)
|
||||
})
|
||||
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
dispatchInsertText(textarea, ',')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
it('does not forward input when no candidate keydown was claimed', () => {
|
||||
const sendInput = vi.fn()
|
||||
installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
it('rejects non-keydown types when it did not claim the keydown', () => {
|
||||
const { forwarder, sendInput } = install(() => true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: ',' }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(false)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
dispatchInsertText(textarea, '😀')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim composing keystrokes', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => true,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores composition input events even after a claimed keydown', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: ',', inputType: 'insertCompositionText', bubbles: true })
|
||||
)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears pending forwarding when a Japanese composition input takes over', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: 'に', inputType: 'insertCompositionText', bubbles: true })
|
||||
)
|
||||
dispatchInsertText(textarea, '日本語')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps pending forward after keyup for CJK punctuation until the committed input arrives', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
it('drops pending forwarding when no input follows a claimed keyup', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
describe('forwarding the committed text', () => {
|
||||
it('forwards the IME-committed full-width glyph from the input event', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
const laterInputListener = vi.fn()
|
||||
element.addEventListener('input', laterInputListener, true)
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
vi.advanceTimersByTime(100)
|
||||
textarea.value = ','
|
||||
dispatchInsertText(textarea, ',')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
expect(laterInputListener).not.toHaveBeenCalled()
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
|
||||
it('forwards a multi-code-unit commit from a single press', () => {
|
||||
// `——` is two code units; reading `ev.data` wholesale covers it for free.
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '_', code: 'Minus' }))).toBe(true)
|
||||
dispatchInsertText(textarea, '——')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('——')
|
||||
})
|
||||
|
||||
it('forwards the committed text when the keydown already carries the glyph', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '。', code: 'Period' }))).toBe(true)
|
||||
dispatchInsertText(textarea, '。')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('。')
|
||||
})
|
||||
|
||||
it('forwards what the key binding substituted, not what the key said', () => {
|
||||
// The won-sign key commits a backtick under DefaultKeyBinding.dict; the
|
||||
// committed text wins because `key` is never consulted for identity.
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote' }))).toBe(true)
|
||||
dispatchInsertText(textarea, '`')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
|
||||
})
|
||||
|
||||
it('forwards a plain ASCII symbol unchanged when the IME does not convert it', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
it('forwards Vietnamese letter replacement text and suppresses matching key events', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: 'á', code: 'KeyA' }))).toBe(
|
||||
true
|
||||
)
|
||||
dispatchInsertText(textarea, 'á')
|
||||
|
||||
// The keypress is what must be suppressed (it would double-send the ASCII 'a').
|
||||
// The keyup is not: 'á' reached the pty, so the app is owed the matching release.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'a', code: 'KeyA' }))).toBe(
|
||||
false
|
||||
)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('á')
|
||||
})
|
||||
|
||||
it('does not forward input when no keydown was claimed', () => {
|
||||
const { sendInput } = install()
|
||||
dispatchInsertText(textarea, '😀')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('only forwards a single input per claimed keydown', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
dispatchInsertText(textarea, ',')
|
||||
dispatchInsertText(textarea, '。')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
it('bypasses keypress without clearing the armed forward (avoids ASCII double-send)', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
it('only forwards a single input per claimed keydown', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
dispatchInsertText(textarea, ',')
|
||||
dispatchInsertText(textarea, '。')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
// keydown → keypress → input is the native order after we let the keydown
|
||||
// through; keypress must be claimed (so xterm stays silent) yet preserve the
|
||||
// pending forward armed by the keydown.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
it('bypasses transformed keypresses even when the browser omits physical code', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => VIETNAMESE_FEATURES
|
||||
it('ignores composition input events even after a claimed keydown', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: ',', inputType: 'insertCompositionText', bubbles: true })
|
||||
)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: 'á', code: undefined }))).toBe(
|
||||
true
|
||||
)
|
||||
dispatchInsertText(textarea, 'á')
|
||||
it('clears pending forwarding when a composition input takes over', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: 'に', inputType: 'insertCompositionText', bubbles: true })
|
||||
)
|
||||
dispatchInsertText(textarea, '日本語')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('á')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards Vietnamese letter replacement text and suppresses matching key events', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => VIETNAMESE_FEATURES
|
||||
describe('claim lifetime is bounded by events, never by a timer', () => {
|
||||
it('keeps the claim armed across keyup until the committed input arrives', () => {
|
||||
// Some macOS IMEs deliver keyup before the final insertText.
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: 'á', code: 'KeyA' }))).toBe(
|
||||
true
|
||||
)
|
||||
dispatchInsertText(textarea, 'á')
|
||||
it('drops a stale claim on the next keydown rather than on elapsed time', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { forwarder, sendInput } = install()
|
||||
// First press is swallowed by the IME: no input event ever arrives.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
// Time alone must change nothing — no deferral is armed.
|
||||
vi.advanceTimersByTime(5000)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'a', code: 'KeyA' }))).toBe(true)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('á')
|
||||
// The next keydown re-arms, and its own commit is what gets forwarded.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '.', code: 'Period' }))).toBe(true)
|
||||
dispatchInsertText(textarea, '。')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('。')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bypasses keypress without clearing the armed forward (avoids ASCII double-send)', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
it('bypasses transformed keypresses even when the browser omits physical code', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true)
|
||||
expect(
|
||||
forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: 'á', code: undefined }))
|
||||
).toBe(true)
|
||||
dispatchInsertText(textarea, 'á')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('á')
|
||||
})
|
||||
|
||||
// An app that negotiated kitty `report_event_types` expects a release for every press it
|
||||
// received. The claim now takes every printable keydown, so swallowing the keyup
|
||||
// unconditionally would drop the release for ordinary typing and leave keys stuck down.
|
||||
it('lets the keyup through once the press has reached the pty, so its release still fires', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(false)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
// The paired case: nothing reached the pty, so a release would describe a press the app
|
||||
// never saw. This is the leak the unconditional claim was originally guarding against.
|
||||
it('still swallows the keyup when the input source ate the press', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the claim on blur', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
element.dispatchEvent(new FocusEvent('blur', { bubbles: true }))
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards Vietnamese source letters that commit unchanged', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => VIETNAMESE_FEATURES
|
||||
describe('composition transactions keep ownership', () => {
|
||||
it('lets an accepted transaction own its own commit', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
element.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
|
||||
)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'x', code: 'KeyX' }))).toBe(true)
|
||||
dispatchInsertText(textarea, 'x')
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'x', code: 'KeyX' }))).toBe(true)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('x')
|
||||
})
|
||||
|
||||
it('forwards Vietnamese VNI digit replacement text within the Vietnamese source gate', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => VIETNAMESE_FEATURES
|
||||
it('resumes forwarding once the transaction settles', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
element.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
|
||||
)
|
||||
element.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, { bubbles: true })
|
||||
)
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '9', code: 'Digit9' }))).toBe(true)
|
||||
dispatchInsertText(textarea, 'đ')
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: '9', code: 'Digit9' }))).toBe(
|
||||
true
|
||||
)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('đ')
|
||||
})
|
||||
|
||||
it('forwards synthesized non-CJK text while ordinary input-source features are disabled', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => DISABLED_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'h', code: 'KeyA', keyCode: 0 }))).toBe(true)
|
||||
dispatchInsertText(textarea, 'hello')
|
||||
|
||||
expect(
|
||||
forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'h', code: 'KeyA', keyCode: 0 }))
|
||||
).toBe(true)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('hello')
|
||||
})
|
||||
|
||||
it('does not claim Vietnamese replacement keys when modifier chords are held', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => VIETNAMESE_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA', metaKey: true }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA', ctrlKey: true }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA', altKey: true }))).toBe(false)
|
||||
dispatchInsertText(textarea, 'á')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not forward ordinary letters without a native replacement source', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => DISABLED_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(false)
|
||||
dispatchInsertText(textarea, 'á')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still claims keyup after forwarding input so the kitty release sequence does not leak', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
it('does not claim keypress or keyup when this forwarder did not claim the keydown', () => {
|
||||
const sendInput = vi.fn()
|
||||
let composing = true
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => composing,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false)
|
||||
composing = false
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keypress', key: ',' }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(false)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops forwarding after dispose', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
const { forwarder, sendInput } = install()
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
forwarder.dispose()
|
||||
dispatchInsertText(textarea, ',')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -662,43 +339,10 @@ describe('installTerminalImeNativeTextForwarder', () => {
|
|||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: null,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
sendInput
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false)
|
||||
expect(() => forwarder.dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not forward punctuation when input-source features are disabled', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => DISABLED_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(false)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('can become enabled after the input source changes to a Vietnamese IME', () => {
|
||||
const sendInput = vi.fn()
|
||||
let features: MacNativeTextInputSourceFeatures = DISABLED_FEATURES
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => features
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(false)
|
||||
features = VIETNAMESE_FEATURES
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true)
|
||||
dispatchInsertText(textarea, 'á')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('á')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,25 +1,30 @@
|
|||
import type { IDisposable } from '@xterm/xterm'
|
||||
import {
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES,
|
||||
type MacNativeTextInputSourceFeatures
|
||||
} from './terminal-ime-input-source'
|
||||
import {
|
||||
isImeNativeTextKeydownCandidate,
|
||||
isSinglePrintableTextKey,
|
||||
type ImeNativeTextKeyEvent
|
||||
} from './terminal-ime-native-text-candidates'
|
||||
|
||||
// Why: some macOS input sources and synthetic Unicode injectors commit native
|
||||
// text through a plain `insertText` event after a printable keydown. Xterm's
|
||||
// kitty keyboard protocol can encode and cancel that keydown before Chromium
|
||||
// commits the real text, so this narrowly bypasses known native-text candidates
|
||||
// and forwards the committed glyph from the input event straight to the PTY.
|
||||
// Why: a plain printable keydown never produces terminal bytes. Bytes for
|
||||
// printable characters come only from the `input` event, which on macOS *is*
|
||||
// the text system's commit callback and carries whatever the input source
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
type ClaimedKeyPress = {
|
||||
key: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export type ImeNativeTextKeyEvent = {
|
||||
type: string
|
||||
key: string
|
||||
code?: string
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
altKey: boolean
|
||||
isComposing?: boolean
|
||||
}
|
||||
|
||||
export const XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT = 'xterm-composition-transaction-accepted'
|
||||
export const XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT = 'xterm-composition-transaction-settled'
|
||||
|
||||
|
|
@ -27,12 +32,45 @@ export type TerminalImeNativeTextForwarder = IDisposable & {
|
|||
/**
|
||||
* Returns true when this keyboard event belongs to a direct native text
|
||||
* commit and should bypass xterm (the caller should return `false` from
|
||||
* `attachCustomKeyEventHandler`). The committed glyph is forwarded later from
|
||||
* `attachCustomKeyEventHandler`). The committed text is forwarded later from
|
||||
* the `input` event via the `sendInput` dependency.
|
||||
*/
|
||||
claimKeyEvent: (event: ImeNativeTextKeyEvent) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A single printable keystroke with no control chord and no live composition.
|
||||
*
|
||||
* `key` is read for LENGTH ONLY, never identity — that is what makes the
|
||||
* predicate invariant under the `key` rewrite a CJK input source performs, and
|
||||
* why no punctuation table is needed. Length also excludes named keys (`Enter`,
|
||||
* `ArrowLeft`, `Dead`, `F3`) without enumerating them.
|
||||
*
|
||||
* Claiming a keydown withholds its byte until the commit arrives, so a key the
|
||||
* IME eats without committing would be dropped. That is bounded, not a gap:
|
||||
* across 12,040 recorded keydowns there is no such case. The browser marks
|
||||
* IME-owned presses on the keydown itself — `keyCode 229` on macOS even while
|
||||
* `key` is still a single translated character — and all 4,453 such presses in
|
||||
* the corpus were followed by a composition event. It is a positive marker
|
||||
* only: fcitx5 on Wayland omits it, so it cannot be inverted into a gate.
|
||||
* Stale claims clear on the next keydown rather than on a timer; a timer here
|
||||
* once wrote a newline the user never typed.
|
||||
*/
|
||||
function isNativeTextKeydown(event: ImeNativeTextKeyEvent, compositionActive: boolean): boolean {
|
||||
return (
|
||||
event.type === 'keydown' &&
|
||||
// Control chords are the byte-producing case and belong to xterm's encoder.
|
||||
// Shift stays eligible: shifted punctuation still commits substituted text.
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
event.key.length === 1 &&
|
||||
// Composing keystrokes already belong to xterm's composition helper.
|
||||
event.isComposing !== true &&
|
||||
!compositionActive
|
||||
)
|
||||
}
|
||||
|
||||
function matchesClaimedPress(event: ImeNativeTextKeyEvent, claimedPress: ClaimedKeyPress): boolean {
|
||||
if (event.code && claimedPress.code) {
|
||||
return event.code === claimedPress.code
|
||||
|
|
@ -40,26 +78,10 @@ function matchesClaimedPress(event: ImeNativeTextKeyEvent, claimedPress: Claimed
|
|||
return event.key === claimedPress.key
|
||||
}
|
||||
|
||||
function matchesClaimedKeypress(
|
||||
event: ImeNativeTextKeyEvent,
|
||||
claimedPress: ClaimedKeyPress
|
||||
): boolean {
|
||||
if (matchesClaimedPress(event, claimedPress)) {
|
||||
return true
|
||||
}
|
||||
if (event.code && claimedPress.code) {
|
||||
return false
|
||||
}
|
||||
// Why: IME/native-text keypresses can carry the transformed glyph and omit
|
||||
// physical `code`; keep xterm silent until the input event forwards the text.
|
||||
return isSinglePrintableTextKey(event.key)
|
||||
}
|
||||
|
||||
export function installTerminalImeNativeTextForwarder(args: {
|
||||
terminalElement: HTMLElement | null | undefined
|
||||
isComposing: () => boolean
|
||||
sendInput: (data: string) => void
|
||||
getInputSourceFeatures?: () => MacNativeTextInputSourceFeatures
|
||||
}): TerminalImeNativeTextForwarder {
|
||||
if (!args.terminalElement) {
|
||||
return {
|
||||
|
|
@ -70,31 +92,10 @@ export function installTerminalImeNativeTextForwarder(args: {
|
|||
|
||||
const terminalElement = args.terminalElement
|
||||
let pendingForward = false
|
||||
let pendingForwardClearTimer: number | null = null
|
||||
let compositionTransactionPending = false
|
||||
let claimedPress: ClaimedKeyPress | null = null
|
||||
|
||||
const clearPendingForwardTimer = (): void => {
|
||||
if (pendingForwardClearTimer !== null) {
|
||||
window.clearTimeout(pendingForwardClearTimer)
|
||||
pendingForwardClearTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const disarmPendingForward = (): void => {
|
||||
clearPendingForwardTimer()
|
||||
pendingForward = false
|
||||
}
|
||||
|
||||
const schedulePendingForwardClear = (): void => {
|
||||
clearPendingForwardTimer()
|
||||
// Why: some macOS IMEs deliver keyup before the final insertText event;
|
||||
// keep the native commit armed briefly, then drop genuinely stray inserts.
|
||||
pendingForwardClearTimer = window.setTimeout(() => {
|
||||
pendingForward = false
|
||||
pendingForwardClearTimer = null
|
||||
}, 100)
|
||||
}
|
||||
/** Whether the claimed press actually reached the pty, which decides if its release does. */
|
||||
let forwardedPressBytes = false
|
||||
|
||||
const markCompositionTransactionAccepted = (): void => {
|
||||
compositionTransactionPending = true
|
||||
|
|
@ -106,18 +107,13 @@ export function installTerminalImeNativeTextForwarder(args: {
|
|||
|
||||
const claimKeyEvent = (event: ImeNativeTextKeyEvent): boolean => {
|
||||
if (event.type === 'keydown') {
|
||||
if (
|
||||
!isImeNativeTextKeydownCandidate(
|
||||
event,
|
||||
args.isComposing(),
|
||||
args.getInputSourceFeatures?.() ?? DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
)
|
||||
) {
|
||||
if (!isNativeTextKeydown(event, args.isComposing())) {
|
||||
return false
|
||||
}
|
||||
// Arm forwarding so the upcoming input event is sent to the PTY.
|
||||
clearPendingForwardTimer()
|
||||
// Why: re-arming here is also what drops a stale claim whose input event
|
||||
// never arrived (the input source swallowed the key) — no timer needed.
|
||||
pendingForward = true
|
||||
forwardedPressBytes = false
|
||||
claimedPress = { key: event.key, code: event.code }
|
||||
return true
|
||||
}
|
||||
|
|
@ -131,51 +127,53 @@ export function installTerminalImeNativeTextForwarder(args: {
|
|||
if (!matchesClaimedPress(event, claimedPress)) {
|
||||
return false
|
||||
}
|
||||
const pressReachedThePty = forwardedPressBytes
|
||||
claimedPress = null
|
||||
if (pendingForward) {
|
||||
schedulePendingForwardClear()
|
||||
}
|
||||
// Bypass so the kitty release sequence for the swallowed press cannot leak.
|
||||
return true
|
||||
forwardedPressBytes = false
|
||||
// Why: a release report describes a press the app received. Suppress it only when
|
||||
// this press put nothing on the wire — swallowed by the input source, or owned by a
|
||||
// composition transaction. Suppressing unconditionally would drop the kitty release
|
||||
// for ordinary typing, because the structural claim takes every printable keydown
|
||||
// rather than the short punctuation list the previous design claimed.
|
||||
return !pressReachedThePty
|
||||
}
|
||||
if (event.type === 'keypress') {
|
||||
// Keep the keydown's armed state but still bypass xterm so it does not
|
||||
// double-send printable text before our input forward runs.
|
||||
return matchesClaimedKeypress(event, claimedPress)
|
||||
}
|
||||
return false
|
||||
// Keep the keydown's armed state but still bypass xterm so it does not
|
||||
// double-send printable text before our input forward runs.
|
||||
return event.type === 'keypress'
|
||||
}
|
||||
|
||||
const forwardCommittedText = (event: Event): void => {
|
||||
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') {
|
||||
disarmPendingForward()
|
||||
pendingForward = false
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
if (!pendingForward) {
|
||||
return
|
||||
}
|
||||
pendingForward = false
|
||||
if (event.inputType !== 'insertText') {
|
||||
disarmPendingForward()
|
||||
return
|
||||
}
|
||||
disarmPendingForward()
|
||||
if (event.data) {
|
||||
args.sendInput(event.data)
|
||||
forwardedPressBytes = true
|
||||
}
|
||||
event.stopImmediatePropagation()
|
||||
// Clear the helper textarea so the native glyph doesn't accumulate;
|
||||
// safe in practice since only synthetic injectors interleave commits.
|
||||
// Clear the helper textarea so the committed text doesn't accumulate.
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPending = (): void => {
|
||||
disarmPendingForward()
|
||||
pendingForward = false
|
||||
forwardedPressBytes = false
|
||||
compositionTransactionPending = false
|
||||
claimedPress = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
// @vitest-environment happy-dom
|
||||
// An input source that substitutes text for a printable key commits it through a
|
||||
// bare `insertText` with no composition session. Drives a real Terminal wired the
|
||||
// way the pane lifecycle wires it, so it covers the whole keydown -> input path.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
|
||||
import { shouldBypassXtermKeyboardEvent } from './xterm-bypass-policy'
|
||||
|
||||
type SubstitutionCase = {
|
||||
name: string
|
||||
code: string
|
||||
keyCode: number
|
||||
shiftKey?: boolean
|
||||
layoutText: string
|
||||
imeText: string
|
||||
}
|
||||
|
||||
function open(kittyKeyboardFlags = 0) {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea!
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: terminal.element,
|
||||
isComposing: () => false,
|
||||
sendInput: (data) => terminal.input(data)
|
||||
})
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (forwarder.claimKeyEvent(event)) {
|
||||
return false
|
||||
}
|
||||
return !shouldBypassXtermKeyboardEvent(event, {
|
||||
isMac: true,
|
||||
hasSelection: false,
|
||||
kittyKeyboardFlags
|
||||
})
|
||||
})
|
||||
const emitted: string[] = []
|
||||
terminal.onData((d) => emitted.push(d))
|
||||
return { emitted, terminal, textarea, forwarder }
|
||||
}
|
||||
|
||||
function key(
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: string,
|
||||
init: { key: string; code: string; keyCode: number; shiftKey: boolean }
|
||||
): KeyboardEvent {
|
||||
const ev = new KeyboardEvent(type, {
|
||||
key: init.key,
|
||||
code: init.code,
|
||||
shiftKey: init.shiftKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(ev, 'keyCode', { value: init.keyCode })
|
||||
Object.defineProperty(ev, 'charCode', { value: type === 'keypress' ? init.keyCode : 0 })
|
||||
textarea.dispatchEvent(ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
function insertText(textarea: HTMLTextAreaElement, type: string, data: string): void {
|
||||
const ev = new InputEvent(type, { bubbles: true })
|
||||
Object.defineProperty(ev, 'inputType', { value: 'insertText' })
|
||||
Object.defineProperty(ev, 'data', { value: data })
|
||||
Object.defineProperty(ev, 'composed', { value: true })
|
||||
textarea.dispatchEvent(ev)
|
||||
}
|
||||
|
||||
function press(textarea: HTMLTextAreaElement, c: SubstitutionCase): void {
|
||||
const shiftKey = c.shiftKey === true
|
||||
const kd = key(textarea, 'keydown', {
|
||||
key: c.layoutText,
|
||||
code: c.code,
|
||||
keyCode: c.keyCode,
|
||||
shiftKey
|
||||
})
|
||||
if (!kd.defaultPrevented) {
|
||||
if (c.imeText.length === 1) {
|
||||
key(textarea, 'keypress', {
|
||||
key: c.imeText,
|
||||
code: c.code,
|
||||
keyCode: c.imeText.charCodeAt(0),
|
||||
shiftKey
|
||||
})
|
||||
}
|
||||
textarea.value = c.imeText
|
||||
textarea.setSelectionRange(c.imeText.length, c.imeText.length)
|
||||
insertText(textarea, 'beforeinput', c.imeText)
|
||||
insertText(textarea, 'input', c.imeText)
|
||||
}
|
||||
key(textarea, 'keyup', { key: c.layoutText, code: c.code, keyCode: c.keyCode, shiftKey })
|
||||
}
|
||||
|
||||
function type(cases: SubstitutionCase[], kitty = 0): string {
|
||||
const { emitted, terminal, textarea, forwarder } = open(kitty)
|
||||
for (const c of cases) {
|
||||
press(textarea, c)
|
||||
}
|
||||
forwarder.dispose()
|
||||
terminal.dispose()
|
||||
return emitted.join('')
|
||||
}
|
||||
|
||||
const COMMA: SubstitutionCase = {
|
||||
name: 'comma',
|
||||
code: 'Comma',
|
||||
keyCode: 188,
|
||||
layoutText: ',',
|
||||
imeText: ','
|
||||
}
|
||||
const PERIOD: SubstitutionCase = {
|
||||
name: 'period',
|
||||
code: 'Period',
|
||||
keyCode: 190,
|
||||
layoutText: '.',
|
||||
imeText: '。'
|
||||
}
|
||||
const QUESTION: SubstitutionCase = {
|
||||
name: 'question',
|
||||
code: 'Slash',
|
||||
keyCode: 191,
|
||||
shiftKey: true,
|
||||
layoutText: '?',
|
||||
imeText: '?'
|
||||
}
|
||||
const BACKSLASH: SubstitutionCase = {
|
||||
name: 'ideographic comma',
|
||||
code: 'Backslash',
|
||||
keyCode: 220,
|
||||
layoutText: '\\',
|
||||
imeText: '、'
|
||||
}
|
||||
const EM_DASH: SubstitutionCase = {
|
||||
name: 'em dash pair',
|
||||
code: 'Minus',
|
||||
keyCode: 189,
|
||||
shiftKey: true,
|
||||
layoutText: '_',
|
||||
imeText: '——'
|
||||
}
|
||||
const FULLWIDTH_ONE: SubstitutionCase = {
|
||||
name: 'full-width one',
|
||||
code: 'Digit1',
|
||||
keyCode: 49,
|
||||
layoutText: '1',
|
||||
imeText: '1'
|
||||
}
|
||||
const TELEX_A: SubstitutionCase = {
|
||||
name: 'telex a-acute',
|
||||
code: 'KeyS',
|
||||
keyCode: 83,
|
||||
layoutText: 's',
|
||||
imeText: 'á'
|
||||
}
|
||||
|
||||
describe('input-source text substitution reaches the terminal', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('sends the substituted sentence tail, not the raw layout characters', () => {
|
||||
expect(type([COMMA, PERIOD, PERIOD])).toBe(',。。')
|
||||
})
|
||||
it('sends a shifted substitution', () => {
|
||||
expect(type([QUESTION])).toBe('?')
|
||||
})
|
||||
it('sends the backslash-position substitution (#10896)', () => {
|
||||
expect(type([BACKSLASH])).toBe('、')
|
||||
})
|
||||
it('sends a multi-code-unit substitution from one press', () => {
|
||||
expect(type([EM_DASH])).toBe('——')
|
||||
})
|
||||
it('sends a full-width digit substitution', () => {
|
||||
expect(type([FULLWIDTH_ONE])).toBe('1')
|
||||
})
|
||||
it('sends a letter substitution', () => {
|
||||
expect(type([TELEX_A])).toBe('á')
|
||||
})
|
||||
it('sends the substitution with kitty disambiguate reporting negotiated', () => {
|
||||
expect(type([COMMA], 1)).toBe(',')
|
||||
})
|
||||
|
||||
// Pins a deliberate hole rather than a desired behaviour. Flag 8 asks for every printable key as
|
||||
// an escape code, and this path sends the committed text raw instead — a mature native terminal
|
||||
// makes the same trade, preferring correct characters to protocol fidelity. Recorded here so the
|
||||
// choice is visible: if this ever needs closing, gate on flag 8 alone, never on "kitty active",
|
||||
// which would disable the substitution for every pane that negotiates anything.
|
||||
it('sends the substitution raw even when kitty asks for all keys as escape codes', () => {
|
||||
expect(type([COMMA], 8)).toBe(',')
|
||||
})
|
||||
})
|
||||
|
|
@ -168,7 +168,15 @@ describe('Korean won input after a composition commit', () => {
|
|||
await expect(
|
||||
typeHangulThenWon(['₩'], {
|
||||
beforeWon: (textarea) => {
|
||||
// Why: the full sequence a browser emits for an unprevented printable
|
||||
// key. The keydown alone is not a shape Chromium can produce, and the
|
||||
// `input` event is what carries the character now.
|
||||
textarea.dispatchEvent(keyboardEvent('keydown', 'a', 'KeyA', 65))
|
||||
textarea.dispatchEvent(keyboardEvent('keypress', 'a', 'KeyA', 65))
|
||||
textarea.value += 'a'
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: 'a', inputType: 'insertText', bubbles: true })
|
||||
)
|
||||
}
|
||||
})
|
||||
).resolves.toBe('한a₩')
|
||||
|
|
|
|||
|
|
@ -90,10 +90,6 @@ import {
|
|||
createTerminalImePendingCandidateKeyReleases,
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease
|
||||
} from './terminal-ime-candidate-key-release-guard'
|
||||
import {
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES,
|
||||
getMacNativeTextInputSourceTracker
|
||||
} from './terminal-ime-input-source'
|
||||
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
|
||||
import {
|
||||
shouldBypassXtermKeyboardEvent,
|
||||
|
|
@ -912,7 +908,6 @@ export function useTerminalPaneLifecycle({
|
|||
const linuxImeCandidateState = isLinux
|
||||
? installTerminalImeLinuxCandidateState(pane.terminal.element)
|
||||
: null
|
||||
const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null
|
||||
const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element)
|
||||
imeCompositionDisposablesRef.current.set(pane.id, {
|
||||
dispose: () => {
|
||||
|
|
@ -920,15 +915,12 @@ export function useTerminalPaneLifecycle({
|
|||
linuxImeCandidateState?.dispose()
|
||||
}
|
||||
})
|
||||
// Why: only known macOS native text paths (physical CJK/Vietnamese IME) need the keydown bypass; synthetic Unicode lacks physical key identity.
|
||||
// Why: macOS commits an input source's substituted text through the input event alone, so printable keydowns must not reach xterm's encoder.
|
||||
const imeNativeTextForwarder = isMac
|
||||
? installTerminalImeNativeTextForwarder({
|
||||
terminalElement: pane.terminal.element,
|
||||
isComposing: () => imeCompositionTracker.isActive(),
|
||||
sendInput: (data) => pane.terminal.input(data),
|
||||
getInputSourceFeatures: () =>
|
||||
macNativeTextInputSourceTracker?.getFeatures() ??
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
sendInput: (data) => pane.terminal.input(data)
|
||||
})
|
||||
: {
|
||||
claimKeyEvent: () => false,
|
||||
|
|
|
|||
|
|
@ -119,49 +119,23 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => {
|
|||
expect(shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC' }), opts)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves physical Backslash to native keypress when kitty reporting is inactive', () => {
|
||||
// Why: third-party IMEs outside the forwarder's CJK allowlist (Qingg, #10896)
|
||||
// need Chromium's text pipeline to turn this key into 、 rather than \.
|
||||
for (const type of ['keydown', 'keyup']) {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(
|
||||
event({ type, key: '\\', code: 'Backslash', keyCode: 220 }),
|
||||
{ ...noSel, kittyKeyboardFlags: 0 }
|
||||
)
|
||||
).toBe(true)
|
||||
it('no longer special-cases Backslash — the native-text forwarder owns it', () => {
|
||||
// Why: this policy carried a `code === 'Backslash'` bypass because the old
|
||||
// forwarder only claimed keys for input sources on a hardcoded allowlist.
|
||||
// The structural claim covers every printable key, so the exception is gone
|
||||
// and the physical key is no longer named anywhere in this file.
|
||||
for (const type of ['keydown', 'keyup', 'keypress']) {
|
||||
for (const kittyKeyboardFlags of [0, 1]) {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ type, key: '\\', code: 'Backslash' }), {
|
||||
...noSel,
|
||||
kittyKeyboardFlags
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not bypass the native Backslash keypress', () => {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(
|
||||
event({ type: 'keypress', key: '\\', code: 'Backslash', keyCode: 0x3001 }),
|
||||
{ ...noSel, kittyKeyboardFlags: 0 }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps modified or kitty-reported Backslash in xterm', () => {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ key: '|', code: 'Backslash', shiftKey: true }), {
|
||||
...noSel,
|
||||
kittyKeyboardFlags: 0
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ key: '\\', code: 'Backslash', ctrlKey: true }), {
|
||||
...noSel,
|
||||
kittyKeyboardFlags: 0
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ key: '\\', code: 'Backslash' }), {
|
||||
...noSel,
|
||||
kittyKeyboardFlags: 1
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('bubbles Shift+non-ASCII printable text so the active keyboard layout wins', () => {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ key: 'Ф', code: 'KeyA', shiftKey: true }), opts)
|
||||
|
|
|
|||
|
|
@ -84,33 +84,6 @@ function isSingleNonAsciiPrintableText(key: string): boolean {
|
|||
return codePoint !== undefined && codePoint >= 0x80
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: kept from #13128 when the rest of it was reverted. The native-text
|
||||
* forwarder only claims keys for input sources in its hardcoded CJK allowlist,
|
||||
* so third-party IMEs off that list (Qingg, issue #10896) never get their
|
||||
* backslash-position key claimed and xterm sends the raw `\` instead of `、`.
|
||||
* Bypassing keydown lets Chromium's text pipeline produce the layout character.
|
||||
* Scoped to the bare backslash key: broadening this to all non-ASCII text would
|
||||
* race the forwarder and double-send.
|
||||
*/
|
||||
export function shouldBypassXtermForMacNativeText(
|
||||
event: XtermBypassEvent,
|
||||
isMac: boolean,
|
||||
kittyKeyboardActive = false
|
||||
): boolean {
|
||||
if (
|
||||
!isMac ||
|
||||
!isXtermHandledKeyEvent(event.type) ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return !kittyKeyboardActive && event.code === 'Backslash'
|
||||
}
|
||||
|
||||
function isXtermHandledKeyEvent(type: string): boolean {
|
||||
return type === 'keydown' || type === 'keyup'
|
||||
}
|
||||
|
|
@ -249,9 +222,6 @@ export function shouldBypassXtermKeyboardEvent(
|
|||
}
|
||||
|
||||
const { isMac, hasSelection } = options
|
||||
if (shouldBypassXtermForMacNativeText(event, isMac, (options.kittyKeyboardFlags ?? 0) !== 0)) {
|
||||
return true
|
||||
}
|
||||
const platformModifierHeld = isMac
|
||||
? event.metaKey && !event.ctrlKey
|
||||
: event.ctrlKey && !event.metaKey
|
||||
|
|
|
|||
Loading…
Reference in New Issue