mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): render the row tail the IME preedit overlay covers (#15014)
* fix(terminal): render the covered row tail inside the IME preedit overlay Closes #12545. Composing mid-line hid the character at the cursor for the whole composition. The preedit overlay is an opaque box anchored to the cursor cell, and nothing reaches the pty while composing, so those cells still held their characters — the box simply covered them. `CompositionHelper` now draws the rest of the row after the preedit inside the view, so the composition reads as inserted text pushing the tail right. Four details come with it: - The view is start-anchored while it carries a tail, 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. - It is themed from `options.theme` instead of the stock `#000`/`#FFF`, with any alpha dropped — the view masks the cells it draws over, so a see-through background would re-expose the very characters the tail stands in for. - The helper textarea syncs to the preedit's own bounds, so IME candidate dialogs anchor to the composing text rather than past the rendered tail. - A TUI can repaint the row under an open composition, so `updateCompositionElements` — which already runs on every render — re-reads the remainder and re-renders on change. A string compare adds no layout read. The tail is read with an explicit end column: the cacheable form of `translateToString` arms the line string cache's self-renewing idle-clear timer, and the composition path must own no timers. Geometry is not the cause. Two mature reference terminal implementations compose marked text into the grid rather than into a floating box, and both still blank the cells under it — one of them literally substitutes the marked characters into the row's character array before rasterizing. Moving off the overlay would not have fixed this report; rendering the covered tail is what does. The e2e arm asserts the invariant an opaque overlay owes the grid: it must render every committed cell its bounding rect covers. That is measured from the real rect against the real cell grid, so it fails on the unfixed build with `covers "하" / renders "가"`. Known limitation: the rendered tail is plain-styled while composing (theme foreground on theme background, no per-cell colors); colors return on commit. This is inherent to the overlay, and drawing the preedit into the cell renderer instead would be a far larger change. Co-authored-by: rayim <rayim@fxy.global> * test(e2e): assert the occlusion invariant, not the runner's cell width CI covered four columns where this machine covers two — 34.4px over an 8.43px grid against 12.3px over an 8px grid — so pinning the covered text verbatim pinned the font metrics rather than the behaviour. Assert instead that every committed cell the overlay covers appears in what it draws, which is the actual invariant and holds at any cell width. Still fails against main: covers "하" / renders "가". * fix(terminal): keep the rendered tail's spacing on the grid The composition view is white-space: nowrap, which collapses runs of spaces exactly like normal — it only suppresses wrapping. So a committed tail carrying padding drew its trailing glyph cells left of where the grid has them: measured in Chromium with xterm's own rule, twenty spaces plus a border rendered two cells wide instead of twenty-one. The visible case is Orca's most common IME context — composing inside an agent TUI input box, where the row is a prompt, padding, then a real border glyph the trim cannot drop. A stray border appeared a cell after the preedit while the real one stayed put. xterm sets white-space: pre on its grid rows for this reason; the view was only nowrap-safe while it held preedit text alone. The existing fixtures are all space-free, and the e2e invariant is that the overlay renders everything it covers — collapsing makes it cover less, so both stayed green. Pinned with a padded-row fixture. --------- Co-authored-by: rayim <rayim@fxy.global>
This commit is contained in:
File diff suppressed because one or more lines are too long
Generated
+24
-24
@@ -18,7 +18,7 @@ patchedDependencies:
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089
|
||||
hash: 0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -45,7 +45,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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -208,25 +208,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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
|
||||
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
|
||||
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
version: 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -9591,39 +9591,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
|
||||
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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* A mid-line composition must not visually swallow the character after the cursor.
|
||||
*
|
||||
* The preedit overlay (`.composition-view`) is an opaque box anchored to the cursor cell. Nothing
|
||||
* reaches the PTY while composing, so the covered cells still hold their characters — the box just
|
||||
* hides them for the whole composition (#12545). Composing `가` with the cursor before `하` in
|
||||
* `안녕하세요` 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
|
||||
* 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
|
||||
* on-screen geometry arm lives in `tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts`.
|
||||
*/
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
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 openTerminals: Terminal[] = []
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
type Rig = {
|
||||
compositionView: HTMLElement
|
||||
compose: (preedit: string) => void
|
||||
composeStart: () => void
|
||||
composeUpdate: (preedit: string) => void
|
||||
terminal: Terminal
|
||||
write: (data: string) => Promise<void>
|
||||
writeAwaitingRender: (data: string) => Promise<void>
|
||||
}
|
||||
|
||||
function openTerminal(theme: { background: string; foreground: string } = THEME): Rig {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal({ cols: 80, rows: 24, theme })
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
const compositionView = container.querySelector<HTMLElement>('.composition-view')
|
||||
if (!textarea || !compositionView) {
|
||||
throw new Error('xterm did not create the helper textarea and composition view')
|
||||
}
|
||||
openTerminals.push(terminal)
|
||||
|
||||
const cell = (
|
||||
terminal as unknown as {
|
||||
_core: {
|
||||
_renderService: { dimensions: { css: { cell: { height: number; width: number } } } }
|
||||
}
|
||||
}
|
||||
)._core._renderService.dimensions.css.cell
|
||||
cell.width = CELL_WIDTH_PX
|
||||
cell.height = CELL_HEIGHT_PX
|
||||
|
||||
const write = (data: string): Promise<void> =>
|
||||
new Promise((resolve) => terminal.write(data, resolve))
|
||||
|
||||
// Awaits the repaint the write triggers, so the tail refresh runs through the production
|
||||
// terminal.onRender path rather than a test shortcut. The listener arms only after the write's
|
||||
// parse callback, because a repaint scheduled by an earlier write can fire first and still show
|
||||
// the old row.
|
||||
const writeAwaitingRender = async (data: string): Promise<void> => {
|
||||
await write(data)
|
||||
await new Promise<void>((resolve) => {
|
||||
const rendered = terminal.onRender(() => {
|
||||
rendered.dispose()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const composeStart = (): void => {
|
||||
const start = new CompositionEvent('compositionstart', { bubbles: true })
|
||||
Object.defineProperty(start, 'data', { value: '' })
|
||||
textarea.dispatchEvent(start)
|
||||
}
|
||||
|
||||
const composeUpdate = (preedit: string): void => {
|
||||
const update = new CompositionEvent('compositionupdate', { bubbles: true })
|
||||
Object.defineProperty(update, 'data', { value: preedit })
|
||||
textarea.value = preedit
|
||||
textarea.dispatchEvent(update)
|
||||
}
|
||||
|
||||
const compose = (preedit: string): void => {
|
||||
composeStart()
|
||||
composeUpdate(preedit)
|
||||
}
|
||||
|
||||
return {
|
||||
compositionView,
|
||||
compose,
|
||||
composeStart,
|
||||
composeUpdate,
|
||||
terminal,
|
||||
write,
|
||||
writeAwaitingRender
|
||||
}
|
||||
}
|
||||
|
||||
function stripMarks(text: string | null): string {
|
||||
return (text ?? '').replaceAll('', '')
|
||||
}
|
||||
|
||||
describe('mid-line composition renders the covered row tail after the preedit', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// updateCompositionElements re-arms on a timer; let the pending one run before dispose.
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
while (openTerminals.length > 0) {
|
||||
openTerminals.pop()?.dispose()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('shows the tail from the cursor when composing before committed text (#12545 repro)', async () => {
|
||||
const rig = openTerminal()
|
||||
// 안녕하세요 then CUB 6: each Hangul syllable is two cells, so the cursor lands on 하 (x=4).
|
||||
await rig.write('안녕하세요\x1b[6D')
|
||||
|
||||
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('하세요')
|
||||
// Start-anchored so the preedit stays put and the pushed tail clips at the right edge.
|
||||
expect(rig.compositionView.style.direction).toBe('ltr')
|
||||
})
|
||||
|
||||
it('keeps the tail current as the preedit grows through the composition', async () => {
|
||||
const rig = openTerminal()
|
||||
await rig.write('안녕하세요\x1b[6D')
|
||||
|
||||
// One composition stays active while the preedit grows through updates,
|
||||
// matching how an IME actually streams ㄱ → 가 → 강.
|
||||
rig.composeStart()
|
||||
rig.composeUpdate('ㄱ')
|
||||
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('하세요')
|
||||
})
|
||||
|
||||
// The view is `white-space: nowrap`, which collapses runs of spaces exactly like `normal`.
|
||||
// Without `pre` on the tail, an agent TUI's padded input row — `> text …spaces… |` — renders its
|
||||
// right border a cell after the preedit while the real border stays put. xterm sets `pre` on its
|
||||
// grid rows for the same reason.
|
||||
it('preserves the tail spacing of a padded row so its trailing glyph stays on the grid', async () => {
|
||||
const rig = openTerminal()
|
||||
// A TUI input row: text, padding, then a real border glyph the trim cannot drop.
|
||||
await rig.write('> hi |\x1b[13D')
|
||||
|
||||
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 |')
|
||||
expect(
|
||||
spans[1]!.style.whiteSpace,
|
||||
'nowrap collapses the padding, so the border lands left of its grid column'
|
||||
).toBe('pre')
|
||||
})
|
||||
|
||||
it('keeps the plain single-text overlay when composing at the end of the row', async () => {
|
||||
const rig = openTerminal()
|
||||
await rig.write('안녕하세요')
|
||||
|
||||
rig.compose('가')
|
||||
|
||||
expect(rig.compositionView.children).toHaveLength(0)
|
||||
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')
|
||||
})
|
||||
|
||||
it('themes the overlay from options.theme instead of the stock #000/#FFF', async () => {
|
||||
const rig = openTerminal()
|
||||
await rig.write('안녕하세요\x1b[6D')
|
||||
|
||||
rig.compose('가')
|
||||
|
||||
const { background, color } = rig.compositionView.style
|
||||
expect([THEME.background, 'rgb(17, 34, 51)']).toContain(background)
|
||||
expect([THEME.foreground, 'rgb(170, 187, 204)']).toContain(color)
|
||||
})
|
||||
|
||||
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' })
|
||||
await rig.write('안녕하세요\x1b[6D')
|
||||
|
||||
rig.compose('가')
|
||||
|
||||
expect(rig.compositionView.style.background).toBe('rgb(17, 34, 51)')
|
||||
})
|
||||
|
||||
it('refreshes the tail when the row repaints under an open composition', async () => {
|
||||
const rig = openTerminal()
|
||||
await rig.write('안녕하세요\x1b[6D')
|
||||
rig.compose('가')
|
||||
|
||||
// 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('체크')
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// 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('하세요')
|
||||
})
|
||||
|
||||
it('leaves no tail behind for the next composition after one ends', async () => {
|
||||
const rig = openTerminal()
|
||||
await rig.write('안녕하세요\x1b[6D')
|
||||
rig.compose('가')
|
||||
|
||||
const end = new CompositionEvent('compositionend', { bubbles: true })
|
||||
Object.defineProperty(end, 'data', { value: '가' })
|
||||
rig.terminal.textarea!.dispatchEvent(end)
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
|
||||
expect(rig.compositionView.classList.contains('active')).toBe(false)
|
||||
expect(rig.compositionView.children).toHaveLength(0)
|
||||
expect(rig.compositionView.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
|
||||
/**
|
||||
* Measures what an active preedit overlay hides, against what it renders.
|
||||
*
|
||||
* The overlay has an opaque background and is absolutely positioned over the grid, so every cell
|
||||
* its bounding rect covers is unreadable for the duration of the composition. That gives one
|
||||
* invariant worth asserting on screen: **the overlay must render everything it covers**. Composing
|
||||
* mid-line broke it — the box covered the character at the cursor and rendered only the preedit.
|
||||
*
|
||||
* This has to be measured from the real rect against the real cell grid. The class, the
|
||||
* `textContent` and `display` are all identical either way, and a DOM emulator reports every rect
|
||||
* as zero, so a unit-level arm cannot see the difference.
|
||||
*/
|
||||
export type MidlinePreeditOcclusionSample = {
|
||||
/** Row text from the cursor rightwards — the committed characters a mid-line preedit sits over. */
|
||||
rowTailFromCursor: string
|
||||
/** Characters in the grid columns the overlay's bounding rect covers. */
|
||||
hiddenByOverlay: string
|
||||
/** Overlay text as rendered, LRM marks stripped. */
|
||||
overlayText: string
|
||||
overlayActive: boolean
|
||||
cursorColumn: number
|
||||
/** Columns covered by the overlay, as `[first, last]`; null when it covers none. */
|
||||
coveredColumns: [number, number] | null
|
||||
cellWidth: number
|
||||
/** Kept in the sample so a geometry mismatch prints the rects that produced it. */
|
||||
overlayRect: { left: number; right: number; width: number }
|
||||
screenRect: { left: number; width: number }
|
||||
}
|
||||
|
||||
function readMidlinePreeditOcclusion(): MidlinePreeditOcclusionSample {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('No active terminal pane to sample')
|
||||
}
|
||||
const terminal = pane.terminal
|
||||
const screen = pane.container.querySelector<HTMLElement>('.xterm-screen')
|
||||
const view = pane.container.querySelector<HTMLElement>('.composition-view')
|
||||
if (!screen || !view) {
|
||||
throw new Error('Active terminal has no screen element or composition view')
|
||||
}
|
||||
|
||||
const buffer = terminal.buffer.active
|
||||
const line = buffer.getLine(buffer.baseY + buffer.cursorY)
|
||||
const cursorColumn = buffer.cursorX
|
||||
const rowTailFromCursor = (line?.translateToString(true, cursorColumn) ?? '').trimEnd()
|
||||
|
||||
const screenRect = screen.getBoundingClientRect()
|
||||
const overlayRect = view.getBoundingClientRect()
|
||||
const cellWidth = terminal.cols > 0 ? screenRect.width / terminal.cols : 0
|
||||
|
||||
// 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.
|
||||
let first: number | null = null
|
||||
let last = -1
|
||||
let hiddenByOverlay = ''
|
||||
if (cellWidth > 0 && overlayRect.width > 0) {
|
||||
for (let column = 0; column < terminal.cols; column++) {
|
||||
const cellLeft = screenRect.left + column * cellWidth
|
||||
const overlap =
|
||||
Math.min(cellLeft + cellWidth, overlayRect.right) - Math.max(cellLeft, overlayRect.left)
|
||||
if (overlap <= cellWidth / 2) {
|
||||
continue
|
||||
}
|
||||
first ??= column
|
||||
last = column
|
||||
hiddenByOverlay += line?.getCell(column)?.getChars() ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
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('', ''),
|
||||
overlayActive: view.classList.contains('active'),
|
||||
cursorColumn,
|
||||
coveredColumns: first === null ? null : [first, last],
|
||||
cellWidth,
|
||||
overlayRect: {
|
||||
left: overlayRect.left,
|
||||
right: overlayRect.right,
|
||||
width: overlayRect.width
|
||||
},
|
||||
screenRect: { left: screenRect.left, width: screenRect.width }
|
||||
}
|
||||
}
|
||||
|
||||
export async function sampleMidlinePreeditOcclusion(
|
||||
page: Page
|
||||
): Promise<MidlinePreeditOcclusionSample> {
|
||||
return page.evaluate(readMidlinePreeditOcclusion)
|
||||
}
|
||||
|
||||
/** Writes straight to the emulator: the defect is in what the overlay draws, not in the pty. */
|
||||
export async function writeToActiveTerminal(page: Page, data: string): Promise<void> {
|
||||
await page.evaluate(async (payload: string) => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('No active terminal pane to write to')
|
||||
}
|
||||
await new Promise<void>((resolve) => pane.terminal.write(payload, resolve))
|
||||
}, data)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Headless end-to-end coverage for #12545: a mid-line Korean composition must not swallow the
|
||||
* character after the cursor.
|
||||
*
|
||||
* The preedit overlay is an opaque, absolutely-positioned box over the grid, so every cell its
|
||||
* bounding rect covers is unreadable while the composition is open. That gives the invariant
|
||||
* asserted here — **the overlay renders every committed cell it covers** — and it is the one the
|
||||
* bug broke: the box covered `하` and drew only `가`. It is measured from the overlay's real rect
|
||||
* against the real cell grid, which is what makes it an on-screen assertion rather than a
|
||||
* `textContent` one; a DOM emulator reports every rect as zero and cannot see the difference.
|
||||
*
|
||||
* Reference terminal implementations do not settle this by geometry. Two mature ones compose marked
|
||||
* text into the grid rather than into a floating box, and both still blank the cells under it — so
|
||||
* moving off the overlay would not fix the report. Rendering the covered tail is what does.
|
||||
*
|
||||
* Composition is driven through CDP `Input.imeSetComposition`, so this runs in the normal headless
|
||||
* project with no native input source. The row is written straight to the emulator because the
|
||||
* defect is in what the overlay draws over the buffer, not in anything reaching the pty.
|
||||
*/
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
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,
|
||||
type MidlinePreeditOcclusionSample
|
||||
} from './terminal-ime-midline-occlusion-probe'
|
||||
import { samplePreeditOverlay } from './terminal-ime-preedit-overlay-probe'
|
||||
|
||||
/**
|
||||
* Waits for the preedit to reach the overlay at a non-zero size, then samples once.
|
||||
*
|
||||
* Sampled once rather than polled: the pane runs a live shell that can repaint the row out from
|
||||
* under the composition, so only the frame the composition opened on carries the state the
|
||||
* 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> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const overlay = await samplePreeditOverlay(page)
|
||||
return overlay.active && overlay.rect.width > 0 && overlay.text.startsWith('가')
|
||||
},
|
||||
{ message: 'the preedit never reached the overlay at a non-zero size' }
|
||||
)
|
||||
.toBe(true)
|
||||
return sampleMidlinePreeditOcclusion(page)
|
||||
}
|
||||
|
||||
/** Reads as one string so a failure prints what the overlay covered next to what it drew. */
|
||||
function describeOcclusion(sample: MidlinePreeditOcclusionSample): string {
|
||||
return `covers ${JSON.stringify(sample.hiddenByOverlay)} / renders ${JSON.stringify(sample.overlayText)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant, stated so it survives a different cell width: every committed cell the overlay
|
||||
* covers must appear in what it draws. How MANY cells it covers is a function of the runner's
|
||||
* font metrics — 34.4px over an 8.43px grid spans four columns where an 8px grid spans two — so
|
||||
* asserting the covered text verbatim pins the machine, not the behaviour.
|
||||
*/
|
||||
function rendersEverythingItCovers(sample: MidlinePreeditOcclusionSample): boolean {
|
||||
return sample.overlayText.includes(sample.hiddenByOverlay)
|
||||
}
|
||||
|
||||
test.describe('Terminal mid-line Korean preedit occlusion', () => {
|
||||
test('renders the row tail it covers, so the character after the cursor stays readable', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const arena = await openTerminalImePaneArena(orcaPage)
|
||||
let completed = false
|
||||
try {
|
||||
// The issue's repro: 안녕하세요, then CUB 6. Each Hangul syllable is two cells, so the
|
||||
// cursor lands on 하.
|
||||
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H안녕하세요\x1b[6D')
|
||||
await setImeComposition(arena.session, '가')
|
||||
|
||||
const sample = await sampleOpenComposition(orcaPage)
|
||||
expect(sample.cursorColumn, 'the cursor is not sitting on 하').toBe(4)
|
||||
expect(sample.rowTailFromCursor, 'the row under the overlay is not the repro').toBe('하세요')
|
||||
expect(sample.coveredColumns, 'the overlay covers no grid columns to assert about').not.toBe(
|
||||
null
|
||||
)
|
||||
|
||||
// The load-bearing assertion, and the only one that reads the overlay's geometry against the
|
||||
// grid: an opaque box that covers committed cells has to reproduce them, or the user loses
|
||||
// text for the length of the composition.
|
||||
expect(
|
||||
sample.hiddenByOverlay.length,
|
||||
`the overlay covers no committed text, so there is nothing to assert — ${describeOcclusion(sample)}`
|
||||
).toBeGreaterThan(0)
|
||||
expect(
|
||||
rendersEverythingItCovers(sample),
|
||||
`the preedit overlay must render every committed cell it covers — ${describeOcclusion(sample)} ${JSON.stringify(sample)}`
|
||||
).toBe(true)
|
||||
completed = true
|
||||
} finally {
|
||||
await closeTerminalImePaneArena(
|
||||
arena,
|
||||
testInfo,
|
||||
'korean-midline-preedit-occlusion',
|
||||
!completed
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('leaves the end-of-row composition untouched, with nothing covered to render', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
// Passes before and after the fix by design: the guard is against over-correcting into
|
||||
// rendering a tail where the row has none.
|
||||
const arena = await openTerminalImePaneArena(orcaPage)
|
||||
let completed = false
|
||||
try {
|
||||
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H안녕하세요')
|
||||
await setImeComposition(arena.session, '가')
|
||||
|
||||
const sample = await sampleOpenComposition(orcaPage)
|
||||
expect(sample.rowTailFromCursor, 'text still sits after the cursor').toBe('')
|
||||
expect(
|
||||
describeOcclusion(sample),
|
||||
`an end-of-row composition covers no committed text — ${JSON.stringify(sample)}`
|
||||
).toBe('covers "" / renders "가"')
|
||||
completed = true
|
||||
} finally {
|
||||
await closeTerminalImePaneArena(arena, testInfo, 'korean-endofrow-preedit', !completed)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user