diff --git a/src/features/terminal/imeCommit.test.ts b/src/features/terminal/imeCommit.test.ts new file mode 100644 index 000000000..76753353b --- /dev/null +++ b/src/features/terminal/imeCommit.test.ts @@ -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 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); + }); +}); diff --git a/src/features/terminal/imeCommit.ts b/src/features/terminal/imeCommit.ts new file mode 100644 index 000000000..baeca349f --- /dev/null +++ b/src/features/terminal/imeCommit.ts @@ -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; + } +} diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 9c7c0f8cf..cdc92247d 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -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;