fix(terminal): support CJK IME composition on mobile (#3391)
* fix(terminal): let Korean be typed in the mobile terminal The hidden native terminal input set keyboardType="ascii-capable", which removes the globe key on iOS — the key that switches to the Korean layout. There was no way to reach a Hangul keyboard from a terminal at all. Reaching the layout is not enough. receiveTextChange forwards a text change only when it extends the previous buffer, and a Korean IME rewrites the syllable it is composing in place: ㅎ → 하 → 한. Every keystroke after the first jamo failed startsWith and was dropped, so Korean produced one stray jamo and nothing else. Forward a composition edit as a rubout plus the replacement when it rewrites exactly one trailing Hangul character. Wider rewrites stay swallowed: those are autocorrect and suggestion replacements, which can touch text the terminal has already committed. Hangul also stops travelling the keypress path. The IME reports the jamo it is composing while the text change reports the syllable, and dispatching both puts two conflicting characters on the wire. * fix(terminal): stop composition rubouts after a swallowed replacement A swallowed replacement advances previousText to text the terminal never received, so the buffer and the terminal disagree from that point on. A composition rubout deletes from the terminal, which means it deletes whatever is actually sitting there rather than the character the buffer thinks it is replacing: 한글 swallowed to 우리 then rewritten to 우루 emitted DEL and left 한루 on the line. Latch the disagreement when a replacement is swallowed and keep rubouts off until the line is cleared, which is where the buffer and the terminal are known to match again. Appends are unchanged. * fix(terminal): support CJK IME composition on mobile * fix(terminal): guard backspace after input desync * fix(terminal): retain desync guard after hidden input clears * fix(terminal): preserve Unicode text on Backspace --------- Co-authored-by: ljy900825-dev <ljy900825@gmail.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
ljy900825-dev
Mohamed Boudra
parent
4470cc4c56
commit
cd2b6bca9d
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,8 +40,53 @@ interface TerminalInputProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
// 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<TerminalInputHandle, TerminalInputProps>
|
||||
);
|
||||
|
||||
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.
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
accessibilityLabel="Terminal input"
|
||||
@@ -251,7 +317,6 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
defaultValue=""
|
||||
blurOnSubmit={false}
|
||||
importantForAutofill="no"
|
||||
keyboardType="ascii-capable"
|
||||
multiline={true}
|
||||
onChangeText={handleChangeText}
|
||||
onBlur={handleBlur}
|
||||
|
||||
Reference in New Issue
Block a user