Files
NetMesh/components/terminal/runtime/terminalImeTextInput.ts

191 lines
7.5 KiB
TypeScript
Raw Permalink Normal View History

/**
* CJK IMEs (notably Sogou on macOS) often emit a keydown whose `event.key` is
* still the half-width ASCII punctuation, then commit the full-width glyph via
* an `input`/`insertText` event. xterm.js sends the keydown character and then
* drops the input event because `_keyDownSeen` is set so the PTY receives
* "," instead of "".
*
* Defer those keydowns to the following insertText. If no remap arrives before
* keyup/blur, the original ASCII key is flushed. Composition (keyCode 229 /
* isComposing) stays on xterm's CompositionHelper path.
*
* The deferral must never outlive the keystroke it was armed for: Windows IMEs
* report `Process` / keyCode 229 as the keyup of a key they consumed (or drop
* the keyup), so a deferral flushed only on an exact key match stayed armed and
* blocked typed input from then on (#3103). Any real key release, and any later
* unrelated keydown, now ends the deferral.
*/
export type ImeTextInputKeyEvent = {
type?: string;
key: string;
code?: string;
keyCode?: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
isComposing?: boolean;
};
export type ImeTextInputEvent = Pick<InputEvent, "data" | "inputType">;
/** Printable ASCII punctuation IMEs commonly remap to full-width forms. */
const ASCII_PUNCTUATION_RE = /^[\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]$/;
export function isAsciiPunctuationKey(key: string): boolean {
return ASCII_PUNCTUATION_RE.test(key);
}
export function shouldDeferKeyDownForImeTextInput(
event: ImeTextInputKeyEvent,
): boolean {
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
return isAsciiPunctuationKey(event.key);
}
/** Key releases that carry no keystroke of their own. */
const MODIFIER_ONLY_KEY_RE =
/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Hyper|Super|Fn|FnLock|Symbol|SymbolLock)$/;
export function isModifierOnlyKey(key: string): boolean {
return MODIFIER_ONLY_KEY_RE.test(key);
}
/**
* DOM keys that stand in for a key the IME consumed, mirroring the non-text
* DOM keys the Kitty encoder already refuses to send as text.
*/
const IME_SENTINEL_KEYS = new Set(["Dead", "Process", "Unidentified", "Compose"]);
export function isImeSentinelKeyUp(event: ImeTextInputKeyEvent): boolean {
if (event.type !== undefined && event.type !== "keyup") return false;
return event.keyCode === 229 || IME_SENTINEL_KEYS.has(event.key);
}
/**
* A deferred punctuation keystroke is over once any real key release arrives.
* The IME remap (insertText) is dispatched before keyup, so a release means the
* IME did not remap the key and the ASCII character must be flushed.
*
* The release key cannot be matched exactly: Windows IMEs report `Process` /
* keyCode 229 as the release of a key they consumed, and some drop the release
* entirely. Requiring an exact key match left the deferral armed, and the
* armed deferral then blocked input (#3103). Composing releases are excluded
* an active composition still owns the keystroke and resolves it via
* insertText.
*/
export function shouldFlushDeferredImeTextInputOnKeyUp(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keyup") return false;
if (event.isComposing === true) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
return !isModifierOnlyKey(event.key);
}
/**
* A deferral that outlived its own keystroke is stale the IME swallowed the
* release. Flush it when a new, unmodified, non-composing keydown for a
* different key arrives so the pending ASCII character still reaches the PTY.
* A same-key keydown is auto-repeat (or a second IME attempt) and keeps
* re-arming instead.
*/
export function shouldFlushStaleDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
if (isModifierOnlyKey(event.key)) return false;
return event.key !== deferredKey;
}
/**
* A modified keydown is a command (Ctrl+C, Alt+), not the continuation of the
* lost punctuation keystroke: flushing there would inject the ASCII character
* in front of the interrupt or shortcut. Drop the stale deferral instead the
* keystroke was already lost to the IME.
*/
export function shouldDiscardStaleDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
return Boolean(event.altKey || event.ctrlKey || event.metaKey);
}
/**
* How the keyup that ends a deferral relates to the deferred key.
* - `deferred`: an IME sentinel stood in for the deferred key, so the paired
* release must be encoded from the deferred physical key.
* - `own`: the release belongs to the deferred key itself (matched key or
* physical code), so it already pairs the press the flush emitted.
* - `unrelated`: another held key was released; it keeps its own identity and
* the deferred press needs a separate synthesized release.
*/
export type DeferredKeyupReleaseMode = "deferred" | "own" | "unrelated";
export function resolveDeferredKeyupRelease(
deferredKey: string | null | undefined,
deferredCode: string | null | undefined,
event: ImeTextInputKeyEvent,
): DeferredKeyupReleaseMode {
if (!deferredKey) return "own";
if (event.type !== undefined && event.type !== "keyup") return "own";
if (event.altKey || event.ctrlKey || event.metaKey) return "own";
if (isModifierOnlyKey(event.key)) return "own";
if (event.key === deferredKey) return "own";
if (event.code && deferredCode && event.code === deferredCode) return "own";
if (isImeSentinelKeyUp(event)) return "deferred";
return "unrelated";
}
export function shouldBlockKeyPressForImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
deferredKeyCode?: number | null,
): boolean {
if (!deferredKey || event.type !== "keypress") return false;
// Composition keypresses carry no committable character on this path.
if (event.isComposing === true || event.keyCode === 229) return true;
// Only the deferred keystroke itself is suppressed. Blocking every keypress
// while armed turned one stale deferral into a terminal that ignored all
// typed input (#3103).
return (
event.key === deferredKey ||
(deferredKeyCode != null && event.keyCode === deferredKeyCode)
);
}
export function shouldCommitDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputEvent,
): event is ImeTextInputEvent & { data: string } {
return (
Boolean(deferredKey) &&
event.inputType === "insertText" &&
typeof event.data === "string" &&
event.data.length > 0
);
}
/**
* True when insertText/flush kept the deferred ASCII key (no CJK remap).
* Those commits must not use Kitty composition encoding under report-all
* that emits unidentified CSI 0 u and drops press/release.
*/
export function isUnchangedDeferredImeTextInput(
deferredKey: string | null | undefined,
text: string,
): boolean {
return deferredKey != null && text === deferredKey;
}