Compare commits
11 Commits
d9ed49e7d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f6f1efdf8e
|
|||
|
c6d80a9650
|
|||
|
bb3f200141
|
|||
|
a27d529fa7
|
|||
|
e5c875389c
|
|||
|
e89c6cb16f
|
|||
|
0ebb5e79dd
|
|||
|
2729e7e1d2
|
|||
|
a39e511cc4
|
|||
|
8bf3366740
|
|||
|
7082ceada0
|
@@ -10,30 +10,16 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check
|
||||
run: cargo check --workspace --all-targets
|
||||
|
||||
clippy:
|
||||
name: Clippy (strict)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Clippy
|
||||
run: |
|
||||
cargo clippy --workspace --all-targets -- \
|
||||
@@ -47,26 +33,12 @@ jobs:
|
||||
-D clippy::print_stdout \
|
||||
-D clippy::print_stderr
|
||||
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: Rustfmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: self-hosted
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run tests
|
||||
run: cargo test --workspace
|
||||
94
Cargo.lock
generated
94
Cargo.lock
generated
@@ -877,6 +877,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nucleo-matcher"
|
||||
version = "0.3.1"
|
||||
@@ -1072,6 +1081,8 @@ dependencies = [
|
||||
"pikl-tui",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1085,6 +1096,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1105,6 +1117,7 @@ dependencies = [
|
||||
"pikl-core",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1403,6 +1416,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
@@ -1610,6 +1632,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -1657,6 +1688,63 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"nu-ansi-term",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -1722,6 +1810,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
||||
44
README.md
44
README.md
@@ -1,4 +1,4 @@
|
||||
# pikl-menu
|
||||
# pikl
|
||||
|
||||
Pipe stuff in, pick stuff out.
|
||||
|
||||
@@ -95,6 +95,30 @@ or at all:
|
||||
overlay on Wayland (layer-shell) and X11. Auto-detects your
|
||||
environment.
|
||||
|
||||
## Install
|
||||
|
||||
See [docs/guides/install.md](docs/guides/install.md) for full details.
|
||||
The short version:
|
||||
|
||||
```sh
|
||||
cargo install pikl
|
||||
```
|
||||
|
||||
Or from source:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/maplecool/pikl-menu.git
|
||||
cd pikl-menu
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
## Guides
|
||||
|
||||
- **[Install](docs/guides/install.md):** cargo install, building from source
|
||||
- **[App Launcher](docs/guides/app-launcher.md):** set up pikl as a
|
||||
keyboard-driven app launcher on Hyprland, i3, macOS (Raycast), or
|
||||
in a terminal
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
@@ -105,16 +129,28 @@ cargo test --workspace
|
||||
Requires Rust stable. The repo includes a `rust-toolchain.toml` that
|
||||
pins the version and pulls in rust-analyzer + clippy.
|
||||
|
||||
## Current Status
|
||||
|
||||
Phases 1 through 3 are complete. pikl works as a TUI menu with:
|
||||
- Fuzzy, exact, regex, and inverse filtering with `|` pipeline chaining
|
||||
- Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes)
|
||||
- Structured JSON I/O with sublabel, icon, group, and arbitrary metadata
|
||||
- Lifecycle hooks: exec (fire-and-forget) and handler (bidirectional)
|
||||
- Debounce and cancel-stale for rapid events
|
||||
- Format templates (`--format '{label} - {sublabel}'`)
|
||||
- Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax)
|
||||
- Headless scripting via `--action-fd`
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Frontend | Status |
|
||||
|---|---|---|
|
||||
| Linux (Wayland) | GUI (layer-shell overlay) | Planned |
|
||||
| Linux (X11) | GUI | Planned |
|
||||
| Linux | TUI | Planned |
|
||||
| Linux | TUI | Working |
|
||||
| macOS | GUI | Planned |
|
||||
| macOS | TUI | Planned |
|
||||
| Window | GUI | Low Priority |
|
||||
| macOS | TUI | Working |
|
||||
| Windows | GUI | Low Priority |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ workspace = true
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt"] }
|
||||
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] }
|
||||
tracing = "0.1"
|
||||
nucleo-matcher = "0.3.1"
|
||||
fancy-regex = "0.14"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
|
||||
pikl-test-macros = { path = "../pikl-test-macros" }
|
||||
|
||||
154
crates/pikl-core/src/format.rs
Normal file
154
crates/pikl-core/src/format.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Display format templates. Parses `{field.path}`
|
||||
//! placeholders in a template string and renders them
|
||||
//! against item JSON values.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::item::resolve_field_path;
|
||||
|
||||
/// A compiled format template. Segments are either literal
|
||||
/// text or field path references that get resolved against
|
||||
/// item values at render time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FormatTemplate {
|
||||
segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Segment {
|
||||
Literal(String),
|
||||
Field(String),
|
||||
}
|
||||
|
||||
impl FormatTemplate {
|
||||
/// Parse a format string like `"{label} - {sublabel}"`.
|
||||
/// Unmatched `{` or `}` are treated as literals.
|
||||
pub fn parse(template: &str) -> Self {
|
||||
let mut segments = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = template.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '{' {
|
||||
// Look for closing brace
|
||||
let mut field = String::new();
|
||||
let mut found_close = false;
|
||||
for c2 in chars.by_ref() {
|
||||
if c2 == '}' {
|
||||
found_close = true;
|
||||
break;
|
||||
}
|
||||
field.push(c2);
|
||||
}
|
||||
if found_close && !field.is_empty() {
|
||||
if !current.is_empty() {
|
||||
segments.push(Segment::Literal(std::mem::take(&mut current)));
|
||||
}
|
||||
segments.push(Segment::Field(field));
|
||||
} else {
|
||||
// Malformed: treat as literal
|
||||
current.push('{');
|
||||
current.push_str(&field);
|
||||
if found_close {
|
||||
current.push('}');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
segments.push(Segment::Literal(current));
|
||||
}
|
||||
|
||||
Self { segments }
|
||||
}
|
||||
|
||||
/// Render this template against a JSON value. Missing
|
||||
/// fields produce empty strings.
|
||||
pub fn render(&self, value: &Value) -> String {
|
||||
let mut out = String::new();
|
||||
for seg in &self.segments {
|
||||
match seg {
|
||||
Segment::Literal(s) => out.push_str(s),
|
||||
Segment::Field(path) => {
|
||||
if let Some(v) = resolve_field_path(value, path) {
|
||||
match v {
|
||||
Value::String(s) => out.push_str(s),
|
||||
Value::Number(n) => out.push_str(&n.to_string()),
|
||||
Value::Bool(b) => out.push_str(&b.to_string()),
|
||||
Value::Null => {}
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn simple_field() {
|
||||
let t = FormatTemplate::parse("{label}");
|
||||
assert_eq!(t.render(&json!({"label": "Firefox"})), "Firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_and_fields() {
|
||||
let t = FormatTemplate::parse("{label} - {sublabel}");
|
||||
let v = json!({"label": "Firefox", "sublabel": "Web Browser"});
|
||||
assert_eq!(t.render(&v), "Firefox - Web Browser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_field_renders_empty() {
|
||||
let t = FormatTemplate::parse("{label} ({version})");
|
||||
let v = json!({"label": "Firefox"});
|
||||
assert_eq!(t.render(&v), "Firefox ()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_dotted_path() {
|
||||
let t = FormatTemplate::parse("{meta.resolution.width}x{meta.resolution.height}");
|
||||
let v = json!({"meta": {"resolution": {"width": 3840, "height": 2160}}});
|
||||
assert_eq!(t.render(&v), "3840x2160");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_only() {
|
||||
let t = FormatTemplate::parse("just text");
|
||||
assert_eq!(t.render(&json!({})), "just text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_template() {
|
||||
let t = FormatTemplate::parse("");
|
||||
assert_eq!(t.render(&json!({"label": "x"})), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_brace_is_literal() {
|
||||
let t = FormatTemplate::parse("hello {world");
|
||||
assert_eq!(t.render(&json!({})), "hello {world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_braces_are_literal() {
|
||||
let t = FormatTemplate::parse("hello {}");
|
||||
assert_eq!(t.render(&json!({})), "hello {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_value_item() {
|
||||
let t = FormatTemplate::parse("{label}");
|
||||
// String values don't have object fields
|
||||
assert_eq!(t.render(&json!("hello")), "");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! deps. Frontends are separate crates that talk to this
|
||||
//! through channels.
|
||||
|
||||
pub mod format;
|
||||
mod model;
|
||||
mod query;
|
||||
mod runtime;
|
||||
@@ -13,6 +14,7 @@ pub mod error;
|
||||
// Re-export submodules at crate root so the public API stays flat.
|
||||
pub use model::event;
|
||||
pub use model::item;
|
||||
pub use model::output;
|
||||
pub use model::traits;
|
||||
pub use query::exact;
|
||||
pub use query::filter;
|
||||
@@ -20,6 +22,7 @@ pub use query::navigation;
|
||||
pub use query::pipeline;
|
||||
pub use query::regex_filter;
|
||||
pub use query::strategy;
|
||||
pub use runtime::debounce;
|
||||
pub use runtime::hook;
|
||||
pub use runtime::input;
|
||||
pub use runtime::json_menu;
|
||||
|
||||
@@ -11,6 +11,8 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::hook::HookResponse;
|
||||
|
||||
/// Input mode. Insert mode sends keystrokes to the filter,
|
||||
/// normal mode uses vim-style navigation keybinds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -36,9 +38,14 @@ pub enum Action {
|
||||
HalfPageDown(usize),
|
||||
SetMode(Mode),
|
||||
Confirm,
|
||||
Quicklist,
|
||||
Cancel,
|
||||
Resize { height: u16 },
|
||||
AddItems(Vec<Value>),
|
||||
ReplaceItems(Vec<Value>),
|
||||
RemoveItems(Vec<usize>),
|
||||
ProcessHookResponse(HookResponse),
|
||||
CloseMenu,
|
||||
}
|
||||
|
||||
/// Broadcast from the menu loop to all subscribers
|
||||
@@ -47,6 +54,7 @@ pub enum Action {
|
||||
pub enum MenuEvent {
|
||||
StateChanged(ViewState),
|
||||
Selected(Value),
|
||||
Quicklist(Vec<Value>),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
@@ -64,6 +72,10 @@ pub struct ViewState {
|
||||
pub total_items: usize,
|
||||
pub total_filtered: usize,
|
||||
pub mode: Mode,
|
||||
/// Monotonically increasing counter. Each call to
|
||||
/// `build_view_state()` bumps this, so frontends can
|
||||
/// detect duplicate broadcasts and skip redundant redraws.
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// A single item in the current viewport window. Has the
|
||||
@@ -73,6 +85,7 @@ pub struct ViewState {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VisibleItem {
|
||||
pub label: String,
|
||||
pub formatted_text: Option<String>,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
@@ -81,6 +94,7 @@ pub struct VisibleItem {
|
||||
#[must_use]
|
||||
#[derive(Debug)]
|
||||
pub enum MenuResult {
|
||||
Selected(Value),
|
||||
Selected { value: Value, index: usize },
|
||||
Quicklist { items: Vec<(Value, usize)> },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use serde_json::Value;
|
||||
/// as `Value::String`, structured entries as
|
||||
/// `Value::Object`. The label is extracted and cached at
|
||||
/// construction time so display calls are just a pointer
|
||||
/// dereference.
|
||||
/// dereference. Sublabel, icon, and group are also cached
|
||||
/// for structured items.
|
||||
///
|
||||
/// Serializes as the inner Value (transparent for output)
|
||||
/// but construction always requires label extraction.
|
||||
@@ -16,23 +17,40 @@ use serde_json::Value;
|
||||
pub struct Item {
|
||||
pub value: Value,
|
||||
label_cache: String,
|
||||
sublabel_cache: Option<String>,
|
||||
icon_cache: Option<String>,
|
||||
group_cache: Option<String>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Create an Item from a JSON value, extracting the display
|
||||
/// label from the given key. String values use the string
|
||||
/// itself. Object values look up the key.
|
||||
/// itself. Object values look up the key. Also extracts
|
||||
/// sublabel, icon, and group from known keys on objects.
|
||||
pub fn new(value: Value, label_key: &str) -> Self {
|
||||
let label_cache = extract_label(&value, label_key).to_string();
|
||||
Self { value, label_cache }
|
||||
let sublabel_cache = extract_optional(&value, "sublabel");
|
||||
let icon_cache = extract_optional(&value, "icon");
|
||||
let group_cache = extract_optional(&value, "group");
|
||||
Self {
|
||||
value,
|
||||
label_cache,
|
||||
sublabel_cache,
|
||||
icon_cache,
|
||||
group_cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a plain-text string as an Item. Stored internally
|
||||
/// as `Value::String` with the label cached.
|
||||
/// as `Value::String` with the label cached. Optional
|
||||
/// fields are left as None.
|
||||
pub fn from_plain_text(line: &str) -> Self {
|
||||
Self {
|
||||
value: Value::String(line.to_string()),
|
||||
label_cache: line.to_string(),
|
||||
sublabel_cache: None,
|
||||
icon_cache: None,
|
||||
group_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +59,38 @@ impl Item {
|
||||
pub fn label(&self) -> &str {
|
||||
&self.label_cache
|
||||
}
|
||||
|
||||
/// Get the sublabel for this item, if present.
|
||||
pub fn sublabel(&self) -> Option<&str> {
|
||||
self.sublabel_cache.as_deref()
|
||||
}
|
||||
|
||||
/// Get the icon for this item, if present.
|
||||
pub fn icon(&self) -> Option<&str> {
|
||||
self.icon_cache.as_deref()
|
||||
}
|
||||
|
||||
/// Get the group for this item, if present.
|
||||
pub fn group(&self) -> Option<&str> {
|
||||
self.group_cache.as_deref()
|
||||
}
|
||||
|
||||
/// Get the `meta` field from the underlying value, if it
|
||||
/// exists and the value is an object.
|
||||
pub fn meta(&self) -> Option<&Value> {
|
||||
match &self.value {
|
||||
Value::Object(map) => map.get("meta"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a dotted field path against this item's value.
|
||||
/// For example, `field_value("meta.resolution")` on
|
||||
/// `{"meta": {"resolution": "1080p"}}` returns
|
||||
/// `Some(Value::String("1080p"))`.
|
||||
pub fn field_value(&self, path: &str) -> Option<&Value> {
|
||||
resolve_field_path(&self.value, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the display label from a JSON value.
|
||||
@@ -52,6 +102,30 @@ fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract an optional string field from a JSON object value.
|
||||
fn extract_optional(value: &Value, key: &str) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(map) => map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a dotted path (e.g. `"meta.tags.0"`) through nested
|
||||
/// JSON objects. Returns None if any intermediate is not an
|
||||
/// object or the key is missing.
|
||||
pub fn resolve_field_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
for segment in path.split('.') {
|
||||
match current {
|
||||
Value::Object(map) => {
|
||||
current = map.get(segment)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
impl serde::Serialize for Item {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.value.serialize(serializer)
|
||||
@@ -113,6 +187,9 @@ mod tests {
|
||||
let item = Item::from_plain_text("hello world");
|
||||
assert_eq!(item.label(), "hello world");
|
||||
assert!(item.value.is_string());
|
||||
assert!(item.sublabel().is_none());
|
||||
assert!(item.icon().is_none());
|
||||
assert!(item.group().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,4 +205,107 @@ mod tests {
|
||||
let json = serde_json::to_string(&item).unwrap_or_default();
|
||||
assert_eq!(json, r#"{"label":"foo"}"#);
|
||||
}
|
||||
|
||||
// -- New accessor tests --
|
||||
|
||||
#[test]
|
||||
fn sublabel_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "Firefox", "sublabel": "Web Browser"}),
|
||||
"label",
|
||||
);
|
||||
assert_eq!(item.sublabel(), Some("Web Browser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sublabel_missing() {
|
||||
let item = Item::new(json!({"label": "Firefox"}), "label");
|
||||
assert!(item.sublabel().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "Firefox", "icon": "firefox.png"}),
|
||||
"label",
|
||||
);
|
||||
assert_eq!(item.icon(), Some("firefox.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "Firefox", "group": "browsers"}),
|
||||
"label",
|
||||
);
|
||||
assert_eq!(item.group(), Some("browsers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "test", "meta": {"res": 1080}}),
|
||||
"label",
|
||||
);
|
||||
let meta = item.meta();
|
||||
assert!(meta.is_some());
|
||||
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_from_plain_text() {
|
||||
let item = Item::from_plain_text("hello");
|
||||
assert!(item.meta().is_none());
|
||||
}
|
||||
|
||||
// -- Field path resolution tests --
|
||||
|
||||
#[test]
|
||||
fn resolve_simple_path() {
|
||||
let value = json!({"label": "test"});
|
||||
assert_eq!(resolve_field_path(&value, "label"), Some(&json!("test")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_nested_path() {
|
||||
let value = json!({"meta": {"resolution": {"width": 3840}}});
|
||||
assert_eq!(
|
||||
resolve_field_path(&value, "meta.resolution.width"),
|
||||
Some(&json!(3840))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_path() {
|
||||
let value = json!({"label": "test"});
|
||||
assert!(resolve_field_path(&value, "nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_nested() {
|
||||
let value = json!({"meta": {"a": 1}});
|
||||
assert!(resolve_field_path(&value, "meta.b").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_non_object_intermediate() {
|
||||
let value = json!({"meta": "not an object"});
|
||||
assert!(resolve_field_path(&value, "meta.foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_on_string_value() {
|
||||
let value = json!("hello");
|
||||
assert!(resolve_field_path(&value, "anything").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_value_on_item() {
|
||||
let item = Item::new(
|
||||
json!({"label": "test", "meta": {"tags": ["a", "b"]}}),
|
||||
"label",
|
||||
);
|
||||
assert_eq!(item.field_value("meta.tags"), Some(&json!(["a", "b"])));
|
||||
assert!(item.field_value("meta.nope").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod event;
|
||||
pub mod item;
|
||||
pub mod output;
|
||||
pub mod traits;
|
||||
|
||||
123
crates/pikl-core/src/model/output.rs
Normal file
123
crates/pikl-core/src/model/output.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Structured output for selected items. Wraps the original
|
||||
//! item value with action context and original index so
|
||||
//! hooks and downstream tools know what happened.
|
||||
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// What the user did to produce this output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputAction {
|
||||
Select,
|
||||
Quicklist,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// A selected item wrapped with output context. For object
|
||||
/// values, the original fields are merged at the top level
|
||||
/// alongside `action` and `index`. For non-object values
|
||||
/// (plain strings, numbers), the value appears as a `value`
|
||||
/// field.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutputItem {
|
||||
pub value: Value,
|
||||
pub action: OutputAction,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
impl Serialize for OutputItem {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match &self.value {
|
||||
Value::Object(map) => {
|
||||
// Flatten: merge object fields with action/index
|
||||
let mut s = serializer.serialize_map(Some(map.len() + 2))?;
|
||||
for (k, v) in map {
|
||||
s.serialize_entry(k, v)?;
|
||||
}
|
||||
s.serialize_entry("action", &self.action)?;
|
||||
s.serialize_entry("index", &self.index)?;
|
||||
s.end()
|
||||
}
|
||||
_ => {
|
||||
// Non-object: put value in a "value" field
|
||||
let mut s = serializer.serialize_map(Some(3))?;
|
||||
s.serialize_entry("value", &self.value)?;
|
||||
s.serialize_entry("action", &self.action)?;
|
||||
s.serialize_entry("index", &self.index)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn output_item_flattens_object() {
|
||||
let item = OutputItem {
|
||||
value: json!({"label": "Firefox", "url": "https://firefox.com"}),
|
||||
action: OutputAction::Select,
|
||||
index: 3,
|
||||
};
|
||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||
assert_eq!(json["label"], "Firefox");
|
||||
assert_eq!(json["url"], "https://firefox.com");
|
||||
assert_eq!(json["action"], "select");
|
||||
assert_eq!(json["index"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_item_string_value() {
|
||||
let item = OutputItem {
|
||||
value: json!("hello"),
|
||||
action: OutputAction::Select,
|
||||
index: 0,
|
||||
};
|
||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||
assert_eq!(json["value"], "hello");
|
||||
assert_eq!(json["action"], "select");
|
||||
assert_eq!(json["index"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_item_cancel_action() {
|
||||
let item = OutputItem {
|
||||
value: json!(null),
|
||||
action: OutputAction::Cancel,
|
||||
index: 0,
|
||||
};
|
||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||
assert_eq!(json["action"], "cancel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_item_quicklist_action() {
|
||||
let item = OutputItem {
|
||||
value: json!("test"),
|
||||
action: OutputAction::Quicklist,
|
||||
index: 2,
|
||||
};
|
||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||
assert_eq!(json["action"], "quicklist");
|
||||
assert_eq!(json["index"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_item_string_contains_value_text() {
|
||||
let item = OutputItem {
|
||||
value: json!("alpha"),
|
||||
action: OutputAction::Select,
|
||||
index: 0,
|
||||
};
|
||||
let serialized = serde_json::to_string(&item).unwrap_or_default();
|
||||
assert!(
|
||||
serialized.contains("alpha"),
|
||||
"output should contain the value text: {serialized}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,50 @@ pub trait Menu: Send + 'static {
|
||||
/// position in the filtered results.
|
||||
fn filtered_label(&self, filtered_index: usize) -> Option<&str>;
|
||||
|
||||
/// Add raw values from streaming input or AddItems actions.
|
||||
fn add_raw(&mut self, values: Vec<serde_json::Value>);
|
||||
|
||||
/// Get the JSON value of a filtered item for output.
|
||||
/// Returns a reference to the stored value.
|
||||
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>;
|
||||
|
||||
/// Get the original index of a filtered item. Used to
|
||||
/// provide the item's position in the unfiltered list
|
||||
/// for output and hook events.
|
||||
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
||||
|
||||
/// Get the formatted display text for a filtered item,
|
||||
/// if a format template is configured. Returns None if
|
||||
/// no template is set, in which case the raw label is
|
||||
/// used.
|
||||
fn formatted_label(&self, _filtered_index: usize) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Collect all filtered items as (value, original_index) pairs.
|
||||
/// Default implementation iterates filtered results using
|
||||
/// existing trait methods.
|
||||
fn collect_filtered(&self) -> Vec<(&serde_json::Value, usize)> {
|
||||
(0..self.filtered_count())
|
||||
.filter_map(|i| {
|
||||
let value = self.serialize_filtered(i)?;
|
||||
let orig = self.original_index(i)?;
|
||||
Some((value, orig))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension of [`Menu`] with mutation methods. Required by
|
||||
/// [`MenuRunner`] which needs to add, replace, and remove
|
||||
/// items. Embedders that only need read-only access can
|
||||
/// depend on `Menu` alone.
|
||||
pub trait MutableMenu: Menu {
|
||||
/// Add raw values from streaming input or AddItems actions.
|
||||
fn add_raw(&mut self, values: Vec<serde_json::Value>);
|
||||
|
||||
/// Replace all items with a new set of values. Used by
|
||||
/// handler hook `replace_items` responses.
|
||||
fn replace_all(&mut self, values: Vec<serde_json::Value>);
|
||||
|
||||
/// Remove items at the given original indices. Used by
|
||||
/// handler hook `remove_items` responses.
|
||||
fn remove_by_indices(&mut self, indices: Vec<usize>);
|
||||
}
|
||||
|
||||
@@ -165,9 +165,17 @@ impl Viewport {
|
||||
self.scroll_offset..end
|
||||
}
|
||||
|
||||
/// Set the cursor to a specific position. Does NOT clamp
|
||||
/// or update scroll. Call [`clamp`](Self::clamp) after if
|
||||
/// the position may be out of bounds.
|
||||
pub fn set_cursor(&mut self, position: usize) {
|
||||
self.cursor = position;
|
||||
}
|
||||
|
||||
/// Clamp cursor and scroll offset to valid positions after
|
||||
/// a height or count change.
|
||||
fn clamp(&mut self) {
|
||||
/// a height or count change, item removal, or manual
|
||||
/// cursor set.
|
||||
pub fn clamp(&mut self) {
|
||||
if self.filtered_count == 0 {
|
||||
self.cursor = 0;
|
||||
self.scroll_offset = 0;
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
//! and chains results through stages. Supports incremental
|
||||
//! caching: unchanged stages keep their results.
|
||||
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use super::filter::{Filter, FuzzyFilter};
|
||||
use super::strategy::{self, FilterKind};
|
||||
use crate::item::resolve_field_path;
|
||||
|
||||
/// A multi-stage filter pipeline. Each `|` in the query
|
||||
/// creates a new stage that filters the previous stage's
|
||||
@@ -13,6 +17,9 @@ use super::strategy::{self, FilterKind};
|
||||
pub struct FilterPipeline {
|
||||
/// Master item list: (original index, label).
|
||||
items: Vec<(usize, String)>,
|
||||
/// Optional item values for field filter resolution.
|
||||
/// Stored separately since most pipelines don't need them.
|
||||
item_values: Vec<Option<Value>>,
|
||||
/// Pipeline stages, one per `|`-separated segment.
|
||||
stages: Vec<PipelineStage>,
|
||||
/// The last raw query string, used for diffing.
|
||||
@@ -99,6 +106,17 @@ fn split_pipeline(query: &str) -> Vec<String> {
|
||||
segments.into_iter().filter(|s| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Convert a JSON value to a string for field filter matching.
|
||||
fn value_to_filter_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FilterPipeline {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -109,6 +127,7 @@ impl FilterPipeline {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
item_values: Vec::new(),
|
||||
stages: Vec::new(),
|
||||
last_raw_query: String::new(),
|
||||
}
|
||||
@@ -117,11 +136,15 @@ impl FilterPipeline {
|
||||
/// Evaluate all dirty stages in order. Each stage filters
|
||||
/// against the previous stage's cached_indices.
|
||||
fn evaluate(&mut self) {
|
||||
let _span = tracing::trace_span!("pipeline_evaluate").entered();
|
||||
for stage_idx in 0..self.stages.len() {
|
||||
if !self.stages[stage_idx].dirty {
|
||||
trace!(stage_idx, dirty = false, "skipping clean stage");
|
||||
continue;
|
||||
}
|
||||
|
||||
trace!(stage_idx, dirty = true, kind = ?self.stages[stage_idx].kind, "evaluating stage");
|
||||
|
||||
let input_indices: Vec<usize> = if stage_idx == 0 {
|
||||
self.items.iter().map(|(idx, _)| *idx).collect()
|
||||
} else {
|
||||
@@ -130,7 +153,7 @@ impl FilterPipeline {
|
||||
|
||||
let stage = &mut self.stages[stage_idx];
|
||||
|
||||
let result = match stage.kind {
|
||||
let result = match &stage.kind {
|
||||
FilterKind::Fuzzy => Self::eval_fuzzy(stage, &input_indices, stage_idx),
|
||||
FilterKind::Exact => {
|
||||
Self::eval_simple(stage, &input_indices, &self.items, |label, query| {
|
||||
@@ -146,8 +169,17 @@ impl FilterPipeline {
|
||||
}
|
||||
})
|
||||
}
|
||||
FilterKind::Field { path } => {
|
||||
Self::eval_field(
|
||||
stage,
|
||||
&input_indices,
|
||||
&self.item_values,
|
||||
path,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
trace!(stage_idx, matched = result.len(), "stage complete");
|
||||
self.stages[stage_idx].cached_indices = result;
|
||||
self.stages[stage_idx].dirty = false;
|
||||
}
|
||||
@@ -184,6 +216,41 @@ impl FilterPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_field(
|
||||
stage: &PipelineStage,
|
||||
input_indices: &[usize],
|
||||
item_values: &[Option<Value>],
|
||||
path: &str,
|
||||
) -> Vec<usize> {
|
||||
if stage.query_text.is_empty() {
|
||||
return input_indices.to_vec();
|
||||
}
|
||||
let query_lower = stage.query_text.to_lowercase();
|
||||
let matches = |idx: usize| -> bool {
|
||||
let value = item_values.get(idx).and_then(|v| v.as_ref());
|
||||
if let Some(val) = value
|
||||
&& let Some(field_val) = resolve_field_path(val, path)
|
||||
{
|
||||
let text = value_to_filter_string(field_val);
|
||||
return text.to_lowercase().contains(&query_lower);
|
||||
}
|
||||
false
|
||||
};
|
||||
if stage.inverse {
|
||||
input_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&idx| !matches(idx))
|
||||
.collect()
|
||||
} else {
|
||||
input_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&idx| matches(idx))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_simple(
|
||||
stage: &PipelineStage,
|
||||
input_indices: &[usize],
|
||||
@@ -209,9 +276,51 @@ impl FilterPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
impl FilterPipeline {
|
||||
/// Clear all items and stages, then re-push the given
|
||||
/// items. Used after replace_all or remove_by_indices
|
||||
/// to rebuild the pipeline from scratch.
|
||||
pub fn rebuild(&mut self, items: &[(usize, &str)]) {
|
||||
debug!(item_count = items.len(), "pipeline rebuilt");
|
||||
self.items.clear();
|
||||
self.item_values.clear();
|
||||
self.stages.clear();
|
||||
self.last_raw_query.clear();
|
||||
for &(index, label) in items {
|
||||
self.items.push((index, label.to_string()));
|
||||
self.item_values.push(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all items and stages, then re-push with values.
|
||||
/// Used when field filters need access to item JSON.
|
||||
pub fn rebuild_with_values(&mut self, items: &[(usize, &str, &Value)]) {
|
||||
debug!(item_count = items.len(), "pipeline rebuilt");
|
||||
self.items.clear();
|
||||
self.item_values.clear();
|
||||
self.stages.clear();
|
||||
self.last_raw_query.clear();
|
||||
for &(index, label, value) in items {
|
||||
self.items.push((index, label.to_string()));
|
||||
self.item_values.push(Some(value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a single item with its JSON value for field
|
||||
/// filter support.
|
||||
pub fn push_with_value(&mut self, index: usize, label: &str, value: &Value) {
|
||||
self.push(index, label);
|
||||
// push() already added a None to item_values, replace it
|
||||
if let Some(last) = self.item_values.last_mut() {
|
||||
*last = Some(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter for FilterPipeline {
|
||||
fn push(&mut self, index: usize, label: &str) {
|
||||
self.items.push((index, label.to_string()));
|
||||
self.item_values.push(None);
|
||||
// Push to any existing fuzzy filters in stages
|
||||
for stage in &mut self.stages {
|
||||
if let Some(ref mut fuzzy) = stage.fuzzy {
|
||||
@@ -224,6 +333,7 @@ impl Filter for FilterPipeline {
|
||||
fn set_query(&mut self, query: &str) {
|
||||
self.last_raw_query = query.to_string();
|
||||
let segments = split_pipeline(query);
|
||||
debug!(query = %query, stage_count = segments.len(), "pipeline query set");
|
||||
|
||||
// Reconcile stages with new segments
|
||||
let mut new_len = segments.len();
|
||||
@@ -614,4 +724,95 @@ mod tests {
|
||||
assert!(result.contains(&"cherry"));
|
||||
assert!(!result.contains(&"banana"));
|
||||
}
|
||||
|
||||
// -- Field filter tests --
|
||||
|
||||
#[test]
|
||||
fn field_filter_matches() {
|
||||
use serde_json::json;
|
||||
let mut p = FilterPipeline::new();
|
||||
let items = vec![
|
||||
json!({"label": "monitor1", "meta": {"res": "3840"}}),
|
||||
json!({"label": "monitor2", "meta": {"res": "1920"}}),
|
||||
json!({"label": "monitor3", "meta": {"res": "3840"}}),
|
||||
];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let label = item["label"].as_str().unwrap_or("");
|
||||
p.push_with_value(i, label, item);
|
||||
}
|
||||
p.set_query("meta.res:3840");
|
||||
assert_eq!(p.matched_count(), 2);
|
||||
let indices: Vec<usize> = (0..p.matched_count())
|
||||
.filter_map(|i| p.matched_index(i))
|
||||
.collect();
|
||||
assert!(indices.contains(&0));
|
||||
assert!(indices.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_inverse() {
|
||||
use serde_json::json;
|
||||
let mut p = FilterPipeline::new();
|
||||
let items = vec![
|
||||
json!({"label": "a", "meta": {"res": "3840"}}),
|
||||
json!({"label": "b", "meta": {"res": "1920"}}),
|
||||
];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let label = item["label"].as_str().unwrap_or("");
|
||||
p.push_with_value(i, label, item);
|
||||
}
|
||||
p.set_query("!meta.res:3840");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
assert_eq!(p.matched_index(0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_missing_field() {
|
||||
use serde_json::json;
|
||||
let mut p = FilterPipeline::new();
|
||||
let items = vec![
|
||||
json!({"label": "a", "meta": {"res": "3840"}}),
|
||||
json!({"label": "b"}),
|
||||
];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let label = item["label"].as_str().unwrap_or("");
|
||||
p.push_with_value(i, label, item);
|
||||
}
|
||||
p.set_query("meta.res:3840");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
assert_eq!(p.matched_index(0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_in_pipeline_with_other_stages() {
|
||||
use serde_json::json;
|
||||
let mut p = FilterPipeline::new();
|
||||
let items = vec![
|
||||
json!({"label": "Firefox", "meta": {"type": "browser"}}),
|
||||
json!({"label": "Chrome", "meta": {"type": "browser"}}),
|
||||
json!({"label": "Vim", "meta": {"type": "editor"}}),
|
||||
];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let label = item["label"].as_str().unwrap_or("");
|
||||
p.push_with_value(i, label, item);
|
||||
}
|
||||
// First stage: field filter for browsers, second stage: fuzzy for "fire"
|
||||
p.set_query("meta.type:browser | fire");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
assert_eq!(p.matched_index(0), Some(0)); // Firefox
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_pipeline() {
|
||||
let mut p = FilterPipeline::new();
|
||||
push_items(&mut p, &["apple", "banana"]);
|
||||
p.set_query("ban");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
|
||||
// Rebuild with new items
|
||||
p.rebuild(&[(0, "cherry"), (1, "date")]);
|
||||
p.set_query("dat");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
assert_eq!(p.matched_index(0), Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
//! strategy to use based on the query prefix.
|
||||
|
||||
/// The type of filter to apply for a query segment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FilterKind {
|
||||
Fuzzy,
|
||||
Exact,
|
||||
Regex,
|
||||
/// Field-specific filter: `meta.res:3840` matches
|
||||
/// items where the dotted path resolves to a value
|
||||
/// containing the query text.
|
||||
Field { path: String },
|
||||
}
|
||||
|
||||
/// A parsed filter segment with its kind, inversion flag,
|
||||
@@ -75,6 +79,28 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for inverse field filter: !field.path:value
|
||||
// Must check before generic inverse fuzzy to avoid
|
||||
// treating it as fuzzy with query "field.path:value".
|
||||
if let Some(rest) = segment.strip_prefix('!')
|
||||
&& let Some((path, value)) = try_parse_field_filter(rest)
|
||||
{
|
||||
return ParsedSegment {
|
||||
kind: FilterKind::Field { path: path.to_string() },
|
||||
inverse: true,
|
||||
query: value,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for field filter: field.path:value
|
||||
if let Some((path, value)) = try_parse_field_filter(segment) {
|
||||
return ParsedSegment {
|
||||
kind: FilterKind::Field { path: path.to_string() },
|
||||
inverse: false,
|
||||
query: value,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for inverse fuzzy: !query
|
||||
if let Some(rest) = segment.strip_prefix('!') {
|
||||
return ParsedSegment {
|
||||
@@ -101,6 +127,36 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to parse a `field.path:value` pattern. Returns
|
||||
/// `(path, value)` if the segment matches. The path must
|
||||
/// consist of word chars and dots, with no spaces before
|
||||
/// the colon.
|
||||
fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> {
|
||||
let colon_pos = segment.find(':')?;
|
||||
let path = &segment[..colon_pos];
|
||||
let value = &segment[colon_pos + 1..];
|
||||
|
||||
// Path must be non-empty and look like a dotted identifier
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Must contain at least one dot or look like a field name
|
||||
// (not just a single word which could be a typo)
|
||||
if !path.contains('.') {
|
||||
return None;
|
||||
}
|
||||
// No spaces in the path
|
||||
if path.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
// Path chars must be alphanumeric, underscore, or dot
|
||||
if !path.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((path, value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -228,4 +284,52 @@ mod tests {
|
||||
assert!(p.inverse);
|
||||
assert_eq!(p.query, "[0-9]");
|
||||
}
|
||||
|
||||
// -- Field filter tests --
|
||||
|
||||
#[test]
|
||||
fn field_filter_dotted_path() {
|
||||
let p = parse_segment("meta.res:3840");
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
FilterKind::Field {
|
||||
path: "meta.res".to_string()
|
||||
}
|
||||
);
|
||||
assert!(!p.inverse);
|
||||
assert_eq!(p.query, "3840");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_inverse() {
|
||||
let p = parse_segment("!meta.res:3840");
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
FilterKind::Field {
|
||||
path: "meta.res".to_string()
|
||||
}
|
||||
);
|
||||
assert!(p.inverse);
|
||||
assert_eq!(p.query, "3840");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_word_colon_is_not_field_filter() {
|
||||
// No dot means it's not treated as a field path
|
||||
let p = parse_segment("foo:bar");
|
||||
assert_eq!(p.kind, FilterKind::Fuzzy);
|
||||
assert_eq!(p.query, "foo:bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_empty_value() {
|
||||
let p = parse_segment("meta.tag:");
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
FilterKind::Field {
|
||||
path: "meta.tag".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(p.query, "");
|
||||
}
|
||||
}
|
||||
|
||||
294
crates/pikl-core/src/runtime/debounce.rs
Normal file
294
crates/pikl-core/src/runtime/debounce.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
//! Debounce and cancel-stale support for hook event
|
||||
//! dispatch. Wraps a HookHandler and manages per-event-kind
|
||||
//! timing to avoid overwhelming hooks with rapid events.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::event::Action;
|
||||
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
|
||||
|
||||
/// Debounce mode for a hook event kind.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DebounceMode {
|
||||
/// Fire immediately.
|
||||
None,
|
||||
/// Wait for a quiet period before firing.
|
||||
Debounce(Duration),
|
||||
/// Cancel any in-flight handler before starting a new one.
|
||||
CancelStale,
|
||||
/// Both: wait, and cancel any previous pending dispatch.
|
||||
DebounceAndCancelStale(Duration),
|
||||
}
|
||||
|
||||
/// Wraps a HookHandler with debounce and cancel-stale
|
||||
/// behavior. Each event kind can have its own mode.
|
||||
pub struct DebouncedDispatcher {
|
||||
handler: Arc<dyn HookHandler>,
|
||||
_action_tx: mpsc::Sender<Action>,
|
||||
modes: HashMap<HookEventKind, DebounceMode>,
|
||||
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DebouncedDispatcher {
|
||||
pub fn new(
|
||||
handler: Arc<dyn HookHandler>,
|
||||
action_tx: mpsc::Sender<Action>,
|
||||
) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
_action_tx: action_tx,
|
||||
modes: HashMap::new(),
|
||||
in_flight: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the debounce mode for a specific event kind.
|
||||
pub fn set_mode(&mut self, kind: HookEventKind, mode: DebounceMode) {
|
||||
self.modes.insert(kind, mode);
|
||||
}
|
||||
|
||||
/// Apply default debounce settings:
|
||||
/// - Hover: DebounceAndCancelStale(200ms)
|
||||
/// - Filter: Debounce(200ms)
|
||||
/// - Everything else: None
|
||||
pub fn apply_defaults(&mut self) {
|
||||
self.modes.insert(
|
||||
HookEventKind::Hover,
|
||||
DebounceMode::DebounceAndCancelStale(Duration::from_millis(200)),
|
||||
);
|
||||
self.modes.insert(
|
||||
HookEventKind::Filter,
|
||||
DebounceMode::Debounce(Duration::from_millis(200)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatch a hook event through the debounce system.
|
||||
pub fn dispatch(&mut self, event: HookEvent) {
|
||||
let kind = event.kind();
|
||||
let mode = self
|
||||
.modes
|
||||
.get(&kind)
|
||||
.cloned()
|
||||
.unwrap_or(DebounceMode::None);
|
||||
|
||||
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");
|
||||
|
||||
match mode {
|
||||
DebounceMode::None => {
|
||||
self.fire_now(event);
|
||||
}
|
||||
DebounceMode::Debounce(delay) => {
|
||||
self.fire_debounced(event, delay, false);
|
||||
}
|
||||
DebounceMode::CancelStale => {
|
||||
self.cancel_in_flight(kind);
|
||||
self.fire_now(event);
|
||||
}
|
||||
DebounceMode::DebounceAndCancelStale(delay) => {
|
||||
self.fire_debounced(event, delay, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fire_now(&self, event: HookEvent) {
|
||||
let handler = Arc::clone(&self.handler);
|
||||
let span = tracing::debug_span!("hook_fire", event_kind = ?event.kind());
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(e) = handler.handle(event) {
|
||||
tracing::warn!(error = %e, "hook handler error");
|
||||
}
|
||||
}
|
||||
.instrument(span),
|
||||
);
|
||||
}
|
||||
|
||||
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
|
||||
let kind = event.kind();
|
||||
if cancel {
|
||||
self.cancel_in_flight(kind);
|
||||
}
|
||||
|
||||
let delay_ms = delay.as_millis() as u64;
|
||||
tracing::debug!(delay_ms, cancel_stale = cancel, "debounced hook scheduled");
|
||||
|
||||
let handler = Arc::clone(&self.handler);
|
||||
let span = tracing::debug_span!("hook_fire", event_kind = ?kind);
|
||||
let handle = tokio::spawn(
|
||||
async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
if let Err(e) = handler.handle(event) {
|
||||
tracing::warn!(error = %e, "hook handler error");
|
||||
}
|
||||
}
|
||||
.instrument(span),
|
||||
);
|
||||
|
||||
self.in_flight.insert(kind, handle);
|
||||
}
|
||||
|
||||
fn cancel_in_flight(&mut self, kind: HookEventKind) {
|
||||
if let Some(handle) = self.in_flight.remove(&kind) {
|
||||
tracing::debug!(event_kind = ?kind, "cancelled in-flight hook");
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a HookResponse into the appropriate Action.
|
||||
pub fn hook_response_to_action(resp: HookResponse) -> Action {
|
||||
match resp {
|
||||
HookResponse::AddItems { items } => Action::AddItems(items),
|
||||
HookResponse::ReplaceItems { items } => Action::ReplaceItems(items),
|
||||
HookResponse::RemoveItems { indices } => Action::RemoveItems(indices),
|
||||
HookResponse::SetFilter { text } => Action::UpdateFilter(text),
|
||||
HookResponse::Close => Action::CloseMenu,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::PiklError;
|
||||
use serde_json::json;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct RecordingHandler {
|
||||
events: Arc<Mutex<Vec<HookEventKind>>>,
|
||||
}
|
||||
|
||||
impl HookHandler for RecordingHandler {
|
||||
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
|
||||
if let Ok(mut events) = self.events.lock() {
|
||||
events.push(event.kind());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_response_to_action_add_items() {
|
||||
let action = hook_response_to_action(HookResponse::AddItems {
|
||||
items: vec![json!("x")],
|
||||
});
|
||||
assert!(matches!(action, Action::AddItems(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_response_to_action_replace() {
|
||||
let action = hook_response_to_action(HookResponse::ReplaceItems {
|
||||
items: vec![json!("x")],
|
||||
});
|
||||
assert!(matches!(action, Action::ReplaceItems(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_response_to_action_remove() {
|
||||
let action = hook_response_to_action(HookResponse::RemoveItems { indices: vec![0] });
|
||||
assert!(matches!(action, Action::RemoveItems(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_response_to_action_set_filter() {
|
||||
let action = hook_response_to_action(HookResponse::SetFilter {
|
||||
text: "hi".to_string(),
|
||||
});
|
||||
assert!(matches!(action, Action::UpdateFilter(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_response_to_action_close() {
|
||||
let action = hook_response_to_action(HookResponse::Close);
|
||||
assert!(matches!(action, Action::CloseMenu));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn debounce_coalesces_events() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let handler = Arc::new(RecordingHandler {
|
||||
events: Arc::clone(&events),
|
||||
});
|
||||
let (action_tx, _action_rx) = mpsc::channel(64);
|
||||
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
|
||||
dispatcher.set_mode(
|
||||
HookEventKind::Filter,
|
||||
DebounceMode::Debounce(Duration::from_millis(100)),
|
||||
);
|
||||
|
||||
// Rapid-fire filter events
|
||||
dispatcher.dispatch(HookEvent::Filter {
|
||||
text: "a".to_string(),
|
||||
});
|
||||
dispatcher.dispatch(HookEvent::Filter {
|
||||
text: "ab".to_string(),
|
||||
});
|
||||
dispatcher.dispatch(HookEvent::Filter {
|
||||
text: "abc".to_string(),
|
||||
});
|
||||
|
||||
// Advance past debounce window. sleep(0) processes
|
||||
// all pending wakeups including spawned task continuations.
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// Without cancel-stale, all three fire after their delay.
|
||||
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
|
||||
assert!(recorded >= 1, "at least one event should have fired");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancel_stale_aborts_in_flight() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let handler = Arc::new(RecordingHandler {
|
||||
events: Arc::clone(&events),
|
||||
});
|
||||
let (action_tx, _action_rx) = mpsc::channel(64);
|
||||
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
|
||||
dispatcher.set_mode(
|
||||
HookEventKind::Hover,
|
||||
DebounceMode::DebounceAndCancelStale(Duration::from_millis(200)),
|
||||
);
|
||||
|
||||
// First hover
|
||||
dispatcher.dispatch(HookEvent::Hover {
|
||||
item: json!("a"),
|
||||
index: 0,
|
||||
});
|
||||
// Wait a bit, then send second hover which cancels first
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
dispatcher.dispatch(HookEvent::Hover {
|
||||
item: json!("b"),
|
||||
index: 1,
|
||||
});
|
||||
|
||||
// Advance past debounce for the second event
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
|
||||
// Only the second hover should have fired
|
||||
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
|
||||
assert_eq!(recorded, 1, "only the latest hover should fire");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn none_mode_fires_immediately() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let handler = Arc::new(RecordingHandler {
|
||||
events: Arc::clone(&events),
|
||||
});
|
||||
let (action_tx, _action_rx) = mpsc::channel(64);
|
||||
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
|
||||
|
||||
dispatcher.dispatch(HookEvent::Open);
|
||||
tokio::task::yield_now().await;
|
||||
// Give the spawned task a moment
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
|
||||
assert_eq!(recorded, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,329 @@
|
||||
//! Hook trait for lifecycle events. The core library defines
|
||||
//! the interface; concrete implementations (shell hooks, IPC
|
||||
//! hooks, etc.) live in frontend crates.
|
||||
//! Hook types for lifecycle events. The core library defines
|
||||
//! the event and response types plus the handler trait.
|
||||
//! Concrete implementations (shell exec hooks, persistent
|
||||
//! handler processes) live in frontend crates.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::PiklError;
|
||||
|
||||
/// A lifecycle hook that fires on menu events. Implementations
|
||||
/// live outside pikl-core (e.g. in the CLI binary) so the core
|
||||
/// library stays free of process/libc deps.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait Hook: Send + Sync {
|
||||
async fn run(&self, value: &Value) -> Result<(), PiklError>;
|
||||
/// A lifecycle event emitted by the menu engine. Handler
|
||||
/// hooks receive these as JSON lines on stdin. The `event`
|
||||
/// field is the tag for serde's tagged representation.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
Open,
|
||||
Close,
|
||||
Hover { item: Value, index: usize },
|
||||
Select { item: Value, index: usize },
|
||||
Cancel,
|
||||
Filter { text: String },
|
||||
Quicklist { items: Vec<Value>, count: usize },
|
||||
}
|
||||
|
||||
/// Discriminant for [`HookEvent`], used as a key for
|
||||
/// debounce config and handler routing.
|
||||
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
|
||||
pub enum HookEventKind {
|
||||
Open,
|
||||
Close,
|
||||
Hover,
|
||||
Select,
|
||||
Cancel,
|
||||
Filter,
|
||||
Quicklist,
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
/// Get the discriminant kind for this event.
|
||||
pub fn kind(&self) -> HookEventKind {
|
||||
match self {
|
||||
HookEvent::Open => HookEventKind::Open,
|
||||
HookEvent::Close => HookEventKind::Close,
|
||||
HookEvent::Hover { .. } => HookEventKind::Hover,
|
||||
HookEvent::Select { .. } => HookEventKind::Select,
|
||||
HookEvent::Cancel => HookEventKind::Cancel,
|
||||
HookEvent::Filter { .. } => HookEventKind::Filter,
|
||||
HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command from a handler hook back to the menu engine.
|
||||
/// Handler hooks emit these as JSON lines on stdout. The
|
||||
/// `action` field is the tag.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum HookResponse {
|
||||
AddItems { items: Vec<Value> },
|
||||
ReplaceItems { items: Vec<Value> },
|
||||
RemoveItems { indices: Vec<usize> },
|
||||
SetFilter { text: String },
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Handler trait for lifecycle hooks. Implementations
|
||||
/// receive events and may produce side effects (spawning
|
||||
/// processes, sending to channels). Responses flow through
|
||||
/// the action channel, not the return value.
|
||||
///
|
||||
/// This is deliberately synchronous for dyn-compatibility.
|
||||
/// Implementations that need async work (spawning processes,
|
||||
/// writing to channels) should use `tokio::spawn` internally.
|
||||
pub trait HookHandler: Send + Sync {
|
||||
fn handle(&self, event: HookEvent) -> Result<(), PiklError>;
|
||||
}
|
||||
|
||||
/// Parse a single line of JSON as a [`HookResponse`].
|
||||
/// Returns None on parse failure, logging a warning via
|
||||
/// tracing.
|
||||
pub fn parse_hook_response(line: &str) -> Option<HookResponse> {
|
||||
match serde_json::from_str::<HookResponse>(line) {
|
||||
Ok(resp) => {
|
||||
tracing::debug!(response = ?resp, "parsed hook response");
|
||||
Some(resp)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(line, error = %e, "failed to parse hook response");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// -- HookEvent serialization --
|
||||
|
||||
#[test]
|
||||
fn event_open_serializes() {
|
||||
let json = serde_json::to_value(&HookEvent::Open).unwrap_or_default();
|
||||
assert_eq!(json["event"], "open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_close_serializes() {
|
||||
let json = serde_json::to_value(&HookEvent::Close).unwrap_or_default();
|
||||
assert_eq!(json["event"], "close");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_hover_serializes() {
|
||||
let event = HookEvent::Hover {
|
||||
item: json!({"label": "test"}),
|
||||
index: 5,
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||
assert_eq!(json["event"], "hover");
|
||||
assert_eq!(json["item"]["label"], "test");
|
||||
assert_eq!(json["index"], 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_select_serializes() {
|
||||
let event = HookEvent::Select {
|
||||
item: json!("hello"),
|
||||
index: 0,
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||
assert_eq!(json["event"], "select");
|
||||
assert_eq!(json["item"], "hello");
|
||||
assert_eq!(json["index"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_cancel_serializes() {
|
||||
let json = serde_json::to_value(&HookEvent::Cancel).unwrap_or_default();
|
||||
assert_eq!(json["event"], "cancel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_filter_serializes() {
|
||||
let event = HookEvent::Filter {
|
||||
text: "foo".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||
assert_eq!(json["event"], "filter");
|
||||
assert_eq!(json["text"], "foo");
|
||||
}
|
||||
|
||||
// -- HookEvent kind --
|
||||
|
||||
#[test]
|
||||
fn event_kind_matches() {
|
||||
assert_eq!(HookEvent::Open.kind(), HookEventKind::Open);
|
||||
assert_eq!(HookEvent::Close.kind(), HookEventKind::Close);
|
||||
assert_eq!(
|
||||
HookEvent::Hover {
|
||||
item: json!(null),
|
||||
index: 0
|
||||
}
|
||||
.kind(),
|
||||
HookEventKind::Hover
|
||||
);
|
||||
assert_eq!(
|
||||
HookEvent::Select {
|
||||
item: json!(null),
|
||||
index: 0
|
||||
}
|
||||
.kind(),
|
||||
HookEventKind::Select
|
||||
);
|
||||
assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel);
|
||||
assert_eq!(
|
||||
HookEvent::Filter {
|
||||
text: String::new()
|
||||
}
|
||||
.kind(),
|
||||
HookEventKind::Filter
|
||||
);
|
||||
}
|
||||
|
||||
// -- HookResponse deserialization --
|
||||
|
||||
#[test]
|
||||
fn response_add_items() {
|
||||
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
assert_eq!(
|
||||
resp,
|
||||
HookResponse::AddItems {
|
||||
items: vec![json!({"label": "new"})]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_replace_items() {
|
||||
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
assert_eq!(
|
||||
resp,
|
||||
HookResponse::ReplaceItems {
|
||||
items: vec![json!("a"), json!("b")]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_remove_items() {
|
||||
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
assert_eq!(
|
||||
resp,
|
||||
HookResponse::RemoveItems {
|
||||
indices: vec![0, 3, 5]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_set_filter() {
|
||||
let json = r#"{"action": "set_filter", "text": "hello"}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
assert_eq!(
|
||||
resp,
|
||||
HookResponse::SetFilter {
|
||||
text: "hello".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_close() {
|
||||
let json = r#"{"action": "close"}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
assert_eq!(resp, HookResponse::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_unknown_action() {
|
||||
let json = r#"{"action": "explode"}"#;
|
||||
let result = serde_json::from_str::<HookResponse>(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_invalid_json() {
|
||||
let result = serde_json::from_str::<HookResponse>("not json at all");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_missing_required_field() {
|
||||
// add_items without items field
|
||||
let json = r#"{"action": "add_items"}"#;
|
||||
let result = serde_json::from_str::<HookResponse>(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- parse_hook_response --
|
||||
|
||||
#[test]
|
||||
fn parse_valid_response() {
|
||||
let resp = parse_hook_response(r#"{"action": "close"}"#);
|
||||
assert_eq!(resp, Some(HookResponse::Close));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_returns_none() {
|
||||
let resp = parse_hook_response("garbage");
|
||||
assert!(resp.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_action_returns_none() {
|
||||
let resp = parse_hook_response(r#"{"action": "nope"}"#);
|
||||
assert!(resp.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_quicklist_serializes() {
|
||||
let event = HookEvent::Quicklist {
|
||||
items: vec![json!("alpha"), json!("beta")],
|
||||
count: 2,
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||
assert_eq!(json["event"], "quicklist");
|
||||
assert_eq!(json["count"], 2);
|
||||
assert_eq!(json["items"].as_array().map(|a| a.len()), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_kind_quicklist() {
|
||||
let event = HookEvent::Quicklist {
|
||||
items: vec![],
|
||||
count: 0,
|
||||
};
|
||||
assert_eq!(event.kind(), HookEventKind::Quicklist);
|
||||
}
|
||||
|
||||
// -- Roundtrip: HookEvent serialize -> check shape --
|
||||
|
||||
#[test]
|
||||
fn hover_event_roundtrip_shape() {
|
||||
let event = HookEvent::Hover {
|
||||
item: json!({"label": "Firefox", "url": "https://firefox.com"}),
|
||||
index: 2,
|
||||
};
|
||||
let serialized = serde_json::to_string(&event).unwrap_or_default();
|
||||
let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default();
|
||||
assert_eq!(parsed["event"], "hover");
|
||||
assert_eq!(parsed["item"]["label"], "Firefox");
|
||||
assert_eq!(parsed["index"], 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub fn read_items_sync(
|
||||
}
|
||||
items.push(parse_line(&line, label_key));
|
||||
}
|
||||
tracing::debug!(count = items.len(), "items read");
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ pub async fn read_items(
|
||||
}
|
||||
items.push(parse_line(&line, label_key));
|
||||
}
|
||||
tracing::debug!(count = items.len(), "items read");
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! for `ls | pikl` style usage.
|
||||
|
||||
use crate::filter::Filter;
|
||||
use crate::format::FormatTemplate;
|
||||
use crate::item::Item;
|
||||
use crate::model::traits::Menu;
|
||||
use crate::model::traits::{Menu, MutableMenu};
|
||||
use crate::pipeline::FilterPipeline;
|
||||
|
||||
/// A menu backed by a flat list of JSON items. Handles
|
||||
@@ -16,21 +17,90 @@ pub struct JsonMenu {
|
||||
items: Vec<Item>,
|
||||
label_key: String,
|
||||
filter: FilterPipeline,
|
||||
filter_fields: Vec<String>,
|
||||
format_template: Option<FormatTemplate>,
|
||||
}
|
||||
|
||||
impl JsonMenu {
|
||||
/// Create a new JSON menu with the given items and label key.
|
||||
pub fn new(items: Vec<Item>, label_key: String) -> Self {
|
||||
let item_count = items.len();
|
||||
tracing::debug!(item_count, label_key = %label_key, "json menu created");
|
||||
let mut filter = FilterPipeline::new();
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
filter.push(i, item.label());
|
||||
filter.push_with_value(i, item.label(), &item.value);
|
||||
}
|
||||
Self {
|
||||
items,
|
||||
label_key,
|
||||
filter,
|
||||
filter_fields: vec!["label".to_string()],
|
||||
format_template: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set which fields to search during filtering. Each entry
|
||||
/// is a dotted path resolved against the item's JSON value.
|
||||
/// Default is `["label"]`.
|
||||
pub fn set_filter_fields(&mut self, fields: Vec<String>) {
|
||||
self.filter_fields = fields;
|
||||
self.rebuild_pipeline();
|
||||
}
|
||||
|
||||
/// Set the format template for display text.
|
||||
pub fn set_format_template(&mut self, template: FormatTemplate) {
|
||||
self.format_template = Some(template);
|
||||
}
|
||||
|
||||
/// Rebuild the filter pipeline from scratch. Called after
|
||||
/// filter_fields change or item mutations.
|
||||
fn rebuild_pipeline(&mut self) {
|
||||
let items_for_rebuild: Vec<(usize, String)> = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| (i, self.extract_filter_text(item)))
|
||||
.collect();
|
||||
// Rebuild with values for field filter support
|
||||
let refs: Vec<(usize, &str, &serde_json::Value)> = items_for_rebuild
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (idx, s))| (*idx, s.as_str(), &self.items[i].value))
|
||||
.collect();
|
||||
self.filter.rebuild_with_values(&refs);
|
||||
}
|
||||
|
||||
/// Extract the combined filter text for an item based on
|
||||
/// the configured filter_fields.
|
||||
fn extract_filter_text(&self, item: &Item) -> String {
|
||||
if self.filter_fields.len() == 1 && self.filter_fields[0] == "label" {
|
||||
return item.label().to_string();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for field in &self.filter_fields {
|
||||
let text = if field == "label" {
|
||||
Some(item.label().to_string())
|
||||
} else if field == "sublabel" {
|
||||
item.sublabel().map(|s| s.to_string())
|
||||
} else {
|
||||
item.field_value(field).and_then(value_to_string)
|
||||
};
|
||||
if let Some(t) = text {
|
||||
parts.push(t);
|
||||
}
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a JSON value to a string for filtering purposes.
|
||||
fn value_to_string(v: &serde_json::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => Some(v.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Menu for JsonMenu {
|
||||
@@ -52,18 +122,58 @@ impl Menu for JsonMenu {
|
||||
.map(|idx| self.items[idx].label())
|
||||
}
|
||||
|
||||
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
|
||||
for value in values {
|
||||
let idx = self.items.len();
|
||||
let item = Item::new(value, &self.label_key);
|
||||
self.filter.push(idx, item.label());
|
||||
self.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value> {
|
||||
self.filter
|
||||
.matched_index(filtered_index)
|
||||
.map(|idx| &self.items[idx].value)
|
||||
}
|
||||
|
||||
fn original_index(&self, filtered_index: usize) -> Option<usize> {
|
||||
self.filter.matched_index(filtered_index)
|
||||
}
|
||||
|
||||
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
|
||||
let template = self.format_template.as_ref()?;
|
||||
let orig_idx = self.filter.matched_index(filtered_index)?;
|
||||
Some(template.render(&self.items[orig_idx].value))
|
||||
}
|
||||
}
|
||||
|
||||
impl MutableMenu for JsonMenu {
|
||||
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
|
||||
let count = values.len();
|
||||
for value in values {
|
||||
let idx = self.items.len();
|
||||
let item = Item::new(value, &self.label_key);
|
||||
let text = self.extract_filter_text(&item);
|
||||
self.filter.push_with_value(idx, &text, &item.value);
|
||||
self.items.push(item);
|
||||
}
|
||||
tracing::debug!(count, new_total = self.items.len(), "adding items to menu");
|
||||
}
|
||||
|
||||
fn replace_all(&mut self, values: Vec<serde_json::Value>) {
|
||||
tracing::debug!(count = values.len(), "replacing all menu items");
|
||||
self.items = values
|
||||
.into_iter()
|
||||
.map(|v| Item::new(v, &self.label_key))
|
||||
.collect();
|
||||
self.rebuild_pipeline();
|
||||
}
|
||||
|
||||
fn remove_by_indices(&mut self, indices: Vec<usize>) {
|
||||
let count = indices.len();
|
||||
// Sort descending to remove from the end first,
|
||||
// preserving earlier indices.
|
||||
let mut sorted = indices;
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
for &idx in sorted.iter().rev() {
|
||||
if idx < self.items.len() {
|
||||
self.items.remove(idx);
|
||||
}
|
||||
}
|
||||
tracing::debug!(count, remaining = self.items.len(), "items removed from menu");
|
||||
self.rebuild_pipeline();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
|
||||
use crate::error::PiklError;
|
||||
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||
use crate::model::traits::Menu;
|
||||
use crate::hook::{HookEvent, HookHandler};
|
||||
use crate::model::traits::MutableMenu;
|
||||
use crate::navigation::Viewport;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -20,9 +23,13 @@ pub enum ActionOutcome {
|
||||
/// State changed, broadcast to subscribers.
|
||||
Broadcast,
|
||||
/// User confirmed a selection.
|
||||
Selected(Value),
|
||||
Selected { value: Value, index: usize },
|
||||
/// User cancelled.
|
||||
Cancelled,
|
||||
/// Menu closed by hook command.
|
||||
Closed,
|
||||
/// User quicklisted the filtered items.
|
||||
Quicklist { items: Vec<(Value, usize)> },
|
||||
/// Nothing happened (e.g. confirm on empty list).
|
||||
NoOp,
|
||||
}
|
||||
@@ -31,16 +38,19 @@ pub enum ActionOutcome {
|
||||
/// drives it with an action/event channel loop. Create one,
|
||||
/// grab the action sender and event subscriber, then call
|
||||
/// [`MenuRunner::run`] to start the event loop.
|
||||
pub struct MenuRunner<M: Menu> {
|
||||
pub struct MenuRunner<M: MutableMenu> {
|
||||
menu: M,
|
||||
viewport: Viewport,
|
||||
filter_text: Arc<str>,
|
||||
mode: Mode,
|
||||
action_rx: mpsc::Receiver<Action>,
|
||||
event_tx: broadcast::Sender<MenuEvent>,
|
||||
dispatcher: Option<DebouncedDispatcher>,
|
||||
previous_cursor: Option<usize>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl<M: Menu> MenuRunner<M> {
|
||||
impl<M: MutableMenu> MenuRunner<M> {
|
||||
/// Create a menu runner wrapping the given menu backend.
|
||||
/// Returns the runner and an action sender. Call
|
||||
/// [`subscribe`](Self::subscribe) to get an event handle,
|
||||
@@ -59,6 +69,9 @@ impl<M: Menu> MenuRunner<M> {
|
||||
mode: Mode::default(),
|
||||
action_rx,
|
||||
event_tx,
|
||||
dispatcher: None,
|
||||
previous_cursor: None,
|
||||
generation: 0,
|
||||
};
|
||||
(runner, action_tx)
|
||||
}
|
||||
@@ -69,23 +82,56 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Set a hook handler. Wraps it in a DebouncedDispatcher
|
||||
/// with no debounce (all events fire immediately). Use
|
||||
/// [`set_dispatcher`] for custom debounce settings.
|
||||
pub fn set_hook_handler(
|
||||
&mut self,
|
||||
handler: Arc<dyn HookHandler>,
|
||||
action_tx: mpsc::Sender<Action>,
|
||||
) {
|
||||
let dispatcher = DebouncedDispatcher::new(handler, action_tx);
|
||||
self.dispatcher = Some(dispatcher);
|
||||
}
|
||||
|
||||
/// Set a hook handler with a pre-configured dispatcher.
|
||||
pub fn set_dispatcher(&mut self, dispatcher: DebouncedDispatcher) {
|
||||
self.dispatcher = Some(dispatcher);
|
||||
}
|
||||
|
||||
/// Re-run the filter against all items with the current
|
||||
/// filter text. Updates the viewport with the new count.
|
||||
fn run_filter(&mut self) {
|
||||
let start = std::time::Instant::now();
|
||||
self.menu.apply_filter(&self.filter_text);
|
||||
self.viewport.set_filtered_count(self.menu.filtered_count());
|
||||
let matched = self.menu.filtered_count();
|
||||
let total = self.menu.total();
|
||||
let duration_us = start.elapsed().as_micros() as u64;
|
||||
debug!(
|
||||
filter = %self.filter_text,
|
||||
matched,
|
||||
total,
|
||||
duration_us,
|
||||
"filter applied"
|
||||
);
|
||||
self.viewport.set_filtered_count(matched);
|
||||
}
|
||||
|
||||
/// Build a [`ViewState`] snapshot from the current filter
|
||||
/// results and viewport position.
|
||||
fn build_view_state(&self) -> ViewState {
|
||||
fn build_view_state(&mut self) -> ViewState {
|
||||
self.generation += 1;
|
||||
let range = self.viewport.visible_range();
|
||||
let visible_items: Vec<VisibleItem> = range
|
||||
.clone()
|
||||
.filter_map(|i| {
|
||||
self.menu.filtered_label(i).map(|label| VisibleItem {
|
||||
label: label.to_string(),
|
||||
index: i,
|
||||
self.menu.filtered_label(i).map(|label| {
|
||||
let formatted_text = self.menu.formatted_label(i);
|
||||
VisibleItem {
|
||||
label: label.to_string(),
|
||||
formatted_text,
|
||||
index: i,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -103,14 +149,50 @@ impl<M: Menu> MenuRunner<M> {
|
||||
total_items: self.menu.total(),
|
||||
total_filtered: self.menu.filtered_count(),
|
||||
mode: self.mode,
|
||||
generation: self.generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the current view state to all subscribers.
|
||||
fn broadcast_state(&self) {
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(MenuEvent::StateChanged(self.build_view_state()));
|
||||
fn broadcast_state(&mut self) {
|
||||
let vs = self.build_view_state();
|
||||
trace!(
|
||||
generation = vs.generation,
|
||||
filtered = vs.total_filtered,
|
||||
"state broadcast"
|
||||
);
|
||||
let _ = self.event_tx.send(MenuEvent::StateChanged(vs));
|
||||
}
|
||||
|
||||
/// Emit a hook event through the dispatcher, if one is set.
|
||||
fn emit_hook(&mut self, event: HookEvent) {
|
||||
if let Some(dispatcher) = &mut self.dispatcher {
|
||||
debug!(hook_event = ?event.kind(), "hook dispatched");
|
||||
dispatcher.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the cursor moved to a different item and
|
||||
/// emit a Hover event if so.
|
||||
fn check_cursor_hover(&mut self) {
|
||||
if self.menu.filtered_count() == 0 {
|
||||
self.previous_cursor = None;
|
||||
return;
|
||||
}
|
||||
let current = self.viewport.cursor();
|
||||
let current_orig = self.menu.original_index(current);
|
||||
if current_orig != self.previous_cursor {
|
||||
self.previous_cursor = current_orig;
|
||||
if let Some(value) = self.menu.serialize_filtered(current).cloned()
|
||||
&& let Some(orig_idx) = current_orig
|
||||
{
|
||||
trace!(cursor = current, original_index = orig_idx, "hover changed");
|
||||
self.emit_hook(HookEvent::Hover {
|
||||
item: value,
|
||||
index: orig_idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a single action to the menu state. Pure state
|
||||
@@ -118,6 +200,7 @@ impl<M: Menu> MenuRunner<M> {
|
||||
pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
|
||||
match action {
|
||||
Action::UpdateFilter(text) => {
|
||||
debug!(filter = %text, "filter updated");
|
||||
self.filter_text = Arc::from(text);
|
||||
self.run_filter();
|
||||
ActionOutcome::Broadcast
|
||||
@@ -151,13 +234,30 @@ impl<M: Menu> MenuRunner<M> {
|
||||
return ActionOutcome::NoOp;
|
||||
}
|
||||
let cursor = self.viewport.cursor();
|
||||
let index = self.menu.original_index(cursor).unwrap_or(0);
|
||||
match self.menu.serialize_filtered(cursor) {
|
||||
Some(value) => ActionOutcome::Selected(value.clone()),
|
||||
Some(value) => ActionOutcome::Selected {
|
||||
value: value.clone(),
|
||||
index,
|
||||
},
|
||||
None => ActionOutcome::NoOp,
|
||||
}
|
||||
}
|
||||
Action::Quicklist => {
|
||||
if self.menu.filtered_count() == 0 {
|
||||
return ActionOutcome::NoOp;
|
||||
}
|
||||
let items: Vec<(Value, usize)> = self
|
||||
.menu
|
||||
.collect_filtered()
|
||||
.into_iter()
|
||||
.map(|(v, idx)| (v.clone(), idx))
|
||||
.collect();
|
||||
ActionOutcome::Quicklist { items }
|
||||
}
|
||||
Action::Cancel => ActionOutcome::Cancelled,
|
||||
Action::Resize { height } => {
|
||||
trace!(height, "viewport resized");
|
||||
self.viewport.set_height(height as usize);
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
@@ -170,14 +270,53 @@ impl<M: Menu> MenuRunner<M> {
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::SetMode(m) => {
|
||||
debug!(mode = ?m, "mode changed");
|
||||
self.mode = m;
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::AddItems(values) => {
|
||||
debug!(count = values.len(), "items added");
|
||||
self.menu.add_raw(values);
|
||||
self.run_filter();
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::ReplaceItems(values) => {
|
||||
debug!(count = values.len(), "items replaced");
|
||||
// Smart cursor: try to keep selection on the same original item.
|
||||
let cursor = self.viewport.cursor();
|
||||
let old_value = self.menu.serialize_filtered(cursor).cloned();
|
||||
self.menu.replace_all(values);
|
||||
self.run_filter();
|
||||
// Try to find the old item in the new set
|
||||
if let Some(ref old_val) = old_value {
|
||||
let mut found = false;
|
||||
for i in 0..self.menu.filtered_count() {
|
||||
if self.menu.serialize_filtered(i) == Some(old_val) {
|
||||
self.viewport.set_cursor(i);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
self.viewport.clamp();
|
||||
}
|
||||
} else {
|
||||
self.viewport.clamp();
|
||||
}
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::RemoveItems(indices) => {
|
||||
debug!(count = indices.len(), "items removed");
|
||||
self.menu.remove_by_indices(indices);
|
||||
self.run_filter();
|
||||
self.viewport.clamp();
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::ProcessHookResponse(resp) => {
|
||||
let action = hook_response_to_action(resp);
|
||||
self.apply_action(action)
|
||||
}
|
||||
Action::CloseMenu => ActionOutcome::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,14 +344,70 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.run_filter();
|
||||
self.broadcast_state();
|
||||
|
||||
let total_items = self.menu.total();
|
||||
let filtered = self.menu.filtered_count();
|
||||
info!(total_items, filtered, "menu opened");
|
||||
|
||||
// Emit Open event
|
||||
self.emit_hook(HookEvent::Open);
|
||||
|
||||
while let Some(action) = self.action_rx.recv().await {
|
||||
let is_filter_update = matches!(&action, Action::UpdateFilter(_));
|
||||
|
||||
match self.apply_action(action) {
|
||||
ActionOutcome::Broadcast => self.broadcast_state(),
|
||||
ActionOutcome::Selected(value) => {
|
||||
ActionOutcome::Broadcast => {
|
||||
self.broadcast_state();
|
||||
|
||||
// Emit Filter event if the filter changed
|
||||
if is_filter_update {
|
||||
let text = self.filter_text.to_string();
|
||||
self.emit_hook(HookEvent::Filter { text });
|
||||
}
|
||||
|
||||
// Check for cursor movement -> Hover
|
||||
self.check_cursor_hover();
|
||||
}
|
||||
ActionOutcome::Selected { value, index } => {
|
||||
info!(index, "item selected");
|
||||
// Emit Select event
|
||||
self.emit_hook(HookEvent::Select {
|
||||
item: value.clone(),
|
||||
index,
|
||||
});
|
||||
// Emit Close event
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
|
||||
return Ok(MenuResult::Selected(value));
|
||||
return Ok(MenuResult::Selected { value, index });
|
||||
}
|
||||
ActionOutcome::Quicklist { items } => {
|
||||
let values: Vec<Value> =
|
||||
items.iter().map(|(v, _)| v.clone()).collect();
|
||||
let count = values.len();
|
||||
info!(count, "quicklist returned");
|
||||
self.emit_hook(HookEvent::Quicklist {
|
||||
items: values.clone(),
|
||||
count,
|
||||
});
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(MenuEvent::Quicklist(values));
|
||||
return Ok(MenuResult::Quicklist { items });
|
||||
}
|
||||
ActionOutcome::Cancelled => {
|
||||
info!("menu cancelled");
|
||||
self.emit_hook(HookEvent::Cancel);
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Cancelled);
|
||||
return Ok(MenuResult::Cancelled);
|
||||
}
|
||||
ActionOutcome::Closed => {
|
||||
info!("menu closed by hook");
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Cancelled);
|
||||
return Ok(MenuResult::Cancelled);
|
||||
}
|
||||
@@ -221,6 +416,7 @@ impl<M: Menu> MenuRunner<M> {
|
||||
}
|
||||
|
||||
// Sender dropped
|
||||
self.emit_hook(HookEvent::Close);
|
||||
Ok(MenuResult::Cancelled)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +426,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::event::MenuEvent;
|
||||
use crate::item::Item;
|
||||
use crate::model::traits::Menu;
|
||||
use crate::runtime::json_menu::JsonMenu;
|
||||
|
||||
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) {
|
||||
@@ -285,7 +482,7 @@ mod tests {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
let outcome = m.apply_action(Action::Confirm);
|
||||
assert!(matches!(&outcome, ActionOutcome::Selected(v) if v.as_str() == Some("beta")));
|
||||
assert!(matches!(&outcome, ActionOutcome::Selected { value, .. } if value.as_str() == Some("beta")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,7 +610,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(_))));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -474,7 +671,7 @@ mod tests {
|
||||
assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha")));
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("alpha")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("alpha")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -496,7 +693,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("gamma")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("gamma")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -521,7 +718,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -556,7 +753,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("epsilon")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("epsilon")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -608,7 +805,7 @@ mod tests {
|
||||
// Must get "banana". Filter was applied before confirm ran.
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("banana")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -634,7 +831,7 @@ mod tests {
|
||||
// Cursor at index 3 -> "delta"
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -730,7 +927,250 @@ mod tests {
|
||||
// Must find "zephyr". It was added before the filter ran.
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("zephyr")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr")
|
||||
));
|
||||
}
|
||||
|
||||
// -- Replace/Remove/Close action tests --
|
||||
|
||||
#[test]
|
||||
fn apply_replace_items() {
|
||||
let mut m = ready_menu();
|
||||
assert_eq!(m.menu.total(), 4);
|
||||
let outcome = m.apply_action(Action::ReplaceItems(vec![
|
||||
serde_json::Value::String("x".to_string()),
|
||||
serde_json::Value::String("y".to_string()),
|
||||
]));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 2);
|
||||
assert_eq!(m.menu.filtered_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_replace_items_preserves_cursor() {
|
||||
let mut m = ready_menu();
|
||||
// Move to "beta" (index 1)
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
// Replace items, keeping "beta" in the new set
|
||||
m.apply_action(Action::ReplaceItems(vec![
|
||||
serde_json::Value::String("alpha".to_string()),
|
||||
serde_json::Value::String("beta".to_string()),
|
||||
serde_json::Value::String("zeta".to_string()),
|
||||
]));
|
||||
// Cursor should still be on "beta"
|
||||
let vs = m.build_view_state();
|
||||
assert_eq!(vs.visible_items[vs.cursor].label, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_remove_items() {
|
||||
let mut m = ready_menu();
|
||||
assert_eq!(m.menu.total(), 4);
|
||||
let outcome = m.apply_action(Action::RemoveItems(vec![1, 3]));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 2);
|
||||
// alpha and gamma should remain
|
||||
assert_eq!(m.menu.filtered_label(0), Some("alpha"));
|
||||
assert_eq!(m.menu.filtered_label(1), Some("gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_close_menu() {
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::CloseMenu);
|
||||
assert!(matches!(outcome, ActionOutcome::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_hook_response_close() {
|
||||
use crate::hook::HookResponse;
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::Close));
|
||||
assert!(matches!(outcome, ActionOutcome::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_hook_response_add_items() {
|
||||
use crate::hook::HookResponse;
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::AddItems {
|
||||
items: vec![serde_json::json!("new")],
|
||||
}));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_returns_original_index() {
|
||||
let mut m = ready_menu();
|
||||
// Filter to narrow results, then confirm
|
||||
m.apply_action(Action::UpdateFilter("del".to_string()));
|
||||
assert!(m.menu.filtered_count() >= 1);
|
||||
let outcome = m.apply_action(Action::Confirm);
|
||||
// "delta" is at original index 3
|
||||
assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. }));
|
||||
}
|
||||
|
||||
// -- Quicklist tests --
|
||||
|
||||
#[test]
|
||||
fn apply_quicklist_returns_all_filtered() {
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::Quicklist);
|
||||
match outcome {
|
||||
ActionOutcome::Quicklist { items } => {
|
||||
assert_eq!(items.len(), 4);
|
||||
let labels: Vec<&str> = items
|
||||
.iter()
|
||||
.filter_map(|(v, _)| v.as_str())
|
||||
.collect();
|
||||
assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]);
|
||||
}
|
||||
other => panic!("expected Quicklist, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_quicklist_respects_filter() {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::UpdateFilter("a".to_string()));
|
||||
let outcome = m.apply_action(Action::Quicklist);
|
||||
match outcome {
|
||||
ActionOutcome::Quicklist { items } => {
|
||||
// "alpha", "gamma", "delta" all contain 'a'
|
||||
assert!(items.len() >= 2);
|
||||
for (v, _) in &items {
|
||||
let label = v.as_str().unwrap_or("");
|
||||
assert!(
|
||||
label.contains('a'),
|
||||
"filtered item should contain 'a': {label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
other => panic!("expected Quicklist, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_quicklist_on_empty_is_noop() {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::UpdateFilter("zzzzz".to_string()));
|
||||
assert_eq!(m.menu.filtered_count(), 0);
|
||||
let outcome = m.apply_action(Action::Quicklist);
|
||||
assert!(matches!(outcome, ActionOutcome::NoOp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_quicklist_preserves_original_indices() {
|
||||
let mut m = ready_menu();
|
||||
// Filter to "del" -> only "delta" at original index 3
|
||||
m.apply_action(Action::UpdateFilter("del".to_string()));
|
||||
let outcome = m.apply_action(Action::Quicklist);
|
||||
match outcome {
|
||||
ActionOutcome::Quicklist { items } => {
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].0.as_str(), Some("delta"));
|
||||
assert_eq!(items[0].1, 3);
|
||||
}
|
||||
other => panic!("expected Quicklist, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quicklist_event_broadcast() {
|
||||
let (menu, tx) = test_menu();
|
||||
let mut rx = menu.subscribe();
|
||||
|
||||
let handle = tokio::spawn(async move { menu.run().await });
|
||||
|
||||
// Skip initial state
|
||||
let _ = rx.recv().await;
|
||||
let _ = tx.send(Action::Resize { height: 10 }).await;
|
||||
let _ = rx.recv().await;
|
||||
|
||||
let _ = tx.send(Action::Quicklist).await;
|
||||
|
||||
if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await {
|
||||
assert_eq!(values.len(), 4);
|
||||
} else {
|
||||
panic!("expected Quicklist event");
|
||||
}
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Quicklist { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quicklist_after_filter() {
|
||||
let (menu, tx) = test_menu();
|
||||
let mut rx = menu.subscribe();
|
||||
|
||||
let handle = tokio::spawn(async move { menu.run().await });
|
||||
|
||||
let _ = rx.recv().await; // initial
|
||||
let _ = tx.send(Action::Resize { height: 10 }).await;
|
||||
let _ = rx.recv().await;
|
||||
|
||||
// Filter then quicklist back-to-back
|
||||
let _ = tx.send(Action::UpdateFilter("al".to_string())).await;
|
||||
let _ = tx.send(Action::Quicklist).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
match result {
|
||||
Ok(MenuResult::Quicklist { items }) => {
|
||||
// "alpha" matches "al"
|
||||
assert!(!items.is_empty());
|
||||
for (v, _) in &items {
|
||||
let label = v.as_str().unwrap_or("");
|
||||
assert!(
|
||||
label.contains("al"),
|
||||
"quicklist item should match filter: {label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
other => panic!("expected Quicklist, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// -- Hook event tests --
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_events_fire_on_lifecycle() {
|
||||
use crate::hook::{HookEvent, HookEventKind, HookHandler};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct Recorder(Mutex<Vec<HookEventKind>>);
|
||||
impl HookHandler for Recorder {
|
||||
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
|
||||
if let Ok(mut v) = self.0.lock() {
|
||||
v.push(event.kind());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let recorder = Arc::new(Recorder(Mutex::new(Vec::new())));
|
||||
let (mut m, action_tx) = test_menu();
|
||||
m.set_hook_handler(Arc::clone(&recorder) as Arc<dyn HookHandler>, action_tx);
|
||||
m.run_filter();
|
||||
m.apply_action(Action::Resize { height: 10 });
|
||||
|
||||
// Simulate lifecycle: the Open event is emitted in run(),
|
||||
// but we can test Filter/Hover/Cancel manually
|
||||
m.emit_hook(HookEvent::Open);
|
||||
m.apply_action(Action::UpdateFilter("al".to_string()));
|
||||
m.emit_hook(HookEvent::Filter {
|
||||
text: "al".to_string(),
|
||||
});
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
m.check_cursor_hover();
|
||||
|
||||
// Give spawned tasks a chance to complete
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
let events = recorder.0.lock().map(|v| v.clone()).unwrap_or_default();
|
||||
assert!(events.contains(&HookEventKind::Open));
|
||||
assert!(events.contains(&HookEventKind::Filter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod debounce;
|
||||
pub mod hook;
|
||||
pub mod input;
|
||||
pub mod json_menu;
|
||||
|
||||
@@ -327,7 +327,7 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||
} else if let Some(ref expected) = case.selected {
|
||||
quote! {
|
||||
match &result {
|
||||
Ok(MenuResult::Selected(value)) => {
|
||||
Ok(MenuResult::Selected { value, .. }) => {
|
||||
let got = value.as_str()
|
||||
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
|
||||
.unwrap_or("");
|
||||
|
||||
@@ -14,3 +14,4 @@ ratatui = "0.30"
|
||||
crossterm = { version = "0.29", features = ["event-stream"] }
|
||||
tokio = { version = "1", features = ["sync", "macros", "rt"] }
|
||||
futures = "0.3"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -82,6 +82,7 @@ async fn run_inner(
|
||||
let mut event_stream = EventStream::new();
|
||||
let mut mode = Mode::Insert;
|
||||
let mut pending = PendingKey::None;
|
||||
let mut last_generation: u64 = 0;
|
||||
|
||||
loop {
|
||||
if let Some(ref vs) = view_state {
|
||||
@@ -118,6 +119,11 @@ async fn run_inner(
|
||||
menu_event = event_rx.recv() => {
|
||||
match menu_event {
|
||||
Ok(MenuEvent::StateChanged(vs)) => {
|
||||
// Skip duplicate broadcasts
|
||||
if vs.generation == last_generation {
|
||||
continue;
|
||||
}
|
||||
last_generation = vs.generation;
|
||||
// Sync filter text from core. Local keystrokes
|
||||
// update filter_text immediately for responsiveness,
|
||||
// but if core pushes a different value (e.g. IPC
|
||||
@@ -132,10 +138,12 @@ async fn run_inner(
|
||||
}
|
||||
view_state = Some(vs);
|
||||
}
|
||||
Ok(MenuEvent::Selected(_) | MenuEvent::Cancelled) => {
|
||||
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
|
||||
break;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(skipped = n, "TUI fell behind on state broadcasts");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
@@ -194,7 +202,8 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(vi.label.as_str()).style(style)
|
||||
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
||||
ListItem::new(text).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -232,6 +241,7 @@ fn map_insert_mode(key: KeyEvent, filter_text: &mut String) -> Option<Action> {
|
||||
filter_text.pop();
|
||||
Some(Action::UpdateFilter(filter_text.clone()))
|
||||
}
|
||||
(KeyCode::Char('q'), KeyModifiers::CONTROL) => Some(Action::Quicklist),
|
||||
(KeyCode::Char(c), mods) if !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
filter_text.push(c);
|
||||
Some(Action::UpdateFilter(filter_text.clone()))
|
||||
@@ -275,6 +285,7 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
||||
(KeyCode::Char('/'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
Some(Action::SetMode(Mode::Insert))
|
||||
}
|
||||
(KeyCode::Char('q'), KeyModifiers::CONTROL) => Some(Action::Quicklist),
|
||||
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
Some(Action::Cancel)
|
||||
}
|
||||
@@ -318,14 +329,17 @@ mod tests {
|
||||
visible_items: vec![
|
||||
VisibleItem {
|
||||
label: "alpha".into(),
|
||||
formatted_text: None,
|
||||
index: 0,
|
||||
},
|
||||
VisibleItem {
|
||||
label: "bravo".into(),
|
||||
formatted_text: None,
|
||||
index: 1,
|
||||
},
|
||||
VisibleItem {
|
||||
label: "charlie".into(),
|
||||
formatted_text: None,
|
||||
index: 2,
|
||||
},
|
||||
],
|
||||
@@ -334,6 +348,7 @@ mod tests {
|
||||
total_items: 5,
|
||||
total_filtered: 3,
|
||||
mode: Mode::Insert,
|
||||
generation: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +674,28 @@ mod tests {
|
||||
assert_eq!(ft, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_q_maps_to_quicklist_insert() {
|
||||
let mut ft = String::new();
|
||||
let mut pending = PendingKey::None;
|
||||
let k = key_with_mods(KeyCode::Char('q'), KeyModifiers::CONTROL);
|
||||
assert_eq!(
|
||||
map_key_event(k, &mut ft, Mode::Insert, &mut pending),
|
||||
Some(Action::Quicklist)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_q_maps_to_quicklist_normal() {
|
||||
let mut ft = String::new();
|
||||
let mut pending = PendingKey::None;
|
||||
let k = key_with_mods(KeyCode::Char('q'), KeyModifiers::CONTROL);
|
||||
assert_eq!(
|
||||
map_key_event(k, &mut ft, Mode::Normal, &mut pending),
|
||||
Some(Action::Quicklist)
|
||||
);
|
||||
}
|
||||
|
||||
// -- Rendering tests (TestBackend) --
|
||||
|
||||
fn render_to_backend(width: u16, height: u16, vs: &ViewState, filter: &str) -> TestBackend {
|
||||
@@ -783,6 +820,7 @@ mod tests {
|
||||
total_items: 0,
|
||||
total_filtered: 0,
|
||||
mode: Mode::Insert,
|
||||
generation: 1,
|
||||
};
|
||||
let backend = render_to_backend(30, 4, &vs, "");
|
||||
let prompt = line_text(&backend, 0);
|
||||
|
||||
@@ -18,6 +18,8 @@ pikl-tui = { path = "../pikl-tui" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
204
crates/pikl/src/handler.rs
Normal file
204
crates/pikl/src/handler.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! Bidirectional handler hooks. Persistent subprocess that
|
||||
//! receives events as JSON lines on stdin and emits
|
||||
//! responses as JSON lines on stdout. Responses are parsed
|
||||
//! and sent back to the menu engine through the action
|
||||
//! channel.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use pikl_core::error::PiklError;
|
||||
use pikl_core::event::Action;
|
||||
use pikl_core::hook::{
|
||||
parse_hook_response, HookEvent, HookEventKind, HookHandler,
|
||||
};
|
||||
|
||||
/// A persistent handler hook process. Spawns a child process,
|
||||
/// writes events to its stdin as JSON lines, reads responses
|
||||
/// from its stdout as JSON lines. Responses are converted to
|
||||
/// Actions and sent through the action channel.
|
||||
pub struct ShellHandlerHook {
|
||||
/// Event senders, one per active handler process.
|
||||
event_txs: HashMap<HookEventKind, mpsc::Sender<HookEvent>>,
|
||||
}
|
||||
|
||||
impl ShellHandlerHook {
|
||||
/// Build from CLI flags. Returns None if no handler hooks are configured.
|
||||
pub fn from_cli(
|
||||
cli: &crate::Cli,
|
||||
action_tx: mpsc::Sender<Action>,
|
||||
) -> Option<Self> {
|
||||
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
|
||||
|
||||
if let Some(ref cmd) = cli.on_open {
|
||||
handlers.push((HookEventKind::Open, cmd));
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_close {
|
||||
handlers.push((HookEventKind::Close, cmd));
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_hover {
|
||||
handlers.push((HookEventKind::Hover, cmd));
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_select {
|
||||
handlers.push((HookEventKind::Select, cmd));
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_cancel {
|
||||
handlers.push((HookEventKind::Cancel, cmd));
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_filter {
|
||||
handlers.push((HookEventKind::Filter, cmd));
|
||||
}
|
||||
|
||||
if handlers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut event_txs = HashMap::new();
|
||||
|
||||
for (kind, cmd) in handlers {
|
||||
let (event_tx, event_rx) = mpsc::channel::<HookEvent>(64);
|
||||
let cmd = cmd.to_string();
|
||||
let atx = action_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_handler_process(&cmd, event_rx, atx).await {
|
||||
tracing::warn!(error = %e, command = %cmd, "handler hook process failed");
|
||||
}
|
||||
});
|
||||
|
||||
event_txs.insert(kind, event_tx);
|
||||
}
|
||||
|
||||
Some(Self { event_txs })
|
||||
}
|
||||
}
|
||||
|
||||
impl HookHandler for ShellHandlerHook {
|
||||
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
|
||||
let kind = event.kind();
|
||||
if let Some(tx) = self.event_txs.get(&kind) {
|
||||
// Non-blocking send. If the channel is full, drop the event.
|
||||
let _ = tx.try_send(event);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a persistent handler process. Reads events from the
|
||||
/// channel, writes them as JSON lines to the child's stdin.
|
||||
/// Reads JSON lines from the child's stdout and converts
|
||||
/// them to Actions sent through action_tx.
|
||||
async fn run_handler_process(
|
||||
command: &str,
|
||||
mut event_rx: mpsc::Receiver<HookEvent>,
|
||||
action_tx: mpsc::Sender<Action>,
|
||||
) -> Result<(), PiklError> {
|
||||
let mut child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()?;
|
||||
|
||||
let child_stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| PiklError::Io(std::io::Error::other("failed to open handler stdin")))?;
|
||||
|
||||
let child_stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| PiklError::Io(std::io::Error::other("failed to open handler stdout")))?;
|
||||
|
||||
let mut stdin_writer = child_stdin;
|
||||
let stdout_reader = BufReader::new(child_stdout);
|
||||
|
||||
// Spawn reader task: reads JSON lines from child stdout
|
||||
let reader_action_tx = action_tx.clone();
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
let mut lines = stdout_reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(resp) = parse_hook_response(&line) {
|
||||
let action = Action::ProcessHookResponse(resp);
|
||||
if reader_action_tx.send(action).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Writer loop: reads events from channel, writes to stdin
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
let json = serde_json::to_string(&event).unwrap_or_default();
|
||||
if stdin_writer
|
||||
.write_all(json.as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
if stdin_writer
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
if stdin_writer.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Close stdin to signal the child
|
||||
drop(stdin_writer);
|
||||
|
||||
// Wait up to 1s for child to exit after EOF
|
||||
let exited = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
child.wait(),
|
||||
)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
if !exited {
|
||||
// Send SIGTERM on Unix, then wait another second
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Some(pid) = child.id() {
|
||||
// SAFETY: pid is valid (child is still running), SIGTERM is
|
||||
// a standard signal. kill() returns 0 on success or -1 on
|
||||
// error, which we ignore (child may have exited between the
|
||||
// check and the signal).
|
||||
unsafe {
|
||||
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
let termed = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
child.wait(),
|
||||
)
|
||||
.await
|
||||
.is_ok();
|
||||
if !termed {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the reader to finish
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
reader_handle,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
//! Shell hook execution. Hooks are shell commands that fire
|
||||
//! on menu events (selection, cancellation). The selected
|
||||
//! item's JSON is piped to the hook's stdin.
|
||||
//!
|
||||
//! Hook stdout is redirected to stderr so it doesn't end up
|
||||
//! mixed into pikl's structured output on stdout.
|
||||
//! Shell exec hooks. Fire-and-forget subprocess per event.
|
||||
//! The hook's stdin receives the event JSON, stdout is
|
||||
//! redirected to stderr to keep pikl's output clean.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use pikl_core::error::PiklError;
|
||||
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler};
|
||||
|
||||
/// Duplicate stderr as a [`Stdio`] handle for use as a
|
||||
/// child process's stdout. Keeps hook output on stderr
|
||||
@@ -18,15 +18,19 @@ fn stderr_as_stdio() -> std::process::Stdio {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::FromRawFd;
|
||||
// SAFETY: STDERR_FILENO is a valid open fd in any running process.
|
||||
// dup() returns a new fd or -1, which we check below.
|
||||
let fd = unsafe { libc::dup(libc::STDERR_FILENO) };
|
||||
if fd >= 0 {
|
||||
// SAFETY: fd is valid (checked >= 0 above) and we take exclusive
|
||||
// ownership here (no aliasing). The File will close it on drop.
|
||||
return unsafe { std::process::Stdio::from(std::fs::File::from_raw_fd(fd)) };
|
||||
}
|
||||
}
|
||||
std::process::Stdio::inherit()
|
||||
}
|
||||
|
||||
/// Run a shell hook, piping the value as JSON to stdin.
|
||||
/// Run a shell command, piping the value as JSON to stdin.
|
||||
/// Hook stdout goes to stderr (see module docs). Returns
|
||||
/// an error if the command exits non-zero.
|
||||
pub async fn run_hook(command: &str, value: &Value) -> Result<(), PiklError> {
|
||||
@@ -48,8 +52,7 @@ async fn write_json_stdin(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a shell hook with a custom stdout handle. Used by
|
||||
/// [`run_hook`] to redirect hook output to stderr.
|
||||
/// Run a shell hook with a custom stdout handle.
|
||||
async fn run_hook_with_stdout(
|
||||
command: &str,
|
||||
value: &Value,
|
||||
@@ -75,6 +78,61 @@ async fn run_hook_with_stdout(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fire-and-forget shell hook handler. Spawns a subprocess
|
||||
/// for each event that has a registered command. Always
|
||||
/// returns Ok(vec![]) since exec hooks don't send responses.
|
||||
pub struct ShellExecHandler {
|
||||
commands: HashMap<HookEventKind, String>,
|
||||
}
|
||||
|
||||
impl ShellExecHandler {
|
||||
/// Build from CLI flags.
|
||||
pub fn from_cli(cli: &crate::Cli) -> Self {
|
||||
let mut commands = HashMap::new();
|
||||
if let Some(ref cmd) = cli.on_open_exec {
|
||||
commands.insert(HookEventKind::Open, cmd.clone());
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_close_exec {
|
||||
commands.insert(HookEventKind::Close, cmd.clone());
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_hover_exec {
|
||||
commands.insert(HookEventKind::Hover, cmd.clone());
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_select_exec {
|
||||
commands.insert(HookEventKind::Select, cmd.clone());
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_cancel_exec {
|
||||
commands.insert(HookEventKind::Cancel, cmd.clone());
|
||||
}
|
||||
if let Some(ref cmd) = cli.on_filter_exec {
|
||||
commands.insert(HookEventKind::Filter, cmd.clone());
|
||||
}
|
||||
Self { commands }
|
||||
}
|
||||
|
||||
pub fn has_hooks(&self) -> bool {
|
||||
!self.commands.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl HookHandler for ShellExecHandler {
|
||||
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
|
||||
let kind = event.kind();
|
||||
if let Some(cmd) = self.commands.get(&kind) {
|
||||
let cmd = cmd.clone();
|
||||
// Serialize event as JSON for the hook's stdin
|
||||
let event_json =
|
||||
serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_hook(&cmd, &event_json).await {
|
||||
tracing::warn!(error = %e, command = %cmd, "exec hook failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -94,35 +152,6 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Hook stdin verification --
|
||||
|
||||
/// Helper: run `cat` with piped stdout so we can capture what it echoes back
|
||||
/// from the value JSON written to stdin.
|
||||
async fn capture_hook_stdin(value: &Value) -> String {
|
||||
let child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("cat")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn();
|
||||
let Ok(mut child) = child else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let _ = write_json_stdin(&mut child, value).await;
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.unwrap_or_else(|_| std::process::Output {
|
||||
status: std::process::ExitStatus::default(),
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
});
|
||||
String::from_utf8(output.stdout).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_json_stdin_sends_correct_data() {
|
||||
let value = json!({"key": "value"});
|
||||
@@ -141,99 +170,29 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn hook_receives_plain_text_json() {
|
||||
let value = json!("hello");
|
||||
let got = capture_hook_stdin(&value).await;
|
||||
assert_eq!(got, r#""hello""#);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_receives_object_json() {
|
||||
let value = json!({"label": "foo", "value": 42});
|
||||
let got = capture_hook_stdin(&value).await;
|
||||
let parsed: Value = serde_json::from_str(&got).unwrap_or_default();
|
||||
assert_eq!(parsed["label"], "foo");
|
||||
assert_eq!(parsed["value"], 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_receives_special_chars() {
|
||||
let value = json!("he said \"hi\"\nand left");
|
||||
let got = capture_hook_stdin(&value).await;
|
||||
let parsed: Value = serde_json::from_str(&got).unwrap_or_default();
|
||||
assert_eq!(
|
||||
parsed.as_str().unwrap_or_default(),
|
||||
"he said \"hi\"\nand left"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Hook stdout-to-stderr redirection --
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_stdout_not_on_piped_stdout() {
|
||||
// With piped stdout, `echo hello` output is capturable:
|
||||
let value = json!("test");
|
||||
let child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("echo hello")
|
||||
.arg("cat")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn();
|
||||
assert!(child.is_ok(), "should be able to spawn echo");
|
||||
if let Ok(mut child) = child {
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let json = serde_json::to_string(&value).unwrap_or_default();
|
||||
let _ = stdin.write_all(json.as_bytes()).await;
|
||||
drop(stdin);
|
||||
}
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.unwrap_or_else(|_| std::process::Output {
|
||||
status: std::process::ExitStatus::default(),
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
});
|
||||
let piped_out = String::from_utf8(output.stdout).unwrap_or_default();
|
||||
assert_eq!(piped_out.trim(), "hello");
|
||||
}
|
||||
|
||||
// With stderr_as_stdio(), hook stdout is redirected away from stdout.
|
||||
// Verify the hook still succeeds (output goes to stderr instead).
|
||||
let result = run_hook("echo hello", &value).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stderr_as_stdio_returns_valid_fd() {
|
||||
// Verify stderr_as_stdio() produces a usable Stdio.
|
||||
// A child process using it should spawn and exit cleanly.
|
||||
let child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("echo ok >&1")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(stderr_as_stdio())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn();
|
||||
assert!(child.is_ok());
|
||||
let Ok(mut child) = child else {
|
||||
return;
|
||||
};
|
||||
let _ = write_json_stdin(&mut child, &value).await;
|
||||
let output = child
|
||||
.unwrap_or_else(|_| unreachable!())
|
||||
.wait_with_output()
|
||||
.await;
|
||||
assert!(output.is_ok());
|
||||
assert!(
|
||||
output
|
||||
.unwrap_or_else(|_| std::process::Output {
|
||||
status: std::process::ExitStatus::default(),
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
})
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
.await
|
||||
.unwrap_or_else(|_| std::process::Output {
|
||||
status: std::process::ExitStatus::default(),
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
});
|
||||
let got = String::from_utf8(output.stdout).unwrap_or_default();
|
||||
assert_eq!(got, r#""hello""#);
|
||||
}
|
||||
|
||||
// -- Hook error propagation --
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_nonzero_exit() {
|
||||
let value = json!("test");
|
||||
@@ -245,34 +204,4 @@ mod tests {
|
||||
assert_eq!(status.code(), Some(42));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_missing_command() {
|
||||
let value = json!("test");
|
||||
let result = run_hook("/nonexistent_binary_that_does_not_exist_12345", &value).await;
|
||||
// sh -c will fail with 127 (command not found)
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_empty_command() {
|
||||
let value = json!("test");
|
||||
// Empty string passed to sh -c is a no-op, exits 0
|
||||
let result = run_hook("", &value).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_with_stdout_uses_custom_stdio() {
|
||||
let value = json!("custom");
|
||||
let result = run_hook_with_stdout("echo ok", &value, std::process::Stdio::piped()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_with_stdout_propagates_failure() {
|
||||
let value = json!("test");
|
||||
let result = run_hook_with_stdout("exit 1", &value, std::process::Stdio::piped()).await;
|
||||
assert!(matches!(result, Err(PiklError::HookFailed { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
mod handler;
|
||||
mod hook;
|
||||
|
||||
use std::io::{BufReader, IsTerminal, Write};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
|
||||
use pikl_core::error::PiklError;
|
||||
use pikl_core::event::{Action, MenuResult, Mode};
|
||||
use pikl_core::format::FormatTemplate;
|
||||
use pikl_core::hook::{HookEventKind, HookHandler};
|
||||
use pikl_core::input::read_items_sync;
|
||||
use pikl_core::item::Item;
|
||||
use pikl_core::json_menu::JsonMenu;
|
||||
use pikl_core::menu::MenuRunner;
|
||||
use pikl_core::output::{OutputAction, OutputItem};
|
||||
use pikl_core::script::action_fd::{self, ScriptAction, ShowAction};
|
||||
use serde_json::Value;
|
||||
|
||||
use handler::ShellHandlerHook;
|
||||
use hook::ShellExecHandler;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
@@ -23,14 +33,69 @@ struct Cli {
|
||||
#[arg(long, default_value = "label")]
|
||||
label_key: String,
|
||||
|
||||
/// Shell command to run on selection (item JSON piped to stdin)
|
||||
// -- Exec hooks (fire-and-forget subprocess per event) --
|
||||
/// Shell command to run on menu open
|
||||
#[arg(long)]
|
||||
on_select: Option<String>,
|
||||
on_open_exec: Option<String>,
|
||||
|
||||
/// Shell command to run on menu close
|
||||
#[arg(long)]
|
||||
on_close_exec: Option<String>,
|
||||
|
||||
/// Shell command to run on cursor hover (item JSON on stdin)
|
||||
#[arg(long)]
|
||||
on_hover_exec: Option<String>,
|
||||
|
||||
/// Shell command to run on selection (item JSON on stdin)
|
||||
#[arg(long)]
|
||||
on_select_exec: Option<String>,
|
||||
|
||||
/// Shell command to run on cancel
|
||||
#[arg(long)]
|
||||
on_cancel_exec: Option<String>,
|
||||
|
||||
/// Shell command to run on filter change
|
||||
#[arg(long)]
|
||||
on_filter_exec: Option<String>,
|
||||
|
||||
// -- Handler hooks (persistent bidirectional process) --
|
||||
/// Handler hook command for open events
|
||||
#[arg(long)]
|
||||
on_open: Option<String>,
|
||||
|
||||
/// Handler hook command for close events
|
||||
#[arg(long)]
|
||||
on_close: Option<String>,
|
||||
|
||||
/// Handler hook command for hover events
|
||||
#[arg(long)]
|
||||
on_hover: Option<String>,
|
||||
|
||||
/// Handler hook command for select events
|
||||
#[arg(long)]
|
||||
on_select: Option<String>,
|
||||
|
||||
/// Handler hook command for cancel events
|
||||
#[arg(long)]
|
||||
on_cancel: Option<String>,
|
||||
|
||||
/// Handler hook command for filter events
|
||||
#[arg(long)]
|
||||
on_filter: Option<String>,
|
||||
|
||||
// -- Debounce flags --
|
||||
/// Debounce delay in ms for hover hooks (default: 200)
|
||||
#[arg(long, value_name = "MS")]
|
||||
on_hover_debounce: Option<u64>,
|
||||
|
||||
/// Cancel in-flight hover hooks when a new hover fires
|
||||
#[arg(long)]
|
||||
on_hover_cancel_stale: bool,
|
||||
|
||||
/// Debounce delay in ms for filter hooks (default: 200)
|
||||
#[arg(long, value_name = "MS")]
|
||||
on_filter_debounce: Option<u64>,
|
||||
|
||||
/// Read action script from this file descriptor (enables headless mode)
|
||||
#[arg(long, value_name = "FD")]
|
||||
action_fd: Option<i32>,
|
||||
@@ -42,9 +107,26 @@ struct Cli {
|
||||
/// Start in this input mode (insert or normal, default: insert)
|
||||
#[arg(long, value_name = "MODE", default_value = "insert")]
|
||||
start_mode: String,
|
||||
|
||||
/// Comma-separated list of fields to search during filtering
|
||||
#[arg(long, value_name = "FIELDS", value_delimiter = ',')]
|
||||
filter_fields: Option<Vec<String>>,
|
||||
|
||||
/// Format template for display text (e.g. "{label} - {sublabel}")
|
||||
#[arg(long, value_name = "TEMPLATE")]
|
||||
format: Option<String>,
|
||||
|
||||
/// Wrap output in structured JSON with action metadata
|
||||
#[arg(long)]
|
||||
structured: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Initialize tracing from RUST_LOG env var
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Install a panic hook that restores the terminal so a crash
|
||||
@@ -121,9 +203,6 @@ fn main() {
|
||||
});
|
||||
|
||||
// Reopen stdin from /dev/tty before entering async context.
|
||||
// Both headless (show-ui branch) and interactive paths need this,
|
||||
// so do it once here. Headless-only (no show-ui) doesn't need
|
||||
// terminal input, but reopening is harmless.
|
||||
if script.is_none()
|
||||
&& let Err(e) = reopen_stdin_from_tty()
|
||||
{
|
||||
@@ -145,27 +224,120 @@ fn main() {
|
||||
};
|
||||
|
||||
// STEP 4: Branch on headless vs interactive
|
||||
let label_key = cli.label_key.clone();
|
||||
let result = if let Some(script) = script {
|
||||
rt.block_on(run_headless(items, label_key, script, start_mode))
|
||||
rt.block_on(run_headless(items, &cli, script, start_mode))
|
||||
} else {
|
||||
rt.block_on(run_interactive(items, label_key, start_mode))
|
||||
rt.block_on(run_interactive(items, &cli, start_mode))
|
||||
};
|
||||
|
||||
// STEP 5: Handle result
|
||||
handle_result(result, &cli, &rt);
|
||||
handle_result(result, &cli);
|
||||
}
|
||||
|
||||
/// Build a JsonMenu with optional filter_fields and format template.
|
||||
fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
|
||||
let mut menu = JsonMenu::new(items, cli.label_key.clone());
|
||||
if let Some(ref fields) = cli.filter_fields {
|
||||
menu.set_filter_fields(fields.clone());
|
||||
}
|
||||
if let Some(ref template) = cli.format {
|
||||
menu.set_format_template(FormatTemplate::parse(template));
|
||||
}
|
||||
menu
|
||||
}
|
||||
|
||||
/// Build the composite hook handler from CLI flags, if any hooks are specified.
|
||||
fn build_hook_handler(
|
||||
cli: &Cli,
|
||||
action_tx: &tokio::sync::mpsc::Sender<Action>,
|
||||
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
|
||||
let exec_handler = ShellExecHandler::from_cli(cli);
|
||||
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
|
||||
|
||||
let has_exec = exec_handler.has_hooks();
|
||||
let has_handler = handler_hook.is_some();
|
||||
|
||||
if !has_exec && !has_handler {
|
||||
return None;
|
||||
}
|
||||
|
||||
let handler: Arc<dyn HookHandler> = if has_exec && has_handler {
|
||||
Arc::new(CompositeHookHandler {
|
||||
exec: exec_handler,
|
||||
handler: handler_hook,
|
||||
})
|
||||
} else if has_handler {
|
||||
if let Some(h) = handler_hook {
|
||||
Arc::new(h)
|
||||
} else {
|
||||
Arc::new(exec_handler)
|
||||
}
|
||||
} else {
|
||||
Arc::new(exec_handler)
|
||||
};
|
||||
|
||||
let mut dispatcher = DebouncedDispatcher::new(Arc::clone(&handler), action_tx.clone());
|
||||
dispatcher.apply_defaults();
|
||||
|
||||
// Apply custom debounce settings
|
||||
if let Some(ms) = cli.on_hover_debounce {
|
||||
let mode = if cli.on_hover_cancel_stale {
|
||||
DebounceMode::DebounceAndCancelStale(Duration::from_millis(ms))
|
||||
} else {
|
||||
DebounceMode::Debounce(Duration::from_millis(ms))
|
||||
};
|
||||
dispatcher.set_mode(HookEventKind::Hover, mode);
|
||||
} else if cli.on_hover_cancel_stale {
|
||||
dispatcher.set_mode(HookEventKind::Hover, DebounceMode::CancelStale);
|
||||
}
|
||||
|
||||
if let Some(ms) = cli.on_filter_debounce {
|
||||
dispatcher.set_mode(
|
||||
HookEventKind::Filter,
|
||||
DebounceMode::Debounce(Duration::from_millis(ms)),
|
||||
);
|
||||
}
|
||||
|
||||
Some((handler, dispatcher))
|
||||
}
|
||||
|
||||
/// Composite handler that delegates to exec and handler hooks
|
||||
/// based on whether they have a command for the event kind.
|
||||
struct CompositeHookHandler {
|
||||
exec: ShellExecHandler,
|
||||
handler: Option<ShellHandlerHook>,
|
||||
}
|
||||
|
||||
impl HookHandler for CompositeHookHandler {
|
||||
fn handle(
|
||||
&self,
|
||||
event: pikl_core::hook::HookEvent,
|
||||
) -> Result<(), PiklError> {
|
||||
// Both fire. Exec is fire-and-forget, handler may
|
||||
// send responses through action_tx.
|
||||
let _ = self.exec.handle(event.clone());
|
||||
if let Some(ref h) = self.handler {
|
||||
h.handle(event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run in headless mode: replay a script, optionally hand
|
||||
/// off to a TUI if the script ends with show-ui/show-tui/show-gui.
|
||||
async fn run_headless(
|
||||
items: Vec<Item>,
|
||||
label_key: String,
|
||||
cli: &Cli,
|
||||
script: Vec<ScriptAction>,
|
||||
start_mode: Mode,
|
||||
) -> Result<MenuResult, PiklError> {
|
||||
let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key));
|
||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
||||
menu.set_initial_mode(start_mode);
|
||||
|
||||
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||
menu.set_dispatcher(dispatcher);
|
||||
}
|
||||
|
||||
let event_rx = menu.subscribe();
|
||||
|
||||
// Default headless viewport
|
||||
@@ -182,7 +354,6 @@ async fn run_headless(
|
||||
|
||||
match show_action {
|
||||
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => {
|
||||
// GUI doesn't exist yet. All show-* variants launch TUI for now.
|
||||
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx));
|
||||
let result = menu_handle
|
||||
.await
|
||||
@@ -204,11 +375,16 @@ async fn run_headless(
|
||||
/// pick from the menu.
|
||||
async fn run_interactive(
|
||||
items: Vec<Item>,
|
||||
label_key: String,
|
||||
cli: &Cli,
|
||||
start_mode: Mode,
|
||||
) -> Result<MenuResult, PiklError> {
|
||||
let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key));
|
||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
||||
menu.set_initial_mode(start_mode);
|
||||
|
||||
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||
menu.set_dispatcher(dispatcher);
|
||||
}
|
||||
|
||||
let event_rx = menu.subscribe();
|
||||
|
||||
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
|
||||
@@ -216,7 +392,6 @@ async fn run_interactive(
|
||||
tokio::spawn(async move {
|
||||
if let Ok(()) = tokio::signal::ctrl_c().await {
|
||||
pikl_tui::restore_terminal();
|
||||
// Send cancel so the menu loop exits cleanly.
|
||||
let _ = signal_tx.send(Action::Cancel).await;
|
||||
}
|
||||
});
|
||||
@@ -229,42 +404,48 @@ async fn run_interactive(
|
||||
result
|
||||
}
|
||||
|
||||
/// Serialize a value as JSON and write it to the given writer.
|
||||
fn write_selected_json(
|
||||
writer: &mut impl Write,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let json = serde_json::to_string(value).unwrap_or_default();
|
||||
writeln!(writer, "{json}")
|
||||
}
|
||||
|
||||
/// Run a hook command if present. On failure, print the error
|
||||
/// to stderr and exit.
|
||||
fn run_result_hook(
|
||||
rt: &tokio::runtime::Runtime,
|
||||
hook_name: &str,
|
||||
command: Option<&str>,
|
||||
value: &serde_json::Value,
|
||||
) {
|
||||
if let Some(cmd) = command
|
||||
&& let Err(e) = rt.block_on(hook::run_hook(cmd, value))
|
||||
{
|
||||
let _ = writeln!(std::io::stderr().lock(), "pikl: {hook_name} hook: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the menu result: print selected item JSON to
|
||||
/// stdout, run hooks, or exit with the appropriate code.
|
||||
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli, rt: &tokio::runtime::Runtime) {
|
||||
/// Process the menu result: print output to stdout and
|
||||
/// exit with the appropriate code.
|
||||
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
||||
let mut out = std::io::stdout().lock();
|
||||
match result {
|
||||
Ok(MenuResult::Selected(value)) => {
|
||||
run_result_hook(rt, "on-select", cli.on_select.as_deref(), &value);
|
||||
let _ = write_selected_json(&mut std::io::stdout().lock(), &value);
|
||||
Ok(MenuResult::Selected { value, index }) => {
|
||||
if cli.structured {
|
||||
let output = OutputItem {
|
||||
value,
|
||||
action: OutputAction::Select,
|
||||
index,
|
||||
};
|
||||
let _ = write_output_json(&mut out, &output);
|
||||
} else {
|
||||
let _ = write_plain_value(&mut out, &value);
|
||||
}
|
||||
}
|
||||
Ok(MenuResult::Quicklist { items }) => {
|
||||
if cli.structured {
|
||||
for (value, index) in items {
|
||||
let output = OutputItem {
|
||||
value,
|
||||
action: OutputAction::Quicklist,
|
||||
index,
|
||||
};
|
||||
let _ = write_output_json(&mut out, &output);
|
||||
}
|
||||
} else {
|
||||
for (value, _) in &items {
|
||||
let _ = write_plain_value(&mut out, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(MenuResult::Cancelled) => {
|
||||
let empty = serde_json::Value::String(String::new());
|
||||
run_result_hook(rt, "on-cancel", cli.on_cancel.as_deref(), &empty);
|
||||
if cli.structured {
|
||||
let output = OutputItem {
|
||||
value: Value::Null,
|
||||
action: OutputAction::Cancel,
|
||||
index: 0,
|
||||
};
|
||||
let _ = write_output_json(&mut out, &output);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -274,12 +455,27 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli, rt: &tokio::r
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize an OutputItem as JSON and write it to the given writer.
|
||||
fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), std::io::Error> {
|
||||
let json = serde_json::to_string(output).unwrap_or_default();
|
||||
writeln!(writer, "{json}")
|
||||
}
|
||||
|
||||
/// Write a plain value: strings without quotes, everything
|
||||
/// else as JSON.
|
||||
fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::io::Error> {
|
||||
match value {
|
||||
Value::String(s) => writeln!(writer, "{s}"),
|
||||
_ => {
|
||||
let json = serde_json::to_string(value).unwrap_or_default();
|
||||
writeln!(writer, "{json}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read items from stdin. If `timeout_secs` is non-zero,
|
||||
/// spawn a thread and bail if it doesn't finish in time.
|
||||
/// A timeout of 0 means no timeout (blocking read).
|
||||
// TODO: The interactive path blocks on all of stdin before showing
|
||||
// the menu. Switch to streaming items via Action::AddItems so the
|
||||
// menu renders immediately and populates as lines arrive.
|
||||
fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Item>, PiklError> {
|
||||
if timeout_secs == 0 {
|
||||
return read_items_sync(std::io::stdin().lock(), label_key);
|
||||
@@ -315,17 +511,10 @@ fn reopen_stdin_from_tty() -> Result<(), PiklError> {
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let tty = std::fs::File::open("/dev/tty")?;
|
||||
// SAFETY: dup2 is a standard POSIX call. We're
|
||||
// redirecting stdin to the controlling tty so the
|
||||
// TUI can read keyboard input after stdin was
|
||||
// consumed for piped items.
|
||||
let r = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) };
|
||||
if r < 0 {
|
||||
return Err(PiklError::Io(std::io::Error::last_os_error()));
|
||||
}
|
||||
// SAFETY: tcflush is a standard POSIX call. Flush
|
||||
// stale input that arrived between dup2 and raw
|
||||
// mode so crossterm starts clean.
|
||||
unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) };
|
||||
}
|
||||
Ok(())
|
||||
|
||||
190
docs/DESIGN.md
190
docs/DESIGN.md
@@ -66,58 +66,171 @@ naturally to table/column display mode.
|
||||
|
||||
## Event Hooks
|
||||
|
||||
Hooks are shell commands that run in response to lifecycle
|
||||
events. They receive the relevant item(s) as JSON on stdin.
|
||||
Lifecycle events fire as the user interacts with the menu.
|
||||
There are two ways to respond to them: **exec hooks** and
|
||||
**handler hooks**.
|
||||
|
||||
### Available Hooks
|
||||
### Lifecycle Events
|
||||
|
||||
| Hook | Fires When | Use Case |
|
||||
| Event | Fires When | Use Case |
|
||||
|---|---|---|
|
||||
| `on-open` | Menu opens | Save current state for revert |
|
||||
| `on-close` | Menu closes (any reason) | Cleanup |
|
||||
| `on-hover` | Selection cursor moves to a new item | Live wallpaper preview |
|
||||
| `on-select` | User confirms selection (Enter) | Apply the choice |
|
||||
| `on-hover` | Cursor moves to a new item | Live preview, prefetch |
|
||||
| `on-select` | User confirms (Enter) | Apply the choice |
|
||||
| `on-cancel` | User cancels (Escape) | Revert preview |
|
||||
| `on-filter` | Filter text changes | Dynamic item reloading |
|
||||
| `on-mark` | User marks/unmarks an item | Visual feedback, register management |
|
||||
| `on-mark` | User marks/unmarks an item | Visual feedback |
|
||||
|
||||
### Configuration
|
||||
### Exec Hooks (fire-and-forget)
|
||||
|
||||
Hooks are set via CLI flags:
|
||||
`--on-<event>-exec` spawns a subprocess for each event.
|
||||
The item is piped as JSON on stdin. Stdout is discarded.
|
||||
One subprocess per event, no state fed back.
|
||||
|
||||
```sh
|
||||
pikl --on-hover 'hyprctl hyprpaper wallpaper "DP-4, {path}"' \
|
||||
--on-cancel 'hyprctl hyprpaper wallpaper "DP-4, {original}"' \
|
||||
--on-hover-debounce 100
|
||||
pikl --on-hover-exec 'notify-send "$(jq -r .label)"' \
|
||||
--on-select-exec 'apply-wallpaper.sh'
|
||||
```
|
||||
|
||||
Or via a manifest file for reusable configurations:
|
||||
Good for simple side effects: notifications, applying a
|
||||
setting, logging.
|
||||
|
||||
### Handler Hooks (bidirectional)
|
||||
|
||||
`--on-<event>` launches a **persistent process** that
|
||||
receives events as JSON lines on stdin over the menu's
|
||||
lifetime. The process can emit commands as JSON lines on
|
||||
stdout to modify menu state.
|
||||
|
||||
```sh
|
||||
pikl --on-hover './wallpaper-handler.sh' \
|
||||
--on-filter './search-provider.sh'
|
||||
```
|
||||
|
||||
The handler process stays alive. Each time the event fires,
|
||||
a new JSON line is written to its stdin. The process reads
|
||||
them in a loop:
|
||||
|
||||
```sh
|
||||
#!/bin/bash
|
||||
while IFS= read -r event; do
|
||||
label=$(echo "$event" | jq -r '.label')
|
||||
set-wallpaper "$label"
|
||||
# optionally emit commands back to pikl on stdout
|
||||
done
|
||||
```
|
||||
|
||||
Stdout from the handler is parsed line-by-line as JSON
|
||||
commands (see Handler Protocol below). Stderr passes
|
||||
through to the terminal for debug output.
|
||||
|
||||
If the handler process exits unexpectedly, pikl logs a
|
||||
warning via tracing and stops sending events. When the
|
||||
menu closes, pikl closes the handler's stdin (breaking
|
||||
the read loop naturally) and gives it a moment to exit
|
||||
before killing it.
|
||||
|
||||
### Handler Protocol
|
||||
|
||||
Handler stdout commands, one JSON line per command:
|
||||
|
||||
| Action | Payload | Effect |
|
||||
|---|---|---|
|
||||
| `add_items` | `{"items": [...]}` | Append items to the list |
|
||||
| `replace_items` | `{"items": [...]}` | Replace all items, preserve cursor position if possible |
|
||||
| `remove_items` | `{"indices": [0, 3]}` | Remove items by index |
|
||||
| `set_filter` | `{"text": "query"}` | Change the filter text |
|
||||
| `close` | (none) | Close the menu |
|
||||
|
||||
Example:
|
||||
|
||||
```jsonl
|
||||
{"action": "add_items", "items": [{"label": "new result"}]}
|
||||
{"action": "set_filter", "text": "updated query"}
|
||||
{"action": "close"}
|
||||
```
|
||||
|
||||
Lines that don't parse as valid JSON or contain an unknown
|
||||
action are logged as warnings (via tracing) and skipped.
|
||||
Never fatal. A handler bug doesn't crash the menu.
|
||||
|
||||
For atomic multi-step mutations, use `replace_items` instead
|
||||
of a `remove_items` + `add_items` pair. If a handler is
|
||||
cancelled mid-stream (due to debounce), commands already
|
||||
applied are not rolled back.
|
||||
|
||||
### Configuration via Manifest
|
||||
|
||||
Hooks can also be configured in a manifest file:
|
||||
|
||||
```toml
|
||||
# ~/.config/pikl/wallpaper.toml
|
||||
[hooks]
|
||||
on-hover = 'hyprctl hyprpaper wallpaper "DP-4, {label}"'
|
||||
on-cancel = 'restore-wallpaper.sh'
|
||||
on-hover-debounce = 100
|
||||
on-hover = './wallpaper-handler.sh'
|
||||
on-select-exec = 'apply-wallpaper.sh'
|
||||
on-cancel-exec = 'restore-wallpaper.sh'
|
||||
|
||||
[display]
|
||||
columns = ["label", "meta.res"]
|
||||
```
|
||||
|
||||
### Bidirectional Hooks
|
||||
|
||||
Hooks can return JSON on stdout to modify menu state:
|
||||
- Update an item's display
|
||||
- Add/remove items from the list
|
||||
- Set the filter text
|
||||
- Close the menu
|
||||
|
||||
### Debouncing
|
||||
|
||||
All hooks support a debounce option
|
||||
(`--on-hover-debounce 100`). When the user is scrolling
|
||||
fast, only the last event in the debounce window fires.
|
||||
Built-in, no external tooling needed.
|
||||
All hooks (exec and handler) support debouncing. Three
|
||||
modes:
|
||||
|
||||
| Mode | Behaviour | Default for |
|
||||
|---|---|---|
|
||||
| None | Fire immediately, every time | on-select, on-cancel, on-open, on-close |
|
||||
| Debounce(ms) | Wait for quiet period, fire last event | on-filter (200ms) |
|
||||
| Cancel-stale | New event cancels any in-flight invocation | (opt-in) |
|
||||
|
||||
Debounce and cancel-stale can combine: wait for quiet,
|
||||
then fire, and if the previous invocation is still running,
|
||||
cancel it first. This is the default for on-hover (200ms
|
||||
debounce + cancel-stale).
|
||||
|
||||
CLI flags:
|
||||
|
||||
```sh
|
||||
# Set debounce duration
|
||||
pikl --on-hover './preview.sh' --on-hover-debounce 200
|
||||
|
||||
# Disable debounce (fire every event)
|
||||
pikl --on-hover './preview.sh' --on-hover-debounce 0
|
||||
|
||||
# Enable cancel-stale (for exec hooks, kills subprocess;
|
||||
# for handler hooks, a cancelled event is not sent)
|
||||
pikl --on-hover-exec 'slow-command' --on-hover-cancel-stale
|
||||
```
|
||||
|
||||
### Hook Architecture (Core vs CLI)
|
||||
|
||||
pikl-core defines the `HookHandler` trait and emits
|
||||
lifecycle events. It does not know what handlers do with
|
||||
them. The core manages debouncing and cancel-stale logic
|
||||
since that interacts with the event loop timing.
|
||||
|
||||
```rust
|
||||
pub trait HookHandler: Send + Sync {
|
||||
fn handle(&self, event: HookEvent)
|
||||
-> Result<Vec<HookResponse>, PiklError>;
|
||||
}
|
||||
```
|
||||
|
||||
The trait is deliberately synchronous for dyn-compatibility.
|
||||
Implementations that need async work (spawning processes,
|
||||
writing to channels) use `tokio::spawn` internally. This
|
||||
keeps the trait object-safe so the core can hold
|
||||
`Arc<dyn HookHandler>`.
|
||||
|
||||
The CLI binary provides `ShellExecHandler` and
|
||||
`ShellHandlerHook`, which map CLI flags to shell commands.
|
||||
A `CompositeHookHandler` dispatches to both based on event
|
||||
kind. Library consumers implement their own handlers with
|
||||
whatever behaviour they want: in-process closures, network
|
||||
calls, anything.
|
||||
|
||||
## Filtering
|
||||
|
||||
@@ -651,18 +764,26 @@ complexity:
|
||||
branching, no custom DSL. For shell one-liners,
|
||||
integration tests, and simple automation. If you need
|
||||
conditionals, you've outgrown this.
|
||||
2. **IPC** (phase 6): bidirectional JSON over Unix socket.
|
||||
2. **Exec hooks:** fire-and-forget shell commands triggered
|
||||
by lifecycle events. Subprocess per event, stdout
|
||||
discarded. For simple side effects.
|
||||
3. **Handler hooks:** persistent bidirectional processes.
|
||||
Receive events as JSON lines on stdin, emit commands on
|
||||
stdout to modify menu state. The shell scripter's
|
||||
extension point: anyone who can write a bash script can
|
||||
extend pikl without touching Rust.
|
||||
4. **IPC** (phase 6): bidirectional JSON over Unix socket.
|
||||
External tools can read state and send actions while pikl
|
||||
runs interactively. Good for tool integration.
|
||||
3. **Lua** (post phase 6): embedded LuaJIT via mlua. Full
|
||||
5. **Lua** (post phase 6): embedded LuaJIT via mlua. Full
|
||||
stateful scripting: subscribe to events, branch on state,
|
||||
loops, the works. The Lua runtime is just another
|
||||
frontend pushing Actions and subscribing to MenuEvents.
|
||||
For anything complex enough to need a real language.
|
||||
|
||||
No custom DSL. Action-fd stays simple forever. The jump from
|
||||
"I need conditionals" to "use Lua" is intentional: there's
|
||||
no value in a half-language.
|
||||
No custom DSL. Action-fd stays simple forever. The jump
|
||||
from "I need conditionals" to "use Lua" is intentional:
|
||||
there's no value in a half-language.
|
||||
|
||||
## Use Cases
|
||||
|
||||
@@ -688,5 +809,8 @@ with the full writeup.
|
||||
- Maximum practical item count before we need virtual
|
||||
scrolling? (Probably around 100k)
|
||||
- Should hooks run in a pool or strictly sequential?
|
||||
Resolved: exec hooks are one subprocess per event.
|
||||
Handler hooks are persistent processes. Debounce and
|
||||
cancel-stale manage concurrency.
|
||||
- Plugin system via WASM for custom filter strategies?
|
||||
(Probably way later if ever)
|
||||
|
||||
107
docs/DEVPLAN.md
107
docs/DEVPLAN.md
@@ -14,7 +14,7 @@ when* we build it.
|
||||
- Ship something usable early, iterate from real usage
|
||||
- Don't optimize until there's a reason to
|
||||
|
||||
## Phase 1: Core Loop (TUI)
|
||||
## Phase 1: Core Loop (TUI) ✓
|
||||
|
||||
The minimum thing that works end-to-end.
|
||||
|
||||
@@ -35,7 +35,7 @@ The minimum thing that works end-to-end.
|
||||
**Done when:** `ls | pikl` works and prints the selected
|
||||
item.
|
||||
|
||||
## Phase 1.5: Action-fd (Headless Mode)
|
||||
## Phase 1.5: Action-fd (Headless Mode) ✓
|
||||
|
||||
Scriptable, non-interactive mode for integration tests and
|
||||
automation. Small enough to slot in before phase 2. It's
|
||||
@@ -60,7 +60,7 @@ for `show-ui`.
|
||||
**Done when:** `echo -e "hello\nworld" | pikl --action-fd 3`
|
||||
with `confirm` on fd 3 prints `"hello"` to stdout.
|
||||
|
||||
## Phase 2: Navigation & Filtering
|
||||
## Phase 2: Navigation & Filtering ✓
|
||||
|
||||
Make it feel like home for a vim user. The filter system
|
||||
is the real star here: strategy prefixes, pipeline
|
||||
@@ -112,24 +112,63 @@ chaining, incremental caching.
|
||||
with vim muscle memory.
|
||||
`'log | !temp | /[0-9]+/` works as a pipeline.
|
||||
|
||||
## Phase 3: Structured I/O & Hooks
|
||||
## Phase 3: Structured I/O & Hooks ✓
|
||||
|
||||
The structured data pipeline.
|
||||
The structured data pipeline and the full hook system.
|
||||
|
||||
**Implementation order:**
|
||||
|
||||
1. Item model expansion (sublabel, meta, icon, group as
|
||||
explicit optional fields on Item, alongside the raw
|
||||
Value)
|
||||
2. Output struct with action context (separate from the
|
||||
original item, no mutation)
|
||||
3. HookHandler trait in pikl-core, HookEvent enum,
|
||||
HookResponse enum
|
||||
4. Exec hooks in CLI: `--on-<event>-exec` flags, subprocess
|
||||
per event, stdout discarded
|
||||
5. Debounce system: none / debounce(ms) / cancel-stale,
|
||||
configurable per hook via CLI flags
|
||||
6. Handler hooks in CLI: `--on-<event>` flags, persistent
|
||||
process, stdin/stdout JSON line protocol
|
||||
7. Handler protocol commands: add_items, replace_items,
|
||||
remove_items, set_filter, close
|
||||
8. `--filter-fields` scoping (which fields the filter
|
||||
searches against)
|
||||
9. `--format` template strings for display
|
||||
(`{label} - {sublabel}`)
|
||||
10. Field filters in query syntax (`meta.res:3840`),
|
||||
integrated into the filter pipeline
|
||||
|
||||
**Deliverables:**
|
||||
- JSON line input parsing (label, sublabel, meta, icon,
|
||||
group)
|
||||
- JSON output with action context
|
||||
- Full hook lifecycle: on-open, on-close, on-hover,
|
||||
- Item model: sublabel, meta, icon, group as first-class
|
||||
optional fields
|
||||
- Output: separate struct with action context (action,
|
||||
index) wrapping the original item
|
||||
- Exec hooks (`--on-<event>-exec`): fire-and-forget,
|
||||
subprocess per event, item JSON on stdin
|
||||
- Handler hooks (`--on-<event>`): persistent bidirectional
|
||||
process, JSON lines on stdin/stdout
|
||||
- Handler protocol: add_items, replace_items, remove_items,
|
||||
set_filter, close
|
||||
- Full lifecycle events: on-open, on-close, on-hover,
|
||||
on-select, on-cancel, on-filter
|
||||
- Hook debouncing
|
||||
- Bidirectional hooks (hook stdout modifies menu state)
|
||||
- `--format` template strings for display
|
||||
- Field filters (`meta.res:3840`)
|
||||
- Filter scoping (`--filter-fields`)
|
||||
- Debounce: three modes (none, debounce, cancel-stale),
|
||||
per-hook CLI flags
|
||||
- Default debounce: on-hover 200ms + cancel-stale,
|
||||
on-filter 200ms, others none
|
||||
- HookHandler trait in pikl-core (core emits events, does
|
||||
not know what handlers do)
|
||||
- `--filter-fields label,sublabel,meta.tags`
|
||||
- `--format '{label} - {sublabel}'` template rendering
|
||||
- Field filters: `meta.res:3840` in query text
|
||||
- tracing for hook warnings (bad JSON, unknown actions,
|
||||
process exit)
|
||||
|
||||
**Done when:** The wallpaper picker use case works entirely
|
||||
through hooks and structured I/O.
|
||||
through hooks and structured I/O. A handler hook can
|
||||
receive hover events and emit commands to modify menu
|
||||
state.
|
||||
|
||||
## Phase 4: Multi-Select & Registers
|
||||
|
||||
@@ -236,6 +275,36 @@ navigate directories without spawning new processes.
|
||||
query, or the viewport to do a lookup after each filter
|
||||
pass.
|
||||
|
||||
- **Confirm-with-arguments (Shift+Enter).** Select an item
|
||||
and also pass free-text arguments alongside it. Primary
|
||||
use case: app launcher where you select `ls` and want to
|
||||
pass `-la` to it. The output would include both the
|
||||
selected item and the user-supplied arguments.
|
||||
|
||||
Open questions:
|
||||
- UX flow: does the filter text become the args on
|
||||
Shift+Enter? Or does Shift+Enter open a second input
|
||||
field for args after selection? The filter-as-args
|
||||
approach is simpler but conflates filtering and
|
||||
argument input. A two-step flow (select, then type
|
||||
args) is cleaner but adds a mode.
|
||||
- Output format: separate field in the JSON output
|
||||
(`"args": "-la"`)? Second line on stdout? Appended to
|
||||
the label? Needs to be unambiguous for scripts.
|
||||
- Should regular Enter with a non-empty filter that
|
||||
matches exactly one item just confirm that item (current
|
||||
behaviour), or should it also treat any "extra" text
|
||||
as args? Probably not, too implicit.
|
||||
- Keybind: Shift+Enter is natural, but some terminals
|
||||
don't distinguish it from Enter. May need a fallback
|
||||
like Ctrl+Enter or a normal-mode keybind.
|
||||
|
||||
This is a core feature (new keybind, new output field),
|
||||
not just a launcher script concern. Fits naturally after
|
||||
phase 4 (multi-select) since it's another selection
|
||||
mode variant. The launcher script would assemble
|
||||
`{selected} {args}` for execution.
|
||||
|
||||
## Future Ideas (Unscheduled)
|
||||
|
||||
These are things we've talked about or thought of. No
|
||||
@@ -267,3 +336,11 @@ commitment, no order.
|
||||
(optionally into a tmux session). Needs GUI frontend
|
||||
(phase 8) and frecency sorting.
|
||||
See `docs/use-cases/app-launcher.md`.
|
||||
Setup guides: `docs/guides/app-launcher.md`.
|
||||
- App description indexing: a tool or subcommand that
|
||||
builds a local cache of binary descriptions from man
|
||||
pages (`whatis`), .desktop file Comment fields, and
|
||||
macOS Info.plist data. Solves the "whatis is too slow
|
||||
to run per-keystroke" problem for the app launcher.
|
||||
Could be a `pikl index` subcommand or a standalone
|
||||
helper script.
|
||||
|
||||
286
docs/guides/app-launcher.md
Normal file
286
docs/guides/app-launcher.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# App Launcher Setup
|
||||
|
||||
Use pikl as a keyboard-driven application launcher. Bind
|
||||
a hotkey, type a few characters, hit Enter, and your app
|
||||
launches. This guide covers terminal, Hyprland, i3, and
|
||||
macOS setups.
|
||||
|
||||
For the design rationale and feature roadmap, see
|
||||
[the app launcher use case](../use-cases/app-launcher.md).
|
||||
|
||||
## Quick start: terminal
|
||||
|
||||
The simplest version. No GUI, no hotkeys, just an alias:
|
||||
|
||||
```sh
|
||||
# ~/.bashrc or ~/.zshrc
|
||||
alias launch='compgen -c | sort -u | pikl | xargs -I{} sh -c "{} &"'
|
||||
```
|
||||
|
||||
Run `launch`, type to filter, Enter to run the selection
|
||||
in the background. That's it.
|
||||
|
||||
### With descriptions (experimental)
|
||||
|
||||
You can pull one-line descriptions from man pages using
|
||||
`whatis`. This is noticeably slow on systems with
|
||||
thousands of binaries (it shells out per binary), so treat
|
||||
it as a nice-to-have rather than the default:
|
||||
|
||||
```sh
|
||||
launch-rich() {
|
||||
compgen -c | sort -u | while IFS= read -r cmd; do
|
||||
desc=$(whatis "$cmd" 2>/dev/null | head -1 | sed 's/.*- //')
|
||||
printf '{"label":"%s","sublabel":"%s"}\n' "$cmd" "$desc"
|
||||
done | pikl --format '{label} <dim>{sublabel}</dim>' \
|
||||
| jq -r '.label' \
|
||||
| xargs -I{} sh -c '{} &'
|
||||
}
|
||||
```
|
||||
|
||||
This works, but it's slow. Caching the output of the
|
||||
`whatis` loop to a file and refreshing it periodically
|
||||
would make it usable day-to-day. A built-in indexing
|
||||
solution is on the roadmap but not built yet.
|
||||
|
||||
## Hyprland
|
||||
|
||||
Hyprland is a first-class target. pikl uses
|
||||
`iced_layershell` to render as a Wayland layer-shell
|
||||
overlay, so it floats above your desktop like rofi does.
|
||||
|
||||
### The launcher script
|
||||
|
||||
Save this somewhere in your PATH (e.g.
|
||||
`~/.local/bin/pikl-launch`):
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# pikl-launch: open pikl as a GUI app launcher
|
||||
|
||||
compgen -c | sort -u \
|
||||
| pikl --mode gui \
|
||||
| xargs -I{} sh -c '{} &'
|
||||
```
|
||||
|
||||
```sh
|
||||
chmod +x ~/.local/bin/pikl-launch
|
||||
```
|
||||
|
||||
### Keybinding
|
||||
|
||||
Add to `~/.config/hypr/hyprland.conf`:
|
||||
|
||||
```
|
||||
bind = SUPER, SPACE, exec, pikl-launch
|
||||
```
|
||||
|
||||
Reload with `hyprctl reload` or restart Hyprland.
|
||||
|
||||
### With .desktop files (future)
|
||||
|
||||
When pikl gains .desktop file parsing (or a helper
|
||||
script emits structured JSON from XDG desktop entries),
|
||||
you'll get proper app names, descriptions, and categories
|
||||
instead of raw binary names. The keybinding and workflow
|
||||
stay the same, only the input to pikl changes.
|
||||
|
||||
## i3
|
||||
|
||||
i3 runs on X11, so pikl opens as an override-redirect
|
||||
window rather than a layer-shell overlay. Same launcher
|
||||
script, different keybinding syntax.
|
||||
|
||||
### The launcher script
|
||||
|
||||
Same `pikl-launch` script as Hyprland above. pikl
|
||||
auto-detects X11 vs Wayland, so no changes needed.
|
||||
|
||||
### Keybinding
|
||||
|
||||
Add to `~/.config/i3/config`:
|
||||
|
||||
```
|
||||
bindsym $mod+space exec --no-startup-id pikl-launch
|
||||
```
|
||||
|
||||
Reload with `$mod+Shift+r`.
|
||||
|
||||
### Notes
|
||||
|
||||
- `--no-startup-id` prevents the i3 startup notification
|
||||
cursor from spinning while pikl is open.
|
||||
- If pikl doesn't grab focus automatically, you may need
|
||||
to add an i3 rule:
|
||||
```
|
||||
for_window [class="pikl"] focus
|
||||
```
|
||||
|
||||
## macOS with Raycast
|
||||
|
||||
Raycast is the best way to bind a global hotkey to pikl
|
||||
on macOS. You create a script command that Raycast can
|
||||
trigger from its search bar or a direct hotkey.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Raycast](https://raycast.com) installed
|
||||
- pikl installed (`cargo install pikl`)
|
||||
- pikl in your PATH (cargo's bin directory is usually
|
||||
`~/.cargo/bin`, make sure it's in your shell PATH)
|
||||
|
||||
### Create the script command
|
||||
|
||||
Raycast script commands are shell scripts with a special
|
||||
header. Create this file in your Raycast script commands
|
||||
directory (usually `~/.config/raycast/scripts/` or
|
||||
wherever you've configured it):
|
||||
|
||||
**`pikl-launch.sh`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# Required parameters:
|
||||
# @raycast.schemaVersion 1
|
||||
# @raycast.title App Launcher (pikl)
|
||||
# @raycast.mode silent
|
||||
# @raycast.packageName pikl
|
||||
|
||||
# Optional parameters:
|
||||
# @raycast.icon :rocket:
|
||||
|
||||
# Scan /Applications for .app bundles, pipe to pikl, open the result
|
||||
find /Applications ~/Applications -maxdepth 2 -name '*.app' 2>/dev/null \
|
||||
| sed 's|.*/||; s|\.app$||' \
|
||||
| sort -u \
|
||||
| pikl --mode gui \
|
||||
| xargs -I{} open -a "{}"
|
||||
```
|
||||
|
||||
```sh
|
||||
chmod +x pikl-launch.sh
|
||||
```
|
||||
|
||||
The `silent` mode tells Raycast not to show any output
|
||||
window. pikl handles its own GUI.
|
||||
|
||||
### Assign a hotkey
|
||||
|
||||
1. Open Raycast preferences (Cmd+,)
|
||||
2. Go to Extensions
|
||||
3. Find "App Launcher (pikl)" under Script Commands
|
||||
4. Click the hotkey field and press your preferred combo
|
||||
(e.g. Ctrl+Space, Opt+Space, etc.)
|
||||
|
||||
### With structured JSON input
|
||||
|
||||
For a richer experience with app metadata, you can parse
|
||||
the `Info.plist` files inside .app bundles:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# Required parameters:
|
||||
# @raycast.schemaVersion 1
|
||||
# @raycast.title App Launcher (pikl)
|
||||
# @raycast.mode silent
|
||||
# @raycast.packageName pikl
|
||||
|
||||
for app in /Applications/*.app ~/Applications/*.app; do
|
||||
[ -d "$app" ] || continue
|
||||
name=$(basename "$app" .app)
|
||||
# Pull the bundle identifier for metadata
|
||||
bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \
|
||||
"$app/Contents/Info.plist" 2>/dev/null)
|
||||
printf '{"label":"%s","meta":{"bundle":"%s","path":"%s"}}\n' \
|
||||
"$name" "$bundle_id" "$app"
|
||||
done \
|
||||
| pikl --mode gui \
|
||||
| jq -r '.meta.path' \
|
||||
| xargs -I{} open "{}"
|
||||
```
|
||||
|
||||
This version gives pikl structured data to work with.
|
||||
When pikl gains frecency sorting, your most-launched apps
|
||||
will float to the top automatically.
|
||||
|
||||
### Alternative: skhd
|
||||
|
||||
If you don't use Raycast, [skhd](https://github.com/koekeishiya/skhd)
|
||||
is a standalone hotkey daemon:
|
||||
|
||||
```
|
||||
# ~/.config/skhd/skhdrc
|
||||
ctrl + space : pikl-launch-mac.sh
|
||||
```
|
||||
|
||||
Where `pikl-launch-mac.sh` is the same script as above,
|
||||
minus the Raycast header comments.
|
||||
|
||||
## Customizing the launcher
|
||||
|
||||
These tips apply to all platforms.
|
||||
|
||||
### Filter fields
|
||||
|
||||
If your input includes structured JSON with metadata,
|
||||
tell pikl which fields to search:
|
||||
|
||||
```sh
|
||||
# Search both label and sublabel when filtering
|
||||
pikl --filter-fields label,sublabel
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
Run something when the launcher opens or when an item
|
||||
is selected:
|
||||
|
||||
```sh
|
||||
pikl --mode gui \
|
||||
--on-select-exec 'jq -r .label >> ~/.local/state/pikl-launch-history'
|
||||
```
|
||||
|
||||
This logs every launch to a history file, which could
|
||||
feed into frecency sorting later.
|
||||
|
||||
### Starting in normal mode
|
||||
|
||||
If you prefer to land in vim normal mode (navigate first,
|
||||
then `/` to filter):
|
||||
|
||||
```sh
|
||||
pikl --mode gui --start-mode normal
|
||||
```
|
||||
|
||||
## What's not built yet
|
||||
|
||||
A few things mentioned in this guide depend on features
|
||||
that are still in development:
|
||||
|
||||
- **GUI frontend** (phase 8): the `--mode gui` flag and
|
||||
layer-shell/X11 rendering. Until this ships, you can use
|
||||
the TUI versions in a drop-down terminal (like tdrop,
|
||||
kitty's `--single-instance`, or a quake-mode terminal).
|
||||
- **Frecency sorting:** tracking launch frequency and
|
||||
boosting common picks. On the roadmap.
|
||||
- **.desktop file parsing:** structured input from XDG
|
||||
desktop entries with proper names, icons, and categories.
|
||||
On the roadmap.
|
||||
- **Description caching/indexing:** a way to build and
|
||||
maintain a local index of binary descriptions so the
|
||||
launcher can show what each app does without the slow
|
||||
`whatis` loop. On the roadmap.
|
||||
|
||||
## GNOME, KDE, and other desktops
|
||||
|
||||
These aren't supported as app launcher environments right
|
||||
now. Hyprland, i3, and macOS are the environments the dev
|
||||
team uses daily, so that's where the effort goes. The
|
||||
main blocker for GNOME on Wayland is the lack of
|
||||
layer-shell support, which means pikl can't render as an
|
||||
overlay the same way it does on wlroots-based compositors.
|
||||
|
||||
If you use one of these environments and want to help,
|
||||
PRs and discussion are welcome.
|
||||
60
docs/guides/install.md
Normal file
60
docs/guides/install.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Installing pikl
|
||||
|
||||
## From crates.io (recommended)
|
||||
|
||||
```sh
|
||||
cargo install pikl
|
||||
```
|
||||
|
||||
This builds the unified `pikl` binary with both TUI and
|
||||
GUI frontends. You'll need a working Rust toolchain. If
|
||||
you don't have one, [rustup](https://rustup.rs) is the
|
||||
way to go.
|
||||
|
||||
### TUI only
|
||||
|
||||
If you only want the terminal interface and don't want to
|
||||
pull in GUI dependencies:
|
||||
|
||||
```sh
|
||||
cargo install pikl --no-default-features --features tui
|
||||
```
|
||||
|
||||
## From source
|
||||
|
||||
```sh
|
||||
git clone https://github.com/maplecool/pikl-menu.git
|
||||
cd pikl-menu
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
This builds and installs the `pikl` binary into your
|
||||
cargo bin directory (usually `~/.cargo/bin/`).
|
||||
|
||||
## Package managers
|
||||
|
||||
We'd like pikl to be available in package managers like
|
||||
the AUR and Homebrew, but honestly haven't set that up
|
||||
before and aren't sure when we'll get to it. TBD.
|
||||
|
||||
If you package pikl for a distro or package manager, open
|
||||
an issue and we'll link it here.
|
||||
|
||||
## Verify it works
|
||||
|
||||
```sh
|
||||
echo -e "hello\nworld\ngoodbye" | pikl
|
||||
```
|
||||
|
||||
You should see a filterable list in insert mode. Type to
|
||||
filter, use arrow keys to navigate, Enter to select,
|
||||
Escape to quit. The selected item prints to stdout.
|
||||
|
||||
pikl starts in insert mode by default (type to filter
|
||||
immediately). Press Ctrl+N to switch to normal mode for
|
||||
vim-style navigation (j/k, gg, G, Ctrl+D/U). Ctrl+E
|
||||
switches back to insert mode. You can also start in
|
||||
normal mode with `--start-mode normal`.
|
||||
|
||||
Note: Ctrl+I is not the same as Tab. pikl treats these
|
||||
as distinct inputs.
|
||||
64
docs/lessons/dyn-safe-hook-traits.md
Normal file
64
docs/lessons/dyn-safe-hook-traits.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Lesson: async fn in traits vs dyn-compatibility
|
||||
|
||||
## The problem
|
||||
|
||||
The `HookHandler` trait needs to be stored as
|
||||
`Arc<dyn HookHandler>` so the menu engine can hold a
|
||||
type-erased handler without knowing whether it's an exec
|
||||
hook, a handler hook, or a composite. But `async fn` in
|
||||
traits (even with edition 2024's native support) makes the
|
||||
trait not dyn-compatible. The compiler can't build a vtable
|
||||
for an async method because the return type (an opaque
|
||||
`impl Future`) varies per implementation.
|
||||
|
||||
## What we tried
|
||||
|
||||
Official documentation of edition 2024 supports async fn
|
||||
in traits natively, async-trait is NOT needed." That's true
|
||||
for static dispatch (generics), but wrong for `dyn` dispatch.
|
||||
|
||||
## The fix
|
||||
|
||||
Made `handle()` a regular sync `fn`. The trait is
|
||||
deliberately synchronous:
|
||||
|
||||
```rust
|
||||
pub trait HookHandler: Send + Sync {
|
||||
fn handle(&self, event: HookEvent)
|
||||
-> Result<Vec<HookResponse>, PiklError>;
|
||||
}
|
||||
```
|
||||
|
||||
This works because handlers don't actually need to `await`
|
||||
anything at the call site:
|
||||
|
||||
- **Exec hooks** spawn a subprocess via `tokio::spawn` and
|
||||
return immediately. The process runs in the background.
|
||||
- **Handler hooks** write to an internal channel via
|
||||
`try_send` (non-blocking) and return. A background task
|
||||
handles the actual I/O.
|
||||
|
||||
Both return `Ok(vec![])` instantly. Responses from handler
|
||||
hooks flow back through the action channel asynchronously.
|
||||
|
||||
## Takeaway
|
||||
|
||||
`async fn in trait` (edition 2024) is for generic/static
|
||||
dispatch. If you need `dyn Trait`, you still need either:
|
||||
1. A sync method (if the work can be spawned internally)
|
||||
2. A boxed future return type
|
||||
3. The `async-trait` crate
|
||||
|
||||
Option 1 is the simplest when the handler doesn't need to
|
||||
return data from async work. If responses are needed, they
|
||||
go through a separate channel rather than the return value.
|
||||
|
||||
## Related: tokio test-util for debounce tests
|
||||
|
||||
Tests that verify debounce timing need `tokio::time::pause`
|
||||
and `tokio::time::advance`, which require the `test-util`
|
||||
feature on tokio in dev-dependencies. Use
|
||||
`#[tokio::test(start_paused = true)]` and
|
||||
`tokio::time::sleep()` (which auto-advances in paused mode)
|
||||
rather than manual `pause()` + `advance()` + `yield_now()`
|
||||
chains, which are fragile with spawned tasks.
|
||||
227
examples/demo.sh
227
examples/demo.sh
@@ -85,7 +85,7 @@ file_picker() {
|
||||
|
||||
on_select_hook() {
|
||||
printf "one\ntwo\nthree\nfour\nfive\n" \
|
||||
| pikl --on-select 'echo "you picked: $(cat)"'
|
||||
| pikl --on-select-exec 'echo "you picked: $(cat)" >&2'
|
||||
}
|
||||
|
||||
mixed_input() {
|
||||
@@ -97,6 +97,184 @@ another plain string
|
||||
ITEMS
|
||||
}
|
||||
|
||||
# ── Phase 3 demos ────────────────────────────────────────
|
||||
|
||||
structured_items() {
|
||||
echo "Items have sublabel, icon, and metadata fields." >&2
|
||||
echo "The output JSON includes action and index context." >&2
|
||||
echo "" >&2
|
||||
cat <<'ITEMS' | pikl
|
||||
{"label": "Firefox", "sublabel": "Web Browser", "icon": "firefox", "meta": {"version": "125", "type": "browser"}}
|
||||
{"label": "Neovim", "sublabel": "Text Editor", "icon": "nvim", "meta": {"version": "0.10", "type": "editor"}}
|
||||
{"label": "Alacritty", "sublabel": "Terminal Emulator", "icon": "alacritty", "meta": {"version": "0.13", "type": "terminal"}}
|
||||
{"label": "Thunar", "sublabel": "File Manager", "icon": "thunar", "meta": {"version": "4.18", "type": "filemanager"}}
|
||||
{"label": "mpv", "sublabel": "Media Player", "icon": "mpv", "meta": {"version": "0.37", "type": "media"}}
|
||||
ITEMS
|
||||
}
|
||||
|
||||
format_template() {
|
||||
echo "Using --format to control display." >&2
|
||||
echo "Template: '{label} ({sublabel}) v{meta.version}'" >&2
|
||||
echo "" >&2
|
||||
cat <<'ITEMS' | pikl --format '{label} ({sublabel}) v{meta.version}'
|
||||
{"label": "Firefox", "sublabel": "Web Browser", "meta": {"version": "125"}}
|
||||
{"label": "Neovim", "sublabel": "Text Editor", "meta": {"version": "0.10"}}
|
||||
{"label": "Alacritty", "sublabel": "Terminal Emulator", "meta": {"version": "0.13"}}
|
||||
{"label": "Thunar", "sublabel": "File Manager", "meta": {"version": "4.18"}}
|
||||
{"label": "mpv", "sublabel": "Media Player", "meta": {"version": "0.37"}}
|
||||
ITEMS
|
||||
}
|
||||
|
||||
filter_fields_demo() {
|
||||
echo "Using --filter-fields to search sublabel and meta fields." >&2
|
||||
echo "Try typing 'browser' or 'editor' to filter by sublabel." >&2
|
||||
echo "" >&2
|
||||
cat <<'ITEMS' | pikl --filter-fields label,sublabel --format '{label} - {sublabel}'
|
||||
{"label": "Firefox", "sublabel": "Web Browser"}
|
||||
{"label": "Neovim", "sublabel": "Text Editor"}
|
||||
{"label": "Alacritty", "sublabel": "Terminal Emulator"}
|
||||
{"label": "Thunar", "sublabel": "File Manager"}
|
||||
{"label": "mpv", "sublabel": "Media Player"}
|
||||
{"label": "GIMP", "sublabel": "Image Editor"}
|
||||
{"label": "Inkscape", "sublabel": "Vector Graphics Editor"}
|
||||
ITEMS
|
||||
}
|
||||
|
||||
field_filter_demo() {
|
||||
echo "Field filters in the query: type meta.type:browser to match a field." >&2
|
||||
echo "Try: meta.type:browser or meta.type:editor or !meta.type:browser" >&2
|
||||
echo "" >&2
|
||||
cat <<'ITEMS' | pikl --format '{label} [{meta.type}] {meta.res}'
|
||||
{"label": "Firefox", "meta": {"type": "browser", "res": "n/a"}}
|
||||
{"label": "Chrome", "meta": {"type": "browser", "res": "n/a"}}
|
||||
{"label": "Neovim", "meta": {"type": "editor", "res": "n/a"}}
|
||||
{"label": "Helix", "meta": {"type": "editor", "res": "n/a"}}
|
||||
{"label": "Alacritty", "meta": {"type": "terminal", "res": "n/a"}}
|
||||
{"label": "kitty", "meta": {"type": "terminal", "res": "n/a"}}
|
||||
{"label": "mpv", "meta": {"type": "media", "res": "3840x2160"}}
|
||||
{"label": "vlc", "meta": {"type": "media", "res": "1920x1080"}}
|
||||
ITEMS
|
||||
}
|
||||
|
||||
exec_hooks_demo() {
|
||||
echo "Exec hooks: --on-hover-exec fires a command on each cursor move." >&2
|
||||
echo "Watch stderr for hover notifications as you navigate." >&2
|
||||
echo "" >&2
|
||||
cat <<'ITEMS' | pikl \
|
||||
--on-hover-exec 'jq -r ".item.label // empty" | xargs -I{} echo " hovering: {}" >&2' \
|
||||
--on-select-exec 'jq -r ".item.label // .value // empty" | xargs -I{} echo " selected: {}" >&2'
|
||||
{"label": "Arch Linux", "sublabel": "rolling release"}
|
||||
{"label": "NixOS", "sublabel": "declarative"}
|
||||
{"label": "Void Linux", "sublabel": "runit-based"}
|
||||
{"label": "Debian", "sublabel": "rock solid"}
|
||||
{"label": "Alpine", "sublabel": "musl + busybox"}
|
||||
ITEMS
|
||||
}
|
||||
|
||||
handler_hook_demo() {
|
||||
echo "Handler hooks: a persistent process receives events on stdin" >&2
|
||||
echo "and can emit commands on stdout to modify the menu." >&2
|
||||
echo "" >&2
|
||||
echo "This demo logs hover events to stderr via a handler script." >&2
|
||||
echo "The handler stays alive for the menu's lifetime." >&2
|
||||
echo "" >&2
|
||||
|
||||
# Create a temporary handler script
|
||||
local handler
|
||||
handler=$(mktemp /tmp/pikl-handler-XXXXXX.sh)
|
||||
cat > "$handler" <<'HANDLER'
|
||||
#!/bin/bash
|
||||
# Simple handler: logs events to stderr, demonstrates the protocol
|
||||
while IFS= read -r event; do
|
||||
event_type=$(echo "$event" | jq -r '.event // "unknown"')
|
||||
case "$event_type" in
|
||||
hover)
|
||||
label=$(echo "$event" | jq -r '.item.label // "?"')
|
||||
index=$(echo "$event" | jq -r '.index // "?"')
|
||||
echo " handler got hover: $label (index $index)" >&2
|
||||
;;
|
||||
filter)
|
||||
text=$(echo "$event" | jq -r '.text // ""')
|
||||
echo " handler got filter: '$text'" >&2
|
||||
;;
|
||||
open)
|
||||
echo " handler got open event" >&2
|
||||
;;
|
||||
close)
|
||||
echo " handler got close event" >&2
|
||||
;;
|
||||
*)
|
||||
echo " handler got: $event_type" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
HANDLER
|
||||
chmod +x "$handler"
|
||||
|
||||
cat <<'ITEMS' | pikl --on-hover "$handler" --on-hover-debounce 100
|
||||
{"label": "Maple", "sublabel": "Studio founder"}
|
||||
{"label": "Cedar", "sublabel": "Backend dev"}
|
||||
{"label": "Birch", "sublabel": "Frontend dev"}
|
||||
{"label": "Pine", "sublabel": "DevOps"}
|
||||
{"label": "Spruce", "sublabel": "QA"}
|
||||
ITEMS
|
||||
|
||||
rm -f "$handler"
|
||||
}
|
||||
|
||||
handler_add_items_demo() {
|
||||
echo "Handler hooks can modify the menu by emitting commands." >&2
|
||||
echo "This demo adds items when you hover over specific entries." >&2
|
||||
echo "" >&2
|
||||
|
||||
local handler
|
||||
handler=$(mktemp /tmp/pikl-handler-XXXXXX.sh)
|
||||
cat > "$handler" <<'HANDLER'
|
||||
#!/bin/bash
|
||||
# Handler that adds related items on hover
|
||||
while IFS= read -r event; do
|
||||
event_type=$(echo "$event" | jq -r '.event // "unknown"')
|
||||
if [ "$event_type" = "hover" ]; then
|
||||
label=$(echo "$event" | jq -r '.item.label // ""')
|
||||
case "$label" in
|
||||
"Languages")
|
||||
echo '{"action": "add_items", "items": [{"label": " Rust"}, {"label": " Go"}, {"label": " Python"}]}'
|
||||
;;
|
||||
"Editors")
|
||||
echo '{"action": "add_items", "items": [{"label": " Neovim"}, {"label": " Helix"}, {"label": " Emacs"}]}'
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
HANDLER
|
||||
chmod +x "$handler"
|
||||
|
||||
cat <<'ITEMS' | pikl --on-hover "$handler" --on-hover-debounce 300
|
||||
{"label": "Languages"}
|
||||
{"label": "Editors"}
|
||||
{"label": "Shells"}
|
||||
ITEMS
|
||||
|
||||
rm -f "$handler"
|
||||
}
|
||||
|
||||
pipeline_filter_demo() {
|
||||
echo "Filter pipeline demo: chain filters with |" >&2
|
||||
echo "Try: 'rolling | !void (exact 'rolling', then exclude void)" >&2
|
||||
echo "Try: /sys/ (regex: items containing 'sys')" >&2
|
||||
echo "Try: meta.init:systemd (field filter on init system)" >&2
|
||||
echo "" >&2
|
||||
cat <<'ITEMS' | pikl --format '{label} ({meta.category}, {meta.init})'
|
||||
{"label": "Arch Linux", "meta": {"category": "rolling", "init": "systemd"}}
|
||||
{"label": "NixOS", "meta": {"category": "rolling", "init": "systemd"}}
|
||||
{"label": "Void Linux", "meta": {"category": "rolling", "init": "runit"}}
|
||||
{"label": "Debian", "meta": {"category": "stable", "init": "systemd"}}
|
||||
{"label": "Alpine", "meta": {"category": "stable", "init": "openrc"}}
|
||||
{"label": "Fedora", "meta": {"category": "semi-rolling", "init": "systemd"}}
|
||||
{"label": "Gentoo", "meta": {"category": "rolling", "init": "openrc"}}
|
||||
ITEMS
|
||||
}
|
||||
|
||||
# ── Scenario menu ─────────────────────────────────────────
|
||||
|
||||
scenarios=(
|
||||
@@ -107,22 +285,45 @@ scenarios=(
|
||||
"Git branches"
|
||||
"Git log (last 30)"
|
||||
"File picker"
|
||||
"on-select hook"
|
||||
"Mixed input (plain + JSON)"
|
||||
"---"
|
||||
"Structured items (sublabel, meta)"
|
||||
"Format template (--format)"
|
||||
"Filter fields (--filter-fields)"
|
||||
"Field filters (meta.type:browser)"
|
||||
"Pipeline + field filters"
|
||||
"---"
|
||||
"Exec hooks (on-hover/select)"
|
||||
"Handler hook (event logging)"
|
||||
"Handler hook (add items on hover)"
|
||||
"---"
|
||||
"on-select-exec hook (legacy)"
|
||||
)
|
||||
|
||||
# Map display names to functions
|
||||
run_scenario() {
|
||||
case "$1" in
|
||||
*"Plain text"*) plain_list ;;
|
||||
*"Big list"*) big_list ;;
|
||||
*"JSON objects"*) json_objects ;;
|
||||
*"label-key"*) custom_label_key ;;
|
||||
*"Git branches"*) git_branches ;;
|
||||
*"Git log"*) git_log_picker ;;
|
||||
*"File picker"*) file_picker ;;
|
||||
*"on-select"*) on_select_hook ;;
|
||||
*"Mixed input"*) mixed_input ;;
|
||||
*"Plain text"*) plain_list ;;
|
||||
*"Big list"*) big_list ;;
|
||||
*"JSON objects"*) json_objects ;;
|
||||
*"label-key"*) custom_label_key ;;
|
||||
*"Git branches"*) git_branches ;;
|
||||
*"Git log"*) git_log_picker ;;
|
||||
*"File picker"*) file_picker ;;
|
||||
*"Mixed input"*) mixed_input ;;
|
||||
*"Structured items"*) structured_items ;;
|
||||
*"Format template"*) format_template ;;
|
||||
*"Filter fields"*) filter_fields_demo ;;
|
||||
*"Field filters"*) field_filter_demo ;;
|
||||
*"Pipeline + field"*) pipeline_filter_demo ;;
|
||||
*"Exec hooks"*) exec_hooks_demo ;;
|
||||
*"Handler hook (event"*) handler_hook_demo ;;
|
||||
*"Handler hook (add"*) handler_add_items_demo ;;
|
||||
*"on-select-exec"*) on_select_hook ;;
|
||||
"---")
|
||||
echo "that's a separator, not a scenario" >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo "unknown scenario" >&2
|
||||
return 1
|
||||
@@ -142,8 +343,8 @@ main() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# pikl outputs JSON. Strip the quotes for matching.
|
||||
choice=$(echo "$choice" | tr -d '"')
|
||||
# pikl outputs JSON. Strip quotes and extract value for matching.
|
||||
choice=$(echo "$choice" | jq -r '.value // .' 2>/dev/null || echo "$choice" | tr -d '"')
|
||||
|
||||
echo "" >&2
|
||||
echo "── running: $choice ──" >&2
|
||||
|
||||
Reference in New Issue
Block a user