fix(terminal): preserve macOS Korean composition (#12280)
This commit is contained in:
parent
f82d4100b7
commit
035d8c2a54
|
|
@ -17,6 +17,7 @@ describe('getMacNativeTextInputSourceFeatures', () => {
|
|||
'com.apple.inputmethod.Korean.2SetKorean'
|
||||
]) {
|
||||
expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({
|
||||
forwardHangulJamo: sourceId.includes('Korean'),
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
|
|
@ -25,18 +26,22 @@ describe('getMacNativeTextInputSourceFeatures', () => {
|
|||
|
||||
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
|
||||
})
|
||||
|
|
@ -50,6 +55,7 @@ describe('getMacNativeTextInputSourceFeatures', () => {
|
|||
'org.unikey.inputmethod.Unikey'
|
||||
]) {
|
||||
expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
})
|
||||
|
|
@ -58,6 +64,7 @@ describe('getMacNativeTextInputSourceFeatures', () => {
|
|||
|
||||
it('rejects plain keyboard layouts and unrelated input methods', () => {
|
||||
const disabled = {
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
}
|
||||
|
|
@ -90,6 +97,7 @@ describe('createMacNativeTextInputSourceTracker', () => {
|
|||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(false)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
|
|
@ -98,6 +106,7 @@ describe('createMacNativeTextInputSourceTracker', () => {
|
|||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
|
|
@ -106,6 +115,7 @@ describe('createMacNativeTextInputSourceTracker', () => {
|
|||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
})
|
||||
|
|
@ -140,6 +150,7 @@ describe('createMacNativeTextInputSourceTracker', () => {
|
|||
|
||||
await vi.waitFor(() =>
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { IDisposable } from '@xterm/xterm'
|
||||
|
||||
export type MacNativeTextInputSourceFeatures = Readonly<{
|
||||
forwardHangulJamo: boolean
|
||||
forwardAsciiPunctuation: boolean
|
||||
forwardShortTextReplacements: boolean
|
||||
}>
|
||||
|
|
@ -14,16 +15,25 @@ export type MacNativeTextInputSourceTracker = IDisposable & {
|
|||
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
|
||||
|
|
@ -55,6 +65,7 @@ const CJK_INPUT_SOURCE_TERMS = [
|
|||
] 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
|
||||
|
||||
|
|
@ -90,6 +101,9 @@ export function getMacNativeTextInputSourceFeatures(
|
|||
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
|
||||
}
|
||||
|
|
@ -162,7 +176,11 @@ export function createMacNativeTextInputSourceTracker(
|
|||
requestKeyboardActivityRefresh(true)
|
||||
return
|
||||
}
|
||||
if (!features.forwardAsciiPunctuation && !features.forwardShortTextReplacements) {
|
||||
if (
|
||||
!features.forwardHangulJamo &&
|
||||
!features.forwardAsciiPunctuation &&
|
||||
!features.forwardShortTextReplacements
|
||||
) {
|
||||
requestKeyboardActivityRefresh(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -175,7 +193,10 @@ export function createMacNativeTextInputSourceTracker(
|
|||
requestRefresh()
|
||||
|
||||
return {
|
||||
isActive: () => features.forwardAsciiPunctuation || features.forwardShortTextReplacements,
|
||||
isActive: () =>
|
||||
features.forwardHangulJamo ||
|
||||
features.forwardAsciiPunctuation ||
|
||||
features.forwardShortTextReplacements,
|
||||
getFeatures: () => features,
|
||||
refresh,
|
||||
dispose: () => {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,21 @@ 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) {
|
||||
|
|
@ -143,6 +158,9 @@ export function isImeNativeTextKeydownCandidate(
|
|||
if (isSyntheticUnicodeTextKey(event)) {
|
||||
return true
|
||||
}
|
||||
if (inputSourceFeatures.forwardHangulJamo && isHangulJamoKey(event.key)) {
|
||||
return true
|
||||
}
|
||||
if (isCjkDirectPunctuationKey(event.key)) {
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,25 @@ import {
|
|||
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
|
||||
|
|
@ -68,6 +77,23 @@ describe('isImeNativeTextKeydownCandidate', () => {
|
|||
).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', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
attachTerminalImeBoundaryEvidence,
|
||||
disposeTerminalImeBoundaryProbe,
|
||||
installTerminalImeBoundaryProbe,
|
||||
readTerminalImeBoundaryTrace
|
||||
} from './terminal-ime-boundary-probe'
|
||||
import {
|
||||
createTerminalImeByteReader,
|
||||
removeTerminalImeByteReader,
|
||||
startTerminalImeByteReader,
|
||||
waitForTerminalImeBytes
|
||||
} from './terminal-ime-byte-reader'
|
||||
|
||||
const TWO_SET_KOREAN_ID = 'com.apple.inputmethod.Korean.2SetKorean'
|
||||
|
||||
function typeNativeTwoSetKorean(processId: number): void {
|
||||
execFileSync('osascript', [
|
||||
'-e',
|
||||
`tell application "System Events" to set frontmost of first application process whose unix id is ${processId} to true`,
|
||||
'-e',
|
||||
'tell application "System Events" to key code {5, 40, 1, 15, 46, 3, 36}'
|
||||
])
|
||||
}
|
||||
|
||||
async function runNativeScenario(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
testRepoPath: string,
|
||||
processId: number
|
||||
): Promise<void> {
|
||||
await waitForSessionReady(page)
|
||||
await waitForActiveWorktree(page)
|
||||
await ensureTerminalVisible(page)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
await expect(page.evaluate(() => window.api.app.getKeyboardInputSourceId())).resolves.toBe(
|
||||
TWO_SET_KOREAN_ID
|
||||
)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(page)
|
||||
const reader = createTerminalImeByteReader(testRepoPath, 1)
|
||||
let completed = false
|
||||
try {
|
||||
await startTerminalImeByteReader(page, ptyId, reader)
|
||||
await focusActiveTerminalInput(page)
|
||||
await installTerminalImeBoundaryProbe(page)
|
||||
typeNativeTwoSetKorean(processId)
|
||||
|
||||
const receivedBytes = await waitForTerminalImeBytes(page, reader)
|
||||
expect(receivedBytes).toEqual([Buffer.from('한글\n').toString('hex')])
|
||||
const trace = await readTerminalImeBoundaryTrace(page)
|
||||
expect(trace.onData.join('')).toBe('한글\r')
|
||||
completed = true
|
||||
} finally {
|
||||
await attachTerminalImeBoundaryEvidence(page, testInfo, 'native-macos-2set-boundaries').catch(
|
||||
() => undefined
|
||||
)
|
||||
await disposeTerminalImeBoundaryProbe(page).catch(() => undefined)
|
||||
if (!completed) {
|
||||
await sendToTerminal(page, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
removeTerminalImeByteReader(reader)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Native macOS 2-Set Korean terminal input @headful', () => {
|
||||
test.skip(
|
||||
process.platform !== 'darwin' || process.env.ORCA_E2E_NATIVE_MACOS_KOREAN !== '1',
|
||||
'Requires macOS with 2-Set Korean selected and Accessibility access'
|
||||
)
|
||||
|
||||
test('forwards physical Hangul input as exact PTY bytes', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await runNativeScenario(orcaPage, testInfo, testRepoPath, electronApp.process().pid!)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue