mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
fix(terminal): deliver an IME commit the deferred textarea diff missed
Picking a single Chinese character from the candidate window with a number key loses it. The character flashes and disappears. Picking the same candidate with the mouse works, and picking multi-character words with number keys works. Two paths can deliver an IME commit, and this falls between them. A keydown the input method consumed routes into a setTimeout(0) diff of the helper textarea, and that diff is what normally delivers the commit; xterm's _keyDownSeen guard exists to defer to it. When the commit arrives after that timer has already run, neither path delivers. Mouse selection works because no key is down, and a real composition session works because it takes a different path entirely. That narrows it to an input method whose commit round-trips asynchronously and which shows no in-application preedit. Track that a consumed keydown still owes its commit, and deliver only when the diff did not. The upstream guard and its single read site are untouched, which is what keeps the duplicate-commit behaviour it was added for sealed. Not doing the obvious repairs deliberately: clearing the flag, skipping it for keyCode 229, or setting it after the composition short-circuit each unblock the input path without retiring the diff, and all three were measured emitting the character twice. The patch and the lockfile hash here are generated. Review config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch, which is the hand-written source of the change; the shipped patch and both minified bundles are the regenerator's output from the pinned upstream build, so nothing in this change was hand-transcribed into a bundle. Refs xtermjs/xterm.js#6036 Closes #12099
This commit is contained in:
+194
@@ -0,0 +1,194 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* An IME that shows candidates only in its own panel — ibus+Rime, fcitx5 with application preedit
|
||||
* disabled — opens no composition session in the page, so a candidate picked with a number key
|
||||
* arrives as a bare `insertText` with no compositionstart/update/end around it (#12099, and
|
||||
* upstream xtermjs/xterm.js#6036).
|
||||
*
|
||||
* xterm forwards nothing for the keydown the IME consumed, yet `_keyDownSeen` still records it as a
|
||||
* key being held, and `_inputEvent` admits a `composed` insertText only when no key is down — so
|
||||
* the commit is dropped. The reporter's three cases differ only in that flag: mouse selection has
|
||||
* no key down, a multi-character word runs through a real composition session, and a single
|
||||
* character picked by number key is discarded.
|
||||
*
|
||||
* The commit for such a keydown is owed by exactly one of two paths — CompositionHelper's deferred
|
||||
* textarea diff, or the input event itself — and which one observes it first depends on whether the
|
||||
* IME commits synchronously. Both orderings are pinned here, because a fix that only unblocks the
|
||||
* input event re-sends everything the diff already delivered.
|
||||
*/
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const IME_KEYCODE = 229
|
||||
|
||||
type Rig = {
|
||||
emitted: string[]
|
||||
terminal: Terminal
|
||||
textarea: HTMLTextAreaElement
|
||||
}
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function openTerminal(): Rig {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
if (!textarea) {
|
||||
throw new Error('xterm helper textarea was not created')
|
||||
}
|
||||
const emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
return { emitted, terminal, textarea }
|
||||
}
|
||||
|
||||
function dispatchKey(
|
||||
{ textarea }: Rig,
|
||||
type: 'keydown' | 'keyup' | 'keypress',
|
||||
init: { key: string; code: string; keyCode: number; charCode?: number }
|
||||
): void {
|
||||
const event = new KeyboardEvent(type, {
|
||||
key: init.key,
|
||||
code: init.code,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
// happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm's IME paths read them.
|
||||
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
|
||||
Object.defineProperty(event, 'charCode', { value: init.charCode ?? 0 })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/** The commit itself: the browser has already written it into the textarea when `input` fires. */
|
||||
function dispatchCommit({ textarea }: Rig, data: string): void {
|
||||
textarea.value = data
|
||||
const event = new InputEvent('input', { bubbles: true })
|
||||
Object.defineProperty(event, 'inputType', { value: 'insertText' })
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
// Trusted user events are always composed; that is what makes the guard collapse to "no key down".
|
||||
Object.defineProperty(event, 'composed', { value: true })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function dispatchComposition(
|
||||
{ textarea }: Rig,
|
||||
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
|
||||
data: string
|
||||
): void {
|
||||
if (type !== 'compositionstart') {
|
||||
textarea.value = data
|
||||
}
|
||||
const event = new CompositionEvent(type, { bubbles: true })
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/** Typing pinyin under a panel-only IME: every letter is consumed, nothing reaches the page. */
|
||||
async function typeConsumedPinyin(rig: Rig, codes: string[]): Promise<void> {
|
||||
for (const code of codes) {
|
||||
dispatchKey(rig, 'keydown', { key: 'Process', code, keyCode: IME_KEYCODE })
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: 'Process', code, keyCode: IME_KEYCODE })
|
||||
await nextEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
describe('a candidate commit that arrives with no composition session', () => {
|
||||
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(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('sends a candidate picked with the mouse, with no key down', async () => {
|
||||
const rig = openTerminal()
|
||||
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
|
||||
dispatchCommit(rig, '好')
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好')
|
||||
})
|
||||
|
||||
it('sends a candidate committed by a real composition session exactly once', async () => {
|
||||
const rig = openTerminal()
|
||||
dispatchComposition(rig, 'compositionstart', '')
|
||||
dispatchComposition(rig, 'compositionupdate', 'hao')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
|
||||
dispatchComposition(rig, 'compositionend', '好的')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好的')
|
||||
})
|
||||
|
||||
// The reporter's failing case. ibus answers over DBus, so the commit lands after the deferred
|
||||
// textarea diff has already run against an unchanged textarea — and the number key is still down.
|
||||
it('sends a commit that arrives after the deferred diff while the consumed key is down', async () => {
|
||||
const rig = openTerminal()
|
||||
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
|
||||
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
|
||||
await nextEventLoop()
|
||||
dispatchCommit(rig, '好')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好')
|
||||
})
|
||||
|
||||
// The same gesture from an IME that commits synchronously. Here the deferred diff would also see
|
||||
// the character, so admitting the input event without cancelling the diff sends 好好.
|
||||
it('sends a commit that arrives before the deferred diff exactly once', async () => {
|
||||
const rig = openTerminal()
|
||||
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
|
||||
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
|
||||
dispatchCommit(rig, '好')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好')
|
||||
})
|
||||
|
||||
// Regression guard for xtermjs/xterm.js#3533: Sogou on Linux fires compositionend *and*
|
||||
// insertText for one commit, and the `!ev.composed` half of the guard exists to stop that
|
||||
// doubling. Claiming a sessionless commit must not reopen it.
|
||||
it('sends one commit when the IME fires both compositionend and insertText', async () => {
|
||||
const rig = openTerminal()
|
||||
dispatchComposition(rig, 'compositionstart', '')
|
||||
dispatchComposition(rig, 'compositionupdate', 'hao')
|
||||
await nextEventLoop()
|
||||
dispatchComposition(rig, 'compositionend', '好')
|
||||
dispatchCommit(rig, '好')
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('好')
|
||||
})
|
||||
|
||||
// An IME-consumed keydown that never commits must not leave the claim armed for the next key.
|
||||
// Upper-case letters take xterm's macOS caps-lock path, which forwards from keypress and then
|
||||
// lets the input event through, so a stale claim would send the letter twice.
|
||||
it('does not double an upper-case letter typed after a consumed keydown that never committed', async () => {
|
||||
const rig = openTerminal()
|
||||
await typeConsumedPinyin(rig, ['KeyH'])
|
||||
dispatchKey(rig, 'keydown', { key: 'A', code: 'KeyA', keyCode: 65 })
|
||||
dispatchKey(rig, 'keypress', { key: 'A', code: 'KeyA', keyCode: 65, charCode: 65 })
|
||||
dispatchCommit(rig, 'A')
|
||||
await nextEventLoop()
|
||||
dispatchKey(rig, 'keyup', { key: 'A', code: 'KeyA', keyCode: 65 })
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
expect(rig.emitted.join('')).toBe('A')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user