mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
fix(terminal): render IME caret without placeholder overlap
This commit is contained in:
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) {
|
||||
|
||||
Generated
+24
-24
@@ -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: {}
|
||||
|
||||
|
||||
+178
-32
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user