feat(keys): migrated keyboard parsing logic from JavaScript to native Rust implementation

- Migrated keyboard parsing and matching logic from JavaScript to native Rust implementation for improved performance.
- Exported new native functions parseKey, parseKittySequence, matchesLegacySequence, and matchesKey from @oh-my-pi/pi-natives package.
- Exported KeyEventType enum and ParsedKittyResult type for Kitty keyboard protocol support.
- Removed Key helper object from public API of @oh-my-pi/pi-tui package; users should now pass key identifiers as strings (e.g., 'ctrl+c' instead of Key.ctrl('c')).
- Added comprehensive keyboard parsing support in native layer including Kitty protocol, legacy terminal sequences, function keys, and modifier combinations.
- Added phf dependency for efficient perfect hash map lookups of legacy terminal escape sequences.
This commit is contained in:
can1357
2026-02-01 12:35:57 +01:00
parent c3e7726b8f
commit ce80cb8b94
13 changed files with 2499 additions and 1012 deletions
Generated
+66 -8
View File
@@ -749,13 +749,23 @@ dependencies = [
"windows-link",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared",
"phf_shared 0.13.1",
"serde",
]
@@ -765,8 +775,18 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_shared",
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand",
]
[[package]]
@@ -776,7 +796,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
@@ -803,6 +845,7 @@ dependencies = [
"napi",
"napi-build",
"napi-derive",
"phf 0.11.3",
"rayon",
"syntect",
"unicode-segmentation",
@@ -879,6 +922,21 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rayon"
version = "1.11.0"
@@ -1025,7 +1083,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"phf_shared 0.13.1",
"precomputed-hash",
]
@@ -1035,8 +1093,8 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator",
"phf_shared",
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
]
@@ -1171,7 +1229,7 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
dependencies = [
"phf",
"phf 0.13.1",
"phf_codegen",
"string_cache",
"string_cache_codegen",
+1
View File
@@ -36,6 +36,7 @@ syntect = { version = "5.3", default-features = false, features = [
"regex-fancy",
] }
html-to-markdown-rs = { version = "2.24", default-features = false }
phf = { version = "0.11", features = ["macros"] }
[build-dependencies]
napi-build = "2"
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1,6 +1,16 @@
# Changelog
## [Unreleased]
### Added
- Exported keyboard parsing and matching functions: `parseKey`, `parseKittySequence`, `matchesLegacySequence`, and `matchesKey` for terminal input handling
- Exported `KeyEventType` enum and `ParsedKittyResult` type for Kitty keyboard protocol support
- Added `parseKey` function to parse terminal input and return normalized key identifiers (e.g., "ctrl+c", "shift+tab")
- Added `parseKittySequence` function to parse Kitty keyboard protocol sequences with codepoint, modifier, and event type information
- Added `matchesLegacySequence` function to match legacy escape sequences for specific keys
- Added `matchesKey` function to match input against key identifiers with support for modifiers and Kitty protocol
### Changed
- Improved native binary installation to use atomic rename operations and better fallback handling for Windows DLLs
+9 -3
View File
@@ -97,7 +97,15 @@ export {
// Keyboard sequence helpers
// =============================================================================
export { matchesKittySequence } from "./keys/index";
export {
type KeyEventType,
matchesKey,
matchesKittySequence,
matchesLegacySequence,
type ParsedKittyResult,
parseKey,
parseKittySequence,
} from "./keys/index";
// =============================================================================
// HTML to Markdown
@@ -107,5 +115,3 @@ export {
type HtmlToMarkdownOptions,
htmlToMarkdown,
} from "./html/index";
export type { RequestOptions } from "./request-options";
+55 -1
View File
@@ -2,9 +2,63 @@
* Keyboard sequence utilities powered by native bindings.
*/
import { native } from "../native";
import { type KeyEventType, native, type ParsedKittyResult } from "../native";
export type { KeyEventType, ParsedKittyResult };
/** Match Kitty protocol sequences for codepoint and modifier. */
export function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean {
return native.matchesKittySequence(data, expectedCodepoint, expectedModifier);
}
/**
* Parse a Kitty keyboard protocol sequence.
*
* @param data - Raw escape sequence from terminal
* @returns Parsed sequence with codepoint, modifier, and event type, or undefined if not a valid Kitty sequence
*/
export function parseKittySequence(data: string): ParsedKittyResult | undefined {
return native.parseKittySequence(data) ?? undefined;
}
/**
* Parse terminal input and return a normalized key identifier.
*
* Returns key names like "escape", "ctrl+c", "shift+tab", "alt+enter".
* Returns undefined if the input is not a recognized key sequence.
*
* @param data - Raw input data from terminal
* @param kittyProtocolActive - Whether Kitty keyboard protocol is active
*/
export function parseKey(data: string, kittyProtocolActive: boolean): string | undefined {
return native.parseKey(data, kittyProtocolActive) ?? undefined;
}
/**
* Check if input matches a legacy escape sequence for a specific key.
*
* @param data - Raw input data from terminal
* @param keyName - Key name to match (e.g., "up", "f1", "ctrl+up")
*/
export function matchesLegacySequence(data: string, keyName: string): boolean {
return native.matchesLegacySequence(data, keyName);
}
/**
* Match input data against a key identifier string.
*
* Supported key identifiers:
* - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space"
* - Arrow keys: "up", "down", "left", "right"
* - 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"
*
* @param data - Raw input data from terminal
* @param keyId - Key identifier (e.g., "ctrl+c", "escape")
* @param kittyProtocolActive - Whether Kitty keyboard protocol is active
*/
export function matchesKey(data: string, keyId: string, kittyProtocolActive: boolean): boolean {
return native.matchesKey(data, keyId, kittyProtocolActive);
}
+29
View File
@@ -13,6 +13,26 @@ import type { HighlightColors } from "./highlight/index";
import type { HtmlToMarkdownOptions } from "./html/types";
import type { ExtractSegmentsResult, SliceWithWidthResult } from "./text/index";
export type { RequestOptions } from "./request-options";
/**
* Event types from Kitty keyboard protocol (flag 2)
* 1 = key press, 2 = key repeat, 3 = key release
*/
export const enum KeyEventType {
Press = 1,
Repeat = 2,
Release = 3,
}
/** Parsed Kitty keyboard protocol sequence result. */
export interface ParsedKittyResult {
codepoint: number;
shiftedKey?: number;
baseLayoutKey?: number;
modifier: number;
eventType?: KeyEventType;
}
export interface NativePhotonImage {
getWidth(): number;
getHeight(): number;
@@ -66,6 +86,10 @@ export interface NativeBindings {
strictAfter: boolean,
): ExtractSegmentsResult;
matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean;
parseKey(data: string, kittyProtocolActive: boolean): string | null;
matchesLegacySequence(data: string, keyName: string): boolean;
parseKittySequence(data: string): ParsedKittyResult | null;
matchesKey(data: string, keyId: string, kittyProtocolActive: boolean): boolean;
}
const require = createRequire(import.meta.url);
@@ -141,6 +165,11 @@ function validateNative(bindings: NativeBindings, source: string): void {
checkFn("sliceWithWidth");
checkFn("extractSegments");
checkFn("matchesKittySequence");
checkFn("parseKey");
checkFn("matchesLegacySequence");
checkFn("parseKittySequence");
checkFn("matchesKey");
checkFn("visibleWidth");
if (missing.length) {
throw new Error(
+9
View File
@@ -1,6 +1,15 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed `Key` helper object from public API; use string literals like `"ctrl+c"` instead of `Key.ctrl("c")`
- Removed `KeyEventType` export from public API
### Changed
- Migrated key parsing and matching logic to native implementation for improved performance
- Simplified `isKeyRelease()` and `isKeyRepeat()` to use regex pattern matching instead of string inclusion checks
## [9.6.2] - 2026-02-01
### Changed
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
import { parseKey as nativeParseKey } from "@oh-my-pi/pi-natives";
import * as native from "../src/keys";
import * as js from "./_jskey";
const ITERATIONS = 2000;
// Test cases covering various input types
const samples = [
// Kitty protocol sequences
{ name: "kitty ctrl+a", data: "\x1b[97;5u", expected: "ctrl+a" },
{ name: "kitty shift+tab", data: "\x1b[9;2u", expected: "shift+tab" },
{ name: "kitty alt+enter", data: "\x1b[13;3u", expected: "alt+enter" },
{ name: "kitty ctrl+right", data: "\x1b[1;5C", expected: "ctrl+right" },
{ name: "kitty shift+delete", data: "\x1b[3;2~", expected: "shift+delete" },
{ name: "kitty base-layout", data: "\x1b[108::97;5u", expected: "ctrl+a" },
// Legacy sequences
{ name: "legacy escape", data: "\x1b", expected: "escape" },
{ name: "legacy tab", data: "\t", expected: "tab" },
{ name: "legacy enter", data: "\r", expected: "enter" },
{ name: "legacy space", data: " ", expected: "space" },
{ name: "legacy backspace", data: "\x7f", expected: "backspace" },
{ name: "legacy shift+tab", data: "\x1b[Z", expected: "shift+tab" },
{ name: "legacy up", data: "\x1b[A", expected: "up" },
{ name: "legacy down", data: "\x1b[B", expected: "down" },
{ name: "legacy left", data: "\x1b[D", expected: "left" },
{ name: "legacy right", data: "\x1b[C", expected: "right" },
{ name: "legacy home", data: "\x1b[H", expected: "home" },
{ name: "legacy end", data: "\x1b[F", expected: "end" },
{ name: "legacy delete", data: "\x1b[3~", expected: "delete" },
{ name: "legacy pageUp", data: "\x1b[5~", expected: "pageUp" },
{ name: "legacy pageDown", data: "\x1b[6~", expected: "pageDown" },
// Function keys
{ name: "legacy f1", data: "\x1bOP", expected: "f1" },
{ name: "legacy f5", data: "\x1b[15~", expected: "f5" },
{ name: "legacy f12", data: "\x1b[24~", expected: "f12" },
// Ctrl sequences
{ name: "ctrl+c", data: "\x03", expected: "ctrl+c" },
{ name: "ctrl+z", data: "\x1a", expected: "ctrl+z" },
{ name: "ctrl+space", data: "\x00", expected: "ctrl+space" },
// Alt sequences (legacy mode)
{ name: "alt+backspace", data: "\x1b\x7f", expected: "alt+backspace" },
{ name: "alt+left", data: "\x1bb", expected: "alt+left" },
{ name: "alt+right", data: "\x1bf", expected: "alt+right" },
// Arrow with modifiers (legacy)
{ name: "shift+up", data: "\x1b[a", expected: "shift+up" },
{ name: "ctrl+up", data: "\x1bOa", expected: "ctrl+up" },
// Printable characters
{ name: "letter a", data: "a", expected: "a" },
{ name: "letter z", data: "z", expected: "z" },
{ name: "symbol /", data: "/", expected: "/" },
];
function bench(name: string, fn: () => void): number {
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
fn();
}
const elapsed = performance.now() - start;
const perOp = (elapsed / ITERATIONS).toFixed(4);
console.log(`${name}: ${elapsed.toFixed(2)}ms total (${perOp}ms/op)`);
return elapsed;
}
// Set to legacy mode for consistent comparison
js.setKittyProtocolActive(true);
native.setKittyProtocolActive(true);
console.log(`parseKey benchmark (${ITERATIONS} iterations, ${samples.length} samples each)\n`);
// Verify correctness first
let mismatches = 0;
for (const sample of samples) {
const jsResult = js.parseKey(sample.data);
const nativeResult = nativeParseKey(sample.data, false);
if (jsResult !== nativeResult) {
console.log(`MISMATCH ${sample.name}: js="${jsResult}" native="${nativeResult}" expected="${sample.expected}"`);
mismatches++;
}
}
if (mismatches > 0) {
console.log(`\n${mismatches} mismatches found!\n`);
} else {
console.log("All results match.\n");
}
const jsTime = bench("js/parseKey", () => {
for (const sample of samples) {
js.parseKey(sample.data);
}
});
const nativeTime = bench("native/parseKey", () => {
for (const sample of samples) {
native.parseKey(sample.data);
}
});
console.log(`\nSpeedup: ${(jsTime / nativeTime).toFixed(2)}x`);
bench("js/parse+match", () => {
for (const sample of samples) {
js.matchesKey(sample.data, sample.expected as any);
}
});
bench("native/match", () => {
for (const sample of samples) {
native.matchesKey(sample.data, sample.expected as any);
}
});
-2
View File
@@ -39,8 +39,6 @@ export {
isKeyRelease,
isKeyRepeat,
isKittyProtocolActive,
Key,
type KeyEventType,
type KeyId,
matchesKey,
parseKey,
+45 -910
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "bun:test";
import { Key, matchesKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { matchesKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
describe("matchesKey", () => {
it("matches ctrl+letter sequences", () => {
setKittyProtocolActive(false);
const ctrlC = String.fromCharCode(3);
expect(matchesKey(ctrlC, Key.ctrl("c"))).toBe(true);
expect(matchesKey(ctrlC, "ctrl+c")).toBe(true);
});
it("matches shifted tab", () => {