diff --git a/config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch b/config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch index 951558431c6..dbc500dec22 100644 --- a/config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch +++ b/config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch @@ -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): 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'; diff --git a/docs/reference/ime-regression-checklist.md b/docs/reference/ime-regression-checklist.md index 4ceaba78df2..44d4cb7f526 100644 --- a/docs/reference/ime-regression-checklist.md +++ b/docs/reference/ime-regression-checklist.md @@ -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: diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-xterm-preedit-cell-grid.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-preedit-cell-grid.test.ts new file mode 100644 index 00000000000..2c20d6496a2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-preedit-cell-grid.test.ts @@ -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 + +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('.xterm-composition-preedit')! +} + +function write(text: string): Promise { + 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('.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) + }) +}) diff --git a/tests/e2e/terminal-ime-preedit-cell-grid.spec.ts b/tests/e2e/terminal-ime-preedit-cell-grid.spec.ts new file mode 100644 index 00000000000..95d3178c17d --- /dev/null +++ b/tests/e2e/terminal-ime-preedit-cell-grid.spec.ts @@ -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('.xterm-screen')! + const preedit = screen.querySelector('.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('.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) + } + }) + }) + } +}