feat(deps): upgraded napi to v3, added Kitty keyboard protocol support, and refactored text processing
- Added native Kitty keyboard protocol sequence matching via new `matchesKittySequence` function in pi-natives crate. - Removed `visibleWidth` function from text utilities in favor of consistent Bun.stringWidth approach. - Upgraded napi and napi-derive dependencies from version 2 to 3. - Relaxed grep-regex, grep-searcher, and grep-matcher dependency version constraints to allow any 0.1.x version. - Replaced unicode-segmentation dependency with bstr for improved byte-slice handling in text processing. - Refactored text module to use byte-slice abstractions instead of string references for better performance and flexibility.
This commit is contained in:
Generated
+156
-22
@@ -124,9 +124,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
@@ -167,14 +167,35 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.2.9"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
||||
checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
"ctor-proc-macro",
|
||||
"dtor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor-proc-macro"
|
||||
version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "dtor"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301"
|
||||
dependencies = [
|
||||
"dtor-proc-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtor-proc-macro"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -263,6 +284,95 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -435,9 +545,9 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
@@ -533,15 +643,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "2.16.17"
|
||||
version = "3.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
|
||||
checksum = "909805cbad4d569e69b80e101290fe72e92b9742ba9e333b0c1e83b22fb7447b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"napi-derive",
|
||||
"futures",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"once_cell",
|
||||
"nohash-hasher",
|
||||
"rustc-hash",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -553,12 +665,12 @@ checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "2.16.13"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
|
||||
checksum = "04ba21bbdf40b33496b4ee6eadfc64d17a6a6cde57cd31549117b0882d1fef86"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"convert_case",
|
||||
"ctor",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -567,24 +679,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "1.0.75"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
|
||||
checksum = "e9a63791e230572c3218a7acd86ca0a0529fc64294bcbea567cf906d7b04e077"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "2.4.0"
|
||||
version = "3.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
|
||||
checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
@@ -595,6 +705,12 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -676,6 +792,7 @@ dependencies = [
|
||||
name = "pi-natives"
|
||||
version = "9.6.0"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-regex",
|
||||
@@ -688,7 +805,6 @@ dependencies = [
|
||||
"napi-derive",
|
||||
"rayon",
|
||||
"syntect",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
@@ -698,6 +814,12 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.0"
|
||||
@@ -814,6 +936,12 @@ version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -876,6 +1004,12 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
|
||||
@@ -13,18 +13,27 @@ crate-type = ["cdylib"]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
napi = { version = "2", features = ["napi8", "tokio_rt"] }
|
||||
napi-derive = "2"
|
||||
grep-regex = "0.1.14"
|
||||
grep-searcher = "0.1.16"
|
||||
grep-matcher = "0.1.8"
|
||||
napi = { version = "3", features = ["napi8", "tokio_rt"] }
|
||||
napi-derive = "3"
|
||||
grep-regex = "0.1"
|
||||
grep-searcher = "0.1"
|
||||
grep-matcher = "0.1"
|
||||
globset = "0.4"
|
||||
ignore = "0.4"
|
||||
rayon = "1.10"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
|
||||
unicode-segmentation = "1.11"
|
||||
image = { version = "0.25", default-features = false, features = [
|
||||
"png",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
] }
|
||||
bstr = "1"
|
||||
unicode-width = "0.2"
|
||||
syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] }
|
||||
syntect = { version = "5.3", default-features = false, features = [
|
||||
"default-syntaxes",
|
||||
"default-themes",
|
||||
"regex-fancy",
|
||||
] }
|
||||
html-to-markdown-rs = { version = "2.24", default-features = false }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -19,7 +19,7 @@ use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use ignore::WalkBuilder;
|
||||
use napi::{
|
||||
bindgen_prelude::*,
|
||||
threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
tokio::task,
|
||||
};
|
||||
use napi_derive::napi;
|
||||
@@ -181,7 +181,7 @@ struct FindConfig {
|
||||
|
||||
fn run_find(
|
||||
config: FindConfig,
|
||||
on_match: Option<&ThreadsafeFunction<FindMatch, ErrorStrategy::Fatal>>,
|
||||
on_match: Option<&ThreadsafeFunction<FindMatch>>,
|
||||
cancelled: &AtomicBool,
|
||||
) -> Result<FindResult> {
|
||||
let FindConfig {
|
||||
@@ -255,7 +255,7 @@ fn run_find(
|
||||
|
||||
// Call streaming callback if provided
|
||||
if let Some(callback) = on_match {
|
||||
callback.call(found.clone(), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
callback.call(Ok(found.clone()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
|
||||
matches.push(found);
|
||||
@@ -291,7 +291,7 @@ fn run_find(
|
||||
pub async fn find(
|
||||
options: FindOptions,
|
||||
#[napi(ts_arg_type = "((match: FindMatch) => void) | undefined | null")] on_match: Option<
|
||||
ThreadsafeFunction<FindMatch, ErrorStrategy::Fatal>,
|
||||
ThreadsafeFunction<FindMatch>,
|
||||
>,
|
||||
) -> Result<FindResult> {
|
||||
let FindOptions { pattern, path, file_type, hidden, max_results, gitignore, sort_by_mtime } =
|
||||
|
||||
@@ -24,7 +24,7 @@ use ignore::WalkBuilder;
|
||||
use napi::{
|
||||
JsString,
|
||||
bindgen_prelude::*,
|
||||
threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
tokio::task,
|
||||
};
|
||||
use napi_derive::napi;
|
||||
@@ -751,7 +751,7 @@ fn search_sync(content: &[u8], options: SearchOptions) -> SearchResult {
|
||||
|
||||
fn grep_sync(
|
||||
options: GrepOptions,
|
||||
on_match: Option<&ThreadsafeFunction<GrepMatch, ErrorStrategy::Fatal>>,
|
||||
on_match: Option<&ThreadsafeFunction<GrepMatch>>,
|
||||
) -> Result<GrepResult> {
|
||||
let search_path = resolve_search_path(&options.path)?;
|
||||
let metadata = std::fs::metadata(&search_path)
|
||||
@@ -877,7 +877,7 @@ fn grep_sync(
|
||||
for matched in result.matches {
|
||||
let grep_match = to_grep_match(&result.relative_path, matched);
|
||||
if let Some(callback) = on_match {
|
||||
callback.call(grep_match.clone(), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
callback.call(Ok(grep_match.clone()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
matches.push(grep_match);
|
||||
}
|
||||
@@ -893,7 +893,7 @@ fn grep_sync(
|
||||
match_count: Some(clamp_u32(result.match_count)),
|
||||
};
|
||||
if let Some(callback) = on_match {
|
||||
callback.call(grep_match.clone(), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
callback.call(Ok(grep_match.clone()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
matches.push(grep_match);
|
||||
},
|
||||
@@ -923,7 +923,7 @@ fn grep_sync(
|
||||
// Fire callbacks for sequential search results
|
||||
if let Some(callback) = on_match {
|
||||
for grep_match in &matches {
|
||||
callback.call(grep_match.clone(), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
callback.call(Ok(grep_match.clone()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,7 +1000,7 @@ pub fn has_match(
|
||||
pub async fn grep(
|
||||
options: GrepOptions,
|
||||
#[napi(ts_arg_type = "((match: GrepMatch) => void) | undefined | null")] on_match: Option<
|
||||
ThreadsafeFunction<GrepMatch, ErrorStrategy::Fatal>,
|
||||
ThreadsafeFunction<GrepMatch>,
|
||||
>,
|
||||
) -> Result<GrepResult> {
|
||||
task::spawn_blocking(move || grep_sync(options, on_match.as_ref()))
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Kitty keyboard sequence matching utilities.
|
||||
//!
|
||||
//! # Overview
|
||||
//! Parses Kitty keyboard protocol sequences and matches codepoints plus
|
||||
//! modifiers.
|
||||
//!
|
||||
//! # Example
|
||||
//! ```ignore
|
||||
//! // JS: native.matchesKittySequence("\x1b[65;5u", 65, 4)
|
||||
//! ```
|
||||
|
||||
use napi_derive::napi;
|
||||
|
||||
const LOCK_MASK: u32 = 64 + 128;
|
||||
|
||||
const ARROW_UP: i32 = -1;
|
||||
const ARROW_DOWN: i32 = -2;
|
||||
const ARROW_RIGHT: i32 = -3;
|
||||
const ARROW_LEFT: i32 = -4;
|
||||
|
||||
const FUNC_DELETE: i32 = -10;
|
||||
const FUNC_INSERT: i32 = -11;
|
||||
const FUNC_PAGE_UP: i32 = -12;
|
||||
const FUNC_PAGE_DOWN: i32 = -13;
|
||||
const FUNC_HOME: i32 = -14;
|
||||
const FUNC_END: i32 = -15;
|
||||
|
||||
struct ParsedKittySequence {
|
||||
codepoint: i32,
|
||||
base_layout_key: Option<i32>,
|
||||
modifier: u32,
|
||||
}
|
||||
|
||||
/// Matches Kitty protocol keyboard sequences against a codepoint and modifier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `false` when the input is not a Kitty sequence or does not match.
|
||||
#[napi(js_name = "matchesKittySequence")]
|
||||
pub fn matches_kitty_sequence(
|
||||
data: String,
|
||||
expected_codepoint: i32,
|
||||
expected_modifier: u32,
|
||||
) -> bool {
|
||||
let Some(parsed) = parse_kitty_sequence(&data) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let actual_mod = parsed.modifier & !LOCK_MASK;
|
||||
let expected_mod = expected_modifier & !LOCK_MASK;
|
||||
if actual_mod != expected_mod {
|
||||
return false;
|
||||
}
|
||||
|
||||
if parsed.codepoint == expected_codepoint {
|
||||
return true;
|
||||
}
|
||||
|
||||
if parsed.base_layout_key == Some(expected_codepoint) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_kitty_sequence(data: &str) -> Option<ParsedKittySequence> {
|
||||
parse_csi_u(data)
|
||||
.or_else(|| parse_arrow_sequence(data))
|
||||
.or_else(|| parse_functional_sequence(data))
|
||||
.or_else(|| parse_home_end_sequence(data))
|
||||
}
|
||||
|
||||
fn parse_csi_u(data: &str) -> Option<ParsedKittySequence> {
|
||||
let bytes = data.as_bytes();
|
||||
if bytes.len() < 4 || !bytes.starts_with(b"\x1b[") || *bytes.last()? != b'u' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let end = bytes.len() - 1;
|
||||
let mut idx = 2;
|
||||
let (codepoint, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
let codepoint = to_i32(codepoint)?;
|
||||
idx = next_idx;
|
||||
|
||||
let mut base_layout_key = None;
|
||||
if idx < end && bytes[idx] == b':' {
|
||||
idx += 1;
|
||||
let (_, next_idx) = parse_optional_digits(bytes, idx, end);
|
||||
idx = next_idx;
|
||||
if idx < end && bytes[idx] == b':' {
|
||||
idx += 1;
|
||||
let (base_value, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
base_layout_key = Some(to_i32(base_value)?);
|
||||
idx = next_idx;
|
||||
}
|
||||
}
|
||||
|
||||
let mod_value = if idx < end && bytes[idx] == b';' {
|
||||
idx += 1;
|
||||
let (mod_value, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
mod_value
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
if idx < end && bytes[idx] == b':' {
|
||||
idx += 1;
|
||||
let (_, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
}
|
||||
|
||||
if idx != end || mod_value == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ParsedKittySequence { codepoint, base_layout_key, modifier: mod_value - 1 })
|
||||
}
|
||||
|
||||
fn parse_arrow_sequence(data: &str) -> Option<ParsedKittySequence> {
|
||||
let bytes = data.as_bytes();
|
||||
if !bytes.starts_with(b"\x1b[1;") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let end = bytes.len();
|
||||
let mut idx = 4;
|
||||
let (mod_value, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
|
||||
if idx < end && bytes[idx] == b':' {
|
||||
idx += 1;
|
||||
let (_, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
}
|
||||
|
||||
if idx + 1 != end || mod_value == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let codepoint = match bytes[idx] {
|
||||
b'A' => ARROW_UP,
|
||||
b'B' => ARROW_DOWN,
|
||||
b'C' => ARROW_RIGHT,
|
||||
b'D' => ARROW_LEFT,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 })
|
||||
}
|
||||
|
||||
fn parse_functional_sequence(data: &str) -> Option<ParsedKittySequence> {
|
||||
let bytes = data.as_bytes();
|
||||
if bytes.len() < 4 || !bytes.starts_with(b"\x1b[") || *bytes.last()? != b'~' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let end = bytes.len() - 1;
|
||||
let mut idx = 2;
|
||||
let (key_num, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
|
||||
let mod_value = if idx < end && bytes[idx] == b';' {
|
||||
idx += 1;
|
||||
let (mod_value, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
mod_value
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
if idx < end && bytes[idx] == b':' {
|
||||
idx += 1;
|
||||
let (_, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
}
|
||||
|
||||
if idx != end || mod_value == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let codepoint = match key_num {
|
||||
2 => FUNC_INSERT,
|
||||
3 => FUNC_DELETE,
|
||||
5 => FUNC_PAGE_UP,
|
||||
6 => FUNC_PAGE_DOWN,
|
||||
7 => FUNC_HOME,
|
||||
8 => FUNC_END,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 })
|
||||
}
|
||||
|
||||
fn parse_home_end_sequence(data: &str) -> Option<ParsedKittySequence> {
|
||||
let bytes = data.as_bytes();
|
||||
if !bytes.starts_with(b"\x1b[1;") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let end = bytes.len();
|
||||
let mut idx = 4;
|
||||
let (mod_value, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
|
||||
if idx < end && bytes[idx] == b':' {
|
||||
idx += 1;
|
||||
let (_, next_idx) = parse_digits(bytes, idx, end)?;
|
||||
idx = next_idx;
|
||||
}
|
||||
|
||||
if idx + 1 != end || mod_value == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let codepoint = match bytes[idx] {
|
||||
b'H' => FUNC_HOME,
|
||||
b'F' => FUNC_END,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 })
|
||||
}
|
||||
|
||||
fn parse_digits(bytes: &[u8], mut idx: usize, end: usize) -> Option<(u32, usize)> {
|
||||
if idx >= end || !bytes[idx].is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut value: u32 = 0;
|
||||
while idx < end && bytes[idx].is_ascii_digit() {
|
||||
value = value
|
||||
.checked_mul(10)?
|
||||
.checked_add(u32::from(bytes[idx] - b'0'))?;
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
Some((value, idx))
|
||||
}
|
||||
|
||||
fn parse_optional_digits(bytes: &[u8], idx: usize, end: usize) -> (Option<u32>, usize) {
|
||||
if idx >= end || !bytes[idx].is_ascii_digit() {
|
||||
return (None, idx);
|
||||
}
|
||||
|
||||
let Some((value, next_idx)) = parse_digits(bytes, idx, end) else {
|
||||
return (None, idx);
|
||||
};
|
||||
(Some(value), next_idx)
|
||||
}
|
||||
|
||||
fn to_i32(value: u32) -> Option<i32> {
|
||||
i32::try_from(value).ok()
|
||||
}
|
||||
@@ -25,4 +25,5 @@ pub mod grep;
|
||||
pub mod highlight;
|
||||
pub mod html;
|
||||
pub mod image;
|
||||
pub mod keys;
|
||||
pub mod text;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! ANSI-aware text measurement and slicing utilities.
|
||||
|
||||
use bstr::ByteSlice;
|
||||
use napi::{JsString, JsStringUtf8, bindgen_prelude::*};
|
||||
use napi_derive::napi;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const TAB_WIDTH: usize = 3;
|
||||
@@ -208,8 +208,8 @@ impl AnsiCodeTracker {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_ansi_code(text: &str, pos: usize) -> Option<usize> {
|
||||
let bytes = text.as_bytes();
|
||||
fn extract_ansi_code(text: impl AsRef<[u8]>, pos: usize) -> Option<usize> {
|
||||
let bytes = text.as_ref();
|
||||
if pos >= bytes.len() || bytes[pos] != 0x1b {
|
||||
return None;
|
||||
}
|
||||
@@ -245,10 +245,10 @@ fn extract_ansi_code(text: &str, pos: usize) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
fn next_ansi_start(text: &str, mut pos: usize) -> Option<usize> {
|
||||
let bytes = text.as_bytes();
|
||||
fn next_ansi_start(text: impl AsRef<[u8]>, mut pos: usize) -> Option<usize> {
|
||||
let bytes = text.as_ref();
|
||||
while pos < bytes.len() {
|
||||
if bytes[pos] == 0x1b && extract_ansi_code(text, pos).is_some() {
|
||||
if bytes[pos] == 0x1b && extract_ansi_code(bytes, pos).is_some() {
|
||||
return Some(pos);
|
||||
}
|
||||
pos += 1;
|
||||
@@ -267,13 +267,13 @@ fn clamp_u32(value: usize) -> u32 {
|
||||
value.min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
enum TextInput {
|
||||
Utf8(JsStringUtf8),
|
||||
Bytes(Uint8Array),
|
||||
enum TextInput<'a> {
|
||||
Utf8(JsStringUtf8<'a>),
|
||||
Bytes(&'a Uint8Array),
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
fn new(value: Either<JsString, Uint8Array>) -> Result<Self> {
|
||||
impl<'a> TextInput<'a> {
|
||||
fn new(value: &'a Either<JsString, Uint8Array>) -> Result<Self> {
|
||||
match value {
|
||||
Either::A(value) => Ok(Self::Utf8(value.into_utf8()?)),
|
||||
Either::B(value) => Ok(Self::Bytes(value)),
|
||||
@@ -289,16 +289,21 @@ impl TextInput {
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_width_impl(text: &str) -> usize {
|
||||
impl AsRef<[u8]> for TextInput<'_> {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
match self {
|
||||
Self::Utf8(text) => text.as_slice(),
|
||||
Self::Bytes(bytes) => bytes.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_width_impl(text: impl AsRef<[u8]>) -> usize {
|
||||
let text = text.as_ref();
|
||||
if text.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let is_pure_ascii = text.bytes().all(|byte| (0x20..=0x7e).contains(&byte));
|
||||
if is_pure_ascii {
|
||||
return text.len();
|
||||
}
|
||||
|
||||
// Single-pass: skip ANSI codes, measure graphemes
|
||||
let mut i = 0;
|
||||
let mut width = 0;
|
||||
@@ -310,7 +315,7 @@ fn visible_width_impl(text: &str) -> usize {
|
||||
|
||||
// Find next ANSI code or end of string
|
||||
let next_ansi = next_ansi_start(text, i + 1).unwrap_or(text.len());
|
||||
for grapheme in text[i..next_ansi].graphemes(true) {
|
||||
for (_, _, grapheme) in text[i..next_ansi].grapheme_indices() {
|
||||
width += grapheme_width(grapheme);
|
||||
}
|
||||
i = next_ansi;
|
||||
@@ -319,13 +324,6 @@ fn visible_width_impl(text: &str) -> usize {
|
||||
width
|
||||
}
|
||||
|
||||
/// Compute the visible width of a string, ignoring ANSI codes.
|
||||
#[napi(js_name = "visibleWidth")]
|
||||
pub fn visible_width(text: Either<JsString, Uint8Array>) -> Result<u32> {
|
||||
let text = TextInput::new(text)?;
|
||||
Ok(clamp_u32(visible_width_impl(text.as_str()?)))
|
||||
}
|
||||
|
||||
/// Truncate text to a visible width, preserving ANSI codes.
|
||||
#[napi(js_name = "truncateToWidth")]
|
||||
pub fn truncate_to_width(
|
||||
@@ -334,9 +332,9 @@ pub fn truncate_to_width(
|
||||
ellipsis: Either<JsString, Uint8Array>,
|
||||
pad: bool,
|
||||
) -> Result<String> {
|
||||
let text = TextInput::new(text)?;
|
||||
let text = TextInput::new(&text)?;
|
||||
let text = text.as_str()?;
|
||||
let ellipsis = TextInput::new(ellipsis)?;
|
||||
let ellipsis = TextInput::new(&ellipsis)?;
|
||||
let ellipsis = ellipsis.as_str()?;
|
||||
let max_width = max_width as usize;
|
||||
let text_visible_width = visible_width_impl(text);
|
||||
@@ -350,7 +348,12 @@ pub fn truncate_to_width(
|
||||
let ellipsis_width = visible_width_impl(ellipsis);
|
||||
let target_width = max_width.saturating_sub(ellipsis_width);
|
||||
if target_width == 0 {
|
||||
return Ok(ellipsis.graphemes(true).take(max_width).collect());
|
||||
return Ok(ellipsis
|
||||
.as_bytes()
|
||||
.grapheme_indices()
|
||||
.take(max_width)
|
||||
.map(|(_, _, g)| g)
|
||||
.collect());
|
||||
}
|
||||
|
||||
// Streaming: walk string once, copy ANSI codes through, copy graphemes until
|
||||
@@ -369,7 +372,7 @@ pub fn truncate_to_width(
|
||||
|
||||
// Copy graphemes until we hit target width or next ANSI code
|
||||
let next_ansi = next_ansi_start(text, i + 1).unwrap_or(text.len());
|
||||
for grapheme in text[i..next_ansi].graphemes(true) {
|
||||
for (_, _, grapheme) in text.as_bytes()[i..next_ansi].grapheme_indices() {
|
||||
let w = grapheme_width(grapheme);
|
||||
if width + w > target_width {
|
||||
// Hit limit, stop copying
|
||||
@@ -398,11 +401,13 @@ pub fn truncate_to_width(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn slice_with_width_impl(line: &str, start_col: usize, length: usize, strict: bool) -> SliceResult {
|
||||
if length == 0 {
|
||||
return SliceResult { text: String::new(), width: 0 };
|
||||
}
|
||||
|
||||
fn slice_with_width_impl(
|
||||
line: impl AsRef<[u8]>,
|
||||
start_col: usize,
|
||||
length: usize,
|
||||
strict: bool,
|
||||
) -> SliceResult {
|
||||
let line = line.as_ref();
|
||||
let end_col = start_col + length;
|
||||
let mut result = String::new();
|
||||
let mut result_width = 0;
|
||||
@@ -413,6 +418,9 @@ fn slice_with_width_impl(line: &str, start_col: usize, length: usize, strict: bo
|
||||
while i < line.len() {
|
||||
if let Some(len) = extract_ansi_code(line, i) {
|
||||
let code = &line[i..i + len];
|
||||
// SAFETY: we know the code is valid UTF-8
|
||||
let code = unsafe { std::str::from_utf8_unchecked(code) };
|
||||
|
||||
if current_col >= start_col && current_col < end_col {
|
||||
result.push_str(code);
|
||||
} else if current_col < start_col {
|
||||
@@ -424,7 +432,7 @@ fn slice_with_width_impl(line: &str, start_col: usize, length: usize, strict: bo
|
||||
|
||||
let next_ansi = next_ansi_start(line, i);
|
||||
let end = next_ansi.unwrap_or(line.len());
|
||||
for grapheme in line[i..end].graphemes(true) {
|
||||
for (_, _, grapheme) in line[i..end].grapheme_indices() {
|
||||
let width = grapheme_width(grapheme);
|
||||
let in_range = current_col >= start_col && current_col < end_col;
|
||||
let fits = !strict || current_col + width <= end_col;
|
||||
@@ -460,12 +468,12 @@ pub fn slice_with_width(
|
||||
length: u32,
|
||||
strict: bool,
|
||||
) -> Result<SliceResult> {
|
||||
let line = TextInput::new(line)?;
|
||||
Ok(slice_with_width_impl(line.as_str()?, start_col as usize, length as usize, strict))
|
||||
let line = TextInput::new(&line)?;
|
||||
Ok(slice_with_width_impl(line, start_col as usize, length as usize, strict))
|
||||
}
|
||||
|
||||
fn extract_segments_impl(
|
||||
line: &str,
|
||||
line: impl AsRef<[u8]>,
|
||||
before_end: usize,
|
||||
after_start: usize,
|
||||
after_len: usize,
|
||||
@@ -483,10 +491,12 @@ fn extract_segments_impl(
|
||||
|
||||
let mut tracker = AnsiCodeTracker::new();
|
||||
tracker.clear();
|
||||
|
||||
let line = line.as_ref();
|
||||
while i < line.len() {
|
||||
if let Some(len) = extract_ansi_code(line, i) {
|
||||
let code = &line[i..i + len];
|
||||
// SAFETY: we know the code is valid UTF-8
|
||||
let code = unsafe { std::str::from_utf8_unchecked(code) };
|
||||
tracker.process(code);
|
||||
if current_col < before_end {
|
||||
pending_ansi_before.push_str(code);
|
||||
@@ -499,7 +509,7 @@ fn extract_segments_impl(
|
||||
|
||||
let next_ansi = next_ansi_start(line, i);
|
||||
let end = next_ansi.unwrap_or(line.len());
|
||||
for grapheme in line[i..end].graphemes(true) {
|
||||
for (_, _, grapheme) in line[i..end].grapheme_indices() {
|
||||
let width = grapheme_width(grapheme);
|
||||
|
||||
if current_col < before_end {
|
||||
@@ -559,9 +569,9 @@ pub fn extract_segments(
|
||||
after_len: u32,
|
||||
strict_after: bool,
|
||||
) -> Result<ExtractSegmentsResult> {
|
||||
let line = TextInput::new(line)?;
|
||||
let line = TextInput::new(&line)?;
|
||||
Ok(extract_segments_impl(
|
||||
line.as_str()?,
|
||||
line,
|
||||
before_end as usize,
|
||||
after_start as usize,
|
||||
after_len as usize,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added `matchesKittySequence` function to match Kitty protocol sequences for codepoint and modifier
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `visibleWidth` function from text utilities
|
||||
|
||||
## [9.6.0] - 2026-02-01
|
||||
### Added
|
||||
|
||||
@@ -75,7 +75,6 @@ export {
|
||||
type SliceWithWidthResult,
|
||||
sliceWithWidth,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "./text/index";
|
||||
|
||||
// =============================================================================
|
||||
@@ -89,6 +88,12 @@ export {
|
||||
supportsLanguage,
|
||||
} from "./highlight/index";
|
||||
|
||||
// =============================================================================
|
||||
// Keyboard sequence helpers
|
||||
// =============================================================================
|
||||
|
||||
export { matchesKittySequence } from "./keys/index";
|
||||
|
||||
// =============================================================================
|
||||
// HTML to Markdown
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Keyboard sequence utilities powered by native bindings.
|
||||
*/
|
||||
|
||||
import { native } from "../native";
|
||||
|
||||
/** Match Kitty protocol sequences for codepoint and modifier. */
|
||||
export function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean {
|
||||
return native.matchesKittySequence(data, expectedCodepoint, expectedModifier);
|
||||
}
|
||||
@@ -55,7 +55,6 @@ export interface NativeBindings {
|
||||
getSupportedLanguages(): string[];
|
||||
SamplingFilter: NativeSamplingFilter;
|
||||
PhotonImage: NativePhotonImageConstructor;
|
||||
visibleWidth(text: TextInput): number;
|
||||
truncateToWidth(text: TextInput, maxWidth: number, ellipsis: TextInput, pad: boolean): string;
|
||||
sliceWithWidth(line: TextInput, startCol: number, length: number, strict: boolean): SliceWithWidthResult;
|
||||
extractSegments(
|
||||
@@ -65,6 +64,7 @@ export interface NativeBindings {
|
||||
afterLen: number,
|
||||
strictAfter: boolean,
|
||||
): ExtractSegmentsResult;
|
||||
matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -133,10 +133,10 @@ function validateNative(bindings: NativeBindings, source: string): void {
|
||||
checkFn("highlightCode");
|
||||
checkFn("supportsLanguage");
|
||||
checkFn("getSupportedLanguages");
|
||||
checkFn("visibleWidth");
|
||||
checkFn("truncateToWidth");
|
||||
checkFn("sliceWithWidth");
|
||||
checkFn("extractSegments");
|
||||
checkFn("matchesKittySequence");
|
||||
|
||||
if (!bindings.PhotonImage?.newFromByteslice) {
|
||||
missing.push("PhotonImage.newFromByteslice");
|
||||
|
||||
@@ -18,11 +18,6 @@ export interface ExtractSegmentsResult {
|
||||
|
||||
export type TextInput = string | Uint8Array;
|
||||
|
||||
/** Compute the visible width of a string, ignoring ANSI codes. */
|
||||
export function visibleWidth(text: TextInput): number {
|
||||
return native.visibleWidth(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a visible width, preserving ANSI codes.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- Improved performance of key ID parsing with optimized cache lookup strategy
|
||||
- Simplified `visibleWidth` calculation to use consistent Bun.stringWidth approach for all string lengths
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `visibleWidth` benchmark file in favor of Kitty sequence benchmarking
|
||||
|
||||
## [9.5.0] - 2026-02-01
|
||||
### Changed
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { matchesKittySequence as nativeMatchesKittySequence } from "@oh-my-pi/pi-natives";
|
||||
import { parseKittySequence } from "../src/keys";
|
||||
|
||||
const ITERATIONS = 2000;
|
||||
const LOCK_MASK = 64 + 128;
|
||||
|
||||
const samples = [
|
||||
{ name: "ctrl+a", data: "\x1b[97;5u", codepoint: 97, modifier: 4 },
|
||||
{ name: "shift+tab", data: "\x1b[9;2u", codepoint: 9, modifier: 1 },
|
||||
{ name: "alt+enter", data: "\x1b[13;3u", codepoint: 13, modifier: 2 },
|
||||
{ name: "ctrl+right", data: "\x1b[1;5C", codepoint: -3, modifier: 4 },
|
||||
{ name: "shift+delete", data: "\x1b[3;2~", codepoint: -10, modifier: 1 },
|
||||
{ name: "base-layout", data: "\x1b[108::97;5u", codepoint: 97, modifier: 4 },
|
||||
];
|
||||
|
||||
function matchesKittySequenceJs(data: string, expectedCodepoint: number, expectedModifier: number): boolean {
|
||||
const parsed = parseKittySequence(data);
|
||||
if (!parsed) return false;
|
||||
const actualMod = parsed.modifier & ~LOCK_MASK;
|
||||
const expectedMod = expectedModifier & ~LOCK_MASK;
|
||||
if (actualMod !== expectedMod) return false;
|
||||
if (parsed.codepoint === expectedCodepoint) return true;
|
||||
if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(`Kitty sequence match benchmark (${ITERATIONS} iterations)\n`);
|
||||
|
||||
bench("js/parse+match", () => {
|
||||
for (const sample of samples) {
|
||||
matchesKittySequenceJs(sample.data, sample.codepoint, sample.modifier);
|
||||
}
|
||||
});
|
||||
|
||||
bench("native/match", () => {
|
||||
for (const sample of samples) {
|
||||
nativeMatchesKittySequence(sample.data, sample.codepoint, sample.modifier);
|
||||
}
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Benchmark: native visibleWidth vs Bun.stringWidth vs hybrid implementation
|
||||
*
|
||||
* Run: bun packages/tui/bench/visible-width.ts
|
||||
*/
|
||||
import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives";
|
||||
import { visibleWidth as hybridVisibleWidth } from "../src/utils";
|
||||
|
||||
const ITERATIONS = 10_000;
|
||||
const WARMUP = 500;
|
||||
|
||||
// Test cases covering different scenarios
|
||||
const samples = {
|
||||
// Pure ASCII - different lengths
|
||||
ascii_short: "hello",
|
||||
ascii_medium: "hello world this is a plain ASCII string with some words",
|
||||
ascii_long: "a".repeat(500),
|
||||
|
||||
// ANSI escape codes
|
||||
ansi_simple: "\x1b[31mred\x1b[0m",
|
||||
ansi_complex: "\x1b[31mred text\x1b[0m and \x1b[4munderlined content\x1b[24m with more \x1b[1;33;44mstyles\x1b[0m",
|
||||
ansi_nested: "\x1b[1m\x1b[31m\x1b[4mbold red underline\x1b[0m normal \x1b[32mgreen\x1b[0m",
|
||||
|
||||
// OSC 8 hyperlinks
|
||||
links: "prefix \x1b]8;;https://example.com\x07link text\x1b]8;;\x07 suffix",
|
||||
links_multiple:
|
||||
"Click \x1b]8;;https://a.com\x07here\x1b]8;;\x07 or \x1b]8;;https://b.com\x07there\x1b]8;;\x07 for info",
|
||||
|
||||
// Wide characters (CJK)
|
||||
cjk_short: "日本語",
|
||||
cjk_medium: "日本語のテキストとemoji",
|
||||
cjk_long: "日本語のテキストと中文字符和한국어문자混合在一起形成很长的字符串",
|
||||
|
||||
// Emoji
|
||||
emoji_simple: "👋🌍",
|
||||
emoji_complex: "Hello 👨👩👧👦 family! 🚀✨🎉 Let's go! 🇺🇸🏳️🌈",
|
||||
emoji_zwj: "👨💻👩🔬👨👩👧👦", // ZWJ sequences
|
||||
|
||||
// Mixed content
|
||||
mixed_short: "Hello 世界 🌍",
|
||||
mixed_medium: "\x1b[32mStatus:\x1b[0m 成功 ✓ (took 42ms)",
|
||||
mixed_long:
|
||||
"\x1b[1;34m[INFO]\x1b[0m Processing 日本語テキスト with emoji 🚀 and \x1b]8;;https://example.com\x07links\x1b]8;;\x07 完了",
|
||||
|
||||
// Edge cases
|
||||
tabs: "col1\tcol2\tcol3\tcol4",
|
||||
empty: "",
|
||||
newlines: "line1\nline2\nline3",
|
||||
control_chars: "text\x00with\x01control\x02chars",
|
||||
};
|
||||
|
||||
// Bun.stringWidth with ANSI stripping (what hybrid uses for short strings)
|
||||
function bunStringWidth(str: string): number {
|
||||
if (str.length === 0) return 0;
|
||||
|
||||
let clean = str;
|
||||
if (str.includes("\t")) {
|
||||
clean = clean.replace(/\t/g, " ");
|
||||
}
|
||||
if (clean.includes("\x1b")) {
|
||||
clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
|
||||
clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
|
||||
}
|
||||
return Bun.stringWidth(clean);
|
||||
}
|
||||
|
||||
interface BenchResult {
|
||||
name: string;
|
||||
totalMs: number;
|
||||
perOpUs: number;
|
||||
}
|
||||
|
||||
function bench(name: string, fn: () => void): BenchResult {
|
||||
// Warmup
|
||||
for (let i = 0; i < WARMUP; i++) fn();
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
fn();
|
||||
}
|
||||
const totalMs = performance.now() - start;
|
||||
const perOpUs = (totalMs / ITERATIONS) * 1000;
|
||||
|
||||
return { name, totalMs, perOpUs };
|
||||
}
|
||||
|
||||
function formatResult(r: BenchResult, baseline?: BenchResult): string {
|
||||
const perOp = r.perOpUs.toFixed(3);
|
||||
if (baseline && baseline !== r) {
|
||||
const ratio = r.perOpUs / baseline.perOpUs;
|
||||
const indicator = ratio < 1 ? "faster" : "slower";
|
||||
return `${r.name.padEnd(20)} ${r.totalMs.toFixed(2).padStart(8)}ms ${perOp.padStart(8)}µs/op ${ratio.toFixed(2)}x ${indicator}`;
|
||||
}
|
||||
return `${r.name.padEnd(20)} ${r.totalMs.toFixed(2).padStart(8)}ms ${perOp.padStart(8)}µs/op (baseline)`;
|
||||
}
|
||||
|
||||
console.log(`\n${"=".repeat(80)}`);
|
||||
console.log(`visibleWidth benchmark: ${ITERATIONS.toLocaleString()} iterations, ${WARMUP} warmup`);
|
||||
console.log(`${"=".repeat(80)}\n`);
|
||||
|
||||
for (const [sampleName, sample] of Object.entries(samples)) {
|
||||
console.log(`\n--- ${sampleName} (len=${sample.length}) ---`);
|
||||
if (sample.length > 0 && sample.length < 80) {
|
||||
// Show sample for short strings (escape non-printable)
|
||||
const display = sample.replace(/\x1b/g, "\\e").replace(/\x07/g, "\\a");
|
||||
console.log(` "${display}"`);
|
||||
}
|
||||
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
results.push(
|
||||
bench("native", () => {
|
||||
nativeVisibleWidth(sample);
|
||||
}),
|
||||
);
|
||||
|
||||
results.push(
|
||||
bench("bun+strip", () => {
|
||||
bunStringWidth(sample);
|
||||
}),
|
||||
);
|
||||
|
||||
results.push(
|
||||
bench("hybrid", () => {
|
||||
hybridVisibleWidth(sample);
|
||||
}),
|
||||
);
|
||||
|
||||
// Find fastest as baseline
|
||||
const baseline = results.reduce((a, b) => (a.perOpUs < b.perOpUs ? a : b));
|
||||
|
||||
console.log();
|
||||
for (const r of results) {
|
||||
console.log(` ${formatResult(r, baseline)}`);
|
||||
}
|
||||
|
||||
// Verify correctness
|
||||
const nativeResult = nativeVisibleWidth(sample);
|
||||
const bunResult = bunStringWidth(sample);
|
||||
const hybridResult = hybridVisibleWidth(sample);
|
||||
|
||||
if (nativeResult !== hybridResult) {
|
||||
console.log(` ⚠️ MISMATCH: native=${nativeResult}, hybrid=${hybridResult}`);
|
||||
}
|
||||
if (bunResult !== hybridResult && !sample.includes("\x00")) {
|
||||
// Control chars can differ
|
||||
console.log(` ⚠️ MISMATCH: bun=${bunResult}, hybrid=${hybridResult}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${"=".repeat(80)}`);
|
||||
console.log("Summary");
|
||||
console.log(`${"=".repeat(80)}\n`);
|
||||
|
||||
// Aggregate by category
|
||||
const categories = {
|
||||
ascii: ["ascii_short", "ascii_medium", "ascii_long"],
|
||||
ansi: ["ansi_simple", "ansi_complex", "ansi_nested"],
|
||||
links: ["links", "links_multiple"],
|
||||
cjk: ["cjk_short", "cjk_medium", "cjk_long"],
|
||||
emoji: ["emoji_simple", "emoji_complex", "emoji_zwj"],
|
||||
mixed: ["mixed_short", "mixed_medium", "mixed_long"],
|
||||
};
|
||||
|
||||
for (const [category, sampleNames] of Object.entries(categories)) {
|
||||
const categoryResults = { native: 0, bun: 0, hybrid: 0 };
|
||||
|
||||
for (const name of sampleNames) {
|
||||
const sample = samples[name as keyof typeof samples];
|
||||
|
||||
const nativeTime = bench("", () => nativeVisibleWidth(sample)).perOpUs;
|
||||
const bunTime = bench("", () => bunStringWidth(sample)).perOpUs;
|
||||
const hybridTime = bench("", () => hybridVisibleWidth(sample)).perOpUs;
|
||||
|
||||
categoryResults.native += nativeTime;
|
||||
categoryResults.bun += bunTime;
|
||||
categoryResults.hybrid += hybridTime;
|
||||
}
|
||||
|
||||
const fastest = Math.min(categoryResults.native, categoryResults.bun, categoryResults.hybrid);
|
||||
const winner =
|
||||
fastest === categoryResults.native ? "native" : fastest === categoryResults.bun ? "bun+strip" : "hybrid";
|
||||
|
||||
console.log(
|
||||
`${category.padEnd(10)} native: ${categoryResults.native.toFixed(1)}µs bun: ${categoryResults.bun.toFixed(1)}µs hybrid: ${categoryResults.hybrid.toFixed(1)}µs → ${winner} wins`,
|
||||
);
|
||||
}
|
||||
+12
-27
@@ -18,6 +18,8 @@
|
||||
* - isKittyProtocolActive() - Query global Kitty protocol state
|
||||
*/
|
||||
|
||||
import { matchesKittySequence } from "@oh-my-pi/pi-natives";
|
||||
|
||||
// =============================================================================
|
||||
// Global Kitty Protocol State
|
||||
// =============================================================================
|
||||
@@ -620,26 +622,6 @@ export function parseKittySequence(data: string): ParsedKittySequence | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean {
|
||||
const parsed = parseKittySequence(data);
|
||||
if (!parsed) return false;
|
||||
const actualMod = parsed.modifier & ~LOCK_MASK;
|
||||
const expectedMod = expectedModifier & ~LOCK_MASK;
|
||||
|
||||
// Check if modifiers match
|
||||
if (actualMod !== expectedMod) return false;
|
||||
|
||||
// Primary match: codepoint matches directly
|
||||
if (parsed.codepoint === expectedCodepoint) return true;
|
||||
|
||||
// Alternate match: use base layout key for non-Latin keyboard layouts
|
||||
// This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
|
||||
// the base layout key (the key in standard PC-101 layout)
|
||||
if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~
|
||||
* This is used by terminals when Kitty protocol is not enabled.
|
||||
@@ -666,23 +648,26 @@ function rawCtrlChar(letter: string): string {
|
||||
|
||||
type ParsedKeyId = { key: string; ctrl: boolean; shift: boolean; alt: boolean };
|
||||
|
||||
const PARSED_KEY_ID_CACHE = new Map<string, ParsedKeyId>();
|
||||
const PARSED_KEY_ID_CACHE = new Map<string, ParsedKeyId | null>();
|
||||
|
||||
function parseKeyId(keyId: string): ParsedKeyId | null {
|
||||
function parseKeyIdSlow(keyId: string): ParsedKeyId | null {
|
||||
const normalizedKeyId = keyId.toLowerCase();
|
||||
const cached = PARSED_KEY_ID_CACHE.get(normalizedKeyId);
|
||||
if (cached) return cached;
|
||||
|
||||
const parts = normalizedKeyId.split("+");
|
||||
const key = parts[parts.length - 1];
|
||||
if (!key) return null;
|
||||
const parsed = {
|
||||
return {
|
||||
key,
|
||||
ctrl: parts.includes("ctrl"),
|
||||
shift: parts.includes("shift"),
|
||||
alt: parts.includes("alt"),
|
||||
};
|
||||
PARSED_KEY_ID_CACHE.set(normalizedKeyId, parsed);
|
||||
}
|
||||
|
||||
function parseKeyId(keyId: string): ParsedKeyId | null {
|
||||
const cached = PARSED_KEY_ID_CACHE.get(keyId);
|
||||
if (cached !== undefined) return cached;
|
||||
const parsed = parseKeyIdSlow(keyId);
|
||||
PARSED_KEY_ID_CACHE.set(keyId, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
extractSegments as nativeExtractSegments,
|
||||
sliceWithWidth as nativeSliceWithWidth,
|
||||
truncateToWidth as nativeTruncateToWidth,
|
||||
visibleWidth as nativeVisibleWidth,
|
||||
} from "@oh-my-pi/pi-natives";
|
||||
|
||||
// Pre-allocated space buffer for padding
|
||||
@@ -30,7 +29,6 @@ export function getSegmenter(): Intl.Segmenter {
|
||||
// Cache for non-ASCII strings
|
||||
const WIDTH_CACHE_SIZE = 512;
|
||||
const widthCache = new Map<string, number>();
|
||||
const NATIVE_WIDTH_THRESHOLD = 256;
|
||||
|
||||
/**
|
||||
* Calculate the visible width of a string in terminal columns.
|
||||
@@ -42,15 +40,17 @@ export function visibleWidth(str: string): number {
|
||||
|
||||
// Fast path: pure ASCII printable
|
||||
let isPureAscii = true;
|
||||
let tabLength = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
if (code === 9) {
|
||||
tabLength += 3;
|
||||
} else if (code < 0x20 || code > 0x7e) {
|
||||
isPureAscii = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isPureAscii) {
|
||||
return str.length;
|
||||
return str.length + tabLength;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
@@ -59,25 +59,8 @@ export function visibleWidth(str: string): number {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let width: number;
|
||||
if (str.length <= NATIVE_WIDTH_THRESHOLD) {
|
||||
// Normalize: tabs to 3 spaces, strip ANSI escape codes
|
||||
let clean = str;
|
||||
if (str.includes("\t")) {
|
||||
clean = clean.replace(/\t/g, " ");
|
||||
}
|
||||
if (clean.includes("\x1b")) {
|
||||
// Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J)
|
||||
clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
|
||||
// Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
|
||||
clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
|
||||
}
|
||||
width = Bun.stringWidth(clean);
|
||||
} else {
|
||||
width = nativeVisibleWidth(str);
|
||||
}
|
||||
|
||||
// Cache result
|
||||
const width = Bun.stringWidth(str) + tabLength;
|
||||
if (widthCache.size >= WIDTH_CACHE_SIZE) {
|
||||
const firstKey = widthCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
|
||||
Reference in New Issue
Block a user