fix(tui): complete super key helper support closes #2979
This commit is contained in:
@@ -139,28 +139,17 @@ type SpecialKey =
|
||||
| "f12";
|
||||
|
||||
type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
|
||||
type ModifierName = "ctrl" | "shift" | "alt" | "super";
|
||||
|
||||
type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName = ModifierName> = {
|
||||
[M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
|
||||
}[RemainingModifiers];
|
||||
|
||||
/**
|
||||
* Union type of all valid key identifiers.
|
||||
* Provides autocomplete and catches typos at compile time.
|
||||
*/
|
||||
export type KeyId =
|
||||
| BaseKey
|
||||
| `ctrl+${BaseKey}`
|
||||
| `shift+${BaseKey}`
|
||||
| `alt+${BaseKey}`
|
||||
| `ctrl+shift+${BaseKey}`
|
||||
| `shift+ctrl+${BaseKey}`
|
||||
| `ctrl+alt+${BaseKey}`
|
||||
| `alt+ctrl+${BaseKey}`
|
||||
| `shift+alt+${BaseKey}`
|
||||
| `alt+shift+${BaseKey}`
|
||||
| `ctrl+shift+alt+${BaseKey}`
|
||||
| `ctrl+alt+shift+${BaseKey}`
|
||||
| `shift+ctrl+alt+${BaseKey}`
|
||||
| `shift+alt+ctrl+${BaseKey}`
|
||||
| `alt+ctrl+shift+${BaseKey}`
|
||||
| `alt+shift+ctrl+${BaseKey}`;
|
||||
export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
|
||||
|
||||
/**
|
||||
* Helper object for creating typed key identifiers with autocomplete.
|
||||
@@ -168,8 +157,8 @@ export type KeyId =
|
||||
* Usage:
|
||||
* - Key.escape, Key.enter, Key.tab, etc. for special keys
|
||||
* - Key.backtick, Key.comma, Key.period, etc. for symbol keys
|
||||
* - Key.ctrl("c"), Key.alt("x") for single modifier
|
||||
* - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers
|
||||
* - Key.ctrl("c"), Key.alt("x"), Key.super("k") for single modifiers
|
||||
* - Key.ctrlShift("p"), Key.ctrlAlt("x"), Key.ctrlSuper("k") for combined modifiers
|
||||
*/
|
||||
export const Key = {
|
||||
// Special keys
|
||||
@@ -241,6 +230,7 @@ export const Key = {
|
||||
ctrl: <K extends BaseKey>(key: K): `ctrl+${K}` => `ctrl+${key}`,
|
||||
shift: <K extends BaseKey>(key: K): `shift+${K}` => `shift+${key}`,
|
||||
alt: <K extends BaseKey>(key: K): `alt+${K}` => `alt+${key}`,
|
||||
super: <K extends BaseKey>(key: K): `super+${K}` => `super+${key}`,
|
||||
|
||||
// Combined modifiers
|
||||
ctrlShift: <K extends BaseKey>(key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`,
|
||||
@@ -249,9 +239,16 @@ export const Key = {
|
||||
altCtrl: <K extends BaseKey>(key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`,
|
||||
shiftAlt: <K extends BaseKey>(key: K): `shift+alt+${K}` => `shift+alt+${key}`,
|
||||
altShift: <K extends BaseKey>(key: K): `alt+shift+${K}` => `alt+shift+${key}`,
|
||||
ctrlSuper: <K extends BaseKey>(key: K): `ctrl+super+${K}` => `ctrl+super+${key}`,
|
||||
superCtrl: <K extends BaseKey>(key: K): `super+ctrl+${K}` => `super+ctrl+${key}`,
|
||||
shiftSuper: <K extends BaseKey>(key: K): `shift+super+${K}` => `shift+super+${key}`,
|
||||
superShift: <K extends BaseKey>(key: K): `super+shift+${K}` => `super+shift+${key}`,
|
||||
altSuper: <K extends BaseKey>(key: K): `alt+super+${K}` => `alt+super+${key}`,
|
||||
superAlt: <K extends BaseKey>(key: K): `super+alt+${K}` => `super+alt+${key}`,
|
||||
|
||||
// Triple modifiers
|
||||
ctrlShiftAlt: <K extends BaseKey>(key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`,
|
||||
ctrlShiftSuper: <K extends BaseKey>(key: K): `ctrl+shift+super+${K}` => `ctrl+shift+super+${key}`,
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
@@ -296,6 +293,7 @@ const MODIFIERS = {
|
||||
shift: 1,
|
||||
alt: 2,
|
||||
ctrl: 4,
|
||||
super: 8,
|
||||
} as const;
|
||||
|
||||
const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
|
||||
@@ -759,15 +757,18 @@ function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number,
|
||||
function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined {
|
||||
const mods: string[] = [];
|
||||
const effectiveMod = modifier & ~LOCK_MASK;
|
||||
const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt;
|
||||
const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt | MODIFIERS.super;
|
||||
if ((effectiveMod & ~supportedModifierMask) !== 0) return undefined;
|
||||
if (effectiveMod & MODIFIERS.shift) mods.push("shift");
|
||||
if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl");
|
||||
if (effectiveMod & MODIFIERS.alt) mods.push("alt");
|
||||
if (effectiveMod & MODIFIERS.super) mods.push("super");
|
||||
return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName;
|
||||
}
|
||||
|
||||
function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null {
|
||||
function parseKeyId(
|
||||
keyId: string,
|
||||
): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null {
|
||||
const parts = keyId.toLowerCase().split("+");
|
||||
const key = parts[parts.length - 1];
|
||||
if (!key) return null;
|
||||
@@ -776,6 +777,7 @@ function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean
|
||||
ctrl: parts.includes("ctrl"),
|
||||
shift: parts.includes("shift"),
|
||||
alt: parts.includes("alt"),
|
||||
super: parts.includes("super"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -788,9 +790,10 @@ function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean
|
||||
* - Ctrl combinations: "ctrl+c", "ctrl+z", etc.
|
||||
* - Shift combinations: "shift+tab", "shift+enter"
|
||||
* - Alt combinations: "alt+enter", "alt+backspace"
|
||||
* - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x"
|
||||
* - Super combinations: "super+k", "super+enter"
|
||||
* - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x", "ctrl+super+k"
|
||||
*
|
||||
* Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p")
|
||||
* Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p"), Key.super("k")
|
||||
*
|
||||
* @param data - Raw input data from terminal
|
||||
* @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
|
||||
@@ -799,11 +802,12 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
const parsed = parseKeyId(keyId);
|
||||
if (!parsed) return false;
|
||||
|
||||
const { key, ctrl, shift, alt } = parsed;
|
||||
const { key, ctrl, shift, alt, super: superModifier } = parsed;
|
||||
let modifier = 0;
|
||||
if (shift) modifier |= MODIFIERS.shift;
|
||||
if (alt) modifier |= MODIFIERS.alt;
|
||||
if (ctrl) modifier |= MODIFIERS.ctrl;
|
||||
if (superModifier) modifier |= MODIFIERS.super;
|
||||
|
||||
switch (key) {
|
||||
case "escape":
|
||||
@@ -817,10 +821,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
|
||||
case "space":
|
||||
if (!_kittyProtocolActive) {
|
||||
if (ctrl && !alt && !shift && data === "\x00") {
|
||||
if (modifier === MODIFIERS.ctrl && data === "\x00") {
|
||||
return true;
|
||||
}
|
||||
if (alt && !ctrl && !shift && data === "\x1b ") {
|
||||
if (modifier === MODIFIERS.alt && data === "\x1b ") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -837,7 +841,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
);
|
||||
|
||||
case "tab":
|
||||
if (shift && !ctrl && !alt) {
|
||||
if (modifier === MODIFIERS.shift) {
|
||||
return (
|
||||
data === "\x1b[Z" ||
|
||||
matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift) ||
|
||||
@@ -854,7 +858,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
|
||||
case "enter":
|
||||
case "return":
|
||||
if (shift && !ctrl && !alt) {
|
||||
if (modifier === MODIFIERS.shift) {
|
||||
// CSI u sequences (standard Kitty protocol)
|
||||
if (
|
||||
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
|
||||
@@ -874,7 +878,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (alt && !ctrl && !shift) {
|
||||
if (modifier === MODIFIERS.alt) {
|
||||
// CSI u sequences (standard Kitty protocol)
|
||||
if (
|
||||
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
|
||||
@@ -909,7 +913,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
);
|
||||
|
||||
case "backspace":
|
||||
if (alt && !ctrl && !shift) {
|
||||
if (modifier === MODIFIERS.alt) {
|
||||
if (data === "\x1b\x7f" || data === "\x1b\b") {
|
||||
return true;
|
||||
}
|
||||
@@ -918,7 +922,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.alt)
|
||||
);
|
||||
}
|
||||
if (ctrl && !alt && !shift) {
|
||||
if (modifier === MODIFIERS.ctrl) {
|
||||
// Legacy raw 0x08 is ambiguous: it can be Ctrl+Backspace on Windows
|
||||
// Terminal or plain Backspace on other terminals, while also
|
||||
// overlapping with Ctrl+H.
|
||||
@@ -1019,7 +1023,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier);
|
||||
|
||||
case "up":
|
||||
if (alt && !ctrl && !shift) {
|
||||
if (modifier === MODIFIERS.alt) {
|
||||
return data === "\x1bp" || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt);
|
||||
}
|
||||
if (modifier === 0) {
|
||||
@@ -1034,7 +1038,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier);
|
||||
|
||||
case "down":
|
||||
if (alt && !ctrl && !shift) {
|
||||
if (modifier === MODIFIERS.alt) {
|
||||
return data === "\x1bn" || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt);
|
||||
}
|
||||
if (modifier === 0) {
|
||||
@@ -1049,7 +1053,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier);
|
||||
|
||||
case "left":
|
||||
if (alt && !ctrl && !shift) {
|
||||
if (modifier === MODIFIERS.alt) {
|
||||
return (
|
||||
data === "\x1b[1;3D" ||
|
||||
(!_kittyProtocolActive && data === "\x1bB") ||
|
||||
@@ -1057,7 +1061,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt)
|
||||
);
|
||||
}
|
||||
if (ctrl && !alt && !shift) {
|
||||
if (modifier === MODIFIERS.ctrl) {
|
||||
return (
|
||||
data === "\x1b[1;5D" ||
|
||||
matchesLegacyModifierSequence(data, "left", MODIFIERS.ctrl) ||
|
||||
@@ -1076,7 +1080,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier);
|
||||
|
||||
case "right":
|
||||
if (alt && !ctrl && !shift) {
|
||||
if (modifier === MODIFIERS.alt) {
|
||||
return (
|
||||
data === "\x1b[1;3C" ||
|
||||
(!_kittyProtocolActive && data === "\x1bF") ||
|
||||
@@ -1084,7 +1088,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt)
|
||||
);
|
||||
}
|
||||
if (ctrl && !alt && !shift) {
|
||||
if (modifier === MODIFIERS.ctrl) {
|
||||
return (
|
||||
data === "\x1b[1;5C" ||
|
||||
matchesLegacyModifierSequence(data, "right", MODIFIERS.ctrl) ||
|
||||
@@ -1129,19 +1133,20 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
const isLetter = key >= "a" && key <= "z";
|
||||
const isDigit = isDigitKey(key);
|
||||
|
||||
if (ctrl && alt && !shift && !_kittyProtocolActive && rawCtrl) {
|
||||
if (modifier === MODIFIERS.ctrl + MODIFIERS.alt && !_kittyProtocolActive && rawCtrl) {
|
||||
// Legacy: ctrl+alt+key is ESC followed by the control character.
|
||||
// If that legacy form does not match, continue so CSI-u and
|
||||
// modifyOtherKeys sequences from tmux can still be recognized.
|
||||
if (data === `\x1b${rawCtrl}`) return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (alt && !ctrl && !shift && !_kittyProtocolActive && (isLetter || isDigit)) {
|
||||
if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit)) {
|
||||
// Legacy: alt+letter/digit is ESC followed by the key
|
||||
if (data === `\x1b${key}`) return true;
|
||||
}
|
||||
|
||||
if (ctrl && !shift && !alt) {
|
||||
if (modifier === MODIFIERS.ctrl) {
|
||||
// Legacy: ctrl+key sends the control character
|
||||
if (rawCtrl && data === rawCtrl) return true;
|
||||
return (
|
||||
@@ -1150,14 +1155,14 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
if (ctrl && shift && !alt) {
|
||||
if (modifier === MODIFIERS.shift + MODIFIERS.ctrl) {
|
||||
return (
|
||||
matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) ||
|
||||
matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl)
|
||||
);
|
||||
}
|
||||
|
||||
if (shift && !ctrl && !alt) {
|
||||
if (modifier === MODIFIERS.shift) {
|
||||
// Legacy: shift+letter produces uppercase
|
||||
if (isLetter && data === key.toUpperCase()) return true;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user