mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
fix(terminal): forward the orphaned input event instead of replaying a guessed key
The previous shape guessed the character from `event.key` on keydown, re-emitted it, and then tried to suppress a late canonical copy with a 250 ms character-keyed dedupe. Review found three defects in that, all reproducible: the dedupe matched on the character alone with nothing scoping a candidate to the keydown that created it, so the same character typed twice inside the window had its second, real byte swallowed; anything whose committed text differed from `event.key` (Enter, IME punctuation) was delivered twice, because the dedupe could never match it; and the trigger ignored `key === 'Unidentified'`, which is what a soft keyboard reports, so it may never have fired where it was needed. The input event already carries the committed text in `ev.data` — exactly what xterm itself would have forwarded — so nothing has to be guessed. The controller now only decides WHETHER to forward, by asking whether xterm produced canonical data since the keydown that began the keystroke. No character-keyed matching survives, so the first two defects are structurally impossible rather than defended against, and nothing reads `key`/`keyCode`, so the third cannot recur. Three details are load-bearing and each has a test that fails without it: - The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event. `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a snapshot read at input time already contains that emission, reads it as silence, and delivers the character twice. - Our `input` listener is registered with `capture: true`. The target is visited twice in the event path, so a capture listener calling `stopPropagation()` stops later BUBBLE listeners on that same target; xterm's `cancel()` runs exactly in the branch where it handled the input, so on bubble we would never observe handled events, and whether we observed them at all would hang off `options.cancelEvents`. Measured in jsdom and headless chromium; the table is in the module header. - Enter is deliberately no longer special-cased. That mapping is what made the committed text differ from the re-emitted value in the first place. The scope is also narrower than the old name suggests, and the browser test now proves it rather than assuming it. For a keydown that reports keyCode 229 xterm ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()` diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it" there passes while xterm does all the work, so the browser tests assert WHO delivered the byte: zero canonical emissions for the genuinely orphaned case, exactly one delivery for the case xterm rescues itself. Also addresses review notes: the module gains an `@fileoverview` with `@dependency`/`@loadorder` and an entry in the load-order list and module inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its own. The keydown hook deliberately still runs for every key event rather than moving behind the 229 gate: gating it would reinstate exactly the blindness described above, and it is now a single counter assignment.
This commit is contained in:
@@ -303,6 +303,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the helper textarea and emit the committed Unicode text.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
try {
|
||||
// Deliberately runs for EVERY keydown, not just keyCode 229: the
|
||||
// controller snapshots a counter and reads nothing off the event, and
|
||||
// the devices this exists for report `key: 'Unidentified'` with no
|
||||
// reliable identity to gate on. Gating it would make recovery inert
|
||||
// exactly where it is needed. Cost is one assignment.
|
||||
this._keyCode229Recovery?.handleKeyEvent?.(ev);
|
||||
} catch {
|
||||
// The fallback must never interfere with xterm's canonical handler.
|
||||
@@ -1041,14 +1046,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// mobile connections. The overlay + localStorage persistence ensure input
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
const handleTerminalData = (data, { recovered = false } = {}) => {
|
||||
if (!recovered) {
|
||||
try {
|
||||
if (this._keyCode229Recovery?.consumeTerminalData?.(data)) return;
|
||||
} catch {
|
||||
// A broken dedupe guard must fail open to canonical xterm data.
|
||||
}
|
||||
}
|
||||
const handleTerminalData = (data) => {
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
@@ -1372,19 +1370,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
};
|
||||
|
||||
// Android/GBoard fires keydown with keyCode 229 and, on some paths, never
|
||||
// mutates xterm's helper textarea, so the character is silently dropped.
|
||||
// The controller re-emits exactly those keys, and only after xterm has had
|
||||
// its own chance to produce the canonical data.
|
||||
// Chrome on Android delivers a `composed: true` input event preceded by a
|
||||
// keydown, which is exactly the shape xterm's _inputEvent refuses to
|
||||
// forward, so the committed character is silently dropped. The controller
|
||||
// forwards the input event's own `data` when xterm produced nothing for
|
||||
// that keystroke. Created AFTER terminal.open() on purpose: for an event
|
||||
// targeting the textarea, at-target listeners run in registration order,
|
||||
// so xterm's listener (added in open()) still runs first. The controller
|
||||
// registers its own listener with `capture: true`; on bubble xterm's
|
||||
// `cancel()` (stopPropagation) would swallow exactly the handled events —
|
||||
// see the measured table in terminal-keycode229-recovery.js.
|
||||
try {
|
||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||
textarea: this.terminal.textarea,
|
||||
emitRecovered: (data) => handleTerminalData(data, { recovered: true }),
|
||||
emitRecovered: (data) => handleTerminalData(data),
|
||||
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
||||
});
|
||||
} catch {
|
||||
this._keyCode229Recovery = null;
|
||||
}
|
||||
this.terminal.onData((data) => handleTerminalData(data));
|
||||
this.terminal.onData((data) => {
|
||||
// Canonical xterm data. Telling the controller is what lets it know a
|
||||
// keystroke was already delivered and needs no recovery.
|
||||
try {
|
||||
this._keyCode229Recovery?.notifyCanonicalData?.();
|
||||
} catch {
|
||||
// Bookkeeping must never block real input.
|
||||
}
|
||||
handleTerminalData(data);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user