fix(terminal): macOS Vietnamese Telex keeps its capitals in Chrome too
macOS's own Vietnamese Telex types into xterm's textarea with no composition events: a letter is an `insertText`, every later key of the word an `insertReplacementText`, each keydown (keyCode 229) arriving after the change. v2.7.4 forwarded the replacements, but a capital typed with Shift still vanished — xterm drops any `input` that lands while a key is held — and its 229 fallback could resend an edit. Replayed from a real Chrome trace, "Abc xin chào" reached the pane as " x c". lib/imeInput.ts now owns those edits: capture phase on xterm's element, each one sent as DELs plus the new tail, the IME's input and 229 keydowns hidden from xterm. Keys xterm sends from keypress (Space) and real compositions (CJK) stay xterm's. Verified against xterm 5.5 in headless Chrome: Telex → "Abc xin chào"; a plain keyboard sends the same bytes as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file imeInput.test.ts
|
||||
* @description Pins `attachImeInput` against a real event trace of macOS's
|
||||
* Vietnamese Simple Telex input method in Chrome (typing "Abc xin chào"): the
|
||||
* IME writes into xterm's textarea with `insertText` / `insertReplacementText`
|
||||
* and no composition events, its keydown (keyCode 229) arriving AFTER the
|
||||
* change — which stock xterm 5.5 turned into " x c". The pane must receive
|
||||
* exactly the text typed, a word the IME rewrites as backspaces plus the new
|
||||
* tail, and keys xterm already sent from `keypress` (Space) never twice.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { attachImeInput, textEdit } from "../imeInput";
|
||||
|
||||
type Step = [type: string, a: string, b: string, keyCode?: number, shift?: boolean];
|
||||
|
||||
// Recorded in the user's browser, one line per event on the textarea.
|
||||
const TELEX: Step[] = [
|
||||
["keydown", "Shift", "ShiftLeft", 16, true],
|
||||
["input", "A", "insertText"],
|
||||
["keydown", "A", "KeyA", 229, true],
|
||||
["keyup", "A", "KeyA", 65, true],
|
||||
["keyup", "Shift", "ShiftLeft", 16],
|
||||
["input", "Ab", "insertReplacementText"],
|
||||
["keydown", "b", "KeyB", 229],
|
||||
["keyup", "b", "KeyB", 66],
|
||||
["input", "Abc", "insertReplacementText"],
|
||||
["keydown", "c", "KeyC", 229],
|
||||
["keyup", "c", "KeyC", 67],
|
||||
["keydown", " ", "Space", 32],
|
||||
["keypress", " ", "Space", 32],
|
||||
["input", " ", "insertText"],
|
||||
["keyup", " ", "Space", 32],
|
||||
["input", "x", "insertText"],
|
||||
["keydown", "x", "KeyX", 229],
|
||||
["keyup", "x", "KeyX", 88],
|
||||
["input", "xi", "insertReplacementText"],
|
||||
["keydown", "i", "KeyI", 229],
|
||||
["input", "xin", "insertReplacementText"],
|
||||
["keydown", "n", "KeyN", 229],
|
||||
["keydown", " ", "Space", 32],
|
||||
["keypress", " ", "Space", 32],
|
||||
["input", " ", "insertText"],
|
||||
["input", "c", "insertText"],
|
||||
["keydown", "c", "KeyC", 229],
|
||||
["input", "ch", "insertReplacementText"],
|
||||
["keydown", "h", "KeyH", 229],
|
||||
["input", "cha", "insertReplacementText"],
|
||||
["keydown", "a", "KeyA", 229],
|
||||
["input", "chao", "insertReplacementText"],
|
||||
["keydown", "o", "KeyO", 229],
|
||||
["input", "chào", "insertReplacementText"],
|
||||
["keydown", "f", "KeyF", 229],
|
||||
];
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
/** A terminal stand-in: xterm's element and textarea, `input` collecting what
|
||||
* reaches the pane, and `xterm` what xterm's own textarea listeners saw. */
|
||||
function setup() {
|
||||
const element = document.createElement("div");
|
||||
const textarea = document.createElement("textarea");
|
||||
element.appendChild(textarea);
|
||||
document.body.appendChild(element);
|
||||
let sent = "";
|
||||
const xterm: string[] = [];
|
||||
for (const t of ["keydown", "keypress", "input"])
|
||||
textarea.addEventListener(t, (e) => xterm.push(`${t}:${(e as KeyboardEvent).key ?? ""}`), true);
|
||||
// xterm sends a printable key from its keypress handler (Space, here).
|
||||
textarea.addEventListener("keypress", (e) => (sent += e.key), true);
|
||||
const term = { element, textarea, input: (d: string) => (sent += d) };
|
||||
cleanup = attachImeInput(term);
|
||||
return { textarea, xterm, sent: () => sent };
|
||||
}
|
||||
|
||||
function play(ta: HTMLTextAreaElement, steps: Step[]) {
|
||||
for (const [type, a, b, kc, shift] of steps) {
|
||||
if (type === "input") {
|
||||
const before = ta.value;
|
||||
const next = b === "insertText" ? before + a : before.replace(/\S*$/, "") + a;
|
||||
ta.dispatchEvent(new InputEvent("beforeinput", { data: a, inputType: b, bubbles: true }));
|
||||
ta.value = next;
|
||||
ta.dispatchEvent(new InputEvent("input", { data: a, inputType: b, bubbles: true }));
|
||||
} else {
|
||||
const ev = new KeyboardEvent(type, { key: a, code: b, shiftKey: !!shift, bubbles: true });
|
||||
Object.defineProperty(ev, "keyCode", { get: () => kc });
|
||||
ta.dispatchEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a line editor holds after `data` (DEL erases one character). */
|
||||
const typed = (data: string) => {
|
||||
const out: string[] = [];
|
||||
for (const ch of data) {
|
||||
if (ch === "\x7f") out.pop();
|
||||
else out.push(ch);
|
||||
}
|
||||
return out.join("");
|
||||
};
|
||||
|
||||
describe("textEdit", () => {
|
||||
it("is the backspaces and new tail that turn one value into the other", () => {
|
||||
expect(textEdit("Abc xin cha", "Abc xin chao")).toBe("o");
|
||||
expect(textEdit("Abc xin chao", "Abc xin chào")).toBe("\x7f\x7fào");
|
||||
expect(textEdit("x", "x")).toBe("");
|
||||
expect(textEdit("ab", "")).toBe("\x7f\x7f");
|
||||
});
|
||||
|
||||
it("counts characters, not UTF-16 units", () => {
|
||||
expect(textEdit("a😀", "a")).toBe("\x7f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachImeInput", () => {
|
||||
it("sends what Telex typed — capitals and rewritten words included", () => {
|
||||
const { textarea, sent } = setup();
|
||||
play(textarea, TELEX);
|
||||
expect(typed(sent())).toBe("Abc xin chào");
|
||||
});
|
||||
|
||||
it("leaves xterm the keys it sends itself, and hides the IME's from it", () => {
|
||||
const { textarea, xterm, sent } = setup();
|
||||
play(textarea, TELEX);
|
||||
// Space is xterm's (its keypress sends it, its input event follows); the
|
||||
// 229 keydowns and the IME's own input events never reach it.
|
||||
expect(xterm.filter((x) => x.startsWith("keypress"))).toHaveLength(2);
|
||||
expect(xterm.filter((x) => x.startsWith("input"))).toHaveLength(2);
|
||||
expect(xterm.some((x) => x === "keydown:A" || x === "keydown:b")).toBe(false);
|
||||
expect(sent().split(" ")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not touch a composition (CJK input) — xterm handles that", () => {
|
||||
const { textarea, xterm, sent } = setup();
|
||||
const ev = new InputEvent("input", {
|
||||
data: "に",
|
||||
inputType: "insertCompositionText",
|
||||
bubbles: true,
|
||||
});
|
||||
textarea.value = "に";
|
||||
textarea.dispatchEvent(ev);
|
||||
expect(sent()).toBe("");
|
||||
expect(xterm).toContain("input:");
|
||||
});
|
||||
|
||||
it("stops after dispose", () => {
|
||||
const { textarea, sent } = setup();
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
play(textarea, TELEX.slice(0, 2));
|
||||
expect(sent()).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user