fix(terminal): align IME preedit to terminal cell grid

This commit is contained in:
Neil
2026-09-19 16:30:44 -07:00
committed by Neil
parent e4c7632db2
commit 04604be5f3
4 changed files with 405 additions and 28 deletions
@@ -88,21 +88,23 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc0
export interface IBrowser {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe0c516b06 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..fe3c4273ff1f06ec906eb9e64a249e90af58f612 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -3,8 +3,9 @@
@@ -3,8 +3,10 @@
* @license MIT
*/
-import { IRenderService } from '../services/Services';
-import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
+import { IRenderService, IThemeService } from '../services/Services';
import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
+import { IBufferService, ICoreService, IOptionsService, IUnicodeService } from '../../common/services/Services';
+import { UnicodeService } from '../../common/services/UnicodeService';
+import { color } from '../../common/Color';
import { C0 } from '../../common/data/EscapeSequences';
interface IPosition {
@@ -12,6 +13,27 @@ interface IPosition {
@@ -12,6 +14,27 @@ interface IPosition {
end: number;
}
@@ -130,7 +132,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
/**
* 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 +47,15 @@ export class CompositionHelper {
*/
private _isComposing: boolean;
public get isComposing(): boolean { return this._isComposing; }
@@ -146,7 +148,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
/**
* The position within the input textarea's value of the current composition.
@@ -36,52 +67,144 @@ export class CompositionHelper {
@@ -36,52 +68,145 @@ export class CompositionHelper {
*/
private _compositionSuffix: string;
@@ -237,7 +239,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
@ICoreService private readonly _coreService: ICoreService,
- @IRenderService private readonly _renderService: IRenderService
+ @IRenderService private readonly _renderService: IRenderService,
+ @IThemeService private readonly _themeService?: IThemeService
+ @IThemeService private readonly _themeService?: IThemeService,
+ @IUnicodeService private readonly _unicodeService?: IUnicodeService
) {
this._isComposing = false;
- this._isSendingComposition = false;
@@ -301,7 +304,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
}
/**
@@ -89,22 +212,93 @@ export class CompositionHelper {
@@ -89,22 +214,93 @@ export class CompositionHelper {
* @param ev The event.
*/
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
@@ -404,7 +407,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
}
/**
@@ -113,7 +307,19 @@ export class CompositionHelper {
@@ -113,7 +309,19 @@ export class CompositionHelper {
* @returns Whether the Terminal should continue processing the keydown event.
*/
public keydown(ev: KeyboardEvent): boolean {
@@ -425,7 +428,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
if (ev.keyCode === 20 || ev.keyCode === 229) {
// 20 is CapsLock, 229 is Enter
// Continue composing if the keyCode is the "composition character"
@@ -128,6 +334,10 @@ export class CompositionHelper {
@@ -128,6 +336,10 @@ export class CompositionHelper {
this._finalizeComposition(false);
}
@@ -436,7 +439,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
if (ev.keyCode === 229) {
// If the "composition character" is used but gets to this point it means a non-composition
// character (eg. numbers and punctuation) was pressed when the IME was active.
@@ -138,6 +348,74 @@ export class CompositionHelper {
@@ -138,6 +350,74 @@ export class CompositionHelper {
return true;
}
@@ -511,7 +514,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
/**
* Finalizes the composition, resuming regular input actions. This is called when a composition
* is ending.
@@ -146,23 +424,52 @@ export class CompositionHelper {
@@ -146,23 +426,52 @@ export class CompositionHelper {
* compositionend event is triggered, such as enter, so that the composition is sent before
* the command is executed.
*/
@@ -575,7 +578,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -172,37 +479,315 @@ export class CompositionHelper {
@@ -172,35 +481,313 @@ 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.
@@ -615,9 +618,9 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
+ this._sendPendingComposition(pending, true);
+ }
+ });
}
}
+ }
+ }
+
+ private _sendPendingComposition(
+ pending: IPendingComposition,
+ includeFollowingInput: boolean = false
@@ -625,7 +628,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
+ this._cancelPendingFinalizer(pending);
+ if (this._pendingComposition === pending) {
+ this._pendingComposition = undefined;
+ }
}
+ const textareaInput = this._getPendingTextareaInput(pending, includeFollowingInput);
+ const observedInput = this._removeAlreadySentData(
+ pending.inputData || pending.keypressData,
@@ -913,12 +916,10 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
+ }
+ clearTimeout(timer);
+ this._compositionTimers.delete(timer);
+ }
+
}
/**
* Apply any changes made to the textarea after the current event chain is allowed to complete.
* This should be called when not currently composing but a keydown event with the "composition
@@ -222,6 +807,9 @@ export class CompositionHelper {
@@ -222,6 +809,9 @@ export class CompositionHelper {
const diff = newValue.replace(oldValue, '');
@@ -928,7 +929,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
@@ -236,6 +824,101 @@ export class CompositionHelper {
@@ -236,6 +826,137 @@ export class CompositionHelper {
}, 0);
}
@@ -943,8 +944,6 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
+ this._resetCompositionView();
+ return;
+ }
+ // Keep DOM order LTR so the insertion caret follows the preedit.
+ const preeditText = `${data}`;
+ this._compositionViewData = data;
+ const doc = this._compositionView.ownerDocument;
+ const preedit = doc.createElement('span');
@@ -952,7 +951,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
+ // Underlined so the composing text stays distinguishable from the tail it pushed right.
+ preedit.style.flexShrink = '0';
+ preedit.style.textDecoration = 'underline';
+ preedit.textContent = preeditText;
+ preedit.style.whiteSpace = 'pre';
+ this._renderPreeditCells(preedit, data);
+ const caret = doc.createElement('span');
+ caret.className = 'xterm-composition-caret';
+ caret.setAttribute('aria-hidden', 'true');
@@ -974,6 +974,43 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
+ this._styleCompositionCaret();
+ }
+
+ private _renderPreeditCells(preedit: HTMLElement, data: string): void {
+ if (!this._unicodeService) {
+ preedit.textContent = `${data}`;
+ return;
+ }
+ const doc = preedit.ownerDocument;
+ preedit.style.display = 'inline-block';
+ preedit.style.position = 'relative';
+ // Keep DOM order LTR so the insertion caret follows the preedit.
+ preedit.appendChild(doc.createTextNode(''));
+ let precedingInfo = 0;
+ let columns = 0;
+ let cell: HTMLElement | undefined;
+ // Use the buffer's joining rules so combining marks stay with their base glyph.
+ for (const char of data) {
+ const info = this._unicodeService.charProperties(char.codePointAt(0)!, precedingInfo);
+ if (cell && UnicodeService.extractShouldJoin(info)) {
+ cell.textContent += char;
+ columns -= UnicodeService.extractWidth(precedingInfo);
+ } else {
+ cell = doc.createElement('span');
+ cell.style.position = 'absolute';
+ cell.style.top = '0';
+ cell.style.left = `calc(var(--xterm-composition-cell-width) * ${columns})`;
+ cell.style.textDecoration = 'inherit';
+ cell.textContent = char;
+ preedit.appendChild(cell);
+ }
+ // Fix the advance, not the glyph size, to the active renderer's device-pixel grid.
+ cell.style.width = `calc(var(--xterm-composition-cell-width) * ${UnicodeService.extractWidth(info)})`;
+ columns += UnicodeService.extractWidth(info);
+ precedingInfo = info;
+ }
+ preedit.style.width = `calc(var(--xterm-composition-cell-width) * ${columns})`;
+ preedit.appendChild(doc.createTextNode(''));
+ }
+
+ /** The committed row text from the cursor rightwards — what a mid-line preedit would cover. */
+ private _getRowRemainderText(): string {
+ const buffer = this._bufferService.buffer;
@@ -1030,7 +1067,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
@@ -243,10 +926,23 @@ export class CompositionHelper {
@@ -243,10 +964,23 @@ export class CompositionHelper {
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
@@ -1055,7 +1092,15 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
if (this._bufferService.buffer.isCursorInViewport) {
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
@@ -265,20 +961,38 @@ export class CompositionHelper {
@@ -254,6 +988,7 @@ export class CompositionHelper {
const cursorTop = this._bufferService.buffer.y * this._renderService.dimensions.css.cell.height;
const cursorLeft = cursorX * this._renderService.dimensions.css.cell.width;
+ this._compositionView.style.setProperty('--xterm-composition-cell-width', this._renderService.dimensions.css.cell.width + 'px');
this._compositionView.style.left = cursorLeft + 'px';
this._compositionView.style.top = cursorTop + 'px';
this._compositionView.style.height = cellHeight + 'px';
@@ -265,20 +1000,38 @@ export class CompositionHelper {
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
this._compositionView.style.maxWidth = maxWidth + 'px';
this._compositionView.style.overflow = 'hidden';
@@ -12,6 +12,20 @@ one machine.
| [#16950](https://github.com/stablyai/orca/issues/16950) typing diagnostic records no CJK samples | The probe observes echoing keydowns but not reconciled composition commits, then guesses which queued input owns opaque TUI output. | A reconciled composition is observed even when `compositionend.data` is empty; only an isolated input enters exact percentiles, while overlap or a dropped-input gap produces one aggregate ambiguous burst. | Recorded Linux IBus empty-data commit, isolated direct and IME samples, mixed-source ambiguity, timeout/cap gaps, UTF-8 output bytes, and stop/drain cleanup are covered. |
| [#17104](https://github.com/stablyai/orca/issues/17104) Korean preedit repeats the Codex placeholder | Generic xterm row-tail reproduction exposed an application-semantic Codex or Claude composer placeholder that presentation style cannot identify safely. | Xterm always preserves generic covered row text. Orca's existing structural composer classifier masks only a verified placeholder during the exact active composition session; repaint reclassification runs only while composing, and end, blur, or disposal clears ownership, class, and listeners. Arbitrary dim output and shell lookalikes remain visible. | Codex prompt/footer and Claude prompt/frame classification, arbitrary all-dim and shell-lookalike negatives, repaint entry and exit, end/blur/disposal cleanup, and rendered Electron proof at cursor column 2 preserving generic row text are covered. |
## Preedit cell advances (#19315)
The preedit uses the active xterm Unicode provider's cell widths and joining
rules, and the active renderer's CSS cell width. Font advances must not accumulate
drift against the committed grid. Keep glyphs unscaled, combining marks attached,
spaces intact, the underline visible, and the caret and candidate textarea at the
end of the preedit. Renderer metric changes must update an open composition.
`terminal-ime-xterm-preedit-cell-grid.test.ts` compares preedit cells with committed
buffer cells. `terminal-ime-preedit-cell-grid.spec.ts` checks rendered glyph origins,
caret/textarea geometry, and underlines at DPR 1, 1.25, and 2, with WebGL on/off,
odd/even font sizes, letter spacing, and mixed Latin/CJK text. These checks use
Chromium composition through CDP; they do not replace native OS IME evidence.
## Bounded-state and ownership contracts
Every transient collection and ownership tracker must have an explicit lifetime and bound:
@@ -0,0 +1,139 @@
// @vitest-environment happy-dom
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
let terminal: Terminal
let view: HTMLElement
let assignedWidths: WeakMap<CSSStyleDeclaration, string>
function compose(text: string): HTMLElement {
const textarea = terminal.textarea!
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true, data: '' }))
textarea.value = text
const update = new CompositionEvent('compositionupdate', { bubbles: true })
Object.defineProperty(update, 'data', { value: text })
textarea.dispatchEvent(update)
return view.querySelector<HTMLElement>('.xterm-composition-preedit')!
}
function write(text: string): Promise<void> {
return new Promise((resolve) => terminal.write(text, resolve))
}
describe('IME preedit advances on the terminal cell grid (#19315)', () => {
beforeEach(() => {
assignedWidths = new WeakMap()
const setWidth = Object.getOwnPropertyDescriptor(CSSStyleDeclaration.prototype, 'width')!.set!
// happy-dom drops calc(var(...)); Electron coverage checks the resulting layout.
vi.spyOn(CSSStyleDeclaration.prototype, 'width', 'set').mockImplementation(
function (this: CSSStyleDeclaration, value) {
assignedWidths.set(this, value)
setWidth.call(this, value)
}
)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 6.5 })
} as unknown as CanvasRenderingContext2D)
const container = document.createElement('div')
document.body.appendChild(container)
terminal = new Terminal({ cols: 80, rows: 24, fontSize: 13, allowProposedApi: true })
terminal.open(container)
view = container.querySelector<HTMLElement>('.composition-view')!
})
afterEach(() => {
terminal.dispose()
vi.restoreAllMocks()
document.body.replaceChildren()
})
it.each([
'ああああああああ',
'日本語かなカナ',
'한글입력',
'中文输入',
'abc XYZ',
'Aあアe\u0301か\u3099Z',
'👩‍💻🚀𠮷'
])('uses the same character grouping and advances as committed %s', async (text) => {
terminal.loadAddon(new Unicode11Addon())
terminal.unicode.activeVersion = '11'
await write(text)
const line = terminal.buffer.active.getLine(0)!
const committed: { text: string; width: number }[] = []
for (let column = 0; column < terminal.buffer.active.cursorX; column++) {
const cell = line.getCell(column)!
if (cell.getWidth() > 0) {
committed.push({ text: cell.getChars(), width: cell.getWidth() })
}
}
const preedit = compose(text)
expect(preedit.style.whiteSpace).toBe('pre')
expect(preedit.textContent).toBe(`${text}`)
expect(Array.from(preedit.children)).toHaveLength(committed.length)
for (const [index, cell] of Array.from(preedit.children).entries()) {
expect(cell.textContent).toBe(committed[index].text)
expect((cell as HTMLElement).style.position).toBe('absolute')
expect(assignedWidths.get((cell as HTMLElement).style)).toBe(
`calc(var(--xterm-composition-cell-width) * ${committed[index].width})`
)
}
})
it('honors the active Unicode provider when a joined character widens its base', async () => {
terminal.unicode.register({
version: 'test-joined',
wcwidth: () => 1,
charProperties: (codepoint: number, preceding: number) =>
codepoint === 0xfe0f && preceding ? (2 << 1) | 1 : 1 << 1
})
terminal.unicode.activeVersion = 'test-joined'
await write('a\ufe0fb')
expect(terminal.buffer.active.cursorX).toBe(3)
const preedit = compose('a\ufe0fb')
expect(Array.from(preedit.children, (cell) => cell.textContent)).toEqual(['a\ufe0f', 'b'])
expect(assignedWidths.get((preedit.firstElementChild as HTMLElement).style)).toBe(
'calc(var(--xterm-composition-cell-width) * 2)'
)
})
it('updates advances on a renderer resize without rebuilding the composing glyphs', () => {
const core = (
terminal as unknown as {
_core: {
_renderService: { dimensions: { css: { cell: { width: number } } } }
_compositionHelper: { updateCompositionElements: (dontRecurse: boolean) => void }
}
}
)._core
core._renderService.dimensions.css.cell.width = 6
const preedit = compose('あa')
const children = Array.from(preedit.children)
expect(view.style.getPropertyValue('--xterm-composition-cell-width')).toBe('6px')
core._renderService.dimensions.css.cell.width = 6.5
core._compositionHelper.updateCompositionElements(true)
expect(view.style.getPropertyValue('--xterm-composition-cell-width')).toBe('6.5px')
expect(Array.from(preedit.children)).toEqual(children)
})
it('keeps provisional text out of the PTY and commits exactly once', async () => {
const sent: string[] = []
terminal.onData((data) => sent.push(data))
const text = 'aあe\u0301'
compose(text)
expect(sent).toEqual([])
terminal.textarea!.dispatchEvent(
new CompositionEvent('compositionend', { bubbles: true, data: text })
)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(sent).toEqual([text])
expect(view.children).toHaveLength(0)
})
})
@@ -0,0 +1,179 @@
import { expect, test } from './helpers/orca-app'
import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena'
import { setImeComposition } from './terminal-ime-cdp-composition'
import {
sampleMidlinePreeditOcclusion,
writeToActiveTerminal
} from './terminal-ime-midline-occlusion-probe'
type TerminalGrid = {
_core: { _renderService: { dimensions: { css: { cell: { width: number } } } } }
}
for (const dpr of [1, 1.25, 2]) {
for (const gpu of ['on', 'off'] as const) {
test.describe(`IME preedit grid DPR ${dpr} GPU ${gpu}`, () => {
test.use({ orcaAppExtraArgs: [`--force-device-scale-factor=${dpr}`] })
test('matches committed character advances across font and spacing changes', async ({
orcaPage
}, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
for (const options of [
{ fontSize: 13, letterSpacing: 0 },
{ fontSize: 14, letterSpacing: 0 },
{ fontSize: 13, letterSpacing: 1 }
]) {
await orcaPage.evaluate(
({ gpu, options }) => {
const state = window.__store!.getState()
const manager = window.__paneManagers!.get(state.activeTabId!)!
manager.setTerminalGpuAcceleration(gpu)
const terminal = manager.getActivePane()!.terminal
terminal.options.fontFamily = 'monospace'
terminal.options.fontSize = options.fontSize
terminal.options.letterSpacing = options.letterSpacing
},
{ gpu, options }
)
for (const text of ['あ'.repeat(32), 'Aあアe\u0301か\u3099Z', 'abc XYZ', '한글中文']) {
await writeToActiveTerminal(orcaPage, `\x1b[2J\x1b[H${text}\r\n`)
await setImeComposition(arena.session, text)
const preedit = orcaPage.locator(
'.composition-view.active .xterm-composition-preedit'
)
await expect(preedit).toHaveText(`${text}`)
const sample = await orcaPage.evaluate(() => {
const state = window.__store!.getState()
const terminal = window
.__paneManagers!.get(state.activeTabId!)!
.getActivePane()!.terminal
const screen = terminal.element!.querySelector<HTMLElement>('.xterm-screen')!
const preedit = screen.querySelector<HTMLElement>('.xterm-composition-preedit')!
// The canvas width rounds independently of fractional WebGL cell widths.
const cellWidth = (terminal as unknown as TerminalGrid)._core._renderService
.dimensions.css.cell.width
const line = terminal.buffer.active.getLine(terminal.buffer.active.baseY)!
const committed: { text: string; column: number; width: number }[] = []
const end = line.translateToString(true).length
let seen = 0
let columns = 0
for (let column = 0; column < terminal.cols && seen < end; column++) {
const cell = line.getCell(column)!
if (cell.getWidth() > 0) {
committed.push({ text: cell.getChars(), column, width: cell.getWidth() })
seen += cell.getChars().length
columns = column + cell.getWidth()
}
}
const starts: number[] = []
const walker = document.createTreeWalker(preedit, NodeFilter.SHOW_TEXT)
let node: Node | null
let index = 0
while ((node = walker.nextNode())) {
const value = node.textContent ?? ''
for (let offset = 0; offset < value.length;) {
if (value[offset] === '') {
offset++
continue
}
const cellText = committed[index++]?.text
if (!cellText || !value.startsWith(cellText, offset)) {
throw new Error('Preedit text does not match the committed buffer cells')
}
const range = document.createRange()
range.setStart(node, offset)
range.setEnd(node, offset + cellText.length)
starts.push(range.getBoundingClientRect().left)
offset += cellText.length
}
}
const bounds = preedit.getBoundingClientRect()
const caret = screen.querySelector<HTMLElement>('.xterm-composition-caret')!
return {
dpr: devicePixelRatio,
webgl: Boolean(screen.querySelector('canvas')),
cellWidth,
committed,
starts: starts.map((left) => left - bounds.left),
width: bounds.width,
expectedWidth: columns * cellWidth,
caretRight: caret.getBoundingClientRect().right - bounds.left,
textareaWidth: terminal.textarea!.getBoundingClientRect().width,
underlines: Array.from(
preedit.children,
(cell) => getComputedStyle(cell).textDecorationLine
)
}
})
expect(sample.dpr).toBe(dpr)
expect(sample.webgl).toBe(gpu === 'on')
expect(sample.width).toBeCloseTo(sample.expectedWidth, 1)
expect(sample.caretRight).toBeCloseTo(sample.expectedWidth, 1)
expect(sample.textareaWidth).toBeCloseTo(sample.expectedWidth, 1)
expect(sample.underlines).toHaveLength(sample.committed.length)
expect(
sample.underlines.every((decoration) => decoration.includes('underline'))
).toBe(true)
for (const [index, cell] of sample.committed.entries()) {
expect(sample.starts[index]).toBeCloseTo(cell.column * sample.cellWidth, 1)
}
if (options.fontSize === 13 && options.letterSpacing === 0 && text.startsWith('あ')) {
await testInfo.attach('preedit-cell-grid', {
body: await orcaPage.screenshot(),
contentType: 'image/png'
})
}
await setImeComposition(arena.session, '')
}
}
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H\x1b[999G')
await setImeComposition(arena.session, 'あ'.repeat(8))
await expect(orcaPage.locator('.xterm-composition-preedit')).toHaveText(
`${'あ'.repeat(8)}`
)
const edge = await sampleMidlinePreeditOcclusion(orcaPage)
const screenRight = edge.screenRect.left + edge.screenRect.width
expect(edge.cursorColumn).toBe(edge.terminalColumns - 1)
expect(Math.abs(edge.caretRect!.right - screenRight)).toBeLessThan(1 / dpr)
expect(edge.caretRect!.left).toBeGreaterThanOrEqual(edge.screenRect.left)
expect(Math.abs(edge.textareaRect.right - screenRight)).toBeLessThan(1 / dpr)
await orcaPage.evaluate(() => {
const state = window.__store!.getState()
const terminal = window
.__paneManagers!.get(state.activeTabId!)!
.getActivePane()!.terminal
terminal.options.fontSize = 16
})
await expect
.poll(async () => (await sampleMidlinePreeditOcclusion(orcaPage)).cellWidth)
.not.toBe(edge.cellWidth)
await expect
.poll(() =>
orcaPage.evaluate(() => {
const state = window.__store!.getState()
const terminal = window
.__paneManagers!.get(state.activeTabId!)!
.getActivePane()!.terminal
const cellWidth = (terminal as unknown as TerminalGrid)._core._renderService
.dimensions.css.cell.width
const preedit = terminal.element!.querySelector('.xterm-composition-preedit')!
return Math.abs(preedit.getBoundingClientRect().width - 16 * cellWidth)
})
)
.toBeLessThan(0.05)
await setImeComposition(arena.session, '')
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'preedit-cell-grid', !completed)
}
})
})
}
}