diff --git a/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts b/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts index 1b07c2553..01d3e398d 100644 --- a/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts @@ -228,4 +228,164 @@ describe("native terminal typed input", () => { shouldClear: false, }); }); + + it("types Hangul by rubbing out the syllable the IME rewrites in place", () => { + const input = createTerminalTextInputState(); + + // 한글: the composing syllable is rewritten on every jamo, never appended. + expect(input.receiveTextChange("ㅎ")).toEqual({ data: "ㅎ", shouldClear: false }); + expect(input.receiveTextChange("하")).toEqual({ data: "\x7f하", shouldClear: false }); + expect(input.receiveTextChange("한")).toEqual({ data: "\x7f한", shouldClear: false }); + expect(input.receiveTextChange("한ㄱ")).toEqual({ data: "ㄱ", shouldClear: false }); + expect(input.receiveTextChange("한그")).toEqual({ data: "\x7f그", shouldClear: false }); + expect(input.receiveTextChange("한글")).toEqual({ data: "\x7f글", shouldClear: false }); + }); + + it("types Hangul when a final consonant migrates into the next syllable", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("안")).toEqual({ data: "안", shouldClear: false }); + // Typing ㅣ moves ㄴ off 안 and onto the new syllable: 안 → 아니. + expect(input.receiveTextChange("아니")).toEqual({ data: "\x7f아니", shouldClear: false }); + }); + + it("commits a Chinese pinyin reading as Han characters", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("n")).toEqual({ data: "n", shouldClear: false }); + expect(input.receiveTextChange("ni")).toEqual({ data: "i", shouldClear: false }); + expect(input.receiveTextChange("nih")).toEqual({ data: "h", shouldClear: false }); + expect(input.receiveTextChange("niha")).toEqual({ data: "a", shouldClear: false }); + expect(input.receiveTextChange("nihao")).toEqual({ data: "o", shouldClear: false }); + expect(input.receiveTextChange("你好")).toEqual({ + data: "\x7f\x7f\x7f\x7f\x7f你好", + shouldClear: false, + }); + }); + + it("updates a multi-character Chinese candidate", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("nihao")).toEqual({ data: "nihao", shouldClear: false }); + expect(input.receiveTextChange("你好")).toEqual({ + data: "\x7f\x7f\x7f\x7f\x7f你好", + shouldClear: false, + }); + expect(input.receiveTextChange("拟好")).toEqual({ + data: "\x7f\x7f拟好", + shouldClear: false, + }); + }); + + it("commits and converts a Japanese reading", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("nihongo")).toEqual({ data: "nihongo", shouldClear: false }); + expect(input.receiveTextChange("にほんご")).toEqual({ + data: "\x7f\x7f\x7f\x7f\x7f\x7f\x7fにほんご", + shouldClear: false, + }); + expect(input.receiveTextChange("日本語")).toEqual({ + data: "\x7f\x7f\x7f\x7f日本語", + shouldClear: false, + }); + }); + + it("does not dispatch Hangul through the keypress path", () => { + const input = createTerminalTextInputState(); + + // The IME reports the jamo it is composing; the text change reports the + // syllable. Dispatching both would put two conflicting characters on the wire. + expect(input.receiveKeyPress("ㅎ")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveTextChange("ㅎ")).toEqual({ data: "ㅎ", shouldClear: false }); + expect(input.receiveKeyPress("ㅏ")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveTextChange("하")).toEqual({ data: "\x7f하", shouldClear: false }); + }); + + it("still swallows non-CJK replacement edits", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("teh")).toEqual({ data: "teh", shouldClear: false }); + expect(input.receiveTextChange("the")).toEqual({ data: "", shouldClear: false }); + }); + + it("does not treat a non-Hangul trailing edit as composition", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("ls -a")).toEqual({ data: "ls -a", shouldClear: false }); + expect(input.receiveTextChange("ls -l")).toEqual({ data: "", shouldClear: false }); + }); + + it("keeps anticipated text aligned when Backspace decomposes a syllable", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("한")).toEqual({ data: "한", shouldClear: false }); + // The rubout already removed the whole syllable, so the redraw is a plain append. + expect(input.receiveKeyPress("Backspace")).toEqual({ data: "\x7f", shouldClear: false }); + expect(input.receiveTextChange("하")).toEqual({ data: "하", shouldClear: false }); + }); + + it("keeps CJK composition enabled after Backspace removes an emoji", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("😀")).toEqual({ data: "😀", shouldClear: false }); + expect(input.receiveKeyPress("Backspace")).toEqual({ data: "\x7f", shouldClear: false }); + + expect(input.receiveTextChange("nihao")).toEqual({ data: "nihao", shouldClear: false }); + expect(input.receiveTextChange("你好")).toEqual({ + data: "\x7f\x7f\x7f\x7f\x7f你好", + shouldClear: false, + }); + }); + + it("stops rubbing out once a swallowed replacement leaves the terminal ahead", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("teh")).toEqual({ data: "teh", shouldClear: false }); + // Swallowed: the terminal still holds teh while the buffer moved to the. + expect(input.receiveTextChange("the")).toEqual({ data: "", shouldClear: false }); + // A composition rewrite here would delete text from an already-desynced terminal. + expect(input.receiveTextChange("他们")).toEqual({ data: "", shouldClear: false }); + }); + + it("does not backspace terminal content after a swallowed replacement", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("teh")).toEqual({ data: "teh", shouldClear: false }); + expect(input.receiveTextChange("the")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveKeyPress("Backspace")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveTextChange("th")).toEqual({ data: "", shouldClear: false }); + }); + + it("keeps replacement desync protection after Backspace empties the hidden input", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("teh")).toEqual({ data: "teh", shouldClear: false }); + // Autocorrect changes the buffer but cannot rewrite the terminal. + expect(input.receiveTextChange("the")).toEqual({ data: "", shouldClear: false }); + + for (const text of ["th", "t", ""]) { + expect(input.receiveKeyPress("Backspace")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveTextChange(text)).toEqual({ data: "", shouldClear: false }); + } + + expect(input.receiveTextChange("nihao")).toEqual({ data: "nihao", shouldClear: false }); + // The terminal still contains `teh`, so this must not rub it out. + expect(input.receiveTextChange("你好")).toEqual({ data: "", shouldClear: false }); + }); + + it("resumes composition once the line is cleared", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("teh")).toEqual({ data: "teh", shouldClear: false }); + expect(input.receiveTextChange("the")).toEqual({ data: "", shouldClear: false }); + + input.reset(); + + expect(input.receiveTextChange("nihao")).toEqual({ data: "nihao", shouldClear: false }); + expect(input.receiveTextChange("你好")).toEqual({ + data: "\x7f\x7f\x7f\x7f\x7f你好", + shouldClear: false, + }); + }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-input.native.tsx b/packages/app/src/terminal/native-renderer/terminal-input.native.tsx index 3cced9774..d272843a0 100644 --- a/packages/app/src/terminal/native-renderer/terminal-input.native.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-input.native.tsx @@ -40,8 +40,53 @@ interface TerminalInputProps { style?: StyleProp; } +// Scripts produced by CJK IMEs when they commit or update a candidate. The +// native input does not expose marked-text ranges, so the committed script is +// the only reliable distinction between composition and ordinary autocorrect. +const CJK_COMPOSITION_PATTERN = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Bopomofo}]/u; + +// Only ASCII travels the keypress path. An IME script reports the jamo it is +// composing, which the text-change diff below immediately contradicts, so +// forwarding both would put two conflicting characters on the wire. function isPrintableKey(key: string): boolean { - return key.length === 1 && key >= " " && key !== "\x7f"; + return key.length === 1 && key >= " " && key <= "~"; +} + +function getCommonPrefixLength(left: string[], right: string[]): number { + const limit = Math.min(left.length, right.length); + let index = 0; + while (index < limit && left[index] === right[index]) { + index += 1; + } + return index; +} + +/** + * What the terminal receives for an edit that is not a plain append. + * + * CJK IMEs rewrite their composing suffix in place. Korean updates one + * syllable at a time (`ㅎ` -> `하` -> `한`), while Chinese and Japanese replace + * a multi-character reading (`nihao` -> `你好`, `にほんご` -> `日本語`). None of + * those are appends, so rub out the rewritten suffix and send the replacement. + * + * The inserted suffix must contain a CJK script. Ordinary autocorrect and + * suggestion replacements stay swallowed: the hidden input can edit text the + * terminal has already committed, and the terminal cannot take it back. + */ +function resolveCompositionEdit(previousText: string, text: string): string { + const previousCharacters = Array.from(previousText); + const nextCharacters = Array.from(text); + const commonPrefixLength = getCommonPrefixLength(previousCharacters, nextCharacters); + const removed = previousCharacters.slice(commonPrefixLength); + const inserted = nextCharacters.slice(commonPrefixLength).join(""); + if (removed.length === 0 || inserted.length === 0) { + return ""; + } + if (!CJK_COMPOSITION_PATTERN.test(inserted)) { + return ""; + } + return `${"\x7f".repeat(removed.length)}${inserted}`; } export function resolveTerminalInputFocusRequest(input: { @@ -57,6 +102,11 @@ export function resolveTerminalInputFocusRequest(input: { export function createTerminalTextInputState(): TerminalTextInputState { let previousText = ""; let submittedText: string | null = null; + // A swallowed replacement leaves the terminal holding text the buffer no + // longer describes. A composition rubout deletes from the terminal, so it + // would delete whatever is actually sitting there rather than the character + // the buffer thinks it is replacing. Stay off until the input state resets. + let replacementDesynced = false; return { receiveKeyPress(key: string): TerminalTextInputChange { @@ -65,7 +115,10 @@ export function createTerminalTextInputState(): TerminalTextInputState { return { data: "", key: terminalKey, shouldClear: false }; } if (key === "Backspace") { - previousText = previousText.slice(0, -1); + previousText = Array.from(previousText).slice(0, -1).join(""); + if (replacementDesynced) { + return { data: "", shouldClear: false }; + } return { data: "\x7f", shouldClear: false }; } if (key === "Enter" || key === "Return" || key === "return") { @@ -84,6 +137,7 @@ export function createTerminalTextInputState(): TerminalTextInputState { submittedText = null; if (text === lateSubmitText) { previousText = ""; + replacementDesynced = false; return { data: "", shouldClear: false }; } } @@ -95,12 +149,20 @@ export function createTerminalTextInputState(): TerminalTextInputState { if (text.includes("\n") || text.includes("\r")) { previousText = ""; + replacementDesynced = false; return { data: "", shouldClear: true }; } if (!text.startsWith(previousText)) { + let compositionEdit = ""; + if (!replacementDesynced) { + compositionEdit = resolveCompositionEdit(previousText, text); + } + if (compositionEdit === "") { + replacementDesynced = true; + } previousText = text; - return { data: "", shouldClear: false }; + return { data: compositionEdit, shouldClear: false }; } const appendedText = text.slice(previousText.length); @@ -112,6 +174,7 @@ export function createTerminalTextInputState(): TerminalTextInputState { }, reset(): void { previousText = ""; + replacementDesynced = false; }, }; } @@ -240,6 +303,9 @@ export const TerminalInput = forwardRef ); return ( + // No keyboardType prop: `ascii-capable` drops the globe key on iOS, which + // is how you reach the Korean layout. Terminal safety comes from + // autoCorrect/spellCheck/autoCapitalize being off, not from the layout. defaultValue="" blurOnSubmit={false} importantForAutofill="no" - keyboardType="ascii-capable" multiline={true} onChangeText={handleChangeText} onBlur={handleBlur}