mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
fix(terminal): deliver an IME commit the deferred textarea diff missed
Picking a single Chinese character from the candidate window with a number key loses it. The character flashes and disappears. Picking the same candidate with the mouse works, and picking multi-character words with number keys works. Two paths can deliver an IME commit, and this falls between them. A keydown the input method consumed routes into a setTimeout(0) diff of the helper textarea, and that diff is what normally delivers the commit; xterm's _keyDownSeen guard exists to defer to it. When the commit arrives after that timer has already run, neither path delivers. Mouse selection works because no key is down, and a real composition session works because it takes a different path entirely. That narrows it to an input method whose commit round-trips asynchronously and which shows no in-application preedit. Track that a consumed keydown still owes its commit, and deliver only when the diff did not. The upstream guard and its single read site are untouched, which is what keeps the duplicate-commit behaviour it was added for sealed. Not doing the obvious repairs deliberately: clearing the flag, skipping it for keyCode 229, or setting it after the composition short-circuit each unblock the input path without retiring the diff, and all three were measured emitting the character twice. The patch and the lockfile hash here are generated. Review config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch, which is the hand-written source of the change; the shipped patch and both minified bundles are the regenerator's output from the pinned upstream build, so nothing in this change was hand-transcribed into a bundle. Refs xtermjs/xterm.js#6036 Closes #12099
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -88,7 +88,7 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc0
|
||||
|
||||
export interface IBrowser {
|
||||
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
|
||||
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3ef59ed3b3 100644
|
||||
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8d94e8797 100644
|
||||
--- a/src/browser/input/CompositionHelper.ts
|
||||
+++ b/src/browser/input/CompositionHelper.ts
|
||||
@@ -12,6 +12,28 @@ interface IPosition {
|
||||
@@ -136,7 +136,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
|
||||
/**
|
||||
* The position within the input textarea's value of the current composition.
|
||||
@@ -36,22 +67,57 @@ export class CompositionHelper {
|
||||
@@ -36,22 +67,63 @@ export class CompositionHelper {
|
||||
*/
|
||||
private _compositionSuffix: string;
|
||||
|
||||
@@ -172,6 +172,12 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
*/
|
||||
private _textareaChangeTimer?: number;
|
||||
|
||||
+ /**
|
||||
+ * Whether the IME consumed the last keydown and still owes the commit it produced. Nothing is
|
||||
+ * forwarded for such a keydown, so the commit is claimed by whichever observes it first.
|
||||
+ */
|
||||
+ private _imeKeydownAwaitingCommit: boolean;
|
||||
+
|
||||
+ /**
|
||||
+ * Identifies the composition transaction that owns deferred work.
|
||||
+ */
|
||||
@@ -200,7 +206,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
constructor(
|
||||
private readonly _textarea: HTMLTextAreaElement,
|
||||
private readonly _compositionView: HTMLElement,
|
||||
@@ -61,27 +127,58 @@ export class CompositionHelper {
|
||||
@@ -61,27 +133,61 @@ export class CompositionHelper {
|
||||
@IRenderService private readonly _renderService: IRenderService
|
||||
) {
|
||||
this._isComposing = false;
|
||||
@@ -216,6 +222,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
+ this._compositionHasObservedProgress = false;
|
||||
+ this._compositionTransactionId = 0;
|
||||
+ this._compositionTimers = new Set();
|
||||
+ this._imeKeydownAwaitingCommit = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,6 +249,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
+ this._compositionStartValue = this._textarea.value;
|
||||
+ this._compositionStartSelection = { start, end };
|
||||
+ this._compositionHasObservedProgress = false;
|
||||
+ // A real session owns everything it commits, so no keydown is left owing one.
|
||||
+ this._imeKeydownAwaitingCommit = false;
|
||||
+ if (this._pendingComposition) {
|
||||
+ this._pendingComposition.nextCompositionStart = this._compositionPosition.start;
|
||||
+ }
|
||||
@@ -262,7 +271,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,22 +186,90 @@ export class CompositionHelper {
|
||||
@@ -89,22 +195,90 @@ export class CompositionHelper {
|
||||
* @param ev The event.
|
||||
*/
|
||||
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
|
||||
@@ -362,7 +371,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +278,19 @@ export class CompositionHelper {
|
||||
@@ -113,7 +287,19 @@ export class CompositionHelper {
|
||||
* @returns Whether the Terminal should continue processing the keydown event.
|
||||
*/
|
||||
public keydown(ev: KeyboardEvent): boolean {
|
||||
@@ -383,7 +392,18 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
if (ev.keyCode === 20 || ev.keyCode === 229) {
|
||||
// 20 is CapsLock, 229 is Enter
|
||||
// Continue composing if the keyCode is the "composition character"
|
||||
@@ -138,6 +315,54 @@ export class CompositionHelper {
|
||||
@@ -128,6 +314,10 @@ export class CompositionHelper {
|
||||
this._finalizeComposition(false);
|
||||
}
|
||||
|
||||
+ // Nothing is forwarded for a keydown the IME consumed, so it is left owing its commit; any
|
||||
+ // other keydown either forwards its own text or produces none, and clears the debt.
|
||||
+ this._imeKeydownAwaitingCommit = ev.keyCode === 229;
|
||||
+
|
||||
if (ev.keyCode === 229) {
|
||||
// If the "composition character" is used but gets to this point it means a non-composition
|
||||
// character (eg. numbers and punctuation) was pressed when the IME was active.
|
||||
@@ -138,6 +328,74 @@ export class CompositionHelper {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -416,7 +436,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
+ }
|
||||
+ const pending = this._pendingComposition;
|
||||
+ if (!pending) {
|
||||
+ return false;
|
||||
+ return this._claimImeKeydownCommit(text);
|
||||
+ }
|
||||
+ if (pending.expectsPostCompositionInput) {
|
||||
+ pending.inputData += text;
|
||||
@@ -434,11 +454,31 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
+ }
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * Settles the commit a keydown the IME consumed still owes. That commit normally arrives as the
|
||||
+ * deferred textarea diff, but an asynchronous IME can deliver it after the diff has already run
|
||||
+ * and found the textarea unchanged, and with the key still down the terminal drops the input
|
||||
+ * event instead (#12099). Whichever path sees the commit first sends it and cancels the other, so
|
||||
+ * an IME that commits before the diff runs still sends once.
|
||||
+ */
|
||||
+ private _claimImeKeydownCommit(text: string): boolean {
|
||||
+ if (!this._imeKeydownAwaitingCommit) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ this._imeKeydownAwaitingCommit = false;
|
||||
+ if (this._textareaChangeTimer !== undefined) {
|
||||
+ clearTimeout(this._textareaChangeTimer);
|
||||
+ this._textareaChangeTimer = undefined;
|
||||
+ }
|
||||
+ this._coreService.triggerDataEvent(text, true);
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
/**
|
||||
* Finalizes the composition, resuming regular input actions. This is called when a composition
|
||||
* is ending.
|
||||
@@ -146,23 +371,52 @@ export class CompositionHelper {
|
||||
@@ -146,23 +404,52 @@ export class CompositionHelper {
|
||||
* compositionend event is triggered, such as enter, so that the composition is sent before
|
||||
* the command is executed.
|
||||
*/
|
||||
@@ -502,7 +542,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
|
||||
// Since composition* events happen before the changes take place in the textarea on most
|
||||
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
|
||||
@@ -172,37 +426,310 @@ export class CompositionHelper {
|
||||
@@ -172,37 +459,310 @@ export class CompositionHelper {
|
||||
// - The last compositionupdate event's data property does not always accurately describe
|
||||
// the character, a counter example being Korean where an ending consonsant can move to
|
||||
// the following character if the following input is a vowel.
|
||||
@@ -840,7 +880,17 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
/**
|
||||
* Apply any changes made to the textarea after the current event chain is allowed to complete.
|
||||
* This should be called when not currently composing but a keydown event with the "composition
|
||||
@@ -236,6 +763,77 @@ export class CompositionHelper {
|
||||
@@ -222,6 +782,9 @@ export class CompositionHelper {
|
||||
|
||||
const diff = newValue.replace(oldValue, '');
|
||||
|
||||
+ if (newValue !== oldValue) {
|
||||
+ this._imeKeydownAwaitingCommit = false;
|
||||
+ }
|
||||
this._dataAlreadySent = diff;
|
||||
|
||||
if (newValue.length > oldValue.length) {
|
||||
@@ -236,6 +799,77 @@ export class CompositionHelper {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -918,7 +968,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
/**
|
||||
* Positions the composition view on top of the cursor and the textarea just below it (so the
|
||||
* IME helper dialog is positioned correctly).
|
||||
@@ -243,10 +841,22 @@ export class CompositionHelper {
|
||||
@@ -243,10 +877,22 @@ export class CompositionHelper {
|
||||
* necessary as the IME events across browsers are not consistently triggered.
|
||||
*/
|
||||
public updateCompositionElements(dontRecurse?: boolean): void {
|
||||
@@ -942,7 +992,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
if (this._bufferService.buffer.isCursorInViewport) {
|
||||
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
|
||||
|
||||
@@ -265,10 +875,17 @@ export class CompositionHelper {
|
||||
@@ -265,10 +911,17 @@ export class CompositionHelper {
|
||||
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
|
||||
this._compositionView.style.maxWidth = maxWidth + 'px';
|
||||
this._compositionView.style.overflow = 'hidden';
|
||||
@@ -964,7 +1014,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
|
||||
this._textarea.style.left = cursorLeft + 'px';
|
||||
this._textarea.style.top = cursorTop + 'px';
|
||||
// Ensure the text area is at least 1x1, otherwise certain IMEs may break
|
||||
@@ -278,7 +895,8 @@ export class CompositionHelper {
|
||||
@@ -278,7 +931,8 @@ export class CompositionHelper {
|
||||
}
|
||||
|
||||
if (!dontRecurse) {
|
||||
|
||||
Generated
+24
-24
@@ -18,7 +18,7 @@ patchedDependencies:
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4
|
||||
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -45,7 +45,7 @@ importers:
|
||||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
|
||||
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -208,25 +208,25 @@ importers:
|
||||
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
|
||||
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -9591,39 +9591,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* An IME that shows candidates only in its own panel — ibus+Rime, fcitx5 with application preedit
|
||||
* disabled — opens no composition session in the page, so a candidate picked with a number key
|
||||
* arrives as a bare `insertText` with no compositionstart/update/end around it (#12099, and
|
||||
* upstream xtermjs/xterm.js#6036).
|
||||
*
|
||||
* xterm forwards nothing for the keydown the IME consumed, yet `_keyDownSeen` still records it as a
|
||||
* key being held, and `_inputEvent` admits a `composed` insertText only when no key is down — so
|
||||
* the commit is dropped. The reporter's three cases differ only in that flag: mouse selection has
|
||||
* no key down, a multi-character word runs through a real composition session, and a single
|
||||
* character picked by number key is discarded.
|
||||
*
|
||||
* The commit for such a keydown is owed by exactly one of two paths — CompositionHelper's deferred
|
||||
* textarea diff, or the input event itself — and which one observes it first depends on whether the
|
||||
* IME commits synchronously. Both orderings are pinned here, because a fix that only unblocks the
|
||||
* input event re-sends everything the diff already delivered.
|
||||
*/
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const IME_KEYCODE = 229
|
||||
|
||||
type Rig = {
|
||||
emitted: string[]
|
||||
terminal: Terminal
|
||||
textarea: HTMLTextAreaElement
|
||||
}
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function openTerminal(): Rig {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
if (!textarea) {
|
||||
throw new Error('xterm helper textarea was not created')
|
||||
}
|
||||
const emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
return { emitted, terminal, textarea }
|
||||
}
|
||||
|
||||
function dispatchKey(
|
||||
{ textarea }: Rig,
|
||||
type: 'keydown' | 'keyup' | 'keypress',
|
||||
init: { key: string; code: string; keyCode: number; charCode?: number }
|
||||
): void {
|
||||
const event = new KeyboardEvent(type, {
|
||||
key: init.key,
|
||||
code: init.code,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
// happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm's IME paths read them.
|
||||
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
|
||||
Object.defineProperty(event, 'charCode', { value: init.charCode ?? 0 })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/** The commit itself: the browser has already written it into the textarea when `input` fires. */
|
||||
function dispatchCommit({ textarea }: Rig, data: string): void {
|
||||
textarea.value = data
|
||||
const event = new InputEvent('input', { bubbles: true })
|
||||
Object.defineProperty(event, 'inputType', { value: 'insertText' })
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
// Trusted user events are always composed; that is what makes the guard collapse to "no key down".
|
||||
Object.defineProperty(event, 'composed', { value: true })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function dispatchComposition(
|
||||
{ textarea }: Rig,
|
||||
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
|
||||
data: string
|
||||
): void {
|
||||
if (type !== 'compositionstart') {
|
||||
textarea.value = data
|
||||
}
|
||||
const event = new CompositionEvent(type, { bubbles: true })
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/** Typing pinyin under a panel-only IME: every letter is consumed, nothing reaches the page. */
|
||||
async function typeConsumedPinyin(rig: Rig, codes: string[]): Promise<void> {
|
||||
for (const code of codes) {
|
||||
dispatchKey(rig, 'keydown', { key: 'Process', code, keyCode: IME_KEYCODE })
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: 'Process', code, keyCode: IME_KEYCODE })
|
||||
await nextEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
describe('a candidate commit that arrives with no composition session', () => {
|
||||
beforeEach(() => {
|
||||
// happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('sends a candidate picked with the mouse, with no key down', async () => {
|
||||
const rig = openTerminal()
|
||||
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
|
||||
dispatchCommit(rig, '好')
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好')
|
||||
})
|
||||
|
||||
it('sends a candidate committed by a real composition session exactly once', async () => {
|
||||
const rig = openTerminal()
|
||||
dispatchComposition(rig, 'compositionstart', '')
|
||||
dispatchComposition(rig, 'compositionupdate', 'hao')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
|
||||
dispatchComposition(rig, 'compositionend', '好的')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好的')
|
||||
})
|
||||
|
||||
// The reporter's failing case. ibus answers over DBus, so the commit lands after the deferred
|
||||
// textarea diff has already run against an unchanged textarea — and the number key is still down.
|
||||
it('sends a commit that arrives after the deferred diff while the consumed key is down', async () => {
|
||||
const rig = openTerminal()
|
||||
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
|
||||
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
|
||||
await nextEventLoop()
|
||||
dispatchCommit(rig, '好')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好')
|
||||
})
|
||||
|
||||
// The same gesture from an IME that commits synchronously. Here the deferred diff would also see
|
||||
// the character, so admitting the input event without cancelling the diff sends 好好.
|
||||
it('sends a commit that arrives before the deferred diff exactly once', async () => {
|
||||
const rig = openTerminal()
|
||||
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
|
||||
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
|
||||
dispatchCommit(rig, '好')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好')
|
||||
})
|
||||
|
||||
// Regression guard for xtermjs/xterm.js#3533: Sogou on Linux fires compositionend *and*
|
||||
// insertText for one commit, and the `!ev.composed` half of the guard exists to stop that
|
||||
// doubling. Claiming a sessionless commit must not reopen it.
|
||||
it('sends one commit when the IME fires both compositionend and insertText', async () => {
|
||||
const rig = openTerminal()
|
||||
dispatchComposition(rig, 'compositionstart', '')
|
||||
dispatchComposition(rig, 'compositionupdate', 'hao')
|
||||
await nextEventLoop()
|
||||
dispatchComposition(rig, 'compositionend', '好')
|
||||
dispatchCommit(rig, '好')
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好')
|
||||
})
|
||||
|
||||
// An IME-consumed keydown that never commits must not leave the claim armed for the next key.
|
||||
// Upper-case letters take xterm's macOS caps-lock path, which forwards from keypress and then
|
||||
// lets the input event through, so a stale claim would send the letter twice.
|
||||
it('does not double an upper-case letter typed after a consumed keydown that never committed', async () => {
|
||||
const rig = openTerminal()
|
||||
await typeConsumedPinyin(rig, ['KeyH'])
|
||||
dispatchKey(rig, 'keydown', { key: 'A', code: 'KeyA', keyCode: 65 })
|
||||
dispatchKey(rig, 'keypress', { key: 'A', code: 'KeyA', keyCode: 65, charCode: 65 })
|
||||
dispatchCommit(rig, 'A')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: 'A', code: 'KeyA', keyCode: 65 })
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('A')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user