mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* Upgrade xterm to 6.1.0-beta.303 and generate the addon patches
Takes the current xterm beta line: xterm 287 -> 303, addon-webgl 286 -> 299,
addon-serialize 287 -> 300, headless 302, the remaining addons -> 300, and the
same set on mobile. All four packages stamp upstream commit d3e32b3.
The reasons are upstream #6042/#6043/#6055 (a shared glyph atlas no longer
garbles sibling panes on a page merge, clear, or sampler-budget overflow) and
Note that core 303 is not image-addon-only over 302: it carries the buffer perf
work, including the new BufferLineStringCache.
addon-webgl and addon-serialize move into the patch generator
--------------------------------------------------------------
Both were hand-edited minified bundles, which is what the Known Gaps section of
docs/reference/xterm-patch-regeneration.md described. Both reproduce byte for
byte from the pinned commit, so they are now manifest entries generated from a
source patch like @xterm/xterm already was. Their sourcemaps now move with their
bundles; before this they shipped maps whose offsets did not match the code
beside them.
The webgl patch shrinks from a 1.06 MB hand-edited bundle to a 6.6 KB source
patch, because upstream took the invalidation half Orca had backported. What is
left is only what upstream still lacks: the fragment-shader else branch for a
v_texpage past the sampler budget, the clearTexture guard that no-ops once a
merged page holds index 0, spending the merge retry budget before beginFrame
latches the version it saw, and Orca's font-weight probe.
The serialize source patch is byte-for-byte the same fixes as before; upstream
changed nothing in that addon between 287 and 300.
Generator fixes, each of which failed silently
----------------------------------------------
- `--relative` was appended after the `--` separator in CHECKOUT_DIFF_FLAGS, so
git read it as a pathspec and kept repo-root-relative paths, dropping every
source hunk from an addon's patch.
- `git apply` run from a package subdirectory still resolves patch paths from
the repo root, skips every hunk and exits 0. It now runs from the root with
`--directory=<packageDir>`, and a source patch that leaves the checkout
unchanged is a hard failure rather than an empty patch.
- An addon's own `tsgo -p .` has empty files/include and only project
references, so it emits nothing and the addon webpack then fails on a missing
./out/. The root build now runs first.
- versionStampFile is optional; publish.js stamps an addon's package.json, which
overlayBuildOutput never patches.
- On a version bump the lockfile has no entry under the new key yet, so --write
reports the gap instead of aborting mid-run. --check still fails on it.
Adding the two addons pushed the generator and the Electron packaging contract
test over max-lines, so the patch-text helpers move to xterm-patch-text.mjs
(pure text: no checkout, no build) and the vendored-xterm assertions move out of
the packaging contract into xterm-webgl-runtime-contract.test.mjs.
Tests
-----
Four tests asserted upstream bugs that are now fixed, not Orca behaviour:
- xterm-user-scrolling-contract pinned headless and core by version string.
Upstream bumps each package only when its own output changes, so headless 302
and core 303 are the same source. It now asserts they share a commit.
- Five CSI 3 J assertions expected a reader stranded at the top after an erase.
Upstream #6081 clears isUserScrolling there, so the erase releases them to the
bottom instead. Orca's pin still lands them correctly, because its parser
handler observes the erase before xterm's own handler runs.
- The IME transaction test hard-coded the xterm version; it now reads the
installed package, since the point is that bundle, map and version agree.
- The Electron runtime contract asserted Orca's old clearModelGeneration. Shared
atlas invalidation is upstream's now, so it asserts pageLayoutVersion on the
resolved dependency, plus the Orca-only hunks on the patch.
Verified: 66,008 unit tests, mobile's 3,863, the four WebGL atlas e2e specs, and
`regenerate-xterm-patches.mjs --check` in sync on all three packages.
Left alone deliberately: resetAllTerminalWebglAtlases still fans out globally
even though clearTexture now self-heals siblings, and upstream #6068
(WebglAddon.dispose leaks the GL context) is still open.
* Drop the two unused WebGL atlas fan-out exports
resetAllTerminalWebglAtlases and presentAllTerminalPanesWithoutAtlasClear have
no callers, and had none at cadfc55102 either — the last call site went in
#6949, which routed reveal recovery through
resetAndRefreshAllTerminalWebglAtlases instead. Only a comment in
pane-manager.ts still named the first one; it now points at the live entry
point. scheduleRevealPresent leaves the registry's structural type with them,
though the manager method stays: terminal-visibility-resume.ts calls it
directly.
This is dead-code removal, not a consequence of the xterm bump. The live
recovery path is unchanged.
resetAndRefreshAllTerminalWebglAtlases stays, and so does the reveal-time
escalation in pane-reveal-repaint.ts. Upstream 299 does make a pane-local
clearTexture bump pageLayoutVersion so siblings rebuild on their next frame,
which is the bug the escalation was written for, but I could not demonstrate
that removing it is safe: with the escalation removed,
floating-workspace-shared-glyph-atlas.spec.ts still passed headful, and it also
passed with upstream's mechanism deliberately disabled (pageLayoutVersion
pinned to 0 in the installed bundle, verified present in the built renderer).
A guard that passes with the fix disabled cannot license removing the
workaround, so the escalation stays until that spec can reproduce the garbling.
Verified: pane-manager and terminal-pane suites (4,713 tests), typecheck, the
headful shared-atlas spec, and the three headless WebGL specs.
* Give the shared glyph atlas spec a trigger that can fail
floating-workspace-shared-glyph-atlas.spec.ts guards the corruption where one
terminal wiping the module-global atlas leaves sibling terminals drawing from
stale texture coordinates. Both of its tests drive that through a floating
panel reveal, and Orca's reveal paths escalate to a registry-wide atlas reset
that repaints every pane — so the recovery under test heals the damage before
the assertion runs, and the tests pass whether or not xterm propagates the
invalidation at all.
The new test clears the shared atlas straight through the floating manager with
the panel closed, so nothing else repaints the workspace terminal, then repaints
it with terminal.refresh(). That is the load-bearing detail: _updateModel skips
cells whose content is unchanged, so the refresh reuses vertices baked against
the pages that were just wiped, which is exactly the state the fix has to
recover from.
Verified as a discriminator rather than assumed. Pinning ITextureAtlas's
pageLayoutVersion getter to 0 in the installed bundle, which disables the
per-renderer invalidation upstream added in addon-webgl 0.20.0-beta.299, and
confirming that reached the built renderer:
fix intact: siblingClearIntact=true 1 passed
fix disabled: siblingClearIntact=false 1 failed
The failure renders the workspace terminal completely blank — stale coordinates
into a wiped atlas sample nothing. The two reveal tests pass unchanged in both
configurations, which is the gap this closes.
* Compare shared-atlas screenshots with tolerance instead of byte equality
Byte equality fails on sub-pixel antialiasing noise that leaves every glyph
legible, so the headful spec flaked under xterm 303. Reuse the existing
compareTerminalScreenshots helper: real stale-model corruption blanks the
terminal at ~3% of pixels, twice the helper's 1.5% threshold, so the looser
oracle keeps its teeth. Log the ratio so failures are diagnosable.
* fix(xterm): cancel empty deferred IME compositions
* test(xterm): strengthen runtime patch contracts
1135 lines
46 KiB
Diff
1135 lines
46 KiB
Diff
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
|
||
index 67893b7966eb095db4459b8837de9766921f36c5..0abf7ecac1aa108f7caf711d0a5c610a688d34fd 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()));
|
||
}
|
||
@@ -552,6 +566,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);
|
||
@@ -1009,7 +1028,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;
|
||
|
||
@@ -1027,6 +1048,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..d4d5106d8dd723eceed87310c193ccfe0c516b06 100644
|
||
--- a/src/browser/input/CompositionHelper.ts
|
||
+++ b/src/browser/input/CompositionHelper.ts
|
||
@@ -3,8 +3,9 @@
|
||
* @license MIT
|
||
*/
|
||
|
||
-import { IRenderService } from '../services/Services';
|
||
+import { IRenderService, IThemeService } from '../services/Services';
|
||
import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
|
||
+import { color } from '../../common/Color';
|
||
import { C0 } from '../../common/data/EscapeSequences';
|
||
|
||
interface IPosition {
|
||
@@ -12,6 +13,27 @@ 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,52 +67,144 @@ 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;
|
||
|
||
+ /**
|
||
+ * 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.
|
||
+ */
|
||
+ 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, used to anchor the native candidate window. */
|
||
+ private _compositionPreedit?: HTMLElement;
|
||
+
|
||
+ /** The rendered row tail, set only while the cursor sits mid-line. */
|
||
+ private _compositionRemainder?: HTMLElement;
|
||
+
|
||
+ /** The insertion caret painted above the renderer cursor the composition view covers. */
|
||
+ private _compositionCaret?: HTMLElement;
|
||
+
|
||
+ /** The last preedit rendered, so a row repaint can re-render without a composition event. */
|
||
+ private _compositionViewData?: string;
|
||
+
|
||
+ // Keep the six-argument form callable: upstream's own CompositionHelper.test.ts constructs
|
||
+ // this class directly, and `.npmignore` strips src/**/*.test.ts from the published tarball, so
|
||
+ // the shipped patch has no hunk that could update that call. Dropping this overload fails the
|
||
+ // upstream build with TS2554. The theme service is therefore optional, and every color read
|
||
+ // below keeps the stock fallback that path needs.
|
||
+ constructor(
|
||
+ textarea: HTMLTextAreaElement,
|
||
+ compositionView: HTMLElement,
|
||
+ bufferService: IBufferService,
|
||
+ optionsService: IOptionsService,
|
||
+ coreService: ICoreService,
|
||
+ renderService: IRenderService
|
||
+ );
|
||
constructor(
|
||
private readonly _textarea: HTMLTextAreaElement,
|
||
private readonly _compositionView: HTMLElement,
|
||
@IBufferService private readonly _bufferService: IBufferService,
|
||
@IOptionsService private readonly _optionsService: IOptionsService,
|
||
@ICoreService private readonly _coreService: ICoreService,
|
||
- @IRenderService private readonly _renderService: IRenderService
|
||
+ @IRenderService private readonly _renderService: IRenderService,
|
||
+ @IThemeService private readonly _themeService?: IThemeService
|
||
) {
|
||
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();
|
||
+ this._imeKeydownAwaitingCommit = false;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
+ // 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;
|
||
+ }
|
||
+ 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 +212,93 @@ 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`;
|
||
+ if (ev.data && !this._isComposing) {
|
||
+ this.compositionstart();
|
||
+ }
|
||
+ 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 without compositionstart; keep that inferred transaction visible until
|
||
+ // compositionend settles it. An empty update hides the overlay without ending the transaction.
|
||
+ 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++;
|
||
+ this._compositionView.classList.remove('active');
|
||
+ this._resetCompositionView();
|
||
}
|
||
|
||
/**
|
||
@@ -113,7 +307,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"
|
||
@@ -128,6 +334,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 +348,74 @@ 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 this._claimImeKeydownCommit(text);
|
||
+ }
|
||
+ 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;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * 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 +424,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 +479,315 @@ 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;
|
||
}
|
||
- }, 0);
|
||
+ if (this._pendingComposition === pending) {
|
||
+ this._sendPendingComposition(pending, true);
|
||
+ }
|
||
+ });
|
||
}
|
||
}
|
||
|
||
+ 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
|
||
+ ) {
|
||
+ return;
|
||
+ }
|
||
+ this._compositionEndTimer = undefined;
|
||
+ if (!this._compositionEndBelongsToCurrentTransaction(endData)) {
|
||
+ if (endData.length === 0 && !this._hasCompositionProgress()) {
|
||
+ this._cancelComposition();
|
||
+ }
|
||
+ return;
|
||
+ }
|
||
+ 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
|
||
@@ -222,6 +807,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 +824,101 @@ 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, rowRemainder = this._getRowRemainderText()): void {
|
||
+ if (!data) {
|
||
+ this._resetCompositionView();
|
||
+ return;
|
||
+ }
|
||
+ // Keep DOM order LTR so the insertion caret follows the preedit.
|
||
+ const preeditText = `${data}`;
|
||
+ this._compositionViewData = data;
|
||
+ const doc = this._compositionView.ownerDocument;
|
||
+ const preedit = doc.createElement('span');
|
||
+ preedit.className = 'xterm-composition-preedit';
|
||
+ // Underlined so the composing text stays distinguishable from the tail it pushed right.
|
||
+ preedit.style.flexShrink = '0';
|
||
+ preedit.style.textDecoration = 'underline';
|
||
+ preedit.textContent = preeditText;
|
||
+ const caret = doc.createElement('span');
|
||
+ caret.className = 'xterm-composition-caret';
|
||
+ caret.setAttribute('aria-hidden', 'true');
|
||
+ const children = [preedit, caret];
|
||
+ let remainder: HTMLElement | undefined;
|
||
+ if (rowRemainder) {
|
||
+ remainder = doc.createElement('span');
|
||
+ remainder.className = 'xterm-composition-remainder';
|
||
+ // 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 = rowRemainder;
|
||
+ children.push(remainder);
|
||
+ }
|
||
+ this._compositionView.replaceChildren(...children);
|
||
+ this._compositionPreedit = preedit;
|
||
+ this._compositionCaret = caret;
|
||
+ this._compositionRemainder = remainder;
|
||
+ this._styleCompositionCaret();
|
||
+ }
|
||
+
|
||
+ /** 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 _styleCompositionCaret(): void {
|
||
+ const caret = this._compositionCaret;
|
||
+ if (!caret) {
|
||
+ return;
|
||
+ }
|
||
+ const width = Math.max(1, this._optionsService.rawOptions.cursorWidth);
|
||
+ const cellHeight = this._renderService.dimensions.css.cell.height;
|
||
+ const colors = this._themeService?.colors;
|
||
+ const cursor = colors && (
|
||
+ color.ensureContrastRatio(colors.background, colors.cursor, 3) ?? colors.cursor
|
||
+ );
|
||
+ caret.style.backgroundColor = cursor?.css ?? '#FFF';
|
||
+ caret.style.display = 'inline-block';
|
||
+ caret.style.flexShrink = '0';
|
||
+ caret.style.height = cellHeight + 'px';
|
||
+ caret.style.marginLeft = -width + 'px';
|
||
+ caret.style.verticalAlign = 'top';
|
||
+ caret.style.width = width + 'px';
|
||
+ }
|
||
+
|
||
+ private _resetCompositionView(): void {
|
||
+ this._compositionView.textContent = '';
|
||
+ this._compositionPreedit = undefined;
|
||
+ this._compositionRemainder = undefined;
|
||
+ this._compositionCaret = undefined;
|
||
+ this._compositionViewData = '';
|
||
+ this._compositionView.style.display = '';
|
||
+ this._compositionView.style.justifyContent = '';
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * 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 background = this._themeService?.colors.background;
|
||
+ return background ? color.opaque(background).css : '#000';
|
||
+ }
|
||
+
|
||
/**
|
||
* 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 +926,23 @@ export class CompositionHelper {
|
||
* necessary as the IME events across browsers are not consistently triggered.
|
||
*/
|
||
public updateCompositionElements(dontRecurse?: boolean): void {
|
||
- if (!this._isComposing) {
|
||
+ // Empty updates hide the overlay without ending the inferred transaction.
|
||
+ 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.
|
||
+ const rowRemainder = this._getRowRemainderText();
|
||
+ if (
|
||
+ this._compositionViewData &&
|
||
+ rowRemainder !== (this._compositionRemainder?.textContent ?? '')
|
||
+ ) {
|
||
+ this._renderCompositionView(this._compositionViewData, rowRemainder);
|
||
+ }
|
||
+ this._styleCompositionCaret();
|
||
+
|
||
if (this._bufferService.buffer.isCursorInViewport) {
|
||
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
|
||
|
||
@@ -265,20 +961,38 @@ 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();
|
||
- this._textarea.style.left = cursorLeft + 'px';
|
||
+ const anchorBounds =
|
||
+ (this._compositionPreedit ?? this._compositionView).getBoundingClientRect();
|
||
+ const anchorLeft = cursorLeft + Math.min(0, maxWidth - anchorBounds.width);
|
||
+ const showsRemainder =
|
||
+ Boolean(this._compositionRemainder) && anchorBounds.width < maxWidth;
|
||
+ if (this._compositionRemainder) {
|
||
+ this._compositionRemainder.style.display = showsRemainder ? '' : 'none';
|
||
+ }
|
||
+ // End alignment keeps the caret visible when the preedit consumes the remaining width.
|
||
+ this._compositionView.style.direction = 'ltr';
|
||
+ this._compositionView.style.display = showsRemainder ? '' : 'flex';
|
||
+ this._compositionView.style.justifyContent = showsRemainder ? '' : 'flex-end';
|
||
+ // 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._themeService?.colors.foreground.css ?? '#FFF';
|
||
+ // Sized and placed to match the preedit, not the whole view, so the candidate window
|
||
+ // anchors to the composing text rather than the end of the rendered tail. The clamp has to
|
||
+ // be applied here and not only in Orca's terminal-ime-candidate-anchor.ts, because
|
||
+ // CoreBrowserTerminal calls this from onRender as well as from composition events, and a
|
||
+ // render can land after the last composition event that module can hear.
|
||
+ this._textarea.style.left = anchorLeft + 'px';
|
||
this._textarea.style.top = cursorTop + 'px';
|
||
// Ensure the text area is at least 1x1, otherwise certain IMEs may break
|
||
- this._textarea.style.width = Math.max(compositionViewBounds.width, 1) + 'px';
|
||
- this._textarea.style.height = Math.max(compositionViewBounds.height, 1) + 'px';
|
||
- this._textarea.style.lineHeight = compositionViewBounds.height + 'px';
|
||
+ this._textarea.style.width = Math.max(anchorBounds.width, 1) + 'px';
|
||
+ this._textarea.style.height = Math.max(anchorBounds.height, 1) + 'px';
|
||
+ this._textarea.style.lineHeight = anchorBounds.height + 'px';
|
||
}
|
||
|
||
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;
|