дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(terminal): stop fcitx5 commits from being sent twice (#336)
With WebKitGTK and fcitx5, committed IME text reached the terminal twice: the IME commits without firing compositionstart, so xterm's composition bookkeeping never advanced and every commit was emitted both as the committed text and as a stale textarea diff. An IME commit gate now lets only the first emission of a commit through (with a 250 ms safety timeout) and empties the hidden textarea once the commit ends. It engages only for input that looks like a composition (keyCode 229 / composition input events) and stands down for IMEs that report compositionstart (macOS, IBus, Windows), leaving xterm's own handling unchanged there. Co-authored-by: Hepengzhou <[email protected]> 未在 Linux(fcitx5)实机验证。
Цей коміт міститься в:
1 джерело
97b8928e4f
коміт
0a27d02f4d
3 files changed
+228
-1
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IME_COMMIT_TIMEOUT_MS, ImeCommitGate, type ImeScheduler } from "./imeCommit";
|
||||
|
||||
/** Runs queued callbacks in order, the way setTimeout(0) drains after the current event. */
|
||||
function manualScheduler() {
|
||||
let next = 1;
|
||||
const tasks = new Map<number, () => void>();
|
||||
const scheduler: ImeScheduler = {
|
||||
after: (callback) => {
|
||||
const id = next++;
|
||||
tasks.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
cancel: (id) => {
|
||||
tasks.delete(id);
|
||||
},
|
||||
};
|
||||
const run = () => {
|
||||
for (const [id, task] of [...tasks]) {
|
||||
tasks.delete(id);
|
||||
task();
|
||||
}
|
||||
};
|
||||
return { scheduler, run, pending: () => tasks.size };
|
||||
}
|
||||
|
||||
function gateWithRebase() {
|
||||
const { scheduler, run, pending } = manualScheduler();
|
||||
let rebased = 0;
|
||||
const gate = new ImeCommitGate(() => rebased++, scheduler);
|
||||
return { gate, run, pending, rebases: () => rebased };
|
||||
}
|
||||
|
||||
describe("ImeCommitGate", () => {
|
||||
it("takes the first emission of a commit and drops the repeated one", () => {
|
||||
const { gate } = gateWithRebase();
|
||||
gate.begin();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the real text of the pi case and drops the stale suffix", () => {
|
||||
// xterm sends the committed diff first, then a stale `value.substring(start)`.
|
||||
const { gate, run } = gateWithRebase();
|
||||
const sent: string[] = [];
|
||||
const emit = (data: string) => {
|
||||
if (gate.accept()) sent.push(data);
|
||||
};
|
||||
gate.begin();
|
||||
emit("在吗");
|
||||
gate.finish();
|
||||
emit("好在吗");
|
||||
run();
|
||||
expect(sent).toEqual(["在吗"]);
|
||||
});
|
||||
|
||||
it("accepts every emission once the commit is over", () => {
|
||||
const { gate, run } = gateWithRebase();
|
||||
gate.begin();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(false);
|
||||
gate.finish();
|
||||
run();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(true);
|
||||
});
|
||||
|
||||
it("rebases the textarea when the commit closes, not before", () => {
|
||||
const { gate, run, rebases } = gateWithRebase();
|
||||
gate.begin();
|
||||
gate.finish();
|
||||
expect(rebases()).toBe(0);
|
||||
run();
|
||||
expect(rebases()).toBe(1);
|
||||
});
|
||||
|
||||
it("stands down once a real compositionstart arrives", () => {
|
||||
const { gate, rebases } = gateWithRebase();
|
||||
gate.begin();
|
||||
gate.compositionStart();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(rebases()).toBe(0);
|
||||
// And a later commit is not gated either.
|
||||
gate.begin();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(true);
|
||||
});
|
||||
|
||||
it("closes a commit whose compositionend never arrives", () => {
|
||||
const { gate, run, pending, rebases } = gateWithRebase();
|
||||
gate.begin();
|
||||
expect(pending()).toBe(1);
|
||||
run();
|
||||
expect(IME_COMMIT_TIMEOUT_MS).toBeGreaterThan(0);
|
||||
expect(rebases()).toBe(1);
|
||||
expect(gate.accept()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Collapses the repeated data emissions of one IME commit.
|
||||
*
|
||||
* WebKitGTK + fcitx5 commit text without firing `compositionstart`, so xterm's `CompositionHelper`
|
||||
* never advances `_compositionPosition.start` and keeps appending the committed text to its hidden
|
||||
* textarea. A commit then reaches `onData` twice — once with the committed text, once with a stale
|
||||
* `value.substring(start)` — and the same text shows up in the terminal twice.
|
||||
*
|
||||
* One commit must send one chunk: take the first emission and drop the rest, then rebase the
|
||||
* textarea so the next commit starts from an empty value. IMEs that do report `compositionstart`
|
||||
* (macOS, IBus) keep xterm's own bookkeeping, so the gate stands down for them.
|
||||
*/
|
||||
|
||||
/** How long a commit may stay open when its `compositionend` never arrives. */
|
||||
export const IME_COMMIT_TIMEOUT_MS = 250;
|
||||
|
||||
export interface ImeScheduler {
|
||||
after(callback: () => void, ms: number): number;
|
||||
cancel(id: number): void;
|
||||
}
|
||||
|
||||
const WINDOW_SCHEDULER: ImeScheduler = {
|
||||
after: (callback, ms) => window.setTimeout(callback, ms),
|
||||
cancel: (id) => window.clearTimeout(id),
|
||||
};
|
||||
|
||||
export class ImeCommitGate {
|
||||
private open = false;
|
||||
private sent = false;
|
||||
private timer: number | null = null;
|
||||
private native = false;
|
||||
private readonly rebase: () => void;
|
||||
private readonly scheduler: ImeScheduler;
|
||||
|
||||
constructor(rebase: () => void, scheduler: ImeScheduler = WINDOW_SCHEDULER) {
|
||||
this.rebase = rebase;
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
/** A commit starts: the IME's own keystroke (code 229) or its composition input event. */
|
||||
begin(): void {
|
||||
if (this.native || this.open) return;
|
||||
this.open = true;
|
||||
this.sent = false;
|
||||
this.timer = this.scheduler.after(() => this.close(), IME_COMMIT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** The commit ended: let the emissions both of xterm's senders queued go out, then rebase. */
|
||||
finish(): void {
|
||||
if (!this.open) return;
|
||||
this.cancelTimer();
|
||||
this.timer = this.scheduler.after(() => this.close(), 0);
|
||||
}
|
||||
|
||||
/** Whether this emission should reach the PTY. */
|
||||
accept(): boolean {
|
||||
if (!this.open) return true;
|
||||
if (this.sent) return false;
|
||||
this.sent = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The IME reports composition events, so xterm needs no help — stay out of its way. */
|
||||
compositionStart(): void {
|
||||
this.native = true;
|
||||
this.cancelTimer();
|
||||
this.open = false;
|
||||
this.sent = false;
|
||||
}
|
||||
|
||||
/** Ends the commit and rebases the textarea to an empty value. */
|
||||
close(): void {
|
||||
this.cancelTimer();
|
||||
if (!this.open) return;
|
||||
this.open = false;
|
||||
this.rebase();
|
||||
}
|
||||
|
||||
private cancelTimer(): void {
|
||||
if (this.timer === null) return;
|
||||
this.scheduler.cancel(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Channel } from "@tauri-apps/api/core";
|
||||
import { call, commands, isTauri, type TerminalId } from "@/platform/ipc";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { fitScheduler, settledResize } from "./fit";
|
||||
import { ImeCommitGate } from "./imeCommit";
|
||||
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
|
||||
import { splitCommandLine } from "@/platform/commandLine";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -176,6 +177,10 @@ export class TerminalSession {
|
||||
private lastSize = { cols: 0, rows: 0 };
|
||||
/** Bumped on restart so late output from a previous PTY is ignored. */
|
||||
private generation = 0;
|
||||
/** WebKitGTK delivers one IME commit' worth of text more than once; see `ImeCommitGate`. */
|
||||
private readonly ime = new ImeCommitGate(() => {
|
||||
if (this.term.textarea) this.term.textarea.value = "";
|
||||
});
|
||||
|
||||
constructor(opts: SessionOptions) {
|
||||
this.opts = opts;
|
||||
@@ -227,7 +232,7 @@ export class TerminalSession {
|
||||
this.searchAddon.onDidChangeResults((r) => {
|
||||
for (const l of this.searchListeners) l({ index: r.resultIndex, count: r.resultCount });
|
||||
}),
|
||||
this.term.onData((data) => this.input(data)),
|
||||
this.term.onData((data) => this.fromTerminal(data)),
|
||||
this.term.onBinary((data) => this.input(data)),
|
||||
this.term.onTitleChange((title) => this.update({ appTitle: title.trim() || null })),
|
||||
// OSC 7: `file://host/path` — shells report their cwd so relative links resolve correctly.
|
||||
@@ -347,6 +352,7 @@ export class TerminalSession {
|
||||
() => this.scheduleFit(),
|
||||
);
|
||||
this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key));
|
||||
this.watchImeCommit();
|
||||
}
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = new ResizeObserver(() => this.scheduleFit());
|
||||
@@ -364,6 +370,44 @@ export class TerminalSession {
|
||||
this.fitter.schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a WebKitGTK / fcitx5 IME commit through `ImeCommitGate`: the platform reports the
|
||||
* composition's own keystroke (code 229) and its input event but never `compositionstart`,
|
||||
* which is what xterm's own bookkeeping is built on.
|
||||
*/
|
||||
private watchImeCommit() {
|
||||
const textarea = this.term.textarea;
|
||||
if (!textarea) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.keyCode === 229) this.ime.begin();
|
||||
};
|
||||
const onInput = (e: InputEvent) => {
|
||||
if (e.inputType === "insertFromComposition" || e.inputType === "insertCompositionText") this.ime.begin();
|
||||
};
|
||||
const onCompositionStart = () => this.ime.compositionStart();
|
||||
// Bubble phase, i.e. after xterm's own listener: the rebase must land behind the emissions
|
||||
// that xterm's composition and textarea-diff paths already scheduled.
|
||||
const onCompositionEnd = () => this.ime.finish();
|
||||
this.host.addEventListener("keydown", onKeyDown, true);
|
||||
textarea.addEventListener("input", onInput, true);
|
||||
textarea.addEventListener("compositionstart", onCompositionStart, true);
|
||||
textarea.addEventListener("compositionend", onCompositionEnd);
|
||||
this.disposables.push({
|
||||
dispose: () => {
|
||||
this.ime.close();
|
||||
this.host.removeEventListener("keydown", onKeyDown, true);
|
||||
textarea.removeEventListener("input", onInput, true);
|
||||
textarea.removeEventListener("compositionstart", onCompositionStart, true);
|
||||
textarea.removeEventListener("compositionend", onCompositionEnd);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** xterm's data event, with one IME commit's repeated emissions collapsed. */
|
||||
private fromTerminal(data: string) {
|
||||
if (this.ime.accept()) this.input(data);
|
||||
}
|
||||
|
||||
private doFit() {
|
||||
if (this.disposed || !this.host.isConnected) return;
|
||||
if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) return;
|
||||
|
||||
Reference in new issue
Заблокувати користувача