Compare commits

...

5 Commits

Author SHA1 Message Date
0ebb5e79dd feat(action): Add gitea action for continuous integration.
Some checks are pending
CI / Lint (push) Waiting to run
CI / Test (push) Blocked by required conditions
2026-03-14 01:55:32 -04:00
2729e7e1d2 feat(core): Add Quicklist function to return all visible via ctrl+Q.
Some checks failed
CI / Check (macos-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Clippy (strict) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
2026-03-14 01:49:24 -04:00
a39e511cc4 doc: Add lesson learned about dyn async traits. 2026-03-14 01:42:50 -04:00
8bf3366740 feat: Expand hook system to handle simple exec and plugin extensibility.
Item Model Expansion - Item now caches sublabel, icon, group with accessors. Added resolve_field_path() for dotted path traversal and field_value() on Item.
Output Struct - New OutputItem with OutputAction (select/cancel) and index. Object values flatten, strings get a value field. MenuResult::Selected now carries { value, index }.
Hook Types - Replaced the old Hook trait with HookEvent (serializable, 6 variants), HookResponse (deserializable, 5 commands), HookHandler trait (sync for dyn-compatibility), and parse_hook_response() with tracing warnings.
New Actions & Menu Methods - Added ReplaceItems, RemoveItems, ProcessHookResponse, CloseMenu actions. Menu trait gained original_index(), replace_all(), remove_by_indices(), formatted_label(). Pipeline got rebuild() and rebuild_with_values(). Smart cursor preservation on replace.
Lifecycle Events - MenuRunner emits Open, Close, Hover, Select, Cancel, Filter events through the dispatcher. Cursor tracking for Hover detection.
Debounce - DebouncedDispatcher with 4 modes: None, Debounce, CancelStale, DebounceAndCancelStale. Defaults: hover=DebounceAndCancelStale(200ms), filter=Debounce(200ms).
Exec Hooks - ShellExecHandler maps --on-{open,close,hover,select,cancel,filter}-exec flags to fire-and-forget subprocesses. Event JSON piped to stdin.
Handler Hooks - ShellHandlerHook launches persistent processes per --on-{event} flag. Bidirectional JSON lines: events on stdin, responses on stdout flowing back through Action::ProcessHookResponse. CompositeHookHandler dispatches to both.
--filter-fields - --filter-fields label,sublabel,meta.tags searches multiple fields. Combined text for fuzzy, individual for exact/regex.
--format - FormatTemplate parses {field.path} placeholders. --format '{label} - {sublabel}' controls display. TUI renders formatted_text when available.
Field Filters - meta.res:3840 in query syntax matches specific fields. !meta.res:3840 for inverse. Pipeline stores item Values for field resolution. Requires dotted path (single word colons stay fuzzy).
2026-03-14 01:42:11 -04:00
7082ceada0 doc: Update plans around hover actions and bidirectional hook communication.
Some checks failed
CI / Check (macos-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Clippy (strict) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
2026-03-14 00:42:06 -04:00
29 changed files with 3089 additions and 350 deletions

View File

@@ -10,30 +10,16 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
jobs: jobs:
check: lint:
name: Check name: Lint
runs-on: ${{ matrix.os }} runs-on: self-hosted
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
components: clippy, rustfmt components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2 - name: Format
- name: Check run: cargo fmt --all -- --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: Clippy - name: Clippy
run: | run: |
cargo clippy --workspace --all-targets -- \ cargo clippy --workspace --all-targets -- \
@@ -47,26 +33,12 @@ jobs:
-D clippy::print_stdout \ -D clippy::print_stdout \
-D clippy::print_stderr -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: test:
name: Test name: Test
runs-on: ${{ matrix.os }} runs-on: self-hosted
strategy: needs: lint
matrix:
os: [ubuntu-latest, macos-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests - name: Run tests
run: cargo test --workspace run: cargo test --workspace

93
Cargo.lock generated
View File

@@ -877,6 +877,15 @@ dependencies = [
"minimal-lexical", "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]] [[package]]
name = "nucleo-matcher" name = "nucleo-matcher"
version = "0.3.1" version = "0.3.1"
@@ -1072,6 +1081,8 @@ dependencies = [
"pikl-tui", "pikl-tui",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
] ]
[[package]] [[package]]
@@ -1085,6 +1096,7 @@ dependencies = [
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -1403,6 +1415,15 @@ dependencies = [
"digest", "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]] [[package]]
name = "signal-hook" name = "signal-hook"
version = "0.3.18" version = "0.3.18"
@@ -1610,6 +1631,15 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -1657,6 +1687,63 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@@ -1722,6 +1809,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View File

@@ -105,16 +105,28 @@ cargo test --workspace
Requires Rust stable. The repo includes a `rust-toolchain.toml` that Requires Rust stable. The repo includes a `rust-toolchain.toml` that
pins the version and pulls in rust-analyzer + clippy. 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 Support
| Platform | Frontend | Status | | Platform | Frontend | Status |
|---|---|---| |---|---|---|
| Linux (Wayland) | GUI (layer-shell overlay) | Planned | | Linux (Wayland) | GUI (layer-shell overlay) | Planned |
| Linux (X11) | GUI | Planned | | Linux (X11) | GUI | Planned |
| Linux | TUI | Planned | | Linux | TUI | Working |
| macOS | GUI | Planned | | macOS | GUI | Planned |
| macOS | TUI | Planned | | macOS | TUI | Working |
| Window | GUI | Low Priority | | Windows | GUI | Low Priority |
## License ## License

View File

@@ -12,10 +12,11 @@ workspace = true
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
thiserror = "2.0.18" 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" nucleo-matcher = "0.3.1"
fancy-regex = "0.14" fancy-regex = "0.14"
[dev-dependencies] [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" } pikl-test-macros = { path = "../pikl-test-macros" }

View 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")), "");
}
}

View File

@@ -3,6 +3,7 @@
//! deps. Frontends are separate crates that talk to this //! deps. Frontends are separate crates that talk to this
//! through channels. //! through channels.
pub mod format;
mod model; mod model;
mod query; mod query;
mod runtime; mod runtime;
@@ -13,6 +14,7 @@ pub mod error;
// Re-export submodules at crate root so the public API stays flat. // Re-export submodules at crate root so the public API stays flat.
pub use model::event; pub use model::event;
pub use model::item; pub use model::item;
pub use model::output;
pub use model::traits; pub use model::traits;
pub use query::exact; pub use query::exact;
pub use query::filter; pub use query::filter;
@@ -20,6 +22,7 @@ pub use query::navigation;
pub use query::pipeline; pub use query::pipeline;
pub use query::regex_filter; pub use query::regex_filter;
pub use query::strategy; pub use query::strategy;
pub use runtime::debounce;
pub use runtime::hook; pub use runtime::hook;
pub use runtime::input; pub use runtime::input;
pub use runtime::json_menu; pub use runtime::json_menu;

View File

@@ -11,6 +11,8 @@ use std::sync::Arc;
use serde_json::Value; use serde_json::Value;
use crate::hook::HookResponse;
/// Input mode. Insert mode sends keystrokes to the filter, /// Input mode. Insert mode sends keystrokes to the filter,
/// normal mode uses vim-style navigation keybinds. /// normal mode uses vim-style navigation keybinds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -36,9 +38,14 @@ pub enum Action {
HalfPageDown(usize), HalfPageDown(usize),
SetMode(Mode), SetMode(Mode),
Confirm, Confirm,
Quicklist,
Cancel, Cancel,
Resize { height: u16 }, Resize { height: u16 },
AddItems(Vec<Value>), AddItems(Vec<Value>),
ReplaceItems(Vec<Value>),
RemoveItems(Vec<usize>),
ProcessHookResponse(HookResponse),
CloseMenu,
} }
/// Broadcast from the menu loop to all subscribers /// Broadcast from the menu loop to all subscribers
@@ -47,6 +54,7 @@ pub enum Action {
pub enum MenuEvent { pub enum MenuEvent {
StateChanged(ViewState), StateChanged(ViewState),
Selected(Value), Selected(Value),
Quicklist(Vec<Value>),
Cancelled, Cancelled,
} }
@@ -73,6 +81,7 @@ pub struct ViewState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct VisibleItem { pub struct VisibleItem {
pub label: String, pub label: String,
pub formatted_text: Option<String>,
pub index: usize, pub index: usize,
} }
@@ -81,6 +90,7 @@ pub struct VisibleItem {
#[must_use] #[must_use]
#[derive(Debug)] #[derive(Debug)]
pub enum MenuResult { pub enum MenuResult {
Selected(Value), Selected { value: Value, index: usize },
Quicklist { items: Vec<(Value, usize)> },
Cancelled, Cancelled,
} }

View File

@@ -8,7 +8,8 @@ use serde_json::Value;
/// as `Value::String`, structured entries as /// as `Value::String`, structured entries as
/// `Value::Object`. The label is extracted and cached at /// `Value::Object`. The label is extracted and cached at
/// construction time so display calls are just a pointer /// 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) /// Serializes as the inner Value (transparent for output)
/// but construction always requires label extraction. /// but construction always requires label extraction.
@@ -16,23 +17,40 @@ use serde_json::Value;
pub struct Item { pub struct Item {
pub value: Value, pub value: Value,
label_cache: String, label_cache: String,
sublabel_cache: Option<String>,
icon_cache: Option<String>,
group_cache: Option<String>,
} }
impl Item { impl Item {
/// Create an Item from a JSON value, extracting the display /// Create an Item from a JSON value, extracting the display
/// label from the given key. String values use the string /// 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 { pub fn new(value: Value, label_key: &str) -> Self {
let label_cache = extract_label(&value, label_key).to_string(); 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 /// 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 { pub fn from_plain_text(line: &str) -> Self {
Self { Self {
value: Value::String(line.to_string()), value: Value::String(line.to_string()),
label_cache: 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 { pub fn label(&self) -> &str {
&self.label_cache &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. /// 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 { impl serde::Serialize for Item {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.value.serialize(serializer) self.value.serialize(serializer)
@@ -113,6 +187,9 @@ mod tests {
let item = Item::from_plain_text("hello world"); let item = Item::from_plain_text("hello world");
assert_eq!(item.label(), "hello world"); assert_eq!(item.label(), "hello world");
assert!(item.value.is_string()); assert!(item.value.is_string());
assert!(item.sublabel().is_none());
assert!(item.icon().is_none());
assert!(item.group().is_none());
} }
#[test] #[test]
@@ -128,4 +205,107 @@ mod tests {
let json = serde_json::to_string(&item).unwrap_or_default(); let json = serde_json::to_string(&item).unwrap_or_default();
assert_eq!(json, r#"{"label":"foo"}"#); 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());
}
} }

View File

@@ -1,3 +1,4 @@
pub mod event; pub mod event;
pub mod item; pub mod item;
pub mod output;
pub mod traits; pub mod traits;

View 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}"
);
}
}

View File

@@ -38,4 +38,38 @@ pub trait Menu: Send + 'static {
/// Get the JSON value of a filtered item for output. /// Get the JSON value of a filtered item for output.
/// Returns a reference to the stored value. /// Returns a reference to the stored value.
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::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>;
/// 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>);
/// 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()
}
} }

View File

@@ -165,9 +165,17 @@ impl Viewport {
self.scroll_offset..end 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 /// Clamp cursor and scroll offset to valid positions after
/// a height or count change. /// a height or count change, item removal, or manual
fn clamp(&mut self) { /// cursor set.
pub fn clamp(&mut self) {
if self.filtered_count == 0 { if self.filtered_count == 0 {
self.cursor = 0; self.cursor = 0;
self.scroll_offset = 0; self.scroll_offset = 0;

View File

@@ -3,8 +3,11 @@
//! and chains results through stages. Supports incremental //! and chains results through stages. Supports incremental
//! caching: unchanged stages keep their results. //! caching: unchanged stages keep their results.
use serde_json::Value;
use super::filter::{Filter, FuzzyFilter}; use super::filter::{Filter, FuzzyFilter};
use super::strategy::{self, FilterKind}; use super::strategy::{self, FilterKind};
use crate::item::resolve_field_path;
/// A multi-stage filter pipeline. Each `|` in the query /// A multi-stage filter pipeline. Each `|` in the query
/// creates a new stage that filters the previous stage's /// creates a new stage that filters the previous stage's
@@ -13,6 +16,9 @@ use super::strategy::{self, FilterKind};
pub struct FilterPipeline { pub struct FilterPipeline {
/// Master item list: (original index, label). /// Master item list: (original index, label).
items: Vec<(usize, String)>, 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. /// Pipeline stages, one per `|`-separated segment.
stages: Vec<PipelineStage>, stages: Vec<PipelineStage>,
/// The last raw query string, used for diffing. /// The last raw query string, used for diffing.
@@ -99,6 +105,17 @@ fn split_pipeline(query: &str) -> Vec<String> {
segments.into_iter().filter(|s| !s.is_empty()).collect() 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 { impl Default for FilterPipeline {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -109,6 +126,7 @@ impl FilterPipeline {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
items: Vec::new(), items: Vec::new(),
item_values: Vec::new(),
stages: Vec::new(), stages: Vec::new(),
last_raw_query: String::new(), last_raw_query: String::new(),
} }
@@ -130,7 +148,7 @@ impl FilterPipeline {
let stage = &mut self.stages[stage_idx]; 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::Fuzzy => Self::eval_fuzzy(stage, &input_indices, stage_idx),
FilterKind::Exact => { FilterKind::Exact => {
Self::eval_simple(stage, &input_indices, &self.items, |label, query| { Self::eval_simple(stage, &input_indices, &self.items, |label, query| {
@@ -146,6 +164,14 @@ impl FilterPipeline {
} }
}) })
} }
FilterKind::Field { path } => {
Self::eval_field(
stage,
&input_indices,
&self.item_values,
path,
)
}
}; };
self.stages[stage_idx].cached_indices = result; self.stages[stage_idx].cached_indices = result;
@@ -184,6 +210,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( fn eval_simple(
stage: &PipelineStage, stage: &PipelineStage,
input_indices: &[usize], input_indices: &[usize],
@@ -209,9 +270,49 @@ 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)]) {
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)]) {
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 { impl Filter for FilterPipeline {
fn push(&mut self, index: usize, label: &str) { fn push(&mut self, index: usize, label: &str) {
self.items.push((index, label.to_string())); self.items.push((index, label.to_string()));
self.item_values.push(None);
// Push to any existing fuzzy filters in stages // Push to any existing fuzzy filters in stages
for stage in &mut self.stages { for stage in &mut self.stages {
if let Some(ref mut fuzzy) = stage.fuzzy { if let Some(ref mut fuzzy) = stage.fuzzy {
@@ -614,4 +715,95 @@ mod tests {
assert!(result.contains(&"cherry")); assert!(result.contains(&"cherry"));
assert!(!result.contains(&"banana")); 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));
}
} }

View File

@@ -2,11 +2,15 @@
//! strategy to use based on the query prefix. //! strategy to use based on the query prefix.
/// The type of filter to apply for a query segment. /// The type of filter to apply for a query segment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterKind { pub enum FilterKind {
Fuzzy, Fuzzy,
Exact, Exact,
Regex, 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, /// 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 // Check for inverse fuzzy: !query
if let Some(rest) = segment.strip_prefix('!') { if let Some(rest) = segment.strip_prefix('!') {
return ParsedSegment { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -228,4 +284,52 @@ mod tests {
assert!(p.inverse); assert!(p.inverse);
assert_eq!(p.query, "[0-9]"); 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, "");
}
} }

View File

@@ -0,0 +1,295 @@
//! 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 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,
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);
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 action_tx = self.action_tx.clone();
tokio::spawn(async move {
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "hook handler error");
}
}
});
}
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
let kind = event.kind();
if cancel {
self.cancel_in_flight(kind);
}
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
let handle = tokio::spawn(async move {
tokio::time::sleep(delay).await;
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "hook handler error");
}
}
});
self.in_flight.insert(kind, handle);
}
fn cancel_in_flight(&mut self, kind: HookEventKind) {
if let Some(handle) = self.in_flight.remove(&kind) {
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<Vec<HookResponse>, PiklError> {
if let Ok(mut events) = self.events.lock() {
events.push(event.kind());
}
Ok(vec![])
}
}
#[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);
}
}

View File

@@ -1,15 +1,327 @@
//! Hook trait for lifecycle events. The core library defines //! Hook types for lifecycle events. The core library defines
//! the interface; concrete implementations (shell hooks, IPC //! the event and response types plus the handler trait.
//! hooks, etc.) live in frontend crates. //! Concrete implementations (shell exec hooks, persistent
//! handler processes) live in frontend crates.
use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::error::PiklError; use crate::error::PiklError;
/// A lifecycle hook that fires on menu events. Implementations /// A lifecycle event emitted by the menu engine. Handler
/// live outside pikl-core (e.g. in the CLI binary) so the core /// hooks receive these as JSON lines on stdin. The `event`
/// library stays free of process/libc deps. /// field is the tag for serde's tagged representation.
#[allow(async_fn_in_trait)] #[derive(Debug, Clone, Serialize)]
pub trait Hook: Send + Sync { #[serde(tag = "event", rename_all = "snake_case")]
async fn run(&self, value: &Value) -> Result<(), PiklError>; 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 optionally return responses.
/// Exec hooks return empty vecs. Handler hooks send
/// responses back through the action channel asynchronously
/// and also return empty vecs.
///
/// 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<Vec<HookResponse>, 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) => 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);
}
} }

View File

@@ -3,6 +3,7 @@
//! for `ls | pikl` style usage. //! for `ls | pikl` style usage.
use crate::filter::Filter; use crate::filter::Filter;
use crate::format::FormatTemplate;
use crate::item::Item; use crate::item::Item;
use crate::model::traits::Menu; use crate::model::traits::Menu;
use crate::pipeline::FilterPipeline; use crate::pipeline::FilterPipeline;
@@ -16,6 +17,8 @@ pub struct JsonMenu {
items: Vec<Item>, items: Vec<Item>,
label_key: String, label_key: String,
filter: FilterPipeline, filter: FilterPipeline,
filter_fields: Vec<String>,
format_template: Option<FormatTemplate>,
} }
impl JsonMenu { impl JsonMenu {
@@ -23,14 +26,79 @@ impl JsonMenu {
pub fn new(items: Vec<Item>, label_key: String) -> Self { pub fn new(items: Vec<Item>, label_key: String) -> Self {
let mut filter = FilterPipeline::new(); let mut filter = FilterPipeline::new();
for (i, item) in items.iter().enumerate() { for (i, item) in items.iter().enumerate() {
filter.push(i, item.label()); filter.push_with_value(i, item.label(), &item.value);
} }
Self { Self {
items, items,
label_key, label_key,
filter, 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 { impl Menu for JsonMenu {
@@ -56,7 +124,8 @@ impl Menu for JsonMenu {
for value in values { for value in values {
let idx = self.items.len(); let idx = self.items.len();
let item = Item::new(value, &self.label_key); let item = Item::new(value, &self.label_key);
self.filter.push(idx, item.label()); let text = self.extract_filter_text(&item);
self.filter.push_with_value(idx, &text, &item.value);
self.items.push(item); self.items.push(item);
} }
} }
@@ -66,4 +135,36 @@ impl Menu for JsonMenu {
.matched_index(filtered_index) .matched_index(filtered_index)
.map(|idx| &self.items[idx].value) .map(|idx| &self.items[idx].value)
} }
fn original_index(&self, filtered_index: usize) -> Option<usize> {
self.filter.matched_index(filtered_index)
}
fn replace_all(&mut self, values: Vec<serde_json::Value>) {
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>) {
// 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);
}
}
self.rebuild_pipeline();
}
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))
}
} }

View File

@@ -7,8 +7,10 @@ use std::sync::Arc;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem}; use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
use crate::hook::{HookEvent, HookHandler};
use crate::model::traits::Menu; use crate::model::traits::Menu;
use crate::navigation::Viewport; use crate::navigation::Viewport;
use serde_json::Value; use serde_json::Value;
@@ -20,9 +22,13 @@ pub enum ActionOutcome {
/// State changed, broadcast to subscribers. /// State changed, broadcast to subscribers.
Broadcast, Broadcast,
/// User confirmed a selection. /// User confirmed a selection.
Selected(Value), Selected { value: Value, index: usize },
/// User cancelled. /// User cancelled.
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). /// Nothing happened (e.g. confirm on empty list).
NoOp, NoOp,
} }
@@ -38,6 +44,8 @@ pub struct MenuRunner<M: Menu> {
mode: Mode, mode: Mode,
action_rx: mpsc::Receiver<Action>, action_rx: mpsc::Receiver<Action>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>,
} }
impl<M: Menu> MenuRunner<M> { impl<M: Menu> MenuRunner<M> {
@@ -59,6 +67,8 @@ impl<M: Menu> MenuRunner<M> {
mode: Mode::default(), mode: Mode::default(),
action_rx, action_rx,
event_tx, event_tx,
dispatcher: None,
previous_cursor: None,
}; };
(runner, action_tx) (runner, action_tx)
} }
@@ -69,6 +79,23 @@ impl<M: Menu> MenuRunner<M> {
self.event_tx.subscribe() 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 /// Re-run the filter against all items with the current
/// filter text. Updates the viewport with the new count. /// filter text. Updates the viewport with the new count.
fn run_filter(&mut self) { fn run_filter(&mut self) {
@@ -83,9 +110,13 @@ impl<M: Menu> MenuRunner<M> {
let visible_items: Vec<VisibleItem> = range let visible_items: Vec<VisibleItem> = range
.clone() .clone()
.filter_map(|i| { .filter_map(|i| {
self.menu.filtered_label(i).map(|label| VisibleItem { self.menu.filtered_label(i).map(|label| {
label: label.to_string(), let formatted_text = self.menu.formatted_label(i);
index: i, VisibleItem {
label: label.to_string(),
formatted_text,
index: i,
}
}) })
}) })
.collect(); .collect();
@@ -113,6 +144,35 @@ impl<M: Menu> MenuRunner<M> {
.send(MenuEvent::StateChanged(self.build_view_state())); .send(MenuEvent::StateChanged(self.build_view_state()));
} }
/// 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 {
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
{
self.emit_hook(HookEvent::Hover {
item: value,
index: orig_idx,
});
}
}
}
/// Apply a single action to the menu state. Pure state /// Apply a single action to the menu state. Pure state
/// transition: no channels, no async. Testable in isolation. /// transition: no channels, no async. Testable in isolation.
pub fn apply_action(&mut self, action: Action) -> ActionOutcome { pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
@@ -151,11 +211,27 @@ impl<M: Menu> MenuRunner<M> {
return ActionOutcome::NoOp; return ActionOutcome::NoOp;
} }
let cursor = self.viewport.cursor(); let cursor = self.viewport.cursor();
let index = self.menu.original_index(cursor).unwrap_or(0);
match self.menu.serialize_filtered(cursor) { match self.menu.serialize_filtered(cursor) {
Some(value) => ActionOutcome::Selected(value.clone()), Some(value) => ActionOutcome::Selected {
value: value.clone(),
index,
},
None => ActionOutcome::NoOp, 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::Cancel => ActionOutcome::Cancelled,
Action::Resize { height } => { Action::Resize { height } => {
self.viewport.set_height(height as usize); self.viewport.set_height(height as usize);
@@ -178,6 +254,41 @@ impl<M: Menu> MenuRunner<M> {
self.run_filter(); self.run_filter();
ActionOutcome::Broadcast ActionOutcome::Broadcast
} }
Action::ReplaceItems(values) => {
// 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) => {
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 +316,62 @@ impl<M: Menu> MenuRunner<M> {
self.run_filter(); self.run_filter();
self.broadcast_state(); self.broadcast_state();
// Emit Open event
self.emit_hook(HookEvent::Open);
while let Some(action) = self.action_rx.recv().await { while let Some(action) = self.action_rx.recv().await {
let is_filter_update = matches!(&action, Action::UpdateFilter(_));
match self.apply_action(action) { match self.apply_action(action) {
ActionOutcome::Broadcast => self.broadcast_state(), ActionOutcome::Broadcast => {
ActionOutcome::Selected(value) => { 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 } => {
// 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())); 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();
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 => { ActionOutcome::Cancelled => {
self.emit_hook(HookEvent::Cancel);
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
}
ActionOutcome::Closed => {
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled); let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled); return Ok(MenuResult::Cancelled);
} }
@@ -221,6 +380,7 @@ impl<M: Menu> MenuRunner<M> {
} }
// Sender dropped // Sender dropped
self.emit_hook(HookEvent::Close);
Ok(MenuResult::Cancelled) Ok(MenuResult::Cancelled)
} }
} }
@@ -285,7 +445,7 @@ mod tests {
let mut m = ready_menu(); let mut m = ready_menu();
m.apply_action(Action::MoveDown(1)); m.apply_action(Action::MoveDown(1));
let outcome = m.apply_action(Action::Confirm); 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] #[test]
@@ -413,7 +573,7 @@ mod tests {
} }
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(_)))); assert!(matches!(result, Ok(MenuResult::Selected { .. })));
} }
#[tokio::test] #[tokio::test]
@@ -474,7 +634,7 @@ mod tests {
assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha"))); assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha")));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); 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] #[tokio::test]
@@ -496,7 +656,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); 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] #[tokio::test]
@@ -521,7 +681,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); 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] #[tokio::test]
@@ -556,7 +716,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); 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] #[tokio::test]
@@ -608,7 +768,7 @@ mod tests {
// Must get "banana". Filter was applied before confirm ran. // Must get "banana". Filter was applied before confirm ran.
assert!(matches!( assert!(matches!(
result, 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 +794,7 @@ mod tests {
// Cursor at index 3 -> "delta" // Cursor at index 3 -> "delta"
assert!(matches!( assert!(matches!(
result, 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 +890,250 @@ mod tests {
// Must find "zephyr". It was added before the filter ran. // Must find "zephyr". It was added before the filter ran.
assert!(matches!( assert!(matches!(
result, 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, HookResponse};
use std::sync::Mutex;
struct Recorder(Mutex<Vec<HookEventKind>>);
impl HookHandler for Recorder {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
if let Ok(mut v) = self.0.lock() {
v.push(event.kind());
}
Ok(vec![])
}
}
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));
}
} }

View File

@@ -1,3 +1,4 @@
pub mod debounce;
pub mod hook; pub mod hook;
pub mod input; pub mod input;
pub mod json_menu; pub mod json_menu;

View File

@@ -327,7 +327,7 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
} else if let Some(ref expected) = case.selected { } else if let Some(ref expected) = case.selected {
quote! { quote! {
match &result { match &result {
Ok(MenuResult::Selected(value)) => { Ok(MenuResult::Selected { value, .. }) => {
let got = value.as_str() let got = value.as_str()
.or_else(|| value.get(#label_key).and_then(|v| v.as_str())) .or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
.unwrap_or(""); .unwrap_or("");

View File

@@ -132,7 +132,7 @@ async fn run_inner(
} }
view_state = Some(vs); view_state = Some(vs);
} }
Ok(MenuEvent::Selected(_) | MenuEvent::Cancelled) => { Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
break; break;
} }
Err(broadcast::error::RecvError::Lagged(_)) => {} Err(broadcast::error::RecvError::Lagged(_)) => {}
@@ -194,7 +194,8 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
} else { } else {
Style::default() 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(); .collect();
@@ -232,6 +233,7 @@ fn map_insert_mode(key: KeyEvent, filter_text: &mut String) -> Option<Action> {
filter_text.pop(); filter_text.pop();
Some(Action::UpdateFilter(filter_text.clone())) 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) => { (KeyCode::Char(c), mods) if !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
filter_text.push(c); filter_text.push(c);
Some(Action::UpdateFilter(filter_text.clone())) Some(Action::UpdateFilter(filter_text.clone()))
@@ -275,6 +277,7 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
(KeyCode::Char('/'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { (KeyCode::Char('/'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
Some(Action::SetMode(Mode::Insert)) Some(Action::SetMode(Mode::Insert))
} }
(KeyCode::Char('q'), KeyModifiers::CONTROL) => Some(Action::Quicklist),
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { (KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
Some(Action::Cancel) Some(Action::Cancel)
} }
@@ -318,14 +321,17 @@ mod tests {
visible_items: vec![ visible_items: vec![
VisibleItem { VisibleItem {
label: "alpha".into(), label: "alpha".into(),
formatted_text: None,
index: 0, index: 0,
}, },
VisibleItem { VisibleItem {
label: "bravo".into(), label: "bravo".into(),
formatted_text: None,
index: 1, index: 1,
}, },
VisibleItem { VisibleItem {
label: "charlie".into(), label: "charlie".into(),
formatted_text: None,
index: 2, index: 2,
}, },
], ],
@@ -659,6 +665,28 @@ mod tests {
assert_eq!(ft, ""); 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) -- // -- Rendering tests (TestBackend) --
fn render_to_backend(width: u16, height: u16, vs: &ViewState, filter: &str) -> TestBackend { fn render_to_backend(width: u16, height: u16, vs: &ViewState, filter: &str) -> TestBackend {

View File

@@ -18,6 +18,8 @@ pikl-tui = { path = "../pikl-tui" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util"] } tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util"] }
serde_json = "1" serde_json = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
libc = "0.2" libc = "0.2"
[dev-dependencies] [dev-dependencies]

170
crates/pikl/src/handler.rs Normal file
View File

@@ -0,0 +1,170 @@
//! 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, HookResponse,
};
/// 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<Vec<HookResponse>, 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(vec![])
}
}
/// 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 briefly for the reader to finish
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
reader_handle,
)
.await;
// Kill child if still running
let _ = child.kill().await;
Ok(())
}

View File

@@ -1,15 +1,15 @@
//! Shell hook execution. Hooks are shell commands that fire //! Shell exec hooks. Fire-and-forget subprocess per event.
//! on menu events (selection, cancellation). The selected //! The hook's stdin receives the event JSON, stdout is
//! item's JSON is piped to the hook's stdin. //! redirected to stderr to keep pikl's output clean.
//!
//! Hook stdout is redirected to stderr so it doesn't end up use std::collections::HashMap;
//! mixed into pikl's structured output on stdout.
use serde_json::Value; use serde_json::Value;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::process::Command; use tokio::process::Command;
use pikl_core::error::PiklError; use pikl_core::error::PiklError;
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
/// Duplicate stderr as a [`Stdio`] handle for use as a /// Duplicate stderr as a [`Stdio`] handle for use as a
/// child process's stdout. Keeps hook output on stderr /// child process's stdout. Keeps hook output on stderr
@@ -26,7 +26,7 @@ fn stderr_as_stdio() -> std::process::Stdio {
std::process::Stdio::inherit() 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 /// Hook stdout goes to stderr (see module docs). Returns
/// an error if the command exits non-zero. /// an error if the command exits non-zero.
pub async fn run_hook(command: &str, value: &Value) -> Result<(), PiklError> { pub async fn run_hook(command: &str, value: &Value) -> Result<(), PiklError> {
@@ -48,8 +48,7 @@ async fn write_json_stdin(
Ok(()) Ok(())
} }
/// Run a shell hook with a custom stdout handle. Used by /// Run a shell hook with a custom stdout handle.
/// [`run_hook`] to redirect hook output to stderr.
async fn run_hook_with_stdout( async fn run_hook_with_stdout(
command: &str, command: &str,
value: &Value, value: &Value,
@@ -75,6 +74,61 @@ async fn run_hook_with_stdout(
Ok(()) 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<Vec<HookResponse>, 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(vec![])
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -94,35 +148,6 @@ mod tests {
assert!(result.is_err()); 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] #[tokio::test]
async fn write_json_stdin_sends_correct_data() { async fn write_json_stdin_sends_correct_data() {
let value = json!({"key": "value"}); let value = json!({"key": "value"});
@@ -141,99 +166,29 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn hook_receives_plain_text_json() { async fn hook_receives_plain_text_json() {
let value = json!("hello"); 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") let child = Command::new("sh")
.arg("-c") .arg("-c")
.arg("echo hello") .arg("cat")
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit())
.spawn(); .spawn();
assert!(child.is_ok(), "should be able to spawn echo"); let Ok(mut child) = child else {
if let Ok(mut child) = child { return;
if let Some(mut stdin) = child.stdin.take() { };
let json = serde_json::to_string(&value).unwrap_or_default(); let _ = write_json_stdin(&mut child, &value).await;
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 output = child let output = child
.unwrap_or_else(|_| unreachable!())
.wait_with_output() .wait_with_output()
.await; .await
assert!(output.is_ok()); .unwrap_or_else(|_| std::process::Output {
assert!( status: std::process::ExitStatus::default(),
output stdout: Vec::new(),
.unwrap_or_else(|_| std::process::Output { stderr: Vec::new(),
status: std::process::ExitStatus::default(), });
stdout: Vec::new(), let got = String::from_utf8(output.stdout).unwrap_or_default();
stderr: Vec::new(), assert_eq!(got, r#""hello""#);
})
.status
.success()
);
} }
// -- Hook error propagation --
#[tokio::test] #[tokio::test]
async fn hook_nonzero_exit() { async fn hook_nonzero_exit() {
let value = json!("test"); let value = json!("test");
@@ -245,34 +200,4 @@ mod tests {
assert_eq!(status.code(), Some(42)); 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 { .. })));
}
} }

View File

@@ -1,17 +1,27 @@
mod handler;
mod hook; mod hook;
use std::io::{BufReader, IsTerminal, Write}; use std::io::{BufReader, IsTerminal, Write};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use clap::Parser; use clap::Parser;
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
use pikl_core::error::PiklError; use pikl_core::error::PiklError;
use pikl_core::event::{Action, MenuResult, Mode}; 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::input::read_items_sync;
use pikl_core::item::Item; use pikl_core::item::Item;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
use pikl_core::menu::MenuRunner; use pikl_core::menu::MenuRunner;
use pikl_core::output::{OutputAction, OutputItem};
use pikl_core::script::action_fd::{self, ScriptAction, ShowAction}; use pikl_core::script::action_fd::{self, ScriptAction, ShowAction};
use serde_json::Value;
use handler::ShellHandlerHook;
use hook::ShellExecHandler;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@@ -23,14 +33,69 @@ struct Cli {
#[arg(long, default_value = "label")] #[arg(long, default_value = "label")]
label_key: String, 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)] #[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 /// Shell command to run on cancel
#[arg(long)] #[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>, 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) /// Read action script from this file descriptor (enables headless mode)
#[arg(long, value_name = "FD")] #[arg(long, value_name = "FD")]
action_fd: Option<i32>, action_fd: Option<i32>,
@@ -42,9 +107,24 @@ struct Cli {
/// Start in this input mode (insert or normal, default: insert) /// Start in this input mode (insert or normal, default: insert)
#[arg(long, value_name = "MODE", default_value = "insert")] #[arg(long, value_name = "MODE", default_value = "insert")]
start_mode: String, 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() { fn main() {
// Initialize tracing from RUST_LOG env var
tracing_subscriber::fmt::init();
let cli = Cli::parse(); let cli = Cli::parse();
// Install a panic hook that restores the terminal so a crash // Install a panic hook that restores the terminal so a crash
@@ -121,9 +201,6 @@ fn main() {
}); });
// Reopen stdin from /dev/tty before entering async context. // 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() if script.is_none()
&& let Err(e) = reopen_stdin_from_tty() && let Err(e) = reopen_stdin_from_tty()
{ {
@@ -145,27 +222,120 @@ fn main() {
}; };
// STEP 4: Branch on headless vs interactive // STEP 4: Branch on headless vs interactive
let label_key = cli.label_key.clone();
let result = if let Some(script) = script { 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 { } else {
rt.block_on(run_interactive(items, label_key, start_mode)) rt.block_on(run_interactive(items, &cli, start_mode))
}; };
// STEP 5: Handle result // 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<Vec<pikl_core::hook::HookResponse>, 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(vec![])
}
} }
/// Run in headless mode: replay a script, optionally hand /// Run in headless mode: replay a script, optionally hand
/// off to a TUI if the script ends with show-ui/show-tui/show-gui. /// off to a TUI if the script ends with show-ui/show-tui/show-gui.
async fn run_headless( async fn run_headless(
items: Vec<Item>, items: Vec<Item>,
label_key: String, cli: &Cli,
script: Vec<ScriptAction>, script: Vec<ScriptAction>,
start_mode: Mode, start_mode: Mode,
) -> Result<MenuResult, PiklError> { ) -> 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); 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(); let event_rx = menu.subscribe();
// Default headless viewport // Default headless viewport
@@ -182,7 +352,6 @@ async fn run_headless(
match show_action { match show_action {
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => { 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 tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx));
let result = menu_handle let result = menu_handle
.await .await
@@ -204,11 +373,16 @@ async fn run_headless(
/// pick from the menu. /// pick from the menu.
async fn run_interactive( async fn run_interactive(
items: Vec<Item>, items: Vec<Item>,
label_key: String, cli: &Cli,
start_mode: Mode, start_mode: Mode,
) -> Result<MenuResult, PiklError> { ) -> 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); 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(); let event_rx = menu.subscribe();
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly. // Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
@@ -216,7 +390,6 @@ async fn run_interactive(
tokio::spawn(async move { tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await { if let Ok(()) = tokio::signal::ctrl_c().await {
pikl_tui::restore_terminal(); pikl_tui::restore_terminal();
// Send cancel so the menu loop exits cleanly.
let _ = signal_tx.send(Action::Cancel).await; let _ = signal_tx.send(Action::Cancel).await;
} }
}); });
@@ -229,42 +402,48 @@ async fn run_interactive(
result result
} }
/// Serialize a value as JSON and write it to the given writer. /// Process the menu result: print output to stdout and
fn write_selected_json( /// exit with the appropriate code.
writer: &mut impl Write, fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
value: &serde_json::Value, let mut out = std::io::stdout().lock();
) -> 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) {
match result { match result {
Ok(MenuResult::Selected(value)) => { Ok(MenuResult::Selected { value, index }) => {
run_result_hook(rt, "on-select", cli.on_select.as_deref(), &value); if cli.structured {
let _ = write_selected_json(&mut std::io::stdout().lock(), &value); 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) => { Ok(MenuResult::Cancelled) => {
let empty = serde_json::Value::String(String::new()); if cli.structured {
run_result_hook(rt, "on-cancel", cli.on_cancel.as_deref(), &empty); let output = OutputItem {
value: Value::Null,
action: OutputAction::Cancel,
index: 0,
};
let _ = write_output_json(&mut out, &output);
}
std::process::exit(1); std::process::exit(1);
} }
Err(e) => { Err(e) => {
@@ -274,12 +453,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, /// Read items from stdin. If `timeout_secs` is non-zero,
/// spawn a thread and bail if it doesn't finish in time. /// spawn a thread and bail if it doesn't finish in time.
/// A timeout of 0 means no timeout (blocking read). /// 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> { fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Item>, PiklError> {
if timeout_secs == 0 { if timeout_secs == 0 {
return read_items_sync(std::io::stdin().lock(), label_key); return read_items_sync(std::io::stdin().lock(), label_key);
@@ -315,17 +509,10 @@ fn reopen_stdin_from_tty() -> Result<(), PiklError> {
{ {
use std::os::unix::io::AsRawFd; use std::os::unix::io::AsRawFd;
let tty = std::fs::File::open("/dev/tty")?; 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) }; let r = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) };
if r < 0 { if r < 0 {
return Err(PiklError::Io(std::io::Error::last_os_error())); 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) }; unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) };
} }
Ok(()) Ok(())

View File

@@ -66,58 +66,171 @@ naturally to table/column display mode.
## Event Hooks ## Event Hooks
Hooks are shell commands that run in response to lifecycle Lifecycle events fire as the user interacts with the menu.
events. They receive the relevant item(s) as JSON on stdin. 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-open` | Menu opens | Save current state for revert |
| `on-close` | Menu closes (any reason) | Cleanup | | `on-close` | Menu closes (any reason) | Cleanup |
| `on-hover` | Selection cursor moves to a new item | Live wallpaper preview | | `on-hover` | Cursor moves to a new item | Live preview, prefetch |
| `on-select` | User confirms selection (Enter) | Apply the choice | | `on-select` | User confirms (Enter) | Apply the choice |
| `on-cancel` | User cancels (Escape) | Revert preview | | `on-cancel` | User cancels (Escape) | Revert preview |
| `on-filter` | Filter text changes | Dynamic item reloading | | `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 ```sh
pikl --on-hover 'hyprctl hyprpaper wallpaper "DP-4, {path}"' \ pikl --on-hover-exec 'notify-send "$(jq -r .label)"' \
--on-cancel 'hyprctl hyprpaper wallpaper "DP-4, {original}"' \ --on-select-exec 'apply-wallpaper.sh'
--on-hover-debounce 100
``` ```
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 ```toml
# ~/.config/pikl/wallpaper.toml # ~/.config/pikl/wallpaper.toml
[hooks] [hooks]
on-hover = 'hyprctl hyprpaper wallpaper "DP-4, {label}"' on-hover = './wallpaper-handler.sh'
on-cancel = 'restore-wallpaper.sh' on-select-exec = 'apply-wallpaper.sh'
on-hover-debounce = 100 on-cancel-exec = 'restore-wallpaper.sh'
[display] [display]
columns = ["label", "meta.res"] 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 ### Debouncing
All hooks support a debounce option All hooks (exec and handler) support debouncing. Three
(`--on-hover-debounce 100`). When the user is scrolling modes:
fast, only the last event in the debounce window fires.
Built-in, no external tooling needed. | 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 ## Filtering
@@ -651,18 +764,26 @@ complexity:
branching, no custom DSL. For shell one-liners, branching, no custom DSL. For shell one-liners,
integration tests, and simple automation. If you need integration tests, and simple automation. If you need
conditionals, you've outgrown this. 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 External tools can read state and send actions while pikl
runs interactively. Good for tool integration. 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, stateful scripting: subscribe to events, branch on state,
loops, the works. The Lua runtime is just another loops, the works. The Lua runtime is just another
frontend pushing Actions and subscribing to MenuEvents. frontend pushing Actions and subscribing to MenuEvents.
For anything complex enough to need a real language. For anything complex enough to need a real language.
No custom DSL. Action-fd stays simple forever. The jump from No custom DSL. Action-fd stays simple forever. The jump
"I need conditionals" to "use Lua" is intentional: there's from "I need conditionals" to "use Lua" is intentional:
no value in a half-language. there's no value in a half-language.
## Use Cases ## Use Cases
@@ -688,5 +809,8 @@ with the full writeup.
- Maximum practical item count before we need virtual - Maximum practical item count before we need virtual
scrolling? (Probably around 100k) scrolling? (Probably around 100k)
- Should hooks run in a pool or strictly sequential? - 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? - Plugin system via WASM for custom filter strategies?
(Probably way later if ever) (Probably way later if ever)

View File

@@ -14,7 +14,7 @@ when* we build it.
- Ship something usable early, iterate from real usage - Ship something usable early, iterate from real usage
- Don't optimize until there's a reason to - 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. 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 **Done when:** `ls | pikl` works and prints the selected
item. item.
## Phase 1.5: Action-fd (Headless Mode) ## Phase 1.5: Action-fd (Headless Mode)
Scriptable, non-interactive mode for integration tests and Scriptable, non-interactive mode for integration tests and
automation. Small enough to slot in before phase 2. It's 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` **Done when:** `echo -e "hello\nworld" | pikl --action-fd 3`
with `confirm` on fd 3 prints `"hello"` to stdout. 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 Make it feel like home for a vim user. The filter system
is the real star here: strategy prefixes, pipeline is the real star here: strategy prefixes, pipeline
@@ -112,24 +112,63 @@ chaining, incremental caching.
with vim muscle memory. with vim muscle memory.
`'log | !temp | /[0-9]+/` works as a pipeline. `'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:** **Deliverables:**
- JSON line input parsing (label, sublabel, meta, icon, - Item model: sublabel, meta, icon, group as first-class
group) optional fields
- JSON output with action context - Output: separate struct with action context (action,
- Full hook lifecycle: on-open, on-close, on-hover, 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 on-select, on-cancel, on-filter
- Hook debouncing - Debounce: three modes (none, debounce, cancel-stale),
- Bidirectional hooks (hook stdout modifies menu state) per-hook CLI flags
- `--format` template strings for display - Default debounce: on-hover 200ms + cancel-stale,
- Field filters (`meta.res:3840`) on-filter 200ms, others none
- Filter scoping (`--filter-fields`) - 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 **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 ## Phase 4: Multi-Select & Registers

View 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.

View File

@@ -85,7 +85,7 @@ file_picker() {
on_select_hook() { on_select_hook() {
printf "one\ntwo\nthree\nfour\nfive\n" \ 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() { mixed_input() {
@@ -97,6 +97,184 @@ another plain string
ITEMS 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 ───────────────────────────────────────── # ── Scenario menu ─────────────────────────────────────────
scenarios=( scenarios=(
@@ -107,22 +285,45 @@ scenarios=(
"Git branches" "Git branches"
"Git log (last 30)" "Git log (last 30)"
"File picker" "File picker"
"on-select hook"
"Mixed input (plain + JSON)" "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 # Map display names to functions
run_scenario() { run_scenario() {
case "$1" in case "$1" in
*"Plain text"*) plain_list ;; *"Plain text"*) plain_list ;;
*"Big list"*) big_list ;; *"Big list"*) big_list ;;
*"JSON objects"*) json_objects ;; *"JSON objects"*) json_objects ;;
*"label-key"*) custom_label_key ;; *"label-key"*) custom_label_key ;;
*"Git branches"*) git_branches ;; *"Git branches"*) git_branches ;;
*"Git log"*) git_log_picker ;; *"Git log"*) git_log_picker ;;
*"File picker"*) file_picker ;; *"File picker"*) file_picker ;;
*"on-select"*) on_select_hook ;; *"Mixed input"*) mixed_input ;;
*"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 echo "unknown scenario" >&2
return 1 return 1
@@ -142,8 +343,8 @@ main() {
exit 1 exit 1
} }
# pikl outputs JSON. Strip the quotes for matching. # pikl outputs JSON. Strip quotes and extract value for matching.
choice=$(echo "$choice" | tr -d '"') choice=$(echo "$choice" | jq -r '.value // .' 2>/dev/null || echo "$choice" | tr -d '"')
echo "" >&2 echo "" >&2
echo "── running: $choice ──" >&2 echo "── running: $choice ──" >&2