mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(mobile): recompose iOS decomposed (NFD) Hangul before it reaches the PTY
Apple text input hands React Native decomposed text, so one Hangul syllable arrives as 2-3 conjoining jamo (안 = U+110B U+1161 U+11AB). The live-input mirror holds only the trailing code point as the in-composition syllable, so every leading jamo is committed to the PTY on its own and is never recombined: typing 안녕하세요 lands 12 lone jamo instead of 5 syllables. This is a third mechanism behind the #6995 symptom, distinct from the Samsung Keyboard inputType kill on Android — the iOS field itself composes correctly, it is the string handed to JS that is decomposed, which is why the report survives autoCorrect={false}. NFC-normalize at the existing normalizeTerminalTextInput choke point, which covers both the live mirror and the command-bar send. NFC only, never NFKC: NFKC folds compatibility jamo and full-width forms into different characters and would silently rewrite a command. The field write-back stays verbatim, so the iOS dictation/IME constraint from #7925 is untouched. Fixes #11235 Refs #6995, #7925
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTerminalLiveMirrorPayload,
|
||||
computeTerminalLiveMirrorStep
|
||||
} from './terminal-live-hangul-mirror'
|
||||
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
|
||||
|
||||
// 두벌식 (2-set) composition states for 안녕하세요, as the field shows them
|
||||
// keystroke by keystroke: ㅇ ㅏ ㄴ ㄴ ㅕ ㅇ ㅎ ㅏ ㅅ ㅔ ㅇ ㅛ.
|
||||
const DUBEOLSIK_FIELD_STATES = [
|
||||
'ㅇ',
|
||||
'아',
|
||||
'안',
|
||||
'안ㄴ',
|
||||
'안녀',
|
||||
'안녕',
|
||||
'안녕ㅎ',
|
||||
'안녕하',
|
||||
'안녕핫',
|
||||
'안녕하세',
|
||||
'안녕하셍',
|
||||
'안녕하세요'
|
||||
] as const
|
||||
|
||||
const DEL = '\x7f'
|
||||
|
||||
// Minimal PTY line model: DEL erases one code point, everything else appends.
|
||||
function applyToPty(pty: string, payload: string): string {
|
||||
let next = Array.from(pty)
|
||||
for (const codePoint of Array.from(payload)) {
|
||||
if (codePoint === DEL) {
|
||||
next = next.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
next.push(codePoint)
|
||||
}
|
||||
return next.join('')
|
||||
}
|
||||
|
||||
// Replays handleLiveInputChange → applyLiveInputMirror, then the settle-timer
|
||||
// commit that flushes the last held syllable.
|
||||
function replayLiveInput(fieldStates: readonly string[]): string {
|
||||
let sentText = ''
|
||||
let heldText = ''
|
||||
let pty = ''
|
||||
|
||||
const step = (fieldText: string, commitHeld: boolean): void => {
|
||||
const mirror = computeTerminalLiveMirrorStep(sentText, normalizeTerminalTextInput(fieldText), {
|
||||
commitHeld
|
||||
})
|
||||
pty = applyToPty(pty, buildTerminalLiveMirrorPayload(mirror))
|
||||
sentText = mirror.nextSentText
|
||||
heldText = mirror.heldText
|
||||
}
|
||||
|
||||
for (const fieldText of fieldStates) {
|
||||
step(fieldText, false)
|
||||
}
|
||||
step(sentText + heldText, true)
|
||||
return pty
|
||||
}
|
||||
|
||||
describe('iOS decomposed (NFD) Hangul live input', () => {
|
||||
it('Given an NFD field (iOS) When the user types 안녕하세요 Then the PTY receives composed syllables, not jamo', () => {
|
||||
// Given: Apple text input hands React Native decomposed conjoining jamo, so
|
||||
// one syllable arrives as 2-3 code points instead of one.
|
||||
const nfdStates = DUBEOLSIK_FIELD_STATES.map((state) => state.normalize('NFD'))
|
||||
expect(Array.from(nfdStates.at(-1) ?? '')).toHaveLength(12)
|
||||
|
||||
// When
|
||||
const pty = replayLiveInput(nfdStates)
|
||||
|
||||
// Then: the mirror holds only the trailing code point, so without
|
||||
// recomposition every leading jamo is committed on its own (#6995 iOS).
|
||||
expect(Array.from(pty)).toHaveLength(5)
|
||||
expect(pty).toBe('안녕하세요')
|
||||
})
|
||||
|
||||
it('Given a precomposed NFC field (Android/Gboard) When the same keys are typed Then the PTY result is unchanged', () => {
|
||||
// Given / When
|
||||
const pty = replayLiveInput(DUBEOLSIK_FIELD_STATES)
|
||||
|
||||
// Then
|
||||
expect(pty).toBe('안녕하세요')
|
||||
})
|
||||
|
||||
it('Given NFD Latin diacritics When mirrored Then combining marks reach the PTY already composed', () => {
|
||||
// Given / When: iOS also hands back NFD for Vietnamese/European accents.
|
||||
const pty = replayLiveInput(['tie', 'tiế', 'tiếng'].map((state) => state.normalize('NFD')))
|
||||
|
||||
// Then
|
||||
expect(pty).toBe('tiếng')
|
||||
expect(Array.from(pty)).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -11,4 +11,29 @@ describe('normalizeTerminalTextInput', () => {
|
||||
it('keeps ASCII hyphens unchanged', () => {
|
||||
expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file')
|
||||
})
|
||||
|
||||
it('recomposes decomposed Hangul that iOS hands back as conjoining jamo', () => {
|
||||
const decomposed = '안녕하세요'.normalize('NFD')
|
||||
expect(Array.from(decomposed)).toHaveLength(12)
|
||||
|
||||
const normalized = normalizeTerminalTextInput(decomposed)
|
||||
|
||||
expect(normalized).toBe('안녕하세요')
|
||||
expect(Array.from(normalized)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('recomposes decomposed Latin diacritics and stays idempotent on composed text', () => {
|
||||
expect(normalizeTerminalTextInput('tiếng'.normalize('NFD'))).toBe('tiếng')
|
||||
expect(normalizeTerminalTextInput('안녕하세요')).toBe('안녕하세요')
|
||||
expect(normalizeTerminalTextInput(normalizeTerminalTextInput('café'.normalize('NFD')))).toBe(
|
||||
'café'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves compatibility forms alone so NFC never becomes a lossy NFKC fold', () => {
|
||||
// NFKC would fold these into different characters and silently rewrite a command.
|
||||
expect(normalizeTerminalTextInput('ㅇㅏㄴ')).toBe('ㅇㅏㄴ')
|
||||
expect(normalizeTerminalTextInput('git')).toBe('git')
|
||||
expect(normalizeTerminalTextInput('①')).toBe('①')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
// value written back into the field, and that write-back kills iOS dictation (#7925).
|
||||
const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g
|
||||
|
||||
// Why: Apple text input hands back decomposed (NFD) text, so one Hangul syllable
|
||||
// arrives as 2-3 conjoining jamo. The live mirror holds only the trailing code
|
||||
// point, so every leading jamo would commit on its own (#6995 iOS). NFC — never
|
||||
// NFKC, which rewrites compatibility jamo and full-width forms — recomposes them
|
||||
// before the PTY sees anything; it is a no-op for already-composed input.
|
||||
export function normalizeTerminalTextInput(text: string): string {
|
||||
return text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--')
|
||||
return text.normalize('NFC').replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user