fix(terminal): render IME caret without placeholder overlap

This commit is contained in:
Neil
2026-08-29 03:56:52 -07:00
parent 51ed7d4f67
commit 322c636f5f
7 changed files with 645 additions and 199 deletions
File diff suppressed because one or more lines are too long
@@ -88,13 +88,29 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc0
export interface IBrowser {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8d94e8797 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d9883af6ebb1745bfcdd83c4b94e693de600bd5d 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -12,6 +12,28 @@ interface IPosition {
@@ -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 { BgFlags } from '../../common/buffer/Constants';
import { C0 } from '../../common/data/EscapeSequences';
interface IPosition {
@@ -12,6 +13,32 @@ interface IPosition {
end: number;
}
+interface IRowRemainder {
+ text: string;
+ dimOnly: boolean;
+}
+
+interface IPendingComposition {
+ transactionId: number;
+ finalizerTimer?: ReturnType<typeof setTimeout>;
@@ -116,11 +132,10 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+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 {
@@ -24,6 +51,15 @@ export class CompositionHelper {
*/
private _isComposing: boolean;
public get isComposing(): boolean { return this._isComposing; }
@@ -136,7 +151,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
/**
* The position within the input textarea's value of the current composition.
@@ -36,22 +67,63 @@ export class CompositionHelper {
@@ -36,52 +72,152 @@ export class CompositionHelper {
*/
private _compositionSuffix: string;
@@ -194,20 +209,47 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+
+ private _compositionEndTimer?: ReturnType<typeof setTimeout>;
+
+ /** The preedit's own span, set only while the view also renders the row tail behind it. */
+ /** 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;
+
+ /** Whether the rendered tail is application ghost text that must stay masked. */
+ private _compositionRemainderDimOnly: boolean;
+
+ /** 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;
+
+ constructor(
+ textarea: HTMLTextAreaElement,
+ compositionView: HTMLElement,
+ bufferService: IBufferService,
+ optionsService: IOptionsService,
+ coreService: ICoreService,
+ renderService: IRenderService
+ );
+ constructor(
+ textarea: HTMLTextAreaElement,
+ compositionView: HTMLElement,
+ bufferService: IBufferService,
+ optionsService: IOptionsService,
+ coreService: ICoreService,
+ renderService: IRenderService,
+ themeService: IThemeService
+ );
constructor(
private readonly _textarea: HTMLTextAreaElement,
private readonly _compositionView: HTMLElement,
@@ -61,27 +133,61 @@ export class CompositionHelper {
@IRenderService private readonly _renderService: IRenderService
@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;
@@ -223,6 +265,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ this._compositionTransactionId = 0;
+ this._compositionTimers = new Set();
+ this._imeKeydownAwaitingCommit = false;
+ this._compositionRemainderDimOnly = false;
}
/**
@@ -271,7 +314,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
}
/**
@@ -89,22 +195,90 @@ export class CompositionHelper {
@@ -89,22 +225,92 @@ export class CompositionHelper {
* @param ev The event.
*/
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
@@ -368,10 +411,12 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ this._isAwaitingCompositionEnd = false;
+ this._isComposing = false;
+ this._compositionTransactionId++;
+ this._compositionView.classList.remove('active');
+ this._resetCompositionView();
}
/**
@@ -113,7 +287,19 @@ export class CompositionHelper {
@@ -113,7 +319,19 @@ export class CompositionHelper {
* @returns Whether the Terminal should continue processing the keydown event.
*/
public keydown(ev: KeyboardEvent): boolean {
@@ -392,7 +437,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
if (ev.keyCode === 20 || ev.keyCode === 229) {
// 20 is CapsLock, 229 is Enter
// Continue composing if the keyCode is the "composition character"
@@ -128,6 +314,10 @@ export class CompositionHelper {
@@ -128,6 +346,10 @@ export class CompositionHelper {
this._finalizeComposition(false);
}
@@ -403,7 +448,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
if (ev.keyCode === 229) {
// If the "composition character" is used but gets to this point it means a non-composition
// character (eg. numbers and punctuation) was pressed when the IME was active.
@@ -138,6 +328,74 @@ export class CompositionHelper {
@@ -138,6 +360,74 @@ export class CompositionHelper {
return true;
}
@@ -478,7 +523,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
/**
* Finalizes the composition, resuming regular input actions. This is called when a composition
* is ending.
@@ -146,23 +404,52 @@ export class CompositionHelper {
@@ -146,23 +436,52 @@ export class CompositionHelper {
* compositionend event is triggered, such as enter, so that the composition is sent before
* the command is executed.
*/
@@ -542,7 +587,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
// 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 +459,310 @@ export class CompositionHelper {
@@ -172,35 +491,308 @@ 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.
@@ -579,12 +624,11 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ }
+ if (this._pendingComposition === pending) {
+ this._sendPendingComposition(pending, true);
}
- }, 0);
+ }
+ });
}
}
+ }
+ }
+
+ private _sendPendingComposition(
+ pending: IPendingComposition,
+ includeFollowingInput: boolean = false
@@ -772,7 +816,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ id: pending.transactionId,
+ data: input,
+ dataPendingReconciliation: true
+ }
}
- }, 0);
+ }
+ ));
+ }
@@ -830,7 +875,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ private _deferPreeditResync(hadPreedit: boolean): void {
+ if (!hadPreedit || !this._isComposing) {
+ return;
+ }
}
+ const transactionId = this._compositionTransactionId;
+ this._defer(() => {
+ if (
@@ -875,12 +920,10 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ }
+ 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 +782,9 @@ export class CompositionHelper {
@@ -222,6 +814,9 @@ export class CompositionHelper {
const diff = newValue.replace(oldValue, '');
@@ -890,7 +933,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
@@ -236,6 +799,77 @@ export class CompositionHelper {
@@ -236,6 +831,130 @@ export class CompositionHelper {
}, 0);
}
@@ -900,52 +943,101 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ * 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;
+ private _renderCompositionView(data: string, rowRemainder = this._getRowRemainder()): void {
+ if (!data) {
+ this._resetCompositionView();
+ return;
+ }
+ // Keep DOM order LTR; right alignment follows the insertion end for long compositions while
+ // retaining the caret after 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.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);
+ 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.text) {
+ 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';
+ // Preserve the ghost text's width so the opaque view masks it without repeating it.
+ remainder.style.visibility = rowRemainder.dimOnly ? 'hidden' : '';
+ remainder.textContent = rowRemainder.text;
+ children.push(remainder);
+ }
+ this._compositionView.replaceChildren(...children);
+ this._compositionPreedit = preedit;
+ this._compositionCaret = caret;
+ this._compositionRemainder = remainder;
+ this._compositionRemainderDimOnly = rowRemainder.dimOnly;
+ this._styleCompositionCaret();
+ }
+
+ /** The committed row text from the cursor rightwards — what a mid-line preedit would cover. */
+ private _getRowRemainderText(): string {
+ /** The row tail and whether every visible cell in it is dim application ghost text. */
+ private _getRowRemainder(): IRowRemainder {
+ const buffer = this._bufferService.buffer;
+ if (!buffer.isCursorInViewport) {
+ return '';
+ return { text: '', dimOnly: false };
+ }
+ const line = buffer.lines.get(buffer.ybase + buffer.y);
+ if (!line) {
+ return { text: '', dimOnly: false };
+ }
+ const start = Math.min(buffer.x, this._bufferService.cols - 1);
+ // 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)
+ : '';
+ const end = line.getTrimmedLength();
+ const text = line.translateToString(false, start, end);
+ if (!text) {
+ return { text, dimOnly: false };
+ }
+ let hasVisibleCell = false;
+ for (let x = start; x < end; x++) {
+ if (!line.hasContent(x) || !line.hasWidth(x)) {
+ continue;
+ }
+ hasVisibleCell = true;
+ if (!(line.getBg(x) & BgFlags.DIM)) {
+ return { text, dimOnly: false };
+ }
+ }
+ return { text, dimOnly: hasVisibleCell };
+ }
+
+ 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 theme = this._optionsService.rawOptions.theme;
+ caret.style.backgroundColor =
+ this._themeService?.colors.cursor.css ?? theme?.cursor ?? theme?.foreground ?? '#FFF';
+ caret.style.display = 'inline-block';
+ 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._compositionRemainderDimOnly = false;
+ this._compositionCaret = undefined;
+ this._compositionViewData = '';
+ this._compositionView.style.textAlign = '';
+ }
+
+ /**
@@ -953,7 +1045,11 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ * 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();
+ const value = (
+ this._themeService?.colors.background.css ??
+ this._optionsService.rawOptions.theme?.background ??
+ ''
+ ).trim();
+ if (!value) {
+ return '#000';
+ }
@@ -968,7 +1064,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
/**
* 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 +877,22 @@ export class CompositionHelper {
@@ -243,10 +962,24 @@ export class CompositionHelper {
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
@@ -982,17 +1078,19 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
+ // 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);
+ const rowRemainder = this._getRowRemainder();
+ if (this._compositionViewData && (
+ rowRemainder.text !== (this._compositionRemainder?.textContent ?? '') ||
+ rowRemainder.dimOnly !== this._compositionRemainderDimOnly
+ )) {
+ 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,10 +911,17 @@ export class CompositionHelper {
@@ -265,10 +998,21 @@ 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';
@@ -1000,13 +1098,17 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
- // 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';
+ // Keep DOM order as preedit, caret, tail. At the right edge, right alignment clips the start
+ // of a long preedit while retaining its insertion caret, matching xterm's former rtl trick.
+ this._compositionView.style.direction = 'ltr';
+ this._compositionView.style.textAlign = this._compositionRemainder ? 'left' : 'right';
+ // 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';
+ this._compositionView.style.color =
+ this._themeService?.colors.foreground.css ??
+ 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 =
@@ -1014,7 +1116,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8
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 +931,8 @@ export class CompositionHelper {
@@ -278,7 +1022,8 @@ export class CompositionHelper {
}
if (!dontRecurse) {
+24 -24
View File
@@ -21,7 +21,7 @@ patchedDependencies:
hash: 118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
hash: 55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
lint-staged@16.4.0:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
@@ -51,7 +51,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -220,25 +220,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
version: 6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9624,39 +9624,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=55b66527827006dc5dbe9bc486a25afd84292f277b0f47171409619ea618b312)': {}
abbrev@4.0.0: {}
@@ -8,8 +8,8 @@
* `안녕하세요` blanks `하` until the syllable commits.
*
* The fix renders the rest of the row's committed text after the preedit inside the overlay, so the
* composition reads as inserted text pushing the tail right. The overlay is also themed from
* `options.theme` instead of the stock `#000`/`#FFF`, with any alpha dropped — a see-through mask
* composition reads as inserted text pushing the tail right. The overlay also follows xterm's live
* theme service instead of the stock `#000`/`#FFF`, with any alpha dropped — a see-through mask
* would re-expose the very cells the rendered tail stands in for.
*
* happy-dom performs no layout, so the cell size is supplied and geometry is not asserted; the
@@ -20,7 +20,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const CELL_WIDTH_PX = 8
const CELL_HEIGHT_PX = 16
const THEME = { background: '#112233', foreground: '#aabbcc' }
const THEME = { background: '#112233', cursor: '#445566', foreground: '#aabbcc' }
const openTerminals: Terminal[] = []
@@ -34,14 +34,27 @@ type Rig = {
composeStart: () => void
composeUpdate: (preedit: string) => void
terminal: Terminal
textarea: HTMLTextAreaElement
write: (data: string) => Promise<void>
writeAwaitingRender: (data: string) => Promise<void>
}
function openTerminal(theme: { background: string; foreground: string } = THEME): Rig {
type RigOptions = {
cursorBlink?: boolean
cursorWidth?: number
theme?: { background: string; cursor?: string; foreground: string }
}
function openTerminal(options: RigOptions = {}): Rig {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 80, rows: 24, theme })
const terminal = new Terminal({
cols: 80,
rows: 24,
theme: options.theme ?? THEME,
cursorBlink: options.cursorBlink,
cursorWidth: options.cursorWidth
})
terminal.open(container)
const textarea = terminal.textarea
const compositionView = container.querySelector<HTMLElement>('.composition-view')
@@ -101,21 +114,47 @@ function openTerminal(theme: { background: string; foreground: string } = THEME)
composeStart,
composeUpdate,
terminal,
textarea,
write,
writeAwaitingRender
}
}
function viewParts(compositionView: HTMLElement): {
caret: HTMLElement | null
preedit: HTMLElement | null
remainder: HTMLElement | null
} {
return {
caret: compositionView.querySelector<HTMLElement>('.xterm-composition-caret'),
preedit: compositionView.querySelector<HTMLElement>('.xterm-composition-preedit'),
remainder: compositionView.querySelector<HTMLElement>('.xterm-composition-remainder')
}
}
function stripMarks(text: string | null): string {
return (text ?? '').replaceAll('‎', '')
}
describe('mid-line composition renders the covered row tail after the preedit', () => {
const animateCaret = vi.fn(
(
_keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
_options?: number | KeyframeAnimationOptions
) => ({ cancel: vi.fn() }) as unknown as Animation
)
beforeEach(() => {
// happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
animateCaret.mockClear()
Object.defineProperty(HTMLElement.prototype, 'animate', {
configurable: true,
value: animateCaret,
writable: true
})
})
afterEach(async () => {
@@ -126,6 +165,7 @@ describe('mid-line composition renders the covered row tail after the preedit',
openTerminals.pop()?.dispose()
}
vi.restoreAllMocks()
delete (HTMLElement.prototype as { animate?: typeof HTMLElement.prototype.animate }).animate
document.body.replaceChildren()
})
@@ -136,11 +176,11 @@ describe('mid-line composition renders the covered row tail after the preedit',
rig.compose('가')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[0]!.style.textDecoration).toBe('underline')
expect(spans[1]!.textContent).toBe('하세요')
const { caret, preedit, remainder } = viewParts(rig.compositionView)
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret, remainder])
expect(stripMarks(preedit!.textContent)).toBe('가')
expect(preedit!.style.textDecoration).toBe('underline')
expect(remainder!.textContent).toBe('하세요')
// Start-anchored so the preedit stays put and the pushed tail clips at the right edge.
expect(rig.compositionView.style.direction).toBe('ltr')
})
@@ -156,10 +196,10 @@ describe('mid-line composition renders the covered row tail after the preedit',
rig.composeUpdate('가')
rig.composeUpdate('강')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('강')
expect(spans[1]!.textContent).toBe('하세요')
const { caret, preedit, remainder } = viewParts(rig.compositionView)
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret, remainder])
expect(stripMarks(preedit!.textContent)).toBe('강')
expect(remainder!.textContent).toBe('하세요')
})
// The view is `white-space: nowrap`, which collapses runs of spaces exactly like `normal`.
@@ -173,11 +213,10 @@ describe('mid-line composition renders the covered row tail after the preedit',
rig.compose('가')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(spans[1]!.textContent, 'the tail must keep every padding cell').toBe('hi |')
const { remainder } = viewParts(rig.compositionView)
expect(remainder!.textContent, 'the tail must keep every padding cell').toBe('hi |')
expect(
spans[1]!.style.whiteSpace,
remainder!.style.whiteSpace,
'nowrap collapses the padding, so the border lands left of its grid column'
).toBe('pre')
})
@@ -188,10 +227,101 @@ describe('mid-line composition renders the covered row tail after the preedit',
rig.compose('가')
expect(rig.compositionView.children).toHaveLength(0)
const { caret, preedit, remainder } = viewParts(rig.compositionView)
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret])
expect(remainder).toBeNull()
expect(stripMarks(rig.compositionView.textContent)).toBe('가')
// The rtl trick still keeps a long preedit's end in view when nothing follows the cursor.
expect(rig.compositionView.style.direction).toBe('rtl')
expect(rig.compositionView.style.textAlign).toBe('right')
})
it('keeps the themed insertion caret visible inside the final cell', async () => {
const rig = openTerminal({ cursorWidth: 2 })
await rig.write('\x1b[80G')
rig.compose('가')
const { caret, preedit } = viewParts(rig.compositionView)
expect(rig.terminal.buffer.active.cursorX).toBe(79)
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret])
expect(rig.compositionView.style.maxWidth).toBe(`${CELL_WIDTH_PX}px`)
expect(rig.compositionView.style.textAlign).toBe('right')
expect(caret!.style.width).toBe('2px')
expect(caret!.style.marginLeft).toBe('-2px')
expect([THEME.cursor, 'rgb(68, 85, 102)']).toContain(caret!.style.backgroundColor)
})
it('uses xterm’s live default cursor when no cursor color is configured', async () => {
const rig = openTerminal({
theme: { background: '#ffffff', foreground: '#223344' }
})
await rig.write('안녕')
rig.compose('한')
expect(['#ffffff', 'rgb(255, 255, 255)']).toContain(
viewParts(rig.compositionView).caret!.style.backgroundColor
)
})
it('keeps the caret visible without a second blink loop and cleans it on cancel', async () => {
const rig = openTerminal({ cursorBlink: true })
await rig.write('안녕')
rig.compose('한')
expect(viewParts(rig.compositionView).caret).not.toBeNull()
expect(animateCaret).not.toHaveBeenCalled()
rig.textarea.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, code: 'Escape', key: 'Escape' })
)
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
rig.composeUpdate('글')
expect(rig.compositionView.classList.contains('active')).toBe(true)
expect(viewParts(rig.compositionView).caret).not.toBeNull()
rig.composeUpdate('')
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
})
it('masks a dim Codex placeholder instead of repeating it after the preedit', async () => {
const rig = openTerminal()
const placeholder = 'Ask Codex to do anything'
await rig.write(`\x1b[2m${placeholder}\x1b[22m\x1b[${placeholder.length}D`)
rig.compose('아')
const { caret, preedit, remainder } = viewParts(rig.compositionView)
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret, remainder])
expect(stripMarks(preedit!.textContent)).toBe('아')
expect(remainder!.textContent).toBe(placeholder)
expect(remainder!.style.visibility).toBe('hidden')
})
it('keeps a mixed dim and committed tail visible', async () => {
const rig = openTerminal()
await rig.write('\x1b[2mghost\x1b[22m!\x1b[6D')
rig.compose('아')
const { remainder } = viewParts(rig.compositionView)
expect(remainder!.textContent).toBe('ghost!')
expect(remainder!.style.visibility).toBe('')
})
it('re-evaluates dim masking when a repaint changes only cell style', async () => {
const rig = openTerminal()
const placeholder = 'Ask Codex to do anything'
await rig.write(`\x1b[2m${placeholder}\x1b[22m\x1b[${placeholder.length}D`)
rig.compose('아')
expect(viewParts(rig.compositionView).remainder!.style.visibility).toBe('hidden')
await rig.writeAwaitingRender(`\x1b[22m${placeholder}\x1b[${placeholder.length}D`)
expect(viewParts(rig.compositionView).remainder!.style.visibility).toBe('')
await rig.writeAwaitingRender(`\x1b[2m${placeholder}\x1b[22m\x1b[${placeholder.length}D`)
expect(viewParts(rig.compositionView).remainder!.style.visibility).toBe('hidden')
})
it('themes the overlay from options.theme instead of the stock #000/#FFF', async () => {
@@ -205,15 +335,33 @@ describe('mid-line composition renders the covered row tail after the preedit',
expect([THEME.foreground, 'rgb(170, 187, 204)']).toContain(color)
})
it('follows a live OSC 12 cursor-color change during composition', async () => {
const rig = openTerminal()
await rig.write('안녕')
rig.compose('한')
expect([THEME.cursor, 'rgb(68, 85, 102)']).toContain(
viewParts(rig.compositionView).caret!.style.backgroundColor
)
await rig.writeAwaitingRender('\x1b]12;#cc5500\x07')
expect(['#cc5500', 'rgb(204, 85, 0)']).toContain(
viewParts(rig.compositionView).caret!.style.backgroundColor
)
})
it('drops the alpha of a translucent theme background so the mask stays opaque', async () => {
// terminalBackgroundOpacity composes theme.background down to rgba(); carried through as-is it
// would let the covered cells show straight through the tail this renders.
const rig = openTerminal({ background: 'rgba(17, 34, 51, 0.6)', foreground: '#aabbcc' })
const rig = openTerminal({
theme: { background: 'rgba(17, 34, 51, 0.6)', foreground: '#aabbcc' }
})
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
expect(rig.compositionView.style.background).toBe('rgb(17, 34, 51)')
expect(['#112233', 'rgb(17, 34, 51)']).toContain(rig.compositionView.style.background)
expect(rig.compositionView.style.background).not.toMatch(/^rgba/i)
})
it('refreshes the tail when the row repaints under an open composition', async () => {
@@ -224,25 +372,23 @@ describe('mid-line composition renders the covered row tail after the preedit',
// A TUI repaint: erase from the cursor, draw a different tail, put the cursor back.
await rig.writeAwaitingRender('\x1b[K체크\x1b[4D')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[1]!.textContent).toBe('체크')
const { preedit, remainder } = viewParts(rig.compositionView)
expect(stripMarks(preedit!.textContent)).toBe('가')
expect(remainder!.textContent).toBe('체크')
})
it('starts rendering a tail when text lands after an end-of-row composition began', async () => {
const rig = openTerminal()
await rig.write('안녕')
rig.compose('가')
expect(rig.compositionView.children).toHaveLength(0)
expect(viewParts(rig.compositionView).remainder).toBeNull()
// Streamed output arrives to the right of the cursor while the composition is open.
await rig.writeAwaitingRender('하세요\x1b[6D')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[1]!.textContent).toBe('하세요')
const { preedit, remainder } = viewParts(rig.compositionView)
expect(stripMarks(preedit!.textContent)).toBe('가')
expect(remainder!.textContent).toBe('하세요')
})
it('leaves no tail behind for the next composition after one ends', async () => {
@@ -19,6 +19,9 @@ export type MidlinePreeditOcclusionSample = {
hiddenByOverlay: string
/** Overlay text as rendered, LRM marks stripped. */
overlayText: string
remainderText: string | null
remainderVisibility: string | null
caretRect: { left: number; right: number; width: number; height: number } | null
overlayActive: boolean
cursorColumn: number
/** Columns covered by the overlay, as `[first, last]`; null when it covers none. */
@@ -58,6 +61,9 @@ function readMidlinePreeditOcclusion(): MidlinePreeditOcclusionSample {
const screenRect = screen.getBoundingClientRect()
const overlayRect = view.getBoundingClientRect()
const cellWidth = terminal.cols > 0 ? screenRect.width / terminal.cols : 0
const caret = view.querySelector<HTMLElement>('.xterm-composition-caret')
const remainder = view.querySelector<HTMLElement>('.xterm-composition-remainder')
const caretBounds = caret?.getBoundingClientRect()
// A column counts as hidden when the overlay covers most of its cell, which keeps the sample
// stable against the sub-pixel width a proportional text node lands on.
@@ -81,8 +87,24 @@ function readMidlinePreeditOcclusion(): MidlinePreeditOcclusionSample {
return {
rowTailFromCursor,
hiddenByOverlay: hiddenByOverlay.trimEnd(),
// The overlay wraps its text in LRM marks; strip them so assertions read as the user sees it.
overlayText: (view.textContent ?? '').replaceAll('‎', ''),
// Hidden children still contribute to textContent; omit them so this reflects visible text.
overlayText: Array.from(view.childNodes)
.filter(
(node) => !(node instanceof HTMLElement) || getComputedStyle(node).visibility !== 'hidden'
)
.map((node) => node.textContent ?? '')
.join('')
.replaceAll('‎', ''),
remainderText: remainder?.textContent ?? null,
remainderVisibility: remainder ? getComputedStyle(remainder).visibility : null,
caretRect: caretBounds
? {
left: caretBounds.left,
right: caretBounds.right,
width: caretBounds.width,
height: caretBounds.height
}
: null,
overlayActive: view.classList.contains('active'),
cursorColumn,
coveredColumns: first === null ? null : [first, last],
@@ -56,6 +56,39 @@ function describeSpan(sample: MidlinePreeditOcclusionSample): string {
}
test.describe('Terminal end-of-row Korean preedit cell span', () => {
test('keeps the preedit caret inside the final terminal cell', async ({ orcaPage }, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H\x1b[80G')
await setImeComposition(arena.session, '가')
const sample = await sampleOpenComposition(orcaPage)
const caret = sample.caretRect
const screenRight = sample.screenRect.left + sample.screenRect.width
expect(sample.cursorColumn, 'the cursor is not in the final column').toBe(79)
expect(caret, 'the active preedit has no caret element').not.toBeNull()
expect(caret!.width, 'the preedit caret has zero width').toBeGreaterThan(0)
expect(caret!.height, 'the preedit caret has zero height').toBeGreaterThan(0)
expect(
caret!.left,
'the preedit caret is clipped left of its overlay'
).toBeGreaterThanOrEqual(sample.overlayRect.left - 0.5)
expect(
caret!.right,
'the preedit caret overflows the terminal at the right edge'
).toBeLessThanOrEqual(screenRight + 0.5)
completed = true
} finally {
await closeTerminalImePaneArena(
arena,
testInfo,
'korean-right-edge-preedit-caret',
!completed
)
}
})
test('renders a composing syllable wider than the one cell #12729 measured', async ({
orcaPage
}, testInfo) => {
@@ -36,12 +36,15 @@ import { samplePreeditOverlay } from './terminal-ime-preedit-overlay-probe'
* assertion is about. The readiness gate is deliberately blind to the tail, so it holds identically
* with and without the fix and the occlusion assertion is what discriminates.
*/
async function sampleOpenComposition(page: Page): Promise<MidlinePreeditOcclusionSample> {
async function sampleOpenComposition(
page: Page,
expectedPreedit = '가'
): Promise<MidlinePreeditOcclusionSample> {
await expect
.poll(
async () => {
const overlay = await samplePreeditOverlay(page)
return overlay.active && overlay.rect.width > 0 && overlay.text.startsWith('가')
return overlay.active && overlay.rect.width > 0 && overlay.text.startsWith(expectedPreedit)
},
{ message: 'the preedit never reached the overlay at a non-zero size' }
)
@@ -65,6 +68,44 @@ function rendersEverythingItCovers(sample: MidlinePreeditOcclusionSample): boole
}
test.describe('Terminal mid-line Korean preedit occlusion', () => {
test('masks the complete dim Codex placeholder during its first Korean preedit', async ({
orcaPage
}, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
const placeholder = 'Ask Codex to do anything'
await writeToActiveTerminal(
orcaPage,
`\x1b[2J\x1b[H\x1b[2m${placeholder}\x1b[22m\x1b[${placeholder.length}D`
)
await setImeComposition(arena.session, '아')
const sample = await sampleOpenComposition(orcaPage, '아')
expect(sample.cursorColumn, 'the cursor is not at the placeholder start').toBe(0)
expect(sample.rowTailFromCursor, 'the dim placeholder is not under the cursor').toBe(
placeholder
)
expect(
sample.hiddenByOverlay,
`the opaque overlay does not mask the full placeholder — ${describeOcclusion(sample)}`
).toBe(placeholder)
expect(sample.overlayText, 'the placeholder is repeated after the Korean preedit').toBe('아')
expect(sample.remainderText, 'the hidden span lost the placeholder layout width').toBe(
placeholder
)
expect(sample.remainderVisibility, 'the placeholder span is still painted').toBe('hidden')
completed = true
} finally {
await closeTerminalImePaneArena(
arena,
testInfo,
'korean-codex-placeholder-preedit',
!completed
)
}
})
test('renders the row tail it covers, so the character after the cursor stays readable', async ({
orcaPage
}, testInfo) => {