mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui): recognize kitty F1-F12 sequences and ignore F13+ keys (#11468)
With the kitty keyboard protocol active, these function keys were not
recognized:
- F1, F2 and F4 arrive as `CSI P`/`Q`/`S`, or as `CSI 1;<mod> P/Q/S`.
- Any F-key pressed with a modifier, e.g. `CSI 15;5~` for Ctrl+F5.
`matchesKey` returned false whenever a modifier was set and only
compared against the legacy sequences. `parseKittySequence` did not know
any F-key forms. So shortcuts bound to keys like `f1` or `ctrl+f5` never
fired.
Separately, F13–F35 (and other kitty functional keys) arrive as CSI-u
codepoints in the Private Use Area. `decodeKittyPrintable` returned them
as text, so the editor inserted invisible U+E020+ characters into the
prompt.
Changes in `packages/tui/src/keys.ts`:
- Add F1–F12 internal codepoints. Map `~` numbers 11–15, 17–21, 23 and
24 to them.
- Parse the `CSI [1;mod[:event]] P/Q/S` form. F3 is left out because
`CSI 1;<n>R` collides with cursor position reports, so kitty sends F3 as
`CSI 13~`.
- Let the `f1`…`f12` cases in `matchesKey` fall back to
`matchesKittySequence` so modifiers work. Name the keys in `parseKey`
output.
- Make `isKeyRelease` and `isKeyRepeat` recognize `:3`/`:2` events on
P/Q/S, so F1/F2/F4 releases don't trigger shortcuts a second time.
- `decodeKittyPrintable` now rejects the kitty functional-key PUA range
(57344–63743). Keypad keys are normalized to ASCII first, so they still
decode.
Testing:
- Added tests in `packages/tui/test/keys.test.ts`. They cover kitty
F1–F12 with and without modifiers, F3 not being confused with a cursor
position report, release and repeat detection, and F13+ not decoding as
text. Two of the new tests fail without the change; all pass with it.
- Ran `npm test` in `packages/tui` and `npm run check`; both pass.
- Before the change: `parseKey("\x1b[P")` and `parseKey("\x1b[1;5P")`
returned `undefined`, `matchesKey("\x1b[15;5~", "ctrl+f5")` returned
false, and `decodePrintableKey("\x1b[57376u")` returned `"\ue020"`.
- After the change: these give `f1`, `ctrl+f1`, true and `undefined`.
Fixes #11419
Fixes #11345
Requested by @badlogic
Co-authored-by: triagent <triagent@localhost>
This commit is contained in:
parent
f049787d9e
commit
deb12d78eb
2 changed files with 90 additions and 6 deletions
|
|
@ -325,8 +325,25 @@ const FUNCTIONAL_CODEPOINTS = {
|
|||
pageDown: -13,
|
||||
home: -14,
|
||||
end: -15,
|
||||
f1: -21,
|
||||
f2: -22,
|
||||
f3: -23,
|
||||
f4: -24,
|
||||
f5: -25,
|
||||
f6: -26,
|
||||
f7: -27,
|
||||
f8: -28,
|
||||
f9: -29,
|
||||
f10: -30,
|
||||
f11: -31,
|
||||
f12: -32,
|
||||
} as const;
|
||||
|
||||
// Kitty reserves this Private Use Area range for functional keys (F13-F35,
|
||||
// media keys, modifier keys, ...). CSI-u codepoints in it are never text.
|
||||
const KITTY_FUNCTIONAL_PUA_START = 57344;
|
||||
const KITTY_FUNCTIONAL_PUA_END = 63743;
|
||||
|
||||
const KITTY_FUNCTIONAL_KEY_EQUIVALENTS = new Map<number, number>([
|
||||
[57399, 48], // KP_0 -> 0
|
||||
[57400, 49], // KP_1 -> 1
|
||||
|
|
@ -558,7 +575,10 @@ export function isKeyRelease(data: string): boolean {
|
|||
data.includes(":3C") ||
|
||||
data.includes(":3D") ||
|
||||
data.includes(":3H") ||
|
||||
data.includes(":3F")
|
||||
data.includes(":3F") ||
|
||||
data.includes(":3P") ||
|
||||
data.includes(":3Q") ||
|
||||
data.includes(":3S")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -584,7 +604,10 @@ export function isKeyRepeat(data: string): boolean {
|
|||
data.includes(":2C") ||
|
||||
data.includes(":2D") ||
|
||||
data.includes(":2H") ||
|
||||
data.includes(":2F")
|
||||
data.includes(":2F") ||
|
||||
data.includes(":2P") ||
|
||||
data.includes(":2Q") ||
|
||||
data.includes(":2S")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -644,6 +667,18 @@ function parseKittySequence(data: string): ParsedKittySequence | null {
|
|||
6: FUNCTIONAL_CODEPOINTS.pageDown,
|
||||
7: FUNCTIONAL_CODEPOINTS.home,
|
||||
8: FUNCTIONAL_CODEPOINTS.end,
|
||||
11: FUNCTIONAL_CODEPOINTS.f1,
|
||||
12: FUNCTIONAL_CODEPOINTS.f2,
|
||||
13: FUNCTIONAL_CODEPOINTS.f3,
|
||||
14: FUNCTIONAL_CODEPOINTS.f4,
|
||||
15: FUNCTIONAL_CODEPOINTS.f5,
|
||||
17: FUNCTIONAL_CODEPOINTS.f6,
|
||||
18: FUNCTIONAL_CODEPOINTS.f7,
|
||||
19: FUNCTIONAL_CODEPOINTS.f8,
|
||||
20: FUNCTIONAL_CODEPOINTS.f9,
|
||||
21: FUNCTIONAL_CODEPOINTS.f10,
|
||||
23: FUNCTIONAL_CODEPOINTS.f11,
|
||||
24: FUNCTIONAL_CODEPOINTS.f12,
|
||||
};
|
||||
const codepoint = funcCodes[keyNum];
|
||||
if (codepoint !== undefined) {
|
||||
|
|
@ -662,6 +697,21 @@ function parseKittySequence(data: string): ParsedKittySequence | null {
|
|||
return { codepoint, modifier: modValue - 1, eventType };
|
||||
}
|
||||
|
||||
// F1, F2, F4: \x1b[P/Q/S or \x1b[1;<mod>P/Q/S or \x1b[1;<mod>:<event>P/Q/S
|
||||
// F3 is sent as \x1b[13~ because \x1b[1;<mod>R collides with cursor position reports.
|
||||
const fKeyMatch = data.match(/^\x1b\[(?:1;(\d+)(?::(\d+))?)?([PQS])$/);
|
||||
if (fKeyMatch) {
|
||||
const modValue = fKeyMatch[1] ? parseInt(fKeyMatch[1], 10) : 1;
|
||||
const eventType = parseEventType(fKeyMatch[2]);
|
||||
const fKeyCodes: Record<string, number> = {
|
||||
P: FUNCTIONAL_CODEPOINTS.f1,
|
||||
Q: FUNCTIONAL_CODEPOINTS.f2,
|
||||
S: FUNCTIONAL_CODEPOINTS.f4,
|
||||
};
|
||||
_lastEventType = eventType;
|
||||
return { codepoint: fKeyCodes[fKeyMatch[3]!]!, modifier: modValue - 1, eventType };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -1176,11 +1226,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|||
case "f10":
|
||||
case "f11":
|
||||
case "f12": {
|
||||
if (modifier !== 0) {
|
||||
return false;
|
||||
const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES & keyof typeof FUNCTIONAL_CODEPOINTS;
|
||||
if (modifier === 0 && matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey])) {
|
||||
return true;
|
||||
}
|
||||
const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES;
|
||||
return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]);
|
||||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS[functionKey], modifier);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1277,6 +1327,8 @@ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: nu
|
|||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown";
|
||||
else if (effectiveCodepoint <= FUNCTIONAL_CODEPOINTS.f1 && effectiveCodepoint >= FUNCTIONAL_CODEPOINTS.f12)
|
||||
keyName = `f${FUNCTIONAL_CODEPOINTS.f1 - effectiveCodepoint + 1}`;
|
||||
else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up";
|
||||
else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down";
|
||||
else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left";
|
||||
|
|
@ -1428,6 +1480,10 @@ export function decodeKittyPrintable(data: string): string | undefined {
|
|||
effectiveCodepoint = normalizeKittyFunctionalCodepoint(effectiveCodepoint);
|
||||
// Drop control characters or invalid codepoints.
|
||||
if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined;
|
||||
// Drop unmapped Kitty functional keys (F13-F35, media keys, ...).
|
||||
if (effectiveCodepoint >= KITTY_FUNCTIONAL_PUA_START && effectiveCodepoint <= KITTY_FUNCTIONAL_PUA_END) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return String.fromCodePoint(effectiveCodepoint);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { describe, it } from "node:test";
|
|||
import {
|
||||
decodeKittyPrintable,
|
||||
decodePrintableKey,
|
||||
isKeyRelease,
|
||||
isKeyRepeat,
|
||||
Key,
|
||||
matchesKey,
|
||||
parseKey,
|
||||
|
|
@ -509,6 +511,26 @@ describe("matchesKey", () => {
|
|||
assert.strictEqual(matchesKey("\x1b[E", "clear"), true);
|
||||
});
|
||||
|
||||
it("should match Kitty function keys F1-F12 with and without modifiers", () => {
|
||||
assert.strictEqual(matchesKey("\x1b[P", "f1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[Q", "f2"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[13~", "f3"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[S", "f4"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[1;5P", "ctrl+f1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[1;5P", "f1"), false);
|
||||
assert.strictEqual(matchesKey("\x1b[1;2S", "shift+f4"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[15;5~", "ctrl+f5"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[24;3~", "alt+f12"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[15;1:2~", "f5"), true);
|
||||
assert.strictEqual(parseKey("\x1b[P"), "f1");
|
||||
assert.strictEqual(parseKey("\x1b[1;5P"), "ctrl+f1");
|
||||
assert.strictEqual(parseKey("\x1b[13~"), "f3");
|
||||
assert.strictEqual(parseKey("\x1b[21;2~"), "shift+f10");
|
||||
assert.strictEqual(parseKey("\x1b[1;5R"), undefined);
|
||||
assert.strictEqual(isKeyRelease("\x1b[1;1:3P"), true);
|
||||
assert.strictEqual(isKeyRepeat("\x1b[1;1:2S"), true);
|
||||
});
|
||||
|
||||
it("should match alt+arrows", () => {
|
||||
assert.strictEqual(matchesKey("\x1bp", "alt+up"), true);
|
||||
assert.strictEqual(matchesKey("\x1bp", "up"), false);
|
||||
|
|
@ -525,6 +547,12 @@ describe("matchesKey", () => {
|
|||
});
|
||||
|
||||
describe("decodeKittyPrintable", () => {
|
||||
it("ignores unmapped Kitty functional keys such as F13-F35", () => {
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57376u"), undefined);
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57398;2u"), undefined);
|
||||
assert.strictEqual(decodePrintableKey("\x1b[57382u"), undefined);
|
||||
});
|
||||
|
||||
it("should decode Kitty keypad functional keys to printable characters", () => {
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57399u"), "0");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57400u"), "1");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue