mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): clear the preedit overlay when an IME cancels a composition (#14758)
Backspacing over the last radical of a Cangjie composition empties the IME's marked text without reaching compositionend, and the vendored xterm CompositionHelper only dropped the overlay's `active` class there. The box stayed painted with whatever glyph it last held (#11951). Clear on the state rather than on the key, as native terminals do: an empty `compositionupdate` now hides the overlay instead of only ever showing it, and a key the IME swallows re-derives the preedit from the textarea once it settles so a composition emptied with no composition event at all is cancelled too.
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: 8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67
|
||||
hash: 08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089
|
||||
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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
version: 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
// @vitest-environment happy-dom
|
||||
/** Covers #11951: cancelling Cangjie preedit must clear and hide xterm's overlay. */
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const CELL_WIDTH_PX = 8
|
||||
const CELL_HEIGHT_PX = 16
|
||||
|
||||
type RecordedEvent = {
|
||||
data?: string
|
||||
inputType?: string
|
||||
isComposing?: boolean
|
||||
key?: string
|
||||
keyCode?: number
|
||||
type: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
/** Compose a single Cangjie radical: the preedit 尸 is showing and nothing is committed yet. */
|
||||
const COMPOSE_RADICAL: RecordedEvent[] = [
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: false },
|
||||
{ type: 'compositionstart', data: '' },
|
||||
{ type: 'compositionupdate', data: '尸' },
|
||||
{ type: 'beforeinput', data: '尸', isComposing: true },
|
||||
{ type: 'input', data: '尸', isComposing: true, value: '尸' }
|
||||
]
|
||||
|
||||
/** Backspace over the only radical: the IME drops the marked text and reports no composition end. */
|
||||
const CANCEL_WITH_EMPTY_UPDATE: RecordedEvent[] = [
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
|
||||
{ type: 'compositionupdate', data: '' },
|
||||
{ type: 'input', isComposing: true, inputType: 'deleteContentBackward', value: '' }
|
||||
]
|
||||
|
||||
/** The same cancel from an IME that reports only the deletion, with no composition event at all. */
|
||||
const CANCEL_WITH_INPUT_ONLY: RecordedEvent[] = [
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
|
||||
{ type: 'input', isComposing: true, inputType: 'deleteContentBackward', value: '' }
|
||||
]
|
||||
|
||||
/** Backspace over one of several radicals: a preedit is still showing and must stay visible. */
|
||||
const SHORTEN_PREEDIT: RecordedEvent[] = [
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
|
||||
{ type: 'compositionupdate', data: '尸' },
|
||||
{ type: 'input', isComposing: true, inputType: 'deleteContentBackward', value: '尸' }
|
||||
]
|
||||
|
||||
const openTerminals: Terminal[] = []
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function buildEvent(recorded: RecordedEvent): Event {
|
||||
if (recorded.type === 'keydown' || recorded.type === 'keyup') {
|
||||
const keyboard = new KeyboardEvent(recorded.type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
isComposing: recorded.isComposing,
|
||||
key: recorded.key ?? ''
|
||||
})
|
||||
Object.defineProperty(keyboard, 'keyCode', { value: recorded.keyCode })
|
||||
return keyboard
|
||||
}
|
||||
if (recorded.type === 'input' || recorded.type === 'beforeinput') {
|
||||
const input = new InputEvent(recorded.type, {
|
||||
bubbles: true,
|
||||
isComposing: recorded.isComposing
|
||||
})
|
||||
// happy-dom drops these from InputEventInit; Chromium supplies them.
|
||||
Object.defineProperty(input, 'inputType', {
|
||||
value: recorded.inputType ?? 'insertCompositionText'
|
||||
})
|
||||
Object.defineProperty(input, 'data', { value: recorded.data ?? null })
|
||||
Object.defineProperty(input, 'composed', { value: true })
|
||||
return input
|
||||
}
|
||||
const composition = new CompositionEvent(recorded.type, { bubbles: true })
|
||||
Object.defineProperty(composition, 'data', { value: recorded.data ?? '' })
|
||||
return composition
|
||||
}
|
||||
|
||||
type Rig = {
|
||||
compositionView: HTMLElement
|
||||
emitted: string[]
|
||||
replay: (events: RecordedEvent[]) => Promise<void>
|
||||
}
|
||||
|
||||
function openTerminal(): Rig {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal({ cols: 80, rows: 24 })
|
||||
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 emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
|
||||
const replay = async (events: RecordedEvent[]): Promise<void> => {
|
||||
for (const recorded of events) {
|
||||
// Keys were physically separate, so each one began its own task.
|
||||
if (recorded.type === 'keydown' || recorded.type === 'keyup') {
|
||||
await nextEventLoop()
|
||||
}
|
||||
if (recorded.value !== undefined) {
|
||||
textarea.value = recorded.value
|
||||
textarea.setSelectionRange(recorded.value.length, recorded.value.length)
|
||||
}
|
||||
textarea.dispatchEvent(buildEvent(recorded))
|
||||
}
|
||||
// The overlay is re-derived on a deferred task, exactly as the position update is.
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
}
|
||||
|
||||
return { compositionView, emitted, replay }
|
||||
}
|
||||
|
||||
function displayedPreedit(view: HTMLElement): {
|
||||
preedit: string
|
||||
shown: boolean
|
||||
} {
|
||||
const shown = view.classList.contains('active')
|
||||
const preedit = (view.textContent ?? '').replaceAll('', '')
|
||||
return { preedit, shown }
|
||||
}
|
||||
|
||||
describe('preedit visibility when the IME cancels a composition', () => {
|
||||
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('hides the preedit Backspace cancelled with an empty compositionupdate', async () => {
|
||||
const rig = openTerminal()
|
||||
|
||||
await rig.replay(COMPOSE_RADICAL)
|
||||
expect(displayedPreedit(rig.compositionView)).toMatchObject({ preedit: '尸', shown: true })
|
||||
|
||||
await rig.replay(CANCEL_WITH_EMPTY_UPDATE)
|
||||
|
||||
expect(displayedPreedit(rig.compositionView)).toEqual({
|
||||
preedit: '',
|
||||
shown: false
|
||||
})
|
||||
expect(rig.emitted).toEqual([])
|
||||
})
|
||||
|
||||
it('hides the preedit Backspace cancelled without any composition event', async () => {
|
||||
const rig = openTerminal()
|
||||
|
||||
await rig.replay(COMPOSE_RADICAL)
|
||||
expect(displayedPreedit(rig.compositionView)).toMatchObject({ preedit: '尸', shown: true })
|
||||
|
||||
await rig.replay(CANCEL_WITH_INPUT_ONLY)
|
||||
|
||||
expect(displayedPreedit(rig.compositionView)).toEqual({
|
||||
preedit: '',
|
||||
shown: false
|
||||
})
|
||||
expect(rig.emitted).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps showing a preedit Backspace only shortened', async () => {
|
||||
const rig = openTerminal()
|
||||
|
||||
await rig.replay([
|
||||
...COMPOSE_RADICAL,
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
|
||||
{ type: 'compositionupdate', data: '尸口' },
|
||||
{ type: 'input', data: '尸口', isComposing: true, value: '尸口' }
|
||||
])
|
||||
|
||||
await rig.replay(SHORTEN_PREEDIT)
|
||||
|
||||
expect(displayedPreedit(rig.compositionView)).toMatchObject({ preedit: '尸', shown: true })
|
||||
expect(rig.emitted).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user