mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* build(xterm): restore the patch regeneration harness and gate it in CI docs/reference/ime-architecture.md says "Never hand-edit the bundles in the patch" and links to docs/reference/xterm-patch-regeneration.md. That doc does not exist, and neither does the harness it describes. Both landed in29117bf776and were deleted by17cfc968cf, a revert of the composition-ownership change, which swept up a build tool and a CI gate as collateral. The rule survived; its enforcement did not. Every xterm patch since has had to hand-edit minified bundles to comply with the surrounding architecture, because everything resolves to lib/xterm.mjs at runtime and under vitest, so a src-only edit is inert. The shipped bundles were therefore not the output of any build, and this restores them to build output. Comparing identifier multisets against a pristine build of the pinned commit finds hand-written names a minifier never emits ($rl, $hp, $tid), const in an otherwise let-only esbuild bundle, !! where the source reads Boolean(), an escaped LRM where esbuild emits the literal, and a return block esbuild collapses to void(...). Every remaining token difference is a minifier local reallocating. The old source patch could not be reused. It described the reverted composition-ownership architecture, so restoring it would have re-applied an abandoned design on top of dropping three accumulated fixes. It is re-derived from the shipped patch instead, and the derivation is a fixed point. Two deliberate departures from the deleted version. Sourcemaps are included rather than deleted, because a live test reads lib/*.map and asserts the mapped version matches the runtime version. The source-patch superset carve-out is gone, so a source hunk the shipped patch cannot name now fails loudly instead of being carved out silently. The doc's claim that the webgl and serialize addons reproduce byte for byte was half wrong. Their ESM output does reproduce at the pinned commit, but both also publish CJS that the root package script never builds, so folding either in needs a build step this harness lacks. Recorded as a blocker rather than a confident sentence. xterm_patch_sync runs the regenerator in --check mode, so a patch that does not match a rebuild of the pinned upstream now fails PR CI. The -diff -text attribute is required, not cosmetic: pnpm hashes the patch byte-for-byte, so a CRLF checkout breaks the install outright. Not verified: the CI job has not run on a real runner, the addon CJS bundles are unreproduced, and the generator is untested on Windows and Linux. * build(xterm): make the regenerator runnable on Windows and drop dead paths Readiness review on the restore found one blocking gap and two cheap cleanups. None of them change the emitted patch, which is byte-identical before and after. The generator could not run on Windows at all. Three sites called npm through execFileSync with shell:false, but npm ships as npm.cmd there, execFile applies no PATHEXT, and since CVE-2024-27980 it refuses a .cmd target without a shell. That matters because this harness arms a blocking gate whose documented remedy is --write, so a Windows contributor who tripped the gate had no remedy except hand-editing a 7MB minified bundle, which is the practice the gate exists to abolish. Four sibling scripts in config/scripts already handle this; the fix follows them and lands in run(), so the manifest-driven build step is covered too. git and tar are real executables in System32 and keep resolving without a shell, which avoids quoting exposure on paths with spaces. deleteGeneratedSourcemaps was unreachable, since the policy is include. Deleting it left "delete" as a legal policy value that nothing honoured, so a manifest asking for it would have silently shipped sourcemaps that do not match the bundle. The enum is narrowed and an unrecognised policy now throws rather than falling through. generatedHunks moved into the test file rather than being dropped; its partition assertion, that generated and source hunks reconstruct the whole patch, is worth keeping. The -text attribute now covers all five patch files. pnpm hashes each of them byte-for-byte, so the CRLF hazard the xterm patch was protected from applies equally to node-pty and the three addons. All five were already LF in the object DB, so this pins existing behaviour. -diff stays scoped to the xterm patch, since the others are readable. The doc's claim that the addons reproduce byte for byte is now dated and marked a one-off measurement rather than an invariant, because nothing re-runs it. Effective lines fall from 591 to 568 against the 600 budget. Still the largest file in config/scripts, and adding a second package to the manifest would need a split first.
1006 lines
40 KiB
Diff
1006 lines
40 KiB
Diff
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
|
||
index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1e931791d 100644
|
||
--- a/src/browser/CoreBrowserTerminal.ts
|
||
+++ b/src/browser/CoreBrowserTerminal.ts
|
||
@@ -325,6 +325,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||
private _handleTextAreaBlur(): void {
|
||
// Text can safely be removed on blur. Doing it earlier could interfere with
|
||
// screen readers reading it out.
|
||
+ if (this._compositionHelper instanceof CompositionHelper) {
|
||
+ this._compositionHelper.blur();
|
||
+ }
|
||
this.textarea!.value = '';
|
||
this.refresh(this.buffer.y, this.buffer.y);
|
||
if (this.coreService.decPrivateModes.sendFocus) {
|
||
@@ -425,7 +428,18 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||
this._compositionHelper!.updateCompositionElements();
|
||
}));
|
||
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
|
||
- this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
|
||
+ this._register(addDisposableListener(this.textarea!, 'compositionend', (e: CompositionEvent) => {
|
||
+ if (this._compositionHelper instanceof CompositionHelper) {
|
||
+ if (this._compositionHelper.compositionend(e)) {
|
||
+ this.textarea!.dispatchEvent(new CustomEvent(
|
||
+ 'xterm-composition-transaction-accepted',
|
||
+ { bubbles: true }
|
||
+ ));
|
||
+ }
|
||
+ } else {
|
||
+ this._compositionHelper!.compositionend();
|
||
+ }
|
||
+ }));
|
||
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
|
||
this._register(this.onRender(() => this._compositionHelper!.updateCompositionElements()));
|
||
}
|
||
@@ -551,6 +565,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||
this._compositionView = this._document.createElement('div');
|
||
this._compositionView.classList.add('composition-view');
|
||
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
|
||
+ this._register(toDisposable(() => {
|
||
+ if (this._compositionHelper instanceof CompositionHelper) {
|
||
+ this._compositionHelper.dispose();
|
||
+ }
|
||
+ }));
|
||
this._helperContainer.appendChild(this._compositionView);
|
||
|
||
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
|
||
@@ -1008,7 +1027,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||
|
||
this._onKey.fire({ key, domEvent: ev });
|
||
this._showCursor();
|
||
- this.coreService.triggerDataEvent(key, true);
|
||
+ if (!this._compositionHelper!.keypress?.(key)) {
|
||
+ this.coreService.triggerDataEvent(key, true);
|
||
+ }
|
||
|
||
this._keyPressHandled = true;
|
||
|
||
@@ -1026,6 +1047,15 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||
* @param ev The input event to be handled.
|
||
*/
|
||
protected _inputEvent(ev: InputEvent): boolean {
|
||
+ if (
|
||
+ ev.data &&
|
||
+ ev.inputType === 'insertText' &&
|
||
+ !this.optionsService.rawOptions.screenReaderMode &&
|
||
+ this._compositionHelper instanceof CompositionHelper &&
|
||
+ this._compositionHelper.input(ev.data)
|
||
+ ) {
|
||
+ return true;
|
||
+ }
|
||
// Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to
|
||
// support reading out character input which can doubling up input characters
|
||
// Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679
|
||
diff --git a/src/browser/Types.ts b/src/browser/Types.ts
|
||
index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc07dc6b07f 100644
|
||
--- a/src/browser/Types.ts
|
||
+++ b/src/browser/Types.ts
|
||
@@ -41,9 +41,10 @@ export interface ICompositionHelper {
|
||
readonly isComposing: boolean;
|
||
compositionstart(): void;
|
||
compositionupdate(ev: CompositionEvent): void;
|
||
- compositionend(): void;
|
||
+ compositionend(): boolean | void;
|
||
updateCompositionElements(dontRecurse?: boolean): void;
|
||
keydown(ev: KeyboardEvent): boolean;
|
||
+ keypress?(text: string): boolean;
|
||
}
|
||
|
||
export interface IBrowser {
|
||
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
|
||
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3ef59ed3b3 100644
|
||
--- a/src/browser/input/CompositionHelper.ts
|
||
+++ b/src/browser/input/CompositionHelper.ts
|
||
@@ -12,6 +12,28 @@ interface IPosition {
|
||
end: number;
|
||
}
|
||
|
||
+interface IPendingComposition {
|
||
+ transactionId: number;
|
||
+ finalizerTimer?: ReturnType<typeof setTimeout>;
|
||
+ lifecycleSettled: boolean;
|
||
+ sessionEnded: boolean;
|
||
+ position: IPosition;
|
||
+ suffix: string;
|
||
+ dataAlreadySent: string;
|
||
+ compositionData: string;
|
||
+ endData: string;
|
||
+ inputData: string;
|
||
+ keypressData: string;
|
||
+ keypressMayOverlapComposition: boolean;
|
||
+ expectsPostCompositionInput: boolean;
|
||
+ nextCompositionStart?: number;
|
||
+}
|
||
+
|
||
+const XTERM_COMPOSITION_SESSION_START_EVENT = 'xterm-composition-session-start';
|
||
+const XTERM_COMPOSITION_SESSION_END_EVENT = 'xterm-composition-session-end';
|
||
+const XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT =
|
||
+ 'xterm-composition-transaction-accepted';
|
||
+
|
||
/**
|
||
* Encapsulates the logic for handling compositionstart, compositionupdate and compositionend
|
||
* events, displaying the in-progress composition to the UI and forwarding the final composition
|
||
@@ -24,6 +46,15 @@ export class CompositionHelper {
|
||
*/
|
||
private _isComposing: boolean;
|
||
public get isComposing(): boolean { return this._isComposing; }
|
||
+ public get hasPendingCompositionFinalization(): boolean {
|
||
+ return this._pendingComposition !== undefined;
|
||
+ }
|
||
+ public get _isSendingComposition(): boolean {
|
||
+ return this.hasPendingCompositionFinalization;
|
||
+ }
|
||
+ public get _pendingKeypressData(): string {
|
||
+ return this._pendingComposition?.keypressData ?? '';
|
||
+ }
|
||
|
||
/**
|
||
* The position within the input textarea's value of the current composition.
|
||
@@ -36,22 +67,57 @@ export class CompositionHelper {
|
||
*/
|
||
private _compositionSuffix: string;
|
||
|
||
- /**
|
||
- * Whether a composition is in the process of being sent, setting this to false will cancel any
|
||
- * in-progress composition.
|
||
- */
|
||
- private _isSendingComposition: boolean;
|
||
-
|
||
/**
|
||
* Data already sent due to keydown event.
|
||
*/
|
||
private _dataAlreadySent: string;
|
||
|
||
+ private _pendingComposition?: IPendingComposition;
|
||
+
|
||
+ private _isAwaitingCompositionEnd: boolean;
|
||
+
|
||
+ private _compositionInputData: string;
|
||
+
|
||
+ private _lastCompositionData: string;
|
||
+
|
||
+ private _compositionStartValue: string;
|
||
+
|
||
+ private _compositionStartSelection: IPosition;
|
||
+
|
||
+ private _compositionHasObservedProgress: boolean;
|
||
+
|
||
+ private _canceledKey?: Pick<KeyboardEvent, 'code' | 'timeStamp'>;
|
||
+
|
||
/**
|
||
* The pending textarea change timer, if any.
|
||
*/
|
||
private _textareaChangeTimer?: number;
|
||
|
||
+ /**
|
||
+ * Identifies the composition transaction that owns deferred work.
|
||
+ */
|
||
+ private _compositionTransactionId: number;
|
||
+
|
||
+ /**
|
||
+ * Timers that still own deferred composition state.
|
||
+ */
|
||
+ private _compositionTimers: Set<ReturnType<typeof setTimeout>>;
|
||
+
|
||
+ private _compositionPositionTimer?: ReturnType<typeof setTimeout>;
|
||
+
|
||
+ private _compositionViewTimer?: ReturnType<typeof setTimeout>;
|
||
+
|
||
+ private _compositionEndTimer?: ReturnType<typeof setTimeout>;
|
||
+
|
||
+ /** The preedit's own span, set only while the view also renders the row tail behind it. */
|
||
+ private _compositionPreedit?: HTMLElement;
|
||
+
|
||
+ /** The rendered row tail, set only while the cursor sits mid-line. */
|
||
+ private _compositionRemainder?: HTMLElement;
|
||
+
|
||
+ /** The last preedit rendered, so a row repaint can re-render without a composition event. */
|
||
+ private _compositionViewData?: string;
|
||
+
|
||
constructor(
|
||
private readonly _textarea: HTMLTextAreaElement,
|
||
private readonly _compositionView: HTMLElement,
|
||
@@ -61,27 +127,58 @@ export class CompositionHelper {
|
||
@IRenderService private readonly _renderService: IRenderService
|
||
) {
|
||
this._isComposing = false;
|
||
- this._isSendingComposition = false;
|
||
+ this._isAwaitingCompositionEnd = false;
|
||
this._compositionPosition = { start: 0, end: 0 };
|
||
this._compositionSuffix = '';
|
||
this._dataAlreadySent = '';
|
||
+ this._compositionInputData = '';
|
||
+ this._lastCompositionData = '';
|
||
+ this._compositionStartValue = '';
|
||
+ this._compositionStartSelection = { start: 0, end: 0 };
|
||
+ this._compositionHasObservedProgress = false;
|
||
+ this._compositionTransactionId = 0;
|
||
+ this._compositionTimers = new Set();
|
||
}
|
||
|
||
/**
|
||
* Handles the compositionstart event, activating the composition view.
|
||
*/
|
||
public compositionstart(): void {
|
||
- this._isComposing = true;
|
||
+ this._cancelDeferredTimer(this._compositionPositionTimer);
|
||
+ this._compositionPositionTimer = undefined;
|
||
+ this._cancelDeferredTimer(this._compositionViewTimer);
|
||
+ this._compositionViewTimer = undefined;
|
||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||
+ this._compositionEndTimer = undefined;
|
||
+ if (this._textareaChangeTimer !== undefined) {
|
||
+ clearTimeout(this._textareaChangeTimer);
|
||
+ this._textareaChangeTimer = undefined;
|
||
+ }
|
||
// It's important to use the selection here instead of textarea length to avoid conflicts with
|
||
// screen reader mode
|
||
const start = this._textarea.selectionStart ?? this._textarea.value.length;
|
||
const end = this._textarea.selectionEnd ?? start;
|
||
this._compositionPosition.start = Math.min(start, end);
|
||
this._compositionPosition.end = Math.max(start, end);
|
||
+ this._compositionStartValue = this._textarea.value;
|
||
+ this._compositionStartSelection = { start, end };
|
||
+ this._compositionHasObservedProgress = false;
|
||
+ if (this._pendingComposition) {
|
||
+ this._pendingComposition.nextCompositionStart = this._compositionPosition.start;
|
||
+ }
|
||
+ this._compositionTransactionId++;
|
||
+ this._isComposing = true;
|
||
+ this._isAwaitingCompositionEnd = true;
|
||
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
|
||
- this._compositionView.textContent = '';
|
||
+ this._resetCompositionView();
|
||
this._dataAlreadySent = '';
|
||
+ this._compositionInputData = '';
|
||
+ this._lastCompositionData = '';
|
||
this._compositionView.classList.add('active');
|
||
+ this._dispatchCompositionSessionEvent(new CustomEvent(XTERM_COMPOSITION_SESSION_START_EVENT, {
|
||
+ bubbles: true,
|
||
+ detail: { id: this._compositionTransactionId }
|
||
+ }));
|
||
}
|
||
|
||
/**
|
||
@@ -89,22 +186,90 @@ export class CompositionHelper {
|
||
* @param ev The event.
|
||
*/
|
||
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
|
||
- // Mark text as LTR, direction=rtl is used in CSS so the end of the text is followed for long
|
||
- // compositions
|
||
- this._compositionView.textContent = `\u200E${ev.data}\u200E`;
|
||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||
+ this._compositionEndTimer = undefined;
|
||
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
||
+ if (ev.data?.length > 0) {
|
||
+ this._lastCompositionData = ev.data;
|
||
+ }
|
||
+ this._renderCompositionView(ev.data ?? '');
|
||
+ // Some IMEs resume a composition with an update alone and no second compositionstart, by which
|
||
+ // point compositionend has already hidden the view. Without re-showing it the resumed preedit
|
||
+ // is written into a hidden element and the user composes blind. Empty data is the IME saying
|
||
+ // the preedit is gone, so it hides the view again rather than leaving an orphaned overlay.
|
||
+ this._compositionView.classList.toggle('active', Boolean(ev.data));
|
||
this.updateCompositionElements();
|
||
- setTimeout(() => {
|
||
- const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
||
- this._compositionPosition.end = Math.max( this._compositionPosition.start, end);
|
||
- }, 0);
|
||
+ const transactionId = this._compositionTransactionId;
|
||
+ this._cancelDeferredTimer(this._compositionPositionTimer);
|
||
+ this._compositionPositionTimer = this._defer(() => {
|
||
+ if (this._isComposing && this._compositionTransactionId === transactionId) {
|
||
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
||
+ const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
||
+ this._compositionPosition.end = Math.max(this._compositionPosition.start, end);
|
||
+ }
|
||
+ });
|
||
}
|
||
|
||
/**
|
||
* Handles the compositionend event, hiding the composition view and sending the composition to
|
||
* the handler.
|
||
*/
|
||
- public compositionend(): void {
|
||
- this._finalizeComposition(true);
|
||
+ public compositionend(ev?: Pick<CompositionEvent, 'data'>): boolean {
|
||
+ if (!this._isAwaitingCompositionEnd) {
|
||
+ return false;
|
||
+ }
|
||
+ if (!this._isComposing) {
|
||
+ const pending = this._pendingComposition;
|
||
+ if (pending?.transactionId === this._compositionTransactionId) {
|
||
+ pending.endData = ev?.data ?? '';
|
||
+ this._updatePostCompositionInputExpectation(pending);
|
||
+ }
|
||
+ return false;
|
||
+ }
|
||
+ const endData = ev?.data ?? '';
|
||
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
||
+ if (!this._compositionEndBelongsToCurrentTransaction(endData)) {
|
||
+ const pending = this._pendingComposition;
|
||
+ if (pending && pending.transactionId !== this._compositionTransactionId) {
|
||
+ this._sendPendingComposition(pending);
|
||
+ }
|
||
+ this._deferCompositionEnd(endData);
|
||
+ return false;
|
||
+ }
|
||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||
+ this._compositionEndTimer = undefined;
|
||
+ this._finalizeComposition(true, endData);
|
||
+ return true;
|
||
+ }
|
||
+
|
||
+ public blur(): void {
|
||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||
+ this._compositionEndTimer = undefined;
|
||
+ if (this._isComposing) {
|
||
+ const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
||
+ this._compositionPosition.end = Math.max(this._compositionPosition.start, end);
|
||
+ }
|
||
+ if (this._isComposing || this.hasPendingCompositionFinalization) {
|
||
+ this._finalizeComposition(false);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ public dispose(): void {
|
||
+ if (this._textareaChangeTimer !== undefined) {
|
||
+ clearTimeout(this._textareaChangeTimer);
|
||
+ this._textareaChangeTimer = undefined;
|
||
+ }
|
||
+ for (const timer of this._compositionTimers) {
|
||
+ clearTimeout(timer);
|
||
+ }
|
||
+ this._compositionTimers.clear();
|
||
+ this._compositionPositionTimer = undefined;
|
||
+ this._compositionViewTimer = undefined;
|
||
+ this._compositionEndTimer = undefined;
|
||
+ this._pendingComposition = undefined;
|
||
+ this._isAwaitingCompositionEnd = false;
|
||
+ this._isComposing = false;
|
||
+ this._compositionTransactionId++;
|
||
}
|
||
|
||
/**
|
||
@@ -113,7 +278,19 @@ export class CompositionHelper {
|
||
* @returns Whether the Terminal should continue processing the keydown event.
|
||
*/
|
||
public keydown(ev: KeyboardEvent): boolean {
|
||
- if (this._isComposing || this._isSendingComposition) {
|
||
+ if (this._canceledKey?.code === ev.code && this._canceledKey.timeStamp === ev.timeStamp) {
|
||
+ this._canceledKey = undefined;
|
||
+ return false;
|
||
+ }
|
||
+ if (ev.key === 'Escape' && (this._isComposing || this.hasPendingCompositionFinalization)) {
|
||
+ this._canceledKey = { code: ev.code, timeStamp: ev.timeStamp };
|
||
+ this._cancelComposition();
|
||
+ return false;
|
||
+ }
|
||
+ if (this._isComposing || this.hasPendingCompositionFinalization) {
|
||
+ // A key the IME swallows can also empty the preedit — backspacing over the last radical of a
|
||
+ // Cangjie composition — and some IMEs report that with no composition event at all.
|
||
+ this._deferPreeditResync(this._composedRegionLength() > 0);
|
||
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 {
|
||
return true;
|
||
}
|
||
|
||
+ /**
|
||
+ * Defers keypress text while a composition finalizer is pending so all input is emitted once
|
||
+ * after reconciliation with the final textarea candidate.
|
||
+ */
|
||
+ public keypress(text: string): boolean {
|
||
+ const pending = this._pendingComposition;
|
||
+ if (!pending) {
|
||
+ return false;
|
||
+ }
|
||
+ if (pending.keypressMayOverlapComposition) {
|
||
+ pending.keypressData += text;
|
||
+ return true;
|
||
+ }
|
||
+ if (pending.expectsPostCompositionInput && pending.keypressData.length === 0) {
|
||
+ pending.keypressData = text;
|
||
+ return true;
|
||
+ }
|
||
+ this._sendPendingComposition(pending);
|
||
+ return false;
|
||
+ }
|
||
+
|
||
+ public input(text: string): boolean {
|
||
+ if (this._isComposing) {
|
||
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
||
+ this._compositionInputData += text;
|
||
+ return true;
|
||
+ }
|
||
+ const pending = this._pendingComposition;
|
||
+ if (!pending) {
|
||
+ return false;
|
||
+ }
|
||
+ if (pending.expectsPostCompositionInput) {
|
||
+ pending.inputData += text;
|
||
+ pending.expectsPostCompositionInput = false;
|
||
+ this._sendPendingComposition(pending);
|
||
+ return true;
|
||
+ }
|
||
+ const repeatsPendingTextareaInput =
|
||
+ text.length > 0 &&
|
||
+ this._getPendingTextareaInput(pending) === text &&
|
||
+ this._getPendingTextareaInput(pending, true) === text;
|
||
+ this._sendPendingComposition(pending);
|
||
+ if (!repeatsPendingTextareaInput) {
|
||
+ 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 {
|
||
* compositionend event is triggered, such as enter, so that the composition is sent before
|
||
* the command is executed.
|
||
*/
|
||
- private _finalizeComposition(waitForPropagation: boolean): void {
|
||
+ private _finalizeComposition(waitForPropagation: boolean, endData: string = ''): void {
|
||
+ const wasComposing = this._isComposing;
|
||
this._compositionView.classList.remove('active');
|
||
+ // Cleared, not just hidden: a rendered tail left in the view is stale DOM the next composition
|
||
+ // would have to correct before its own first update lands.
|
||
+ this._resetCompositionView();
|
||
this._isComposing = false;
|
||
+ if (waitForPropagation && !wasComposing) {
|
||
+ return;
|
||
+ }
|
||
|
||
if (!waitForPropagation) {
|
||
- // Cancel any delayed composition send requests and send the input immediately.
|
||
- this._isSendingComposition = false;
|
||
- const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
|
||
- this._coreService.triggerDataEvent(input, true);
|
||
+ if (this._pendingComposition) {
|
||
+ this._sendPendingComposition(this._pendingComposition, true);
|
||
+ }
|
||
+ if (wasComposing) {
|
||
+ const input = this._getCompositionInput(
|
||
+ this._compositionPosition.start + this._dataAlreadySent.length,
|
||
+ this._compositionSuffix
|
||
+ );
|
||
+ this._sendCompositionInput(this._compositionTransactionId, input);
|
||
+ }
|
||
} else {
|
||
- // Make a deep copy of the composition position here as a new compositionstart event may
|
||
- // fire before the setTimeout executes.
|
||
- const currentCompositionPosition = {
|
||
- start: this._compositionPosition.start,
|
||
- end: this._compositionPosition.end
|
||
+ if (this._pendingComposition) {
|
||
+ this._sendPendingComposition(this._pendingComposition);
|
||
+ }
|
||
+ const pending: IPendingComposition = {
|
||
+ transactionId: this._compositionTransactionId,
|
||
+ lifecycleSettled: false,
|
||
+ sessionEnded: false,
|
||
+ position: {
|
||
+ start: this._compositionPosition.start,
|
||
+ end: this._compositionPosition.end
|
||
+ },
|
||
+ suffix: this._compositionSuffix,
|
||
+ dataAlreadySent: this._dataAlreadySent,
|
||
+ compositionData: this._lastCompositionData,
|
||
+ endData,
|
||
+ inputData: this._compositionInputData,
|
||
+ keypressData: '',
|
||
+ keypressMayOverlapComposition:
|
||
+ this._lastCompositionData.length === 0 && endData.length === 0,
|
||
+ expectsPostCompositionInput: false
|
||
};
|
||
- const currentCompositionSuffix = this._compositionSuffix;
|
||
+ this._updatePostCompositionInputExpectation(pending);
|
||
+ this._pendingComposition = pending;
|
||
|
||
// 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 {
|
||
// - 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.
|
||
- this._isSendingComposition = true;
|
||
- setTimeout(() => {
|
||
- // Ensure that the input has not already been sent
|
||
- if (this._isSendingComposition) {
|
||
- this._isSendingComposition = false;
|
||
- let input;
|
||
- // Add length of data already sent due to keydown event,
|
||
- // otherwise input characters can be duplicated. (Issue #3191)
|
||
- currentCompositionPosition.start += this._dataAlreadySent.length;
|
||
- if (this._isComposing) {
|
||
- // Use the start position of the new composition to get the string
|
||
- // if a new composition has started.
|
||
- input = this._textarea.value.substring(currentCompositionPosition.start, this._compositionPosition.start);
|
||
- } else {
|
||
- // Keep support for non-composition characters typed immediately after composition end
|
||
- // while avoiding re-sending the trailing text that was already present
|
||
- // before composition started.
|
||
- const value = this._textarea.value;
|
||
- const valueEnd = currentCompositionSuffix.length > 0 && value.endsWith(currentCompositionSuffix)
|
||
- ? value.length - currentCompositionSuffix.length
|
||
- : value.length;
|
||
- input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
|
||
- }
|
||
- if (input.length > 0) {
|
||
- this._coreService.triggerDataEvent(input, true);
|
||
- }
|
||
+ pending.finalizerTimer = this._defer(() => {
|
||
+ pending.finalizerTimer = undefined;
|
||
+ if (this._compositionTransactionId === pending.transactionId) {
|
||
+ this._isAwaitingCompositionEnd = false;
|
||
+ }
|
||
+ if (this._pendingComposition === pending) {
|
||
+ this._sendPendingComposition(pending, true);
|
||
}
|
||
- }, 0);
|
||
+ });
|
||
}
|
||
}
|
||
|
||
+ private _sendPendingComposition(
|
||
+ pending: IPendingComposition,
|
||
+ includeFollowingInput: boolean = false
|
||
+ ): void {
|
||
+ this._cancelPendingFinalizer(pending);
|
||
+ if (this._pendingComposition === pending) {
|
||
+ this._pendingComposition = undefined;
|
||
+ }
|
||
+ const textareaInput = this._getPendingTextareaInput(pending, includeFollowingInput);
|
||
+ const observedInput = this._removeAlreadySentData(
|
||
+ pending.inputData || pending.keypressData,
|
||
+ pending.dataAlreadySent
|
||
+ );
|
||
+ // Why: with no textarea, end, input, or keypress evidence the composition
|
||
+ // was cancelled (e.g. Backspace over the whole preedit); stale
|
||
+ // compositionupdate data must not be replayed as committed text.
|
||
+ const input = this._mergeTextObservations(
|
||
+ textareaInput || pending.endData || (observedInput ? pending.compositionData : ''),
|
||
+ observedInput,
|
||
+ pending.keypressMayOverlapComposition
|
||
+ );
|
||
+ this._sendCompositionInput(pending.transactionId, input, !pending.sessionEnded);
|
||
+ this._settlePendingComposition(pending);
|
||
+ }
|
||
+
|
||
+ private _cancelPendingFinalizer(pending: IPendingComposition): void {
|
||
+ if (pending.finalizerTimer === undefined) {
|
||
+ return;
|
||
+ }
|
||
+ clearTimeout(pending.finalizerTimer);
|
||
+ this._compositionTimers.delete(pending.finalizerTimer);
|
||
+ pending.finalizerTimer = undefined;
|
||
+ }
|
||
+
|
||
+ private _settlePendingComposition(pending: IPendingComposition): void {
|
||
+ if (pending.lifecycleSettled) {
|
||
+ return;
|
||
+ }
|
||
+ pending.lifecycleSettled = true;
|
||
+ this._dispatchCompositionTransactionSettled();
|
||
+ }
|
||
+
|
||
+ private _mergeTextObservations(
|
||
+ candidate: string,
|
||
+ observed: string,
|
||
+ findShortestOrder: boolean
|
||
+ ): string {
|
||
+ if (!observed || candidate.includes(observed)) {
|
||
+ return candidate;
|
||
+ }
|
||
+ if (!candidate || observed.includes(candidate)) {
|
||
+ return observed;
|
||
+ }
|
||
+ if (findShortestOrder) {
|
||
+ let candidateFirstOverlap = Math.min(candidate.length, observed.length);
|
||
+ while (
|
||
+ candidateFirstOverlap > 0 &&
|
||
+ !candidate.endsWith(observed.substring(0, candidateFirstOverlap))
|
||
+ ) {
|
||
+ candidateFirstOverlap--;
|
||
+ }
|
||
+ let observedFirstOverlap = Math.min(candidate.length, observed.length);
|
||
+ while (
|
||
+ observedFirstOverlap > 0 &&
|
||
+ !observed.endsWith(candidate.substring(0, observedFirstOverlap))
|
||
+ ) {
|
||
+ observedFirstOverlap--;
|
||
+ }
|
||
+ return candidateFirstOverlap > observedFirstOverlap
|
||
+ ? candidate + observed.substring(candidateFirstOverlap)
|
||
+ : observed + candidate.substring(observedFirstOverlap);
|
||
+ }
|
||
+ let overlap = Math.min(candidate.length, observed.length);
|
||
+ while (overlap > 0 && !candidate.endsWith(observed.substring(0, overlap))) {
|
||
+ overlap--;
|
||
+ }
|
||
+ return candidate + observed.substring(overlap);
|
||
+ }
|
||
+
|
||
+ private _updatePostCompositionInputExpectation(pending: IPendingComposition): void {
|
||
+ pending.expectsPostCompositionInput =
|
||
+ (pending.endData.length > 0 || pending.compositionData.length > 0) &&
|
||
+ pending.inputData.length === 0 &&
|
||
+ this._getPendingTextareaInput(pending).length === 0;
|
||
+ }
|
||
+
|
||
+ private _getPendingTextareaInput(
|
||
+ pending: IPendingComposition,
|
||
+ includeFollowingInput: boolean = false
|
||
+ ): string {
|
||
+ const value = this._textarea.value;
|
||
+ const start = pending.position.start + pending.dataAlreadySent.length;
|
||
+ if (pending.nextCompositionStart !== undefined) {
|
||
+ return value.substring(start, Math.max(start, pending.nextCompositionStart));
|
||
+ }
|
||
+ const suffixEnd =
|
||
+ pending.suffix.length > 0 && value.endsWith(pending.suffix)
|
||
+ ? value.length - pending.suffix.length
|
||
+ : value.length;
|
||
+ const compositionLength = (pending.endData || pending.compositionData).length;
|
||
+ const observedEnd = includeFollowingInput
|
||
+ ? suffixEnd
|
||
+ : Math.max(pending.position.end, start + compositionLength);
|
||
+ return value.substring(start, Math.max(start, Math.min(suffixEnd, observedEnd)));
|
||
+ }
|
||
+
|
||
+ private _getCompositionInput(start: number, suffix: string): string {
|
||
+ const value = this._textarea.value;
|
||
+ const valueEnd =
|
||
+ suffix.length > 0 && value.endsWith(suffix) ? value.length - suffix.length : value.length;
|
||
+ return value.substring(start, Math.max(start, valueEnd));
|
||
+ }
|
||
+
|
||
+ private _removeAlreadySentData(input: string, dataAlreadySent: string): string {
|
||
+ if (dataAlreadySent.length === 0) {
|
||
+ return input;
|
||
+ }
|
||
+ if (input.startsWith(dataAlreadySent)) {
|
||
+ return input.substring(dataAlreadySent.length);
|
||
+ }
|
||
+ return dataAlreadySent.includes(input) ? '' : input;
|
||
+ }
|
||
+
|
||
+ private _cancelComposition(): void {
|
||
+ const pending = this._pendingComposition;
|
||
+ if (
|
||
+ pending &&
|
||
+ this._isComposing &&
|
||
+ pending.transactionId !== this._compositionTransactionId
|
||
+ ) {
|
||
+ this._sendPendingComposition(pending);
|
||
+ }
|
||
+ const transactionId = this._isComposing
|
||
+ ? this._compositionTransactionId
|
||
+ : this._pendingComposition?.transactionId ?? 0;
|
||
+ const settlesPending = pending !== undefined && this._pendingComposition === pending;
|
||
+ this._pendingComposition = undefined;
|
||
+ this._isAwaitingCompositionEnd = false;
|
||
+ this._isComposing = false;
|
||
+ this._compositionView.classList.remove('active');
|
||
+ this._resetCompositionView();
|
||
+ this._textarea.value =
|
||
+ this._textarea.value.substring(0, this._compositionPosition.start) + this._compositionSuffix;
|
||
+ this._sendCompositionInput(transactionId, '');
|
||
+ if (settlesPending && pending) {
|
||
+ this._settlePendingComposition(pending);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ private _sendCompositionInput(
|
||
+ transactionId: number,
|
||
+ input: string,
|
||
+ dispatchSessionEnd: boolean = true
|
||
+ ): void {
|
||
+ let prevented = false;
|
||
+ if (dispatchSessionEnd) {
|
||
+ const event = new CustomEvent(XTERM_COMPOSITION_SESSION_END_EVENT, {
|
||
+ bubbles: true,
|
||
+ cancelable: true,
|
||
+ detail: { id: transactionId, data: input }
|
||
+ });
|
||
+ this._dispatchCompositionSessionEvent(event);
|
||
+ prevented = event.defaultPrevented;
|
||
+ }
|
||
+ if (input.length > 0 && !prevented) {
|
||
+ this._coreService.triggerDataEvent(input, true);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ private _endPendingCompositionSession(pending: IPendingComposition): void {
|
||
+ if (pending.sessionEnded) {
|
||
+ return;
|
||
+ }
|
||
+ pending.sessionEnded = true;
|
||
+ const input =
|
||
+ this._getPendingTextareaInput(pending) ||
|
||
+ pending.endData ||
|
||
+ pending.compositionData;
|
||
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
||
+ XTERM_COMPOSITION_SESSION_END_EVENT,
|
||
+ {
|
||
+ bubbles: true,
|
||
+ cancelable: true,
|
||
+ detail: {
|
||
+ id: pending.transactionId,
|
||
+ data: input,
|
||
+ dataPendingReconciliation: true
|
||
+ }
|
||
+ }
|
||
+ ));
|
||
+ }
|
||
+
|
||
+ private _dispatchCompositionSessionEvent(event: CustomEvent): void {
|
||
+ if (typeof this._textarea.dispatchEvent === 'function') {
|
||
+ this._textarea.dispatchEvent(event);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ private _dispatchCompositionTransactionSettled(): void {
|
||
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
||
+ 'xterm-composition-transaction-settled',
|
||
+ { bubbles: true }
|
||
+ ));
|
||
+ }
|
||
+
|
||
+ private _deferCompositionEnd(endData: string): void {
|
||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||
+ const transactionId = this._compositionTransactionId;
|
||
+ const timer = this._defer(() => {
|
||
+ if (
|
||
+ this._compositionEndTimer !== timer ||
|
||
+ !this._isComposing ||
|
||
+ this._compositionTransactionId !== transactionId ||
|
||
+ !this._compositionEndBelongsToCurrentTransaction(endData)
|
||
+ ) {
|
||
+ return;
|
||
+ }
|
||
+ this._compositionEndTimer = undefined;
|
||
+ this._finalizeComposition(true, endData);
|
||
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
||
+ XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
|
||
+ { bubbles: true }
|
||
+ ));
|
||
+ const pending = this._pendingComposition;
|
||
+ if (pending?.transactionId === transactionId) {
|
||
+ this._sendPendingComposition(pending, true);
|
||
+ }
|
||
+ });
|
||
+ this._compositionEndTimer = timer;
|
||
+ }
|
||
+
|
||
+ /** How much of the textarea the IME currently owns; 0 means there is no preedit left. */
|
||
+ private _composedRegionLength(): number {
|
||
+ const end = this._textarea.value.length - this._compositionSuffix.length;
|
||
+ return Math.max(0, end - this._compositionPosition.start);
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Re-derives the preedit from the textarea once the key that changed it has settled, and treats
|
||
+ * a composition emptied that way as cancelled. Mirrors how native terminals clear a preedit on
|
||
+ * the empty-marked-text state instead of on a specific key.
|
||
+ */
|
||
+ private _deferPreeditResync(hadPreedit: boolean): void {
|
||
+ if (!hadPreedit || !this._isComposing) {
|
||
+ return;
|
||
+ }
|
||
+ const transactionId = this._compositionTransactionId;
|
||
+ this._defer(() => {
|
||
+ if (
|
||
+ this._isComposing &&
|
||
+ this._compositionTransactionId === transactionId &&
|
||
+ this._composedRegionLength() === 0
|
||
+ ) {
|
||
+ this._cancelComposition();
|
||
+ }
|
||
+ });
|
||
+ }
|
||
+
|
||
+ private _hasCompositionProgress(): boolean {
|
||
+ const start = this._textarea.selectionStart ?? this._textarea.value.length;
|
||
+ const end = this._textarea.selectionEnd ?? start;
|
||
+ return this._compositionHasObservedProgress || (
|
||
+ this._textarea.value !== this._compositionStartValue ||
|
||
+ start !== this._compositionStartSelection.start ||
|
||
+ end !== this._compositionStartSelection.end
|
||
+ );
|
||
+ }
|
||
+
|
||
+ private _compositionEndBelongsToCurrentTransaction(endData: string): boolean {
|
||
+ return (
|
||
+ this._hasCompositionProgress() ||
|
||
+ (endData.length > 0 && endData === this._lastCompositionData)
|
||
+ );
|
||
+ }
|
||
+
|
||
+ private _defer(callback: () => void): ReturnType<typeof setTimeout> {
|
||
+ const timer = setTimeout(() => {
|
||
+ this._compositionTimers.delete(timer);
|
||
+ callback();
|
||
+ }, 0);
|
||
+ this._compositionTimers.add(timer);
|
||
+ return timer;
|
||
+ }
|
||
+
|
||
+ private _cancelDeferredTimer(timer?: ReturnType<typeof setTimeout>): void {
|
||
+ if (timer === undefined) {
|
||
+ return;
|
||
+ }
|
||
+ clearTimeout(timer);
|
||
+ this._compositionTimers.delete(timer);
|
||
+ }
|
||
+
|
||
/**
|
||
* 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 {
|
||
}, 0);
|
||
}
|
||
|
||
+ /**
|
||
+ * Renders the preedit into the view and, when the cursor sits mid-line, the rest of the row
|
||
+ * after it, so a composition reads as inserted text pushing the tail right rather than an opaque
|
||
+ * box hiding the character under the cursor. Nothing reaches the pty while composing, so those
|
||
+ * cells still hold their characters; only what the overlay shows changes.
|
||
+ */
|
||
+ private _renderCompositionView(data: string): void {
|
||
+ // Mark text as LTR, direction=rtl is used in CSS so the end of the text is followed for long
|
||
+ // compositions
|
||
+ const preeditText = `${data}`;
|
||
+ const remainderText = this._getRowRemainderText();
|
||
+ this._compositionViewData = data;
|
||
+ if (!remainderText) {
|
||
+ this._compositionPreedit = undefined;
|
||
+ this._compositionRemainder = undefined;
|
||
+ this._compositionView.textContent = preeditText;
|
||
+ return;
|
||
+ }
|
||
+ const doc = this._compositionView.ownerDocument;
|
||
+ const preedit = doc.createElement('span');
|
||
+ // Underlined so the composing text stays distinguishable from the tail it pushed right.
|
||
+ preedit.style.textDecoration = 'underline';
|
||
+ preedit.textContent = preeditText;
|
||
+ const remainder = doc.createElement('span');
|
||
+ // Why: the view is nowrap, which collapses runs of spaces, so committed padding would draw
|
||
+ // its trailing glyph cells to the left of where the grid has them.
|
||
+ remainder.style.whiteSpace = 'pre';
|
||
+ remainder.textContent = remainderText;
|
||
+ this._compositionView.replaceChildren(preedit, remainder);
|
||
+ this._compositionPreedit = preedit;
|
||
+ this._compositionRemainder = remainder;
|
||
+ }
|
||
+
|
||
+ /** The committed row text from the cursor rightwards — what a mid-line preedit would cover. */
|
||
+ private _getRowRemainderText(): string {
|
||
+ const buffer = this._bufferService.buffer;
|
||
+ if (!buffer.isCursorInViewport) {
|
||
+ return '';
|
||
+ }
|
||
+ const line = buffer.lines.get(buffer.ybase + buffer.y);
|
||
+ // The explicit end column keeps this off the line string cache, whose self-renewing
|
||
+ // idle-clear timer the composition path must not arm.
|
||
+ return line
|
||
+ ? line.translateToString(true, Math.min(buffer.x, this._bufferService.cols - 1), line.length)
|
||
+ : '';
|
||
+ }
|
||
+
|
||
+ private _resetCompositionView(): void {
|
||
+ this._compositionView.textContent = '';
|
||
+ this._compositionPreedit = undefined;
|
||
+ this._compositionRemainder = undefined;
|
||
+ this._compositionViewData = '';
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * The theme background with any alpha dropped. The view masks the cells it draws over, so a
|
||
+ * see-through background would re-expose the very characters the rendered tail stands in for.
|
||
+ */
|
||
+ private _opaqueViewBackground(): string {
|
||
+ const value = this._optionsService.rawOptions.theme?.background?.trim();
|
||
+ if (!value) {
|
||
+ return '#000';
|
||
+ }
|
||
+ const channels = /^rgba?\(([^,()]+),([^,()]+),([^,()]+)(?:,[^()]+)?\)$/.exec(value);
|
||
+ if (channels) {
|
||
+ return `rgb(${channels[1]},${channels[2]},${channels[3]})`;
|
||
+ }
|
||
+ const opaqueHex = /^(#(?:[\da-f]{3}|[\da-f]{6}))[\da-f]{1,2}$/i.exec(value);
|
||
+ return opaqueHex ? opaqueHex[1] : value;
|
||
+ }
|
||
+
|
||
/**
|
||
* 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 {
|
||
* necessary as the IME events across browsers are not consistently triggered.
|
||
*/
|
||
public updateCompositionElements(dontRecurse?: boolean): void {
|
||
- if (!this._isComposing) {
|
||
+ // The shown overlay, not `_isComposing`: a composition the IME resumed without a
|
||
+ // compositionstart has to be positioned too. Every other path sets both together.
|
||
+ if (!this._compositionView.classList.contains('active')) {
|
||
return;
|
||
}
|
||
|
||
+ // A TUI can repaint the row under an open composition (spinners, streamed output), and this
|
||
+ // already runs on every render — so keep the rendered tail current with the buffer. A string
|
||
+ // compare adds no layout read.
|
||
+ if (
|
||
+ this._compositionViewData &&
|
||
+ this._getRowRemainderText() !== (this._compositionRemainder?.textContent ?? '')
|
||
+ ) {
|
||
+ this._renderCompositionView(this._compositionViewData);
|
||
+ }
|
||
+
|
||
if (this._bufferService.buffer.isCursorInViewport) {
|
||
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
|
||
|
||
@@ -265,10 +875,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';
|
||
- this._compositionView.style.direction = 'rtl';
|
||
- // Sync the textarea to the exact position of the composition view so the IME knows where the
|
||
- // text is.
|
||
- const compositionViewBounds = this._compositionView.getBoundingClientRect();
|
||
+ // With a tail rendered the view is start-anchored so the preedit stays put and the pushed
|
||
+ // tail clips at the right edge; alone, rtl still keeps a long preedit's end in view.
|
||
+ this._compositionView.style.direction = this._compositionRemainder ? 'ltr' : 'rtl';
|
||
+ // Themed rather than the stock #000/#FFF, so the pushed tail reads as ordinary terminal text
|
||
+ // and light themes keep contrast.
|
||
+ this._compositionView.style.background = this._opaqueViewBackground();
|
||
+ this._compositionView.style.color = this._optionsService.rawOptions.theme?.foreground ?? '#FFF';
|
||
+ // Sync the textarea to the exact position of the preedit so the IME knows where the text is,
|
||
+ // and so candidate dialogs anchor to it rather than to the end of the rendered tail.
|
||
+ const compositionViewBounds =
|
||
+ (this._compositionPreedit ?? this._compositionView).getBoundingClientRect();
|
||
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 {
|
||
}
|
||
|
||
if (!dontRecurse) {
|
||
- setTimeout(() => this.updateCompositionElements(true), 0);
|
||
+ this._cancelDeferredTimer(this._compositionViewTimer);
|
||
+ this._compositionViewTimer = this._defer(() => this.updateCompositionElements(true));
|
||
}
|
||
}
|
||
}
|
||
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
|
||
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
|
||
--- a/src/common/SortedList.ts
|
||
+++ b/src/common/SortedList.ts
|
||
@@ -87,6 +87,24 @@ export class SortedList<T> {
|
||
if (key === undefined) {
|
||
return false;
|
||
}
|
||
+ if (this._deleteAtKey(value, key)) {
|
||
+ return true;
|
||
+ }
|
||
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
|
||
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
|
||
+ // order, so the binary search above can miss a value that is present.
|
||
+ // Compacting those entries out restores the order; retry before reporting
|
||
+ // the value absent, else its `onDecorationRemoved` never fires and the
|
||
+ // decoration paints forever. Miss path only, so the common bulk delete
|
||
+ // keeps its O(log n) search and deferred-compaction batching.
|
||
+ if (this._deletedIndices.length === 0) {
|
||
+ return false;
|
||
+ }
|
||
+ this._flushCleanupDeleted();
|
||
+ return this._deleteAtKey(value, key);
|
||
+ }
|
||
+
|
||
+ private _deleteAtKey(value: T, key: number): boolean {
|
||
i = this._search(key);
|
||
if (i === -1) {
|
||
return false;
|