mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(terminal): align IME preedit to terminal cell grid
This commit is contained in:
@@ -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:
|
||||
|
||||
+139
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user