mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b78a4a0c1 | ||
|
|
96f1e37faf | ||
|
|
7abc49d5d2 | ||
|
|
e1aca17f57 | ||
|
|
ecaef75514 | ||
|
|
83ceaa51a0 |
No files matched your search
@@ -129,6 +129,7 @@ import {
|
||||
shouldFlushStaleDeferredImeTextInput,
|
||||
} from "./terminalImeTextInput";
|
||||
import { keepImeCommittedTextThroughModifierKeyDowns } from "./imeModifierKeyDownSeenGuard";
|
||||
import { keepLiveImeTranscriptionSingle } from "./imeCompositionRewrite";
|
||||
import { formatSerialLocalEcho } from "./serialLocalEcho";
|
||||
import { getLastChar, removeLastChar, isPrintableInput } from "../../../domain/serialCharMetrics";
|
||||
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
|
||||
@@ -705,6 +706,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
term.open(ctx.container);
|
||||
// CompositionHelper is created in open(). Live transcription rewrites the
|
||||
// hypothesis instead of appending; commit that utterance once (#3421).
|
||||
keepLiveImeTranscriptionSingle(term);
|
||||
|
||||
// Inline raster images (Kitty graphics / SIXEL / iTerm IIP). Loaded right after
|
||||
// term.open so the addon can patch IRenderService.setRenderer before the WebGL
|
||||
@@ -2977,7 +2981,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// must not classify the next physical key as a second composition commit.
|
||||
// Actual composition/input handlers clear the physical-key pairing first.
|
||||
if (broadcastLegacyDataPending) kittyCompositionPending = false;
|
||||
if (kittyCompositionPending && !data.startsWith("\u001b")) {
|
||||
// A rewritten IME hypothesis first emits standalone deletions. Route those
|
||||
// as backspaces and keep the composition marker for the replacement text.
|
||||
if (kittyCompositionPending && data !== "\x7f" && !data.startsWith("\u001b")) {
|
||||
kittyCompositionPending = false;
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
window.clearTimeout(kittyCompositionClearTimer);
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
commitTextareaChange,
|
||||
continuedCompositionData,
|
||||
continuedCompositionPrefix,
|
||||
keepLiveImeTranscriptionSingle,
|
||||
rememberTextareaCommit,
|
||||
type ImeCompositionCommitTarget,
|
||||
} from "./imeCompositionRewrite";
|
||||
|
||||
const BACKSPACE = "\x7f";
|
||||
|
||||
test("commitTextareaChange appends the new suffix", () => {
|
||||
assert.equal(commitTextareaChange("\u6211\u662f\u771f\u7684", "\u6211\u662f\u771f\u7684\u725b\u903c"), "\u725b\u903c");
|
||||
assert.equal(commitTextareaChange("", "\u6211\u662f\u771f\u7684"), "\u6211\u662f\u771f\u7684");
|
||||
assert.equal(commitTextareaChange("ls ", "ls \uff0c"), "\uff0c");
|
||||
});
|
||||
|
||||
test("commitTextareaChange counts a supplementary-plane character as one grapheme", () => {
|
||||
const emoji = "\u{1F600}";
|
||||
const other = "\u{1F601}";
|
||||
assert.equal(commitTextareaChange(`a${emoji}`, "aX"), `${BACKSPACE}X`);
|
||||
assert.equal(commitTextareaChange(emoji, other), `${BACKSPACE}${other}`);
|
||||
assert.equal(commitTextareaChange(`a${emoji}`, "a"), BACKSPACE);
|
||||
});
|
||||
|
||||
test("commitTextareaChange counts only characters forwarded to the terminal", () => {
|
||||
assert.equal(commitTextareaChange("ab\u200bcd", "abXcd"), `${BACKSPACE}${BACKSPACE}Xcd`);
|
||||
});
|
||||
|
||||
test("commitTextareaChange deletes the whole tail and retypes the new one", () => {
|
||||
assert.equal(commitTextareaChange("abcd", "abXY"), `${BACKSPACE}${BACKSPACE}XY`);
|
||||
assert.equal(commitTextareaChange("abcde", "abXde"), `${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`);
|
||||
assert.equal(commitTextareaChange("abcd", "ab"), `${BACKSPACE}${BACKSPACE}`);
|
||||
assert.equal(commitTextareaChange("hello", ""), BACKSPACE);
|
||||
assert.equal(commitTextareaChange("same", "same"), "");
|
||||
});
|
||||
|
||||
test("rememberTextareaCommit grows a hypothesis and replaces a rewrite", () => {
|
||||
assert.equal(rememberTextareaCommit("", "", "\u6211\u662f\u771f\u7684"), "\u6211\u662f\u771f\u7684");
|
||||
assert.equal(rememberTextareaCommit("\u6211\u662f\u771f\u7684", "\u6211\u662f\u771f\u7684", "\u725b\u903c"), "\u6211\u662f\u771f\u7684\u725b\u903c");
|
||||
assert.equal(
|
||||
rememberTextareaCommit("abcd", "abcd", `${BACKSPACE}${BACKSPACE}XY`),
|
||||
"abXY",
|
||||
);
|
||||
assert.equal(
|
||||
rememberTextareaCommit("abcde", "abcde", `${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`, "abXde"),
|
||||
"abXde",
|
||||
);
|
||||
assert.equal(
|
||||
rememberTextareaCommit("a\u{1F600}", "a\u{1F600}", `${BACKSPACE}X`),
|
||||
"aX",
|
||||
);
|
||||
assert.equal(rememberTextareaCommit("hello", "hello", BACKSPACE, ""), "");
|
||||
});
|
||||
|
||||
test("continuedCompositionPrefix keeps a new composition after a finished line", () => {
|
||||
assert.equal(continuedCompositionPrefix(undefined, "\u4f60\u597d"), "");
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: true,
|
||||
pendingPrevious: "hello\uff0c",
|
||||
alreadySent: "\uff0c",
|
||||
textareaAtStart: "hello\uff0c",
|
||||
}, "\u4f60\u597d"),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: false,
|
||||
pendingPrevious: "a",
|
||||
alreadySent: "a",
|
||||
textareaAtStart: "a",
|
||||
}, "apple"),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: true,
|
||||
pendingPrevious: "\u6211",
|
||||
alreadySent: "\u6211",
|
||||
textareaAtStart: "\u6211\u771f",
|
||||
}, "\u6211\u771f"),
|
||||
"\u6211",
|
||||
);
|
||||
});
|
||||
|
||||
test("continuedCompositionData rewrites a revised hypothesis instead of appending it", () => {
|
||||
const marker = {
|
||||
continued: true,
|
||||
pendingPrevious: "abcde",
|
||||
alreadySent: "abcde",
|
||||
textareaAtStart: "abXde",
|
||||
};
|
||||
assert.equal(
|
||||
continuedCompositionData(marker, "abXde", "abXde", 0),
|
||||
`${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`,
|
||||
);
|
||||
// The new character starts at the caret after "hello", so this is a new
|
||||
// composition. The caller sends the slice unchanged.
|
||||
assert.equal(
|
||||
continuedCompositionData({
|
||||
continued: true,
|
||||
pendingPrevious: "hello",
|
||||
alreadySent: "",
|
||||
textareaAtStart: "hello",
|
||||
}, "\u4f60", "hello\u4f60", 5),
|
||||
null,
|
||||
);
|
||||
// In-place extension of a one-character hypothesis still strips the prefix.
|
||||
assert.equal(
|
||||
continuedCompositionData({
|
||||
continued: true,
|
||||
pendingPrevious: "\u6211",
|
||||
alreadySent: "\u6211",
|
||||
textareaAtStart: "\u6211\u771f",
|
||||
}, "\u6211\u771f", "\u6211\u771f", 0),
|
||||
"\u771f",
|
||||
);
|
||||
// A new word that merely starts with the finished character is not a prefix.
|
||||
assert.equal(
|
||||
continuedCompositionData({
|
||||
continued: true,
|
||||
pendingPrevious: "\u6211",
|
||||
alreadySent: "\u6211",
|
||||
textareaAtStart: "\u6211\u6211\u4eec",
|
||||
}, "\u6211\u4eec", "\u6211\u6211\u4eec", 1),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("continuedCompositionPrefix returns the hypothesis already on the PTY", () => {
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: true,
|
||||
pendingPrevious: "\u6211\u662f\u771f\u7684",
|
||||
alreadySent: "\u6211\u662f\u771f\u7684",
|
||||
textareaAtStart: "\u6211\u662f\u771f\u7684\u725b\u903c",
|
||||
}, "\u6211\u662f\u771f\u7684\u725b\u903c"),
|
||||
"\u6211\u662f\u771f\u7684",
|
||||
);
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: true,
|
||||
pendingPrevious: "ls \u6211\u662f\u771f\u7684",
|
||||
alreadySent: "\u6211\u662f\u771f\u7684",
|
||||
textareaAtStart: "ls \u6211\u662f\u771f\u7684\u725b\u903c",
|
||||
}, "ls \u6211\u662f\u771f\u7684\u725b\u903c"),
|
||||
"ls \u6211\u662f\u771f\u7684",
|
||||
);
|
||||
});
|
||||
|
||||
type Harness = ImeCompositionCommitTarget & {
|
||||
compositionstart: () => void;
|
||||
compositionend: () => void;
|
||||
keydown: (event: { keyCode: number }) => boolean;
|
||||
sent: string[];
|
||||
};
|
||||
|
||||
function createHarness(): Harness {
|
||||
const sent: string[] = [];
|
||||
const helper: Harness = {
|
||||
_textarea: { value: "" },
|
||||
_compositionView: { classList: { remove() {} } },
|
||||
_isComposing: false,
|
||||
_isSendingComposition: false,
|
||||
_compositionPosition: { start: 0, end: 0 },
|
||||
_compositionSuffix: "",
|
||||
_dataAlreadySent: "",
|
||||
_coreService: {
|
||||
triggerDataEvent(data: string) {
|
||||
sent.push(data);
|
||||
},
|
||||
},
|
||||
sent,
|
||||
compositionstart() {
|
||||
this._isComposing = true;
|
||||
this._compositionPosition = { start: 0, end: this._textarea.value.length };
|
||||
this._compositionSuffix = "";
|
||||
this._dataAlreadySent = "";
|
||||
this._compositionView.classList.remove("active");
|
||||
},
|
||||
compositionend() {
|
||||
this._finalizeComposition?.(true);
|
||||
},
|
||||
keydown(event) {
|
||||
if (this._isComposing || this._isSendingComposition) {
|
||||
if (event.keyCode === 229 || event.keyCode === 16) return false;
|
||||
this._finalizeComposition?.(false);
|
||||
}
|
||||
if (event.keyCode === 229) {
|
||||
this._handleAnyTextareaChanges?.();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return helper;
|
||||
}
|
||||
|
||||
function install(helper: Harness): void {
|
||||
keepLiveImeTranscriptionSingle({ _core: { _compositionHelper: helper } });
|
||||
}
|
||||
|
||||
async function flushTimers(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test("live transcription of one sentence is written once", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
|
||||
helper.compositionstart();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "\u6211\u662f\u771f\u7684\u725b\u903c");
|
||||
});
|
||||
|
||||
test("a composition range that contains only the new tail is not prefixed again", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls \u6211\u662f\u771f\u7684";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls \u6211\u662f\u771f\u7684\u725b\u903c";
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: "ls \u6211\u662f\u771f\u7684".length, end: "ls \u6211\u662f\u771f\u7684\u725b\u903c".length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "ls \u6211\u662f\u771f\u7684\u725b\u903c");
|
||||
});
|
||||
|
||||
test("a composition that only confirms the hypothesis does not append it again", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper.compositionstart();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "\u6211\u662f\u771f\u7684\u725b\u903c");
|
||||
});
|
||||
|
||||
test("normal compositionend still sends the committed word once", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper._textarea.value = "hello";
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "hello\u4f60";
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 5, end: 6 };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, ["\u4f60"]);
|
||||
});
|
||||
|
||||
test("punctuation entered through keyCode 229 sends only the new character", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper._textarea.value = "ls ";
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls \uff0c";
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, ["\uff0c"]);
|
||||
});
|
||||
|
||||
test("keydown text already sent is not repeated on compositionend", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abc";
|
||||
await flushTimers();
|
||||
helper._compositionPosition = { start: 0, end: 4 };
|
||||
helper._textarea.value = "abcd";
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "abcd");
|
||||
});
|
||||
|
||||
test("a rewritten hypothesis is not sent again on markerless compositionend", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abcde";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abXde";
|
||||
await flushTimers();
|
||||
|
||||
helper._compositionPosition = { start: 0, end: "abXde".length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
|
||||
});
|
||||
|
||||
test("an equal-length rewrite does not resend the shared prefix", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abcd";
|
||||
await flushTimers();
|
||||
helper.sent.length = 0;
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abXY";
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, [BACKSPACE, BACKSPACE, "XY"]);
|
||||
});
|
||||
|
||||
test("a revised composition replaces the hypothesis already on the PTY", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abcde";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abXde";
|
||||
helper.compositionstart();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
|
||||
});
|
||||
|
||||
test("a one-character hypothesis is not repeated when composition extends it", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u771f";
|
||||
helper.compositionstart();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "\u6211\u771f");
|
||||
});
|
||||
|
||||
test("a new word that shares a prefix with a finished character is sent whole", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u6211\u4eec";
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 1, end: "\u6211\u6211\u4eec".length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "\u6211\u6211\u4eec");
|
||||
});
|
||||
|
||||
test("a paused live transcription still extends its previously sent hypothesis", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684";
|
||||
await flushTimers();
|
||||
helper.compositionstart();
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
|
||||
helper._compositionPosition = { start: 0, end: helper._textarea.value.length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["\u6211\u662f\u771f\u7684", "\u725b\u903c"]);
|
||||
});
|
||||
|
||||
test("a later new word with the same prefix is sent whole", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211";
|
||||
await flushTimers();
|
||||
helper.compositionstart();
|
||||
helper._textarea.value = "\u6211\u6211\u4eec";
|
||||
helper._compositionPosition = { start: 1, end: helper._textarea.value.length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["\u6211", "\u6211\u4eec"]);
|
||||
});
|
||||
|
||||
test("a sent rewrite is not repeated when its composition later confirms", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls abcde";
|
||||
await flushTimers();
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls abXde";
|
||||
await flushTimers();
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 3, end: helper._textarea.value.length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["ls abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
|
||||
});
|
||||
|
||||
test("an earlier rewrite is delivered when the next composition starts first", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abcde";
|
||||
await flushTimers();
|
||||
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 0, end: 5 };
|
||||
helper._textarea.value = "abXde";
|
||||
helper.compositionend();
|
||||
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 5, end: 5 };
|
||||
helper._textarea.value = "abXdeY";
|
||||
helper._compositionPosition = { start: 5, end: 6 };
|
||||
|
||||
await flushTimers();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde", "Y"]);
|
||||
});
|
||||
|
||||
test("a punctuation commit does not make the next word repeat", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\uff0c";
|
||||
await flushTimers();
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\uff0c\u4f60";
|
||||
await flushTimers();
|
||||
helper._compositionPosition = { start: 1, end: 2 };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["\uff0c", "\u4f60"]);
|
||||
});
|
||||
|
||||
test("filtered formatting characters do not break final composition deduplication", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ab";
|
||||
await flushTimers();
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ab\u200b";
|
||||
await flushTimers();
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ab\u200bX";
|
||||
await flushTimers();
|
||||
helper._compositionPosition = { start: 0, end: helper._textarea.value.length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["ab", "X"]);
|
||||
});
|
||||
|
||||
test("keepLiveImeTranscriptionSingle is a no-op without a composition helper", () => {
|
||||
assert.doesNotThrow(() => keepLiveImeTranscriptionSingle({}));
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
install(helper);
|
||||
assert.equal(helper.__ncImeCommitInstalled, true);
|
||||
});
|
||||
|
||||
test("createXTermRuntime installs the guard after the composition helper exists", () => {
|
||||
const source = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const openAt = source.indexOf("term.open(ctx.container);");
|
||||
const installAt = source.indexOf("keepLiveImeTranscriptionSingle(term);");
|
||||
assert.ok(openAt >= 0);
|
||||
assert.ok(installAt > openAt);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* xterm.js commits an IME textarea change with `newValue.replace(oldValue, "")`
|
||||
* and, on compositionend, skips `_dataAlreadySent.length` characters. Both
|
||||
* steps assume the IME only appends. Live transcription rewrites the whole
|
||||
* hypothesis, so the utterance is written as the
|
||||
* interim, again as the full final string, and again as the tail (#3421).
|
||||
*
|
||||
* Append-only commits (punctuation, a normal compositionend) stay as they are.
|
||||
* A composition that continues a multi-character hypothesis already sent to
|
||||
* the PTY writes only the unsent suffix, and the in-flight textarea timer is
|
||||
* dropped so it cannot append that suffix a second time.
|
||||
*/
|
||||
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
|
||||
const BACKSPACE = "\x7f";
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
function graphemes(value: string): string[] {
|
||||
return Array.from(graphemeSegmenter.segment(value), (part) => part.segment);
|
||||
}
|
||||
|
||||
export type ImeCompositionCommitTarget = {
|
||||
_textarea: { value: string };
|
||||
_compositionView: { classList: { remove(token: string): void } };
|
||||
_isComposing: boolean;
|
||||
_isSendingComposition: boolean;
|
||||
_compositionPosition: { start: number; end: number };
|
||||
_compositionSuffix: string;
|
||||
_dataAlreadySent: string;
|
||||
_textareaChangeTimer?: ReturnType<typeof setTimeout>;
|
||||
__ncPendingPrevious?: string;
|
||||
_coreService: { triggerDataEvent(data: string, wasUserInput?: boolean): void };
|
||||
compositionstart: () => void;
|
||||
_handleAnyTextareaChanges?: () => void;
|
||||
_finalizeComposition?: (waitForPropagation: boolean) => void;
|
||||
__ncImeCommitInstalled?: boolean;
|
||||
__ncImeCommit?: ImeCompositionMarker;
|
||||
};
|
||||
|
||||
export type ImeCompositionMarker = {
|
||||
/** A keydown-229 textarea timer was still pending when composition started. */
|
||||
continued: boolean;
|
||||
/** Textarea value that timer snapshotted, already reflected on the PTY. */
|
||||
pendingPrevious: string;
|
||||
alreadySent: string;
|
||||
textareaAtStart: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* PTY edit for one textarea rewrite.
|
||||
* A pure append sends the new suffix. A pure shrink sends one backspace per
|
||||
* deleted character, except a cleared textarea, which stays a single
|
||||
* backspace. xterm empties the textarea when a line is submitted, and that
|
||||
* reset is not a dictation rewrite. Any other rewrite deletes only the
|
||||
* diverging span and inserts its replacement, instead of `String.replace`
|
||||
* or the whole new value.
|
||||
*/
|
||||
export function commitTextareaChange(previous: string, next: string): string {
|
||||
previous = sanitizeTerminalInput(previous);
|
||||
next = sanitizeTerminalInput(next);
|
||||
if (previous === next) return "";
|
||||
if (next.startsWith(previous)) return next.slice(previous.length);
|
||||
if (previous.startsWith(next)) {
|
||||
if (next.length === 0) return BACKSPACE;
|
||||
return BACKSPACE.repeat(graphemes(previous.slice(next.length)).length);
|
||||
}
|
||||
return rewriteDivergingSpan(previous, next);
|
||||
}
|
||||
|
||||
export function rememberTextareaCommit(
|
||||
alreadySent: string,
|
||||
previous: string,
|
||||
sent: string,
|
||||
next?: string,
|
||||
): string {
|
||||
if (!sent) return alreadySent;
|
||||
let index = 0;
|
||||
while (sent.charCodeAt(index) === 0x7f) index += 1;
|
||||
const inserted = sent.slice(index);
|
||||
if (index > 0) {
|
||||
// Backspaces edit the PTY in place. A later markerless compositionend
|
||||
// only skips `_dataAlreadySent` when it is a prefix of the final text,
|
||||
// so the whole revised value has to be remembered, not just the tail.
|
||||
// A cleared textarea is xterm's line-submit reset, not that value.
|
||||
if (typeof next === "string") return sanitizeTerminalInput(next);
|
||||
const parts = graphemes(previous);
|
||||
const kept = parts.slice(0, Math.max(0, parts.length - index)).join("");
|
||||
return kept + inserted;
|
||||
}
|
||||
if (sanitizeTerminalInput(previous).endsWith(alreadySent)) return alreadySent + inserted;
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix of `compositionText` already on the PTY. Set only when composition
|
||||
* started while a 229 textarea timer was still pending, so this composition
|
||||
* finishes the update that timer snapshotted, not the next word.
|
||||
*
|
||||
* The timer's snapshot is the text already written, including a one-character
|
||||
* hypothesis. A later composition that does not begin with that snapshot
|
||||
* (normal pinyin after a finished line) keeps every character.
|
||||
*/
|
||||
export function continuedCompositionPrefix(
|
||||
marker: ImeCompositionMarker | undefined,
|
||||
compositionText: string,
|
||||
): string {
|
||||
if (!marker?.continued) return "";
|
||||
if (
|
||||
marker.pendingPrevious.length > 0
|
||||
&& compositionText.startsWith(marker.pendingPrevious)
|
||||
) {
|
||||
return marker.pendingPrevious;
|
||||
}
|
||||
if (
|
||||
marker.alreadySent.length > 0
|
||||
&& compositionText.startsWith(marker.alreadySent)
|
||||
&& marker.textareaAtStart.endsWith(marker.alreadySent)
|
||||
) {
|
||||
return marker.alreadySent;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function rewriteDivergingSpan(previous: string, next: string): string {
|
||||
const previousParts = graphemes(previous);
|
||||
const nextParts = graphemes(next);
|
||||
let start = 0;
|
||||
const shared = Math.min(previousParts.length, nextParts.length);
|
||||
while (start < shared && previousParts[start] === nextParts[start]) start += 1;
|
||||
// The cursor stays after the last cell. A shared suffix is still part of
|
||||
// the tail that must be deleted and typed again. Count graphemes so an
|
||||
// emoji is one deletion, not the two UTF-16 units of a surrogate pair.
|
||||
return BACKSPACE.repeat(previousParts.length - start) + nextParts.slice(start).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* What a continued composition should write. Null means this composition is a
|
||||
* new word and the caller sends the slice unchanged.
|
||||
*
|
||||
* An extension of the snapshot sends only the new suffix. A revision sends
|
||||
* the same edit the cancelled textarea timer would have sent, so `abcde`
|
||||
* replaced by `abXde` does not get appended after the old hypothesis.
|
||||
* A composition that starts at or after the snapshot is a new word. A
|
||||
* finished character followed by a new word that begins with that same
|
||||
* character must be sent whole.
|
||||
*/
|
||||
export function continuedCompositionData(
|
||||
marker: ImeCompositionMarker | undefined,
|
||||
compositionText: string,
|
||||
textareaNow: string,
|
||||
compositionStart: number,
|
||||
followingComposition = false,
|
||||
): string | null {
|
||||
if (!marker || !compositionText) return null;
|
||||
// A live hypothesis can settle before the next composition starts. In that
|
||||
// case the timer has fired, but its sent text is still the textarea suffix.
|
||||
const previous = marker.continued ? marker.pendingPrevious : marker.textareaAtStart;
|
||||
if (!marker.continued && (
|
||||
!marker.alreadySent || !sanitizeTerminalInput(previous).endsWith(marker.alreadySent)
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
if (!previous) return null;
|
||||
if (compositionStart >= previous.length) return null;
|
||||
if (!marker.continued && compositionText === previous.slice(compositionStart)) return "";
|
||||
if (compositionText.startsWith(previous)) return compositionText.slice(previous.length);
|
||||
if (
|
||||
marker.alreadySent.length > 0
|
||||
&& sanitizeTerminalInput(compositionText).startsWith(marker.alreadySent)
|
||||
&& (
|
||||
sanitizeTerminalInput(textareaNow).endsWith(marker.alreadySent)
|
||||
|| sanitizeTerminalInput(marker.textareaAtStart).endsWith(marker.alreadySent)
|
||||
)
|
||||
) {
|
||||
return sanitizeTerminalInput(compositionText).slice(marker.alreadySent.length);
|
||||
}
|
||||
if (textareaNow === compositionText || marker.textareaAtStart === compositionText) {
|
||||
return commitTextareaChange(previous, compositionText);
|
||||
}
|
||||
if (
|
||||
followingComposition
|
||||
&& textareaNow.startsWith(compositionText, compositionStart)
|
||||
&& textareaNow.length > compositionStart + compositionText.length
|
||||
) {
|
||||
return commitTextareaChange(previous.slice(compositionStart), compositionText);
|
||||
}
|
||||
if (textareaNow.endsWith(compositionText) && textareaNow.length > compositionText.length) {
|
||||
const headLength = textareaNow.length - compositionText.length;
|
||||
if (
|
||||
previous.length >= headLength
|
||||
&& textareaNow.startsWith(previous.slice(0, headLength))
|
||||
) {
|
||||
return commitTextareaChange(previous.slice(headLength), compositionText);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function emitPtyData(helper: ImeCompositionCommitTarget, data: string): void {
|
||||
// One event per backspace so mapTerminalBackspaceInput and the command
|
||||
// buffer see a standalone DEL or Ctrl-H, then the replacement text.
|
||||
let index = 0;
|
||||
while (data.charCodeAt(index) === 0x7f) {
|
||||
helper._coreService.triggerDataEvent(BACKSPACE, true);
|
||||
index += 1;
|
||||
}
|
||||
const inserted = data.slice(index);
|
||||
if (inserted.length > 0) helper._coreService.triggerDataEvent(inserted, true);
|
||||
}
|
||||
|
||||
function compositionSlice(
|
||||
helper: ImeCompositionCommitTarget,
|
||||
rangeStart: number,
|
||||
suffix: string,
|
||||
composingNow: boolean,
|
||||
newCompositionStart: number,
|
||||
): string {
|
||||
const value = helper._textarea.value;
|
||||
if (composingNow) return value.substring(rangeStart, newCompositionStart);
|
||||
const valueEnd = suffix.length > 0 && value.endsWith(suffix)
|
||||
? value.length - suffix.length
|
||||
: value.length;
|
||||
return value.substring(rangeStart, Math.max(rangeStart, valueEnd));
|
||||
}
|
||||
|
||||
function deliverComposition(
|
||||
helper: ImeCompositionCommitTarget,
|
||||
compositionText: string,
|
||||
marker: ImeCompositionMarker | undefined,
|
||||
compositionStart: number,
|
||||
followingComposition = false,
|
||||
): void {
|
||||
const continued = continuedCompositionData(
|
||||
marker,
|
||||
compositionText,
|
||||
helper._textarea.value,
|
||||
compositionStart,
|
||||
followingComposition,
|
||||
);
|
||||
if (continued !== null) {
|
||||
if (continued.length > 0) emitPtyData(helper, continued);
|
||||
if (!helper._isComposing) helper._dataAlreadySent = "";
|
||||
return;
|
||||
}
|
||||
const cleanText = sanitizeTerminalInput(compositionText);
|
||||
const live = helper._dataAlreadySent && cleanText.startsWith(helper._dataAlreadySent)
|
||||
? helper._dataAlreadySent
|
||||
: "";
|
||||
const data = live
|
||||
? cleanText.slice(live.length)
|
||||
: helper._dataAlreadySent.endsWith(cleanText) ? "" : cleanText;
|
||||
if (data.length > 0) emitPtyData(helper, data);
|
||||
if (!helper._isComposing) helper._dataAlreadySent = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the commit guard on one terminal. CompositionHelper is created in
|
||||
* `terminal.open()`, so this runs after open. Missing helpers (tests, log
|
||||
* view) are left untouched.
|
||||
*/
|
||||
export function keepLiveImeTranscriptionSingle(term: object): void {
|
||||
const helper = (term as { _core?: { _compositionHelper?: ImeCompositionCommitTarget } })
|
||||
._core?._compositionHelper;
|
||||
if (!helper || helper.__ncImeCommitInstalled || typeof helper.compositionstart !== "function") {
|
||||
return;
|
||||
}
|
||||
helper.__ncImeCommitInstalled = true;
|
||||
const originalStart = helper.compositionstart.bind(helper);
|
||||
|
||||
helper.compositionstart = () => {
|
||||
const alreadySent = helper._dataAlreadySent;
|
||||
const pendingPrevious = helper.__ncPendingPrevious ?? "";
|
||||
const continued = helper._textareaChangeTimer !== undefined;
|
||||
if (helper._textareaChangeTimer !== undefined) {
|
||||
clearTimeout(helper._textareaChangeTimer);
|
||||
helper._textareaChangeTimer = undefined;
|
||||
}
|
||||
helper.__ncPendingPrevious = undefined;
|
||||
originalStart();
|
||||
helper.__ncImeCommit = {
|
||||
continued,
|
||||
pendingPrevious,
|
||||
alreadySent,
|
||||
textareaAtStart: helper._textarea.value,
|
||||
};
|
||||
};
|
||||
|
||||
helper._handleAnyTextareaChanges = () => {
|
||||
if (helper._textareaChangeTimer !== undefined) return;
|
||||
const previous = helper._textarea.value;
|
||||
helper.__ncPendingPrevious = previous;
|
||||
helper._textareaChangeTimer = setTimeout(() => {
|
||||
helper._textareaChangeTimer = undefined;
|
||||
helper.__ncPendingPrevious = undefined;
|
||||
helper.__ncImeCommit = undefined;
|
||||
if (helper._isComposing || helper._isSendingComposition) return;
|
||||
const next = helper._textarea.value;
|
||||
const data = commitTextareaChange(previous, next);
|
||||
if (!data) return;
|
||||
helper._dataAlreadySent = rememberTextareaCommit(helper._dataAlreadySent, previous, data, next);
|
||||
emitPtyData(helper, data);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
helper._finalizeComposition = (waitForPropagation: boolean) => {
|
||||
const marker = helper.__ncImeCommit;
|
||||
helper.__ncImeCommit = undefined;
|
||||
helper._compositionView.classList.remove("active");
|
||||
helper._isComposing = false;
|
||||
|
||||
if (!waitForPropagation) {
|
||||
helper._isSendingComposition = false;
|
||||
const compositionText = helper._textarea.value.substring(
|
||||
helper._compositionPosition.start,
|
||||
helper._compositionPosition.end,
|
||||
);
|
||||
deliverComposition(helper, compositionText, marker, helper._compositionPosition.start);
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = helper._compositionPosition.start;
|
||||
const suffix = helper._compositionSuffix;
|
||||
const alreadySent = helper._dataAlreadySent;
|
||||
helper._isSendingComposition = true;
|
||||
setTimeout(() => {
|
||||
if (!helper._isSendingComposition) return;
|
||||
helper._isSendingComposition = false;
|
||||
const compositionText = compositionSlice(
|
||||
helper,
|
||||
rangeStart,
|
||||
suffix,
|
||||
helper._isComposing,
|
||||
helper._compositionPosition.start,
|
||||
);
|
||||
// compositionstart may have cleared the live field. The no-composition
|
||||
// path (#3191) still has the keydown commit in this snapshot.
|
||||
if (!marker && alreadySent) helper._dataAlreadySent = alreadySent;
|
||||
deliverComposition(helper, compositionText, marker, rangeStart, helper._isComposing && !suffix);
|
||||
}, 0);
|
||||
};
|
||||
}
|
||||
@@ -39,10 +39,10 @@ const callbackCode = ts.transpileModule(`
|
||||
${textInputMarker}
|
||||
${keyboardCallback}
|
||||
${dataCallback}
|
||||
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
|
||||
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput, composition: markKittyCompositionPending };
|
||||
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
function setup(flags = 0) {
|
||||
function setup(flags = 0, sourceFlags = 0) {
|
||||
const writes: string[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
@@ -51,6 +51,8 @@ function setup(flags = 0) {
|
||||
const forwarded = new Map<string, { targetSessionIds: string[] }>();
|
||||
const mode = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(mode, flags);
|
||||
const sourceMode = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(sourceMode, sourceFlags);
|
||||
const options = {
|
||||
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
|
||||
@@ -60,7 +62,7 @@ function setup(flags = 0) {
|
||||
if (result) writes.push(result.data);
|
||||
};
|
||||
const context = {
|
||||
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
|
||||
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void; composition: () => void },
|
||||
window: {
|
||||
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
|
||||
clearTimeout(id: number) { timers.delete(id); },
|
||||
@@ -73,7 +75,7 @@ function setup(flags = 0) {
|
||||
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
|
||||
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
|
||||
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
|
||||
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
|
||||
kittyKeyboardMode: sourceMode, shouldSplitImeTextInputForWire: () => false,
|
||||
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
|
||||
broadcastForwardedKeys: forwarded,
|
||||
broadcastKittyInput: normalized,
|
||||
@@ -99,6 +101,16 @@ function setup(flags = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
test("IME rewrite backspaces leave Kitty composition routing for replacement text", () => {
|
||||
const runtime = setup(0, 24);
|
||||
runtime.controls.composition();
|
||||
runtime.receive("\x7f");
|
||||
runtime.receive("Xde");
|
||||
assert.equal(runtime.writes[0], "\x7f");
|
||||
assert.ok(runtime.writes.includes("\x1b[0;;88:100:101u"));
|
||||
assert.ok(!runtime.writes.includes("\x1b[0;;127u"));
|
||||
});
|
||||
|
||||
for (const flags of [0, 8]) {
|
||||
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
|
||||
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
|
||||
|
||||
Reference in new issue
Block a user