From 8bf33667409a0cb9ba75aec1d21f00df8804cb42 Mon Sep 17 00:00:00 2001 From: "J. Champagne" Date: Sat, 14 Mar 2026 01:33:41 -0400 Subject: [PATCH] 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). --- Cargo.lock | 93 +++++++ README.md | 18 +- crates/pikl-core/Cargo.toml | 5 +- crates/pikl-core/src/format.rs | 154 +++++++++++ crates/pikl-core/src/lib.rs | 3 + crates/pikl-core/src/model/event.rs | 9 +- crates/pikl-core/src/model/item.rs | 188 ++++++++++++- crates/pikl-core/src/model/mod.rs | 1 + crates/pikl-core/src/model/output.rs | 110 ++++++++ crates/pikl-core/src/model/traits.rs | 21 ++ crates/pikl-core/src/query/navigation.rs | 12 +- crates/pikl-core/src/query/pipeline.rs | 194 +++++++++++++- crates/pikl-core/src/query/strategy.rs | 106 +++++++- crates/pikl-core/src/runtime/debounce.rs | 295 +++++++++++++++++++++ crates/pikl-core/src/runtime/hook.rs | 306 +++++++++++++++++++++- crates/pikl-core/src/runtime/json_menu.rs | 105 +++++++- crates/pikl-core/src/runtime/menu.rs | 287 ++++++++++++++++++-- crates/pikl-core/src/runtime/mod.rs | 1 + crates/pikl-test-macros/src/codegen.rs | 2 +- crates/pikl-tui/src/lib.rs | 6 +- crates/pikl/Cargo.toml | 2 + crates/pikl/src/handler.rs | 170 ++++++++++++ crates/pikl/src/hook.rs | 227 ++++++---------- crates/pikl/src/main.rs | 253 ++++++++++++++---- docs/DESIGN.md | 19 +- docs/DEVPLAN.md | 8 +- examples/demo.sh | 227 +++++++++++++++- 27 files changed, 2548 insertions(+), 274 deletions(-) create mode 100644 crates/pikl-core/src/format.rs create mode 100644 crates/pikl-core/src/model/output.rs create mode 100644 crates/pikl-core/src/runtime/debounce.rs create mode 100644 crates/pikl/src/handler.rs diff --git a/Cargo.lock b/Cargo.lock index 061aae8..eb83799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -877,6 +877,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + [[package]] name = "nucleo-matcher" version = "0.3.1" @@ -1072,6 +1081,8 @@ dependencies = [ "pikl-tui", "serde_json", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1085,6 +1096,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tracing", ] [[package]] @@ -1403,6 +1415,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -1610,6 +1631,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -1657,6 +1687,63 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1722,6 +1809,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" diff --git a/README.md b/README.md index 7b35271..180c0bc 100644 --- a/README.md +++ b/README.md @@ -105,16 +105,28 @@ cargo test --workspace Requires Rust stable. The repo includes a `rust-toolchain.toml` that pins the version and pulls in rust-analyzer + clippy. +## Current Status + +Phases 1 through 3 are complete. pikl works as a TUI menu with: +- Fuzzy, exact, regex, and inverse filtering with `|` pipeline chaining +- Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes) +- Structured JSON I/O with sublabel, icon, group, and arbitrary metadata +- Lifecycle hooks: exec (fire-and-forget) and handler (bidirectional) +- Debounce and cancel-stale for rapid events +- Format templates (`--format '{label} - {sublabel}'`) +- Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax) +- Headless scripting via `--action-fd` + ## Platform Support | Platform | Frontend | Status | |---|---|---| | Linux (Wayland) | GUI (layer-shell overlay) | Planned | | Linux (X11) | GUI | Planned | -| Linux | TUI | Planned | +| Linux | TUI | Working | | macOS | GUI | Planned | -| macOS | TUI | Planned | -| Window | GUI | Low Priority | +| macOS | TUI | Working | +| Windows | GUI | Low Priority | ## License diff --git a/crates/pikl-core/Cargo.toml b/crates/pikl-core/Cargo.toml index 1446eda..e2864c4 100644 --- a/crates/pikl-core/Cargo.toml +++ b/crates/pikl-core/Cargo.toml @@ -12,10 +12,11 @@ workspace = true serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" thiserror = "2.0.18" -tokio = { version = "1.50.0", features = ["sync", "io-util", "rt"] } +tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] } +tracing = "0.1" nucleo-matcher = "0.3.1" fancy-regex = "0.14" [dev-dependencies] -tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread"] } +tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] } pikl-test-macros = { path = "../pikl-test-macros" } diff --git a/crates/pikl-core/src/format.rs b/crates/pikl-core/src/format.rs new file mode 100644 index 0000000..ddd9ac5 --- /dev/null +++ b/crates/pikl-core/src/format.rs @@ -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, +} + +#[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")), ""); + } +} diff --git a/crates/pikl-core/src/lib.rs b/crates/pikl-core/src/lib.rs index cc229d9..e5f3499 100644 --- a/crates/pikl-core/src/lib.rs +++ b/crates/pikl-core/src/lib.rs @@ -3,6 +3,7 @@ //! deps. Frontends are separate crates that talk to this //! through channels. +pub mod format; mod model; mod query; mod runtime; @@ -13,6 +14,7 @@ pub mod error; // Re-export submodules at crate root so the public API stays flat. pub use model::event; pub use model::item; +pub use model::output; pub use model::traits; pub use query::exact; pub use query::filter; @@ -20,6 +22,7 @@ pub use query::navigation; pub use query::pipeline; pub use query::regex_filter; pub use query::strategy; +pub use runtime::debounce; pub use runtime::hook; pub use runtime::input; pub use runtime::json_menu; diff --git a/crates/pikl-core/src/model/event.rs b/crates/pikl-core/src/model/event.rs index c553a03..a883590 100644 --- a/crates/pikl-core/src/model/event.rs +++ b/crates/pikl-core/src/model/event.rs @@ -11,6 +11,8 @@ use std::sync::Arc; use serde_json::Value; +use crate::hook::HookResponse; + /// Input mode. Insert mode sends keystrokes to the filter, /// normal mode uses vim-style navigation keybinds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -39,6 +41,10 @@ pub enum Action { Cancel, Resize { height: u16 }, AddItems(Vec), + ReplaceItems(Vec), + RemoveItems(Vec), + ProcessHookResponse(HookResponse), + CloseMenu, } /// Broadcast from the menu loop to all subscribers @@ -73,6 +79,7 @@ pub struct ViewState { #[derive(Debug, Clone)] pub struct VisibleItem { pub label: String, + pub formatted_text: Option, pub index: usize, } @@ -81,6 +88,6 @@ pub struct VisibleItem { #[must_use] #[derive(Debug)] pub enum MenuResult { - Selected(Value), + Selected { value: Value, index: usize }, Cancelled, } diff --git a/crates/pikl-core/src/model/item.rs b/crates/pikl-core/src/model/item.rs index 4ee6ec3..a9acda3 100644 --- a/crates/pikl-core/src/model/item.rs +++ b/crates/pikl-core/src/model/item.rs @@ -8,7 +8,8 @@ use serde_json::Value; /// as `Value::String`, structured entries as /// `Value::Object`. The label is extracted and cached at /// construction time so display calls are just a pointer -/// dereference. +/// dereference. Sublabel, icon, and group are also cached +/// for structured items. /// /// Serializes as the inner Value (transparent for output) /// but construction always requires label extraction. @@ -16,23 +17,40 @@ use serde_json::Value; pub struct Item { pub value: Value, label_cache: String, + sublabel_cache: Option, + icon_cache: Option, + group_cache: Option, } impl Item { /// Create an Item from a JSON value, extracting the display /// label from the given key. String values use the string - /// itself. Object values look up the key. + /// itself. Object values look up the key. Also extracts + /// sublabel, icon, and group from known keys on objects. pub fn new(value: Value, label_key: &str) -> Self { let label_cache = extract_label(&value, label_key).to_string(); - Self { value, label_cache } + let sublabel_cache = extract_optional(&value, "sublabel"); + let icon_cache = extract_optional(&value, "icon"); + let group_cache = extract_optional(&value, "group"); + Self { + value, + label_cache, + sublabel_cache, + icon_cache, + group_cache, + } } /// Wrap a plain-text string as an Item. Stored internally - /// as `Value::String` with the label cached. + /// as `Value::String` with the label cached. Optional + /// fields are left as None. pub fn from_plain_text(line: &str) -> Self { Self { value: Value::String(line.to_string()), label_cache: line.to_string(), + sublabel_cache: None, + icon_cache: None, + group_cache: None, } } @@ -41,6 +59,38 @@ impl Item { pub fn label(&self) -> &str { &self.label_cache } + + /// Get the sublabel for this item, if present. + pub fn sublabel(&self) -> Option<&str> { + self.sublabel_cache.as_deref() + } + + /// Get the icon for this item, if present. + pub fn icon(&self) -> Option<&str> { + self.icon_cache.as_deref() + } + + /// Get the group for this item, if present. + pub fn group(&self) -> Option<&str> { + self.group_cache.as_deref() + } + + /// Get the `meta` field from the underlying value, if it + /// exists and the value is an object. + pub fn meta(&self) -> Option<&Value> { + match &self.value { + Value::Object(map) => map.get("meta"), + _ => None, + } + } + + /// Resolve a dotted field path against this item's value. + /// For example, `field_value("meta.resolution")` on + /// `{"meta": {"resolution": "1080p"}}` returns + /// `Some(Value::String("1080p"))`. + pub fn field_value(&self, path: &str) -> Option<&Value> { + resolve_field_path(&self.value, path) + } } /// Extract the display label from a JSON value. @@ -52,6 +102,30 @@ fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str { } } +/// Extract an optional string field from a JSON object value. +fn extract_optional(value: &Value, key: &str) -> Option { + match value { + Value::Object(map) => map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()), + _ => None, + } +} + +/// Walk a dotted path (e.g. `"meta.tags.0"`) through nested +/// JSON objects. Returns None if any intermediate is not an +/// object or the key is missing. +pub fn resolve_field_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + let mut current = value; + for segment in path.split('.') { + match current { + Value::Object(map) => { + current = map.get(segment)?; + } + _ => return None, + } + } + Some(current) +} + impl serde::Serialize for Item { fn serialize(&self, serializer: S) -> Result { self.value.serialize(serializer) @@ -113,6 +187,9 @@ mod tests { let item = Item::from_plain_text("hello world"); assert_eq!(item.label(), "hello world"); assert!(item.value.is_string()); + assert!(item.sublabel().is_none()); + assert!(item.icon().is_none()); + assert!(item.group().is_none()); } #[test] @@ -128,4 +205,107 @@ mod tests { let json = serde_json::to_string(&item).unwrap_or_default(); assert_eq!(json, r#"{"label":"foo"}"#); } + + // -- New accessor tests -- + + #[test] + fn sublabel_from_object() { + let item = Item::new( + json!({"label": "Firefox", "sublabel": "Web Browser"}), + "label", + ); + assert_eq!(item.sublabel(), Some("Web Browser")); + } + + #[test] + fn sublabel_missing() { + let item = Item::new(json!({"label": "Firefox"}), "label"); + assert!(item.sublabel().is_none()); + } + + #[test] + fn icon_from_object() { + let item = Item::new( + json!({"label": "Firefox", "icon": "firefox.png"}), + "label", + ); + assert_eq!(item.icon(), Some("firefox.png")); + } + + #[test] + fn group_from_object() { + let item = Item::new( + json!({"label": "Firefox", "group": "browsers"}), + "label", + ); + assert_eq!(item.group(), Some("browsers")); + } + + #[test] + fn meta_from_object() { + let item = Item::new( + json!({"label": "test", "meta": {"res": 1080}}), + "label", + ); + let meta = item.meta(); + assert!(meta.is_some()); + assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080))); + } + + #[test] + fn meta_from_plain_text() { + let item = Item::from_plain_text("hello"); + assert!(item.meta().is_none()); + } + + // -- Field path resolution tests -- + + #[test] + fn resolve_simple_path() { + let value = json!({"label": "test"}); + assert_eq!(resolve_field_path(&value, "label"), Some(&json!("test"))); + } + + #[test] + fn resolve_nested_path() { + let value = json!({"meta": {"resolution": {"width": 3840}}}); + assert_eq!( + resolve_field_path(&value, "meta.resolution.width"), + Some(&json!(3840)) + ); + } + + #[test] + fn resolve_missing_path() { + let value = json!({"label": "test"}); + assert!(resolve_field_path(&value, "nope").is_none()); + } + + #[test] + fn resolve_missing_nested() { + let value = json!({"meta": {"a": 1}}); + assert!(resolve_field_path(&value, "meta.b").is_none()); + } + + #[test] + fn resolve_non_object_intermediate() { + let value = json!({"meta": "not an object"}); + assert!(resolve_field_path(&value, "meta.foo").is_none()); + } + + #[test] + fn resolve_on_string_value() { + let value = json!("hello"); + assert!(resolve_field_path(&value, "anything").is_none()); + } + + #[test] + fn field_value_on_item() { + let item = Item::new( + json!({"label": "test", "meta": {"tags": ["a", "b"]}}), + "label", + ); + assert_eq!(item.field_value("meta.tags"), Some(&json!(["a", "b"]))); + assert!(item.field_value("meta.nope").is_none()); + } } diff --git a/crates/pikl-core/src/model/mod.rs b/crates/pikl-core/src/model/mod.rs index 150b115..186085c 100644 --- a/crates/pikl-core/src/model/mod.rs +++ b/crates/pikl-core/src/model/mod.rs @@ -1,3 +1,4 @@ pub mod event; pub mod item; +pub mod output; pub mod traits; diff --git a/crates/pikl-core/src/model/output.rs b/crates/pikl-core/src/model/output.rs new file mode 100644 index 0000000..b1f12ef --- /dev/null +++ b/crates/pikl-core/src/model/output.rs @@ -0,0 +1,110 @@ +//! 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, + 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(&self, serializer: S) -> Result { + 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_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}" + ); + } +} diff --git a/crates/pikl-core/src/model/traits.rs b/crates/pikl-core/src/model/traits.rs index 537aebb..cc22478 100644 --- a/crates/pikl-core/src/model/traits.rs +++ b/crates/pikl-core/src/model/traits.rs @@ -38,4 +38,25 @@ pub trait Menu: Send + 'static { /// Get the JSON value of a filtered item for output. /// Returns a reference to the stored value. fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>; + + /// Get the original index of a filtered item. Used to + /// provide the item's position in the unfiltered list + /// for output and hook events. + fn original_index(&self, filtered_index: usize) -> Option; + + /// Replace all items with a new set of values. Used by + /// handler hook `replace_items` responses. + fn replace_all(&mut self, values: Vec); + + /// Remove items at the given original indices. Used by + /// handler hook `remove_items` responses. + fn remove_by_indices(&mut self, indices: Vec); + + /// 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 { + None + } } diff --git a/crates/pikl-core/src/query/navigation.rs b/crates/pikl-core/src/query/navigation.rs index da6611e..ebb2ad6 100644 --- a/crates/pikl-core/src/query/navigation.rs +++ b/crates/pikl-core/src/query/navigation.rs @@ -165,9 +165,17 @@ impl Viewport { self.scroll_offset..end } + /// Set the cursor to a specific position. Does NOT clamp + /// or update scroll. Call [`clamp`](Self::clamp) after if + /// the position may be out of bounds. + pub fn set_cursor(&mut self, position: usize) { + self.cursor = position; + } + /// Clamp cursor and scroll offset to valid positions after - /// a height or count change. - fn clamp(&mut self) { + /// a height or count change, item removal, or manual + /// cursor set. + pub fn clamp(&mut self) { if self.filtered_count == 0 { self.cursor = 0; self.scroll_offset = 0; diff --git a/crates/pikl-core/src/query/pipeline.rs b/crates/pikl-core/src/query/pipeline.rs index 69344df..7cbc482 100644 --- a/crates/pikl-core/src/query/pipeline.rs +++ b/crates/pikl-core/src/query/pipeline.rs @@ -3,8 +3,11 @@ //! and chains results through stages. Supports incremental //! caching: unchanged stages keep their results. +use serde_json::Value; + use super::filter::{Filter, FuzzyFilter}; use super::strategy::{self, FilterKind}; +use crate::item::resolve_field_path; /// A multi-stage filter pipeline. Each `|` in the query /// creates a new stage that filters the previous stage's @@ -13,6 +16,9 @@ use super::strategy::{self, FilterKind}; pub struct FilterPipeline { /// Master item list: (original index, label). items: Vec<(usize, String)>, + /// Optional item values for field filter resolution. + /// Stored separately since most pipelines don't need them. + item_values: Vec>, /// Pipeline stages, one per `|`-separated segment. stages: Vec, /// The last raw query string, used for diffing. @@ -99,6 +105,17 @@ fn split_pipeline(query: &str) -> Vec { segments.into_iter().filter(|s| !s.is_empty()).collect() } +/// Convert a JSON value to a string for field filter matching. +fn value_to_filter_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => other.to_string(), + } +} + impl Default for FilterPipeline { fn default() -> Self { Self::new() @@ -109,6 +126,7 @@ impl FilterPipeline { pub fn new() -> Self { Self { items: Vec::new(), + item_values: Vec::new(), stages: Vec::new(), last_raw_query: String::new(), } @@ -130,7 +148,7 @@ impl FilterPipeline { let stage = &mut self.stages[stage_idx]; - let result = match stage.kind { + let result = match &stage.kind { FilterKind::Fuzzy => Self::eval_fuzzy(stage, &input_indices, stage_idx), FilterKind::Exact => { Self::eval_simple(stage, &input_indices, &self.items, |label, query| { @@ -146,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; @@ -184,6 +210,41 @@ impl FilterPipeline { } } + fn eval_field( + stage: &PipelineStage, + input_indices: &[usize], + item_values: &[Option], + path: &str, + ) -> Vec { + if stage.query_text.is_empty() { + return input_indices.to_vec(); + } + let query_lower = stage.query_text.to_lowercase(); + let matches = |idx: usize| -> bool { + let value = item_values.get(idx).and_then(|v| v.as_ref()); + if let Some(val) = value + && let Some(field_val) = resolve_field_path(val, path) + { + let text = value_to_filter_string(field_val); + return text.to_lowercase().contains(&query_lower); + } + false + }; + if stage.inverse { + input_indices + .iter() + .copied() + .filter(|&idx| !matches(idx)) + .collect() + } else { + input_indices + .iter() + .copied() + .filter(|&idx| matches(idx)) + .collect() + } + } + fn eval_simple( stage: &PipelineStage, input_indices: &[usize], @@ -209,9 +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 { fn push(&mut self, index: usize, label: &str) { self.items.push((index, label.to_string())); + self.item_values.push(None); // Push to any existing fuzzy filters in stages for stage in &mut self.stages { if let Some(ref mut fuzzy) = stage.fuzzy { @@ -614,4 +715,95 @@ mod tests { assert!(result.contains(&"cherry")); assert!(!result.contains(&"banana")); } + + // -- Field filter tests -- + + #[test] + fn field_filter_matches() { + use serde_json::json; + let mut p = FilterPipeline::new(); + let items = vec![ + json!({"label": "monitor1", "meta": {"res": "3840"}}), + json!({"label": "monitor2", "meta": {"res": "1920"}}), + json!({"label": "monitor3", "meta": {"res": "3840"}}), + ]; + for (i, item) in items.iter().enumerate() { + let label = item["label"].as_str().unwrap_or(""); + p.push_with_value(i, label, item); + } + p.set_query("meta.res:3840"); + assert_eq!(p.matched_count(), 2); + let indices: Vec = (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)); + } } diff --git a/crates/pikl-core/src/query/strategy.rs b/crates/pikl-core/src/query/strategy.rs index 9cca51c..bbc3c0e 100644 --- a/crates/pikl-core/src/query/strategy.rs +++ b/crates/pikl-core/src/query/strategy.rs @@ -2,11 +2,15 @@ //! strategy to use based on the query prefix. /// The type of filter to apply for a query segment. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum FilterKind { Fuzzy, Exact, Regex, + /// Field-specific filter: `meta.res:3840` matches + /// items where the dotted path resolves to a value + /// containing the query text. + Field { path: String }, } /// A parsed filter segment with its kind, inversion flag, @@ -75,6 +79,28 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> { }; } + // Check for inverse field filter: !field.path:value + // Must check before generic inverse fuzzy to avoid + // treating it as fuzzy with query "field.path:value". + if let Some(rest) = segment.strip_prefix('!') + && let Some((path, value)) = try_parse_field_filter(rest) + { + return ParsedSegment { + kind: FilterKind::Field { path: path.to_string() }, + inverse: true, + query: value, + }; + } + + // Check for field filter: field.path:value + if let Some((path, value)) = try_parse_field_filter(segment) { + return ParsedSegment { + kind: FilterKind::Field { path: path.to_string() }, + inverse: false, + query: value, + }; + } + // Check for inverse fuzzy: !query if let Some(rest) = segment.strip_prefix('!') { return ParsedSegment { @@ -101,6 +127,36 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> { } } +/// Try to parse a `field.path:value` pattern. Returns +/// `(path, value)` if the segment matches. The path must +/// consist of word chars and dots, with no spaces before +/// the colon. +fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> { + let colon_pos = segment.find(':')?; + let path = &segment[..colon_pos]; + let value = &segment[colon_pos + 1..]; + + // Path must be non-empty and look like a dotted identifier + if path.is_empty() { + return None; + } + // Must contain at least one dot or look like a field name + // (not just a single word which could be a typo) + if !path.contains('.') { + return None; + } + // No spaces in the path + if path.contains(' ') { + return None; + } + // Path chars must be alphanumeric, underscore, or dot + if !path.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') { + return None; + } + + Some((path, value)) +} + #[cfg(test)] mod tests { use super::*; @@ -228,4 +284,52 @@ mod tests { assert!(p.inverse); assert_eq!(p.query, "[0-9]"); } + + // -- Field filter tests -- + + #[test] + fn field_filter_dotted_path() { + let p = parse_segment("meta.res:3840"); + assert_eq!( + p.kind, + FilterKind::Field { + path: "meta.res".to_string() + } + ); + assert!(!p.inverse); + assert_eq!(p.query, "3840"); + } + + #[test] + fn field_filter_inverse() { + let p = parse_segment("!meta.res:3840"); + assert_eq!( + p.kind, + FilterKind::Field { + path: "meta.res".to_string() + } + ); + assert!(p.inverse); + assert_eq!(p.query, "3840"); + } + + #[test] + fn single_word_colon_is_not_field_filter() { + // No dot means it's not treated as a field path + let p = parse_segment("foo:bar"); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert_eq!(p.query, "foo:bar"); + } + + #[test] + fn field_filter_empty_value() { + let p = parse_segment("meta.tag:"); + assert_eq!( + p.kind, + FilterKind::Field { + path: "meta.tag".to_string() + } + ); + assert_eq!(p.query, ""); + } } diff --git a/crates/pikl-core/src/runtime/debounce.rs b/crates/pikl-core/src/runtime/debounce.rs new file mode 100644 index 0000000..3dd8620 --- /dev/null +++ b/crates/pikl-core/src/runtime/debounce.rs @@ -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, + action_tx: mpsc::Sender, + modes: HashMap, + in_flight: HashMap>, +} + +impl DebouncedDispatcher { + pub fn new( + handler: Arc, + action_tx: mpsc::Sender, + ) -> 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>>, + } + + impl HookHandler for RecordingHandler { + fn handle(&self, event: HookEvent) -> Result, 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); + } +} diff --git a/crates/pikl-core/src/runtime/hook.rs b/crates/pikl-core/src/runtime/hook.rs index 802090a..95d85dd 100644 --- a/crates/pikl-core/src/runtime/hook.rs +++ b/crates/pikl-core/src/runtime/hook.rs @@ -1,15 +1,303 @@ -//! Hook trait for lifecycle events. The core library defines -//! the interface; concrete implementations (shell hooks, IPC -//! hooks, etc.) live in frontend crates. +//! Hook types for lifecycle events. The core library defines +//! the event and response types plus the handler trait. +//! Concrete implementations (shell exec hooks, persistent +//! handler processes) live in frontend crates. +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::error::PiklError; -/// A lifecycle hook that fires on menu events. Implementations -/// live outside pikl-core (e.g. in the CLI binary) so the core -/// library stays free of process/libc deps. -#[allow(async_fn_in_trait)] -pub trait Hook: Send + Sync { - async fn run(&self, value: &Value) -> Result<(), PiklError>; +/// A lifecycle event emitted by the menu engine. Handler +/// hooks receive these as JSON lines on stdin. The `event` +/// field is the tag for serde's tagged representation. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum HookEvent { + Open, + Close, + Hover { item: Value, index: usize }, + Select { item: Value, index: usize }, + Cancel, + Filter { text: String }, +} + +/// 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, +} + +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, + } + } +} + +/// 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 }, + ReplaceItems { items: Vec }, + RemoveItems { indices: Vec }, + 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, 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 { + match serde_json::from_str::(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::(json); + assert!(result.is_err()); + } + + #[test] + fn response_invalid_json() { + let result = serde_json::from_str::("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::(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()); + } + + // -- 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); + } } diff --git a/crates/pikl-core/src/runtime/json_menu.rs b/crates/pikl-core/src/runtime/json_menu.rs index 4d519a2..6a6812f 100644 --- a/crates/pikl-core/src/runtime/json_menu.rs +++ b/crates/pikl-core/src/runtime/json_menu.rs @@ -3,6 +3,7 @@ //! for `ls | pikl` style usage. use crate::filter::Filter; +use crate::format::FormatTemplate; use crate::item::Item; use crate::model::traits::Menu; use crate::pipeline::FilterPipeline; @@ -16,6 +17,8 @@ pub struct JsonMenu { items: Vec, label_key: String, filter: FilterPipeline, + filter_fields: Vec, + format_template: Option, } impl JsonMenu { @@ -23,14 +26,79 @@ impl JsonMenu { pub fn new(items: Vec, label_key: String) -> Self { let mut filter = FilterPipeline::new(); for (i, item) in items.iter().enumerate() { - filter.push(i, item.label()); + filter.push_with_value(i, item.label(), &item.value); } Self { items, label_key, filter, + filter_fields: vec!["label".to_string()], + format_template: None, } } + + /// Set which fields to search during filtering. Each entry + /// is a dotted path resolved against the item's JSON value. + /// Default is `["label"]`. + pub fn set_filter_fields(&mut self, fields: Vec) { + 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 { + 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 { @@ -56,7 +124,8 @@ impl Menu for JsonMenu { for value in values { let idx = self.items.len(); 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); } } @@ -66,4 +135,36 @@ impl Menu for JsonMenu { .matched_index(filtered_index) .map(|idx| &self.items[idx].value) } + + fn original_index(&self, filtered_index: usize) -> Option { + self.filter.matched_index(filtered_index) + } + + fn replace_all(&mut self, values: Vec) { + 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) { + // 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 { + let template = self.format_template.as_ref()?; + let orig_idx = self.filter.matched_index(filtered_index)?; + Some(template.render(&self.items[orig_idx].value)) + } } diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs index f5df78c..80246c5 100644 --- a/crates/pikl-core/src/runtime/menu.rs +++ b/crates/pikl-core/src/runtime/menu.rs @@ -7,8 +7,10 @@ use std::sync::Arc; use tokio::sync::{broadcast, mpsc}; +use crate::debounce::{hook_response_to_action, DebouncedDispatcher}; use crate::error::PiklError; use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem}; +use crate::hook::{HookEvent, HookHandler}; use crate::model::traits::Menu; use crate::navigation::Viewport; use serde_json::Value; @@ -20,9 +22,11 @@ pub enum ActionOutcome { /// State changed, broadcast to subscribers. Broadcast, /// User confirmed a selection. - Selected(Value), + Selected { value: Value, index: usize }, /// User cancelled. Cancelled, + /// Menu closed by hook command. + Closed, /// Nothing happened (e.g. confirm on empty list). NoOp, } @@ -38,6 +42,8 @@ pub struct MenuRunner { mode: Mode, action_rx: mpsc::Receiver, event_tx: broadcast::Sender, + dispatcher: Option, + previous_cursor: Option, } impl MenuRunner { @@ -59,6 +65,8 @@ impl MenuRunner { mode: Mode::default(), action_rx, event_tx, + dispatcher: None, + previous_cursor: None, }; (runner, action_tx) } @@ -69,6 +77,23 @@ impl MenuRunner { 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, + action_tx: mpsc::Sender, + ) { + let dispatcher = DebouncedDispatcher::new(handler, action_tx); + self.dispatcher = Some(dispatcher); + } + + /// Set a hook handler with a pre-configured dispatcher. + pub fn set_dispatcher(&mut self, dispatcher: DebouncedDispatcher) { + self.dispatcher = Some(dispatcher); + } + /// Re-run the filter against all items with the current /// filter text. Updates the viewport with the new count. fn run_filter(&mut self) { @@ -83,9 +108,13 @@ impl MenuRunner { let visible_items: Vec = range .clone() .filter_map(|i| { - self.menu.filtered_label(i).map(|label| VisibleItem { - label: label.to_string(), - index: i, + self.menu.filtered_label(i).map(|label| { + let formatted_text = self.menu.formatted_label(i); + VisibleItem { + label: label.to_string(), + formatted_text, + index: i, + } }) }) .collect(); @@ -113,6 +142,35 @@ impl MenuRunner { .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 /// transition: no channels, no async. Testable in isolation. pub fn apply_action(&mut self, action: Action) -> ActionOutcome { @@ -151,8 +209,12 @@ impl MenuRunner { return ActionOutcome::NoOp; } let cursor = self.viewport.cursor(); + let index = self.menu.original_index(cursor).unwrap_or(0); match self.menu.serialize_filtered(cursor) { - Some(value) => ActionOutcome::Selected(value.clone()), + Some(value) => ActionOutcome::Selected { + value: value.clone(), + index, + }, None => ActionOutcome::NoOp, } } @@ -178,6 +240,41 @@ impl MenuRunner { self.run_filter(); 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 +302,47 @@ impl MenuRunner { self.run_filter(); self.broadcast_state(); + // Emit Open event + self.emit_hook(HookEvent::Open); + while let Some(action) = self.action_rx.recv().await { + let is_filter_update = matches!(&action, Action::UpdateFilter(_)); + match self.apply_action(action) { - ActionOutcome::Broadcast => self.broadcast_state(), - ActionOutcome::Selected(value) => { + ActionOutcome::Broadcast => { + self.broadcast_state(); + + // Emit Filter event if the filter changed + if is_filter_update { + let text = self.filter_text.to_string(); + self.emit_hook(HookEvent::Filter { text }); + } + + // Check for cursor movement -> Hover + self.check_cursor_hover(); + } + ActionOutcome::Selected { value, index } => { + // Emit Select event + self.emit_hook(HookEvent::Select { + item: value.clone(), + index, + }); + // Emit Close event + self.emit_hook(HookEvent::Close); + let _ = self.event_tx.send(MenuEvent::Selected(value.clone())); - return Ok(MenuResult::Selected(value)); + return Ok(MenuResult::Selected { value, index }); } ActionOutcome::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); return Ok(MenuResult::Cancelled); } @@ -221,6 +351,7 @@ impl MenuRunner { } // Sender dropped + self.emit_hook(HookEvent::Close); Ok(MenuResult::Cancelled) } } @@ -285,7 +416,7 @@ mod tests { let mut m = ready_menu(); m.apply_action(Action::MoveDown(1)); let outcome = m.apply_action(Action::Confirm); - assert!(matches!(&outcome, ActionOutcome::Selected(v) if v.as_str() == Some("beta"))); + assert!(matches!(&outcome, ActionOutcome::Selected { value, .. } if value.as_str() == Some("beta"))); } #[test] @@ -413,7 +544,7 @@ mod tests { } let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected(_)))); + assert!(matches!(result, Ok(MenuResult::Selected { .. }))); } #[tokio::test] @@ -474,7 +605,7 @@ mod tests { assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha"))); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("alpha"))); + assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("alpha"))); } #[tokio::test] @@ -496,7 +627,7 @@ mod tests { let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("gamma"))); + assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("gamma"))); } #[tokio::test] @@ -521,7 +652,7 @@ mod tests { let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta"))); + assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta"))); } #[tokio::test] @@ -556,7 +687,7 @@ mod tests { let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); - assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("epsilon"))); + assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("epsilon"))); } #[tokio::test] @@ -608,7 +739,7 @@ mod tests { // Must get "banana". Filter was applied before confirm ran. assert!(matches!( result, - Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("banana") + Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana") )); } @@ -634,7 +765,7 @@ mod tests { // Cursor at index 3 -> "delta" assert!(matches!( result, - Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta") + Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta") )); } @@ -730,7 +861,129 @@ mod tests { // Must find "zephyr". It was added before the filter ran. assert!(matches!( result, - Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("zephyr") + Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr") )); } + + // -- Replace/Remove/Close action tests -- + + #[test] + fn apply_replace_items() { + let mut m = ready_menu(); + assert_eq!(m.menu.total(), 4); + let outcome = m.apply_action(Action::ReplaceItems(vec![ + serde_json::Value::String("x".to_string()), + serde_json::Value::String("y".to_string()), + ])); + assert!(matches!(outcome, ActionOutcome::Broadcast)); + assert_eq!(m.menu.total(), 2); + assert_eq!(m.menu.filtered_count(), 2); + } + + #[test] + fn apply_replace_items_preserves_cursor() { + let mut m = ready_menu(); + // Move to "beta" (index 1) + m.apply_action(Action::MoveDown(1)); + // Replace items, keeping "beta" in the new set + m.apply_action(Action::ReplaceItems(vec![ + serde_json::Value::String("alpha".to_string()), + serde_json::Value::String("beta".to_string()), + serde_json::Value::String("zeta".to_string()), + ])); + // Cursor should still be on "beta" + let vs = m.build_view_state(); + assert_eq!(vs.visible_items[vs.cursor].label, "beta"); + } + + #[test] + fn apply_remove_items() { + let mut m = ready_menu(); + assert_eq!(m.menu.total(), 4); + let outcome = m.apply_action(Action::RemoveItems(vec![1, 3])); + assert!(matches!(outcome, ActionOutcome::Broadcast)); + assert_eq!(m.menu.total(), 2); + // alpha and gamma should remain + assert_eq!(m.menu.filtered_label(0), Some("alpha")); + assert_eq!(m.menu.filtered_label(1), Some("gamma")); + } + + #[test] + fn apply_close_menu() { + let mut m = ready_menu(); + let outcome = m.apply_action(Action::CloseMenu); + assert!(matches!(outcome, ActionOutcome::Closed)); + } + + #[test] + fn apply_hook_response_close() { + use crate::hook::HookResponse; + let mut m = ready_menu(); + let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::Close)); + assert!(matches!(outcome, ActionOutcome::Closed)); + } + + #[test] + fn apply_hook_response_add_items() { + use crate::hook::HookResponse; + let mut m = ready_menu(); + let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::AddItems { + items: vec![serde_json::json!("new")], + })); + assert!(matches!(outcome, ActionOutcome::Broadcast)); + assert_eq!(m.menu.total(), 5); + } + + #[test] + fn confirm_returns_original_index() { + let mut m = ready_menu(); + // Filter to narrow results, then confirm + m.apply_action(Action::UpdateFilter("del".to_string())); + assert!(m.menu.filtered_count() >= 1); + let outcome = m.apply_action(Action::Confirm); + // "delta" is at original index 3 + assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. })); + } + + // -- 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>); + impl HookHandler for Recorder { + fn handle(&self, event: HookEvent) -> Result, 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, 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)); + } } diff --git a/crates/pikl-core/src/runtime/mod.rs b/crates/pikl-core/src/runtime/mod.rs index 90731f6..018cae4 100644 --- a/crates/pikl-core/src/runtime/mod.rs +++ b/crates/pikl-core/src/runtime/mod.rs @@ -1,3 +1,4 @@ +pub mod debounce; pub mod hook; pub mod input; pub mod json_menu; diff --git a/crates/pikl-test-macros/src/codegen.rs b/crates/pikl-test-macros/src/codegen.rs index a0fbccf..b7b1c5b 100644 --- a/crates/pikl-test-macros/src/codegen.rs +++ b/crates/pikl-test-macros/src/codegen.rs @@ -327,7 +327,7 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result { } else if let Some(ref expected) = case.selected { quote! { match &result { - Ok(MenuResult::Selected(value)) => { + Ok(MenuResult::Selected { value, .. }) => { let got = value.as_str() .or_else(|| value.get(#label_key).and_then(|v| v.as_str())) .unwrap_or(""); diff --git a/crates/pikl-tui/src/lib.rs b/crates/pikl-tui/src/lib.rs index e4490d6..85d1f21 100644 --- a/crates/pikl-tui/src/lib.rs +++ b/crates/pikl-tui/src/lib.rs @@ -194,7 +194,8 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) { } else { Style::default() }; - ListItem::new(vi.label.as_str()).style(style) + let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str()); + ListItem::new(text).style(style) }) .collect(); @@ -318,14 +319,17 @@ mod tests { visible_items: vec![ VisibleItem { label: "alpha".into(), + formatted_text: None, index: 0, }, VisibleItem { label: "bravo".into(), + formatted_text: None, index: 1, }, VisibleItem { label: "charlie".into(), + formatted_text: None, index: 2, }, ], diff --git a/crates/pikl/Cargo.toml b/crates/pikl/Cargo.toml index 0bef599..2ff8a4e 100644 --- a/crates/pikl/Cargo.toml +++ b/crates/pikl/Cargo.toml @@ -18,6 +18,8 @@ pikl-tui = { path = "../pikl-tui" } clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util"] } serde_json = "1" +tracing = "0.1" +tracing-subscriber = "0.3" libc = "0.2" [dev-dependencies] diff --git a/crates/pikl/src/handler.rs b/crates/pikl/src/handler.rs new file mode 100644 index 0000000..806a916 --- /dev/null +++ b/crates/pikl/src/handler.rs @@ -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>, +} + +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, + ) -> Option { + 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::(64); + let cmd = cmd.to_string(); + let atx = action_tx.clone(); + + tokio::spawn(async move { + if let Err(e) = run_handler_process(&cmd, event_rx, atx).await { + tracing::warn!(error = %e, command = %cmd, "handler hook process failed"); + } + }); + + event_txs.insert(kind, event_tx); + } + + Some(Self { event_txs }) + } +} + +impl HookHandler for ShellHandlerHook { + fn handle(&self, event: HookEvent) -> Result, PiklError> { + let kind = event.kind(); + if let Some(tx) = self.event_txs.get(&kind) { + // Non-blocking send. If the channel is full, drop the event. + let _ = tx.try_send(event); + } + Ok(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, + action_tx: mpsc::Sender, +) -> 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(()) +} diff --git a/crates/pikl/src/hook.rs b/crates/pikl/src/hook.rs index 5292466..fce3748 100644 --- a/crates/pikl/src/hook.rs +++ b/crates/pikl/src/hook.rs @@ -1,15 +1,15 @@ -//! Shell hook execution. Hooks are shell commands that fire -//! on menu events (selection, cancellation). The selected -//! item's JSON is piped to the hook's stdin. -//! -//! Hook stdout is redirected to stderr so it doesn't end up -//! mixed into pikl's structured output on stdout. +//! Shell exec hooks. Fire-and-forget subprocess per event. +//! The hook's stdin receives the event JSON, stdout is +//! redirected to stderr to keep pikl's output clean. + +use std::collections::HashMap; use serde_json::Value; use tokio::io::AsyncWriteExt; use tokio::process::Command; use pikl_core::error::PiklError; +use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, HookResponse}; /// Duplicate stderr as a [`Stdio`] handle for use as a /// child process's stdout. Keeps hook output on stderr @@ -26,7 +26,7 @@ fn stderr_as_stdio() -> std::process::Stdio { std::process::Stdio::inherit() } -/// Run a shell hook, piping the value as JSON to stdin. +/// Run a shell command, piping the value as JSON to stdin. /// Hook stdout goes to stderr (see module docs). Returns /// an error if the command exits non-zero. pub async fn run_hook(command: &str, value: &Value) -> Result<(), PiklError> { @@ -48,8 +48,7 @@ async fn write_json_stdin( Ok(()) } -/// Run a shell hook with a custom stdout handle. Used by -/// [`run_hook`] to redirect hook output to stderr. +/// Run a shell hook with a custom stdout handle. async fn run_hook_with_stdout( command: &str, value: &Value, @@ -75,6 +74,61 @@ async fn run_hook_with_stdout( Ok(()) } +/// Fire-and-forget shell hook handler. Spawns a subprocess +/// for each event that has a registered command. Always +/// returns Ok(vec![]) since exec hooks don't send responses. +pub struct ShellExecHandler { + commands: HashMap, +} + +impl ShellExecHandler { + /// Build from CLI flags. + pub fn from_cli(cli: &crate::Cli) -> Self { + let mut commands = HashMap::new(); + if let Some(ref cmd) = cli.on_open_exec { + commands.insert(HookEventKind::Open, cmd.clone()); + } + if let Some(ref cmd) = cli.on_close_exec { + commands.insert(HookEventKind::Close, cmd.clone()); + } + if let Some(ref cmd) = cli.on_hover_exec { + commands.insert(HookEventKind::Hover, cmd.clone()); + } + if let Some(ref cmd) = cli.on_select_exec { + commands.insert(HookEventKind::Select, cmd.clone()); + } + if let Some(ref cmd) = cli.on_cancel_exec { + commands.insert(HookEventKind::Cancel, cmd.clone()); + } + if let Some(ref cmd) = cli.on_filter_exec { + commands.insert(HookEventKind::Filter, cmd.clone()); + } + Self { commands } + } + + pub fn has_hooks(&self) -> bool { + !self.commands.is_empty() + } +} + +impl HookHandler for ShellExecHandler { + fn handle(&self, event: HookEvent) -> Result, PiklError> { + let kind = event.kind(); + if let Some(cmd) = self.commands.get(&kind) { + let cmd = cmd.clone(); + // Serialize event as JSON for the hook's stdin + let event_json = + serde_json::to_value(&event).unwrap_or(serde_json::Value::Null); + tokio::spawn(async move { + if let Err(e) = run_hook(&cmd, &event_json).await { + tracing::warn!(error = %e, command = %cmd, "exec hook failed"); + } + }); + } + Ok(vec![]) + } +} + #[cfg(test)] mod tests { use super::*; @@ -94,35 +148,6 @@ mod tests { assert!(result.is_err()); } - // -- Hook stdin verification -- - - /// Helper: run `cat` with piped stdout so we can capture what it echoes back - /// from the value JSON written to stdin. - async fn capture_hook_stdin(value: &Value) -> String { - let child = Command::new("sh") - .arg("-c") - .arg("cat") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .spawn(); - let Ok(mut child) = child else { - return String::new(); - }; - - let _ = write_json_stdin(&mut child, value).await; - - let output = child - .wait_with_output() - .await - .unwrap_or_else(|_| std::process::Output { - status: std::process::ExitStatus::default(), - stdout: Vec::new(), - stderr: Vec::new(), - }); - String::from_utf8(output.stdout).unwrap_or_default() - } - #[tokio::test] async fn write_json_stdin_sends_correct_data() { let value = json!({"key": "value"}); @@ -141,99 +166,29 @@ mod tests { #[tokio::test] async fn hook_receives_plain_text_json() { let value = json!("hello"); - let got = capture_hook_stdin(&value).await; - assert_eq!(got, r#""hello""#); - } - - #[tokio::test] - async fn hook_receives_object_json() { - let value = json!({"label": "foo", "value": 42}); - let got = capture_hook_stdin(&value).await; - let parsed: Value = serde_json::from_str(&got).unwrap_or_default(); - assert_eq!(parsed["label"], "foo"); - assert_eq!(parsed["value"], 42); - } - - #[tokio::test] - async fn hook_receives_special_chars() { - let value = json!("he said \"hi\"\nand left"); - let got = capture_hook_stdin(&value).await; - let parsed: Value = serde_json::from_str(&got).unwrap_or_default(); - assert_eq!( - parsed.as_str().unwrap_or_default(), - "he said \"hi\"\nand left" - ); - } - - // -- Hook stdout-to-stderr redirection -- - - #[tokio::test] - async fn hook_stdout_not_on_piped_stdout() { - // With piped stdout, `echo hello` output is capturable: - let value = json!("test"); let child = Command::new("sh") .arg("-c") - .arg("echo hello") + .arg("cat") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()) .spawn(); - assert!(child.is_ok(), "should be able to spawn echo"); - if let Ok(mut child) = child { - if let Some(mut stdin) = child.stdin.take() { - let json = serde_json::to_string(&value).unwrap_or_default(); - let _ = stdin.write_all(json.as_bytes()).await; - drop(stdin); - } - let output = child - .wait_with_output() - .await - .unwrap_or_else(|_| std::process::Output { - status: std::process::ExitStatus::default(), - stdout: Vec::new(), - stderr: Vec::new(), - }); - let piped_out = String::from_utf8(output.stdout).unwrap_or_default(); - assert_eq!(piped_out.trim(), "hello"); - } - - // With stderr_as_stdio(), hook stdout is redirected away from stdout. - // Verify the hook still succeeds (output goes to stderr instead). - let result = run_hook("echo hello", &value).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn stderr_as_stdio_returns_valid_fd() { - // Verify stderr_as_stdio() produces a usable Stdio. - // A child process using it should spawn and exit cleanly. - let child = Command::new("sh") - .arg("-c") - .arg("echo ok >&1") - .stdin(std::process::Stdio::null()) - .stdout(stderr_as_stdio()) - .stderr(std::process::Stdio::inherit()) - .spawn(); - assert!(child.is_ok()); + let Ok(mut child) = child else { + return; + }; + let _ = write_json_stdin(&mut child, &value).await; let output = child - .unwrap_or_else(|_| unreachable!()) .wait_with_output() - .await; - assert!(output.is_ok()); - assert!( - output - .unwrap_or_else(|_| std::process::Output { - status: std::process::ExitStatus::default(), - stdout: Vec::new(), - stderr: Vec::new(), - }) - .status - .success() - ); + .await + .unwrap_or_else(|_| std::process::Output { + status: std::process::ExitStatus::default(), + stdout: Vec::new(), + stderr: Vec::new(), + }); + let got = String::from_utf8(output.stdout).unwrap_or_default(); + assert_eq!(got, r#""hello""#); } - // -- Hook error propagation -- - #[tokio::test] async fn hook_nonzero_exit() { let value = json!("test"); @@ -245,34 +200,4 @@ mod tests { assert_eq!(status.code(), Some(42)); } } - - #[tokio::test] - async fn hook_missing_command() { - let value = json!("test"); - let result = run_hook("/nonexistent_binary_that_does_not_exist_12345", &value).await; - // sh -c will fail with 127 (command not found) - assert!(result.is_err()); - } - - #[tokio::test] - async fn hook_empty_command() { - let value = json!("test"); - // Empty string passed to sh -c is a no-op, exits 0 - let result = run_hook("", &value).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn hook_with_stdout_uses_custom_stdio() { - let value = json!("custom"); - let result = run_hook_with_stdout("echo ok", &value, std::process::Stdio::piped()).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn hook_with_stdout_propagates_failure() { - let value = json!("test"); - let result = run_hook_with_stdout("exit 1", &value, std::process::Stdio::piped()).await; - assert!(matches!(result, Err(PiklError::HookFailed { .. }))); - } } diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index 3a9dd72..9ff1250 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -1,18 +1,27 @@ +mod handler; mod hook; use std::io::{BufReader, IsTerminal, Write}; +use std::sync::Arc; use std::time::Duration; use clap::Parser; +use pikl_core::debounce::{DebounceMode, DebouncedDispatcher}; use pikl_core::error::PiklError; use pikl_core::event::{Action, MenuResult, Mode}; +use pikl_core::format::FormatTemplate; +use pikl_core::hook::{HookEventKind, HookHandler}; use pikl_core::input::read_items_sync; use pikl_core::item::Item; use pikl_core::json_menu::JsonMenu; use pikl_core::menu::MenuRunner; +use pikl_core::output::{OutputAction, OutputItem}; use pikl_core::script::action_fd::{self, ScriptAction, ShowAction}; +use handler::ShellHandlerHook; +use hook::ShellExecHandler; + #[derive(Parser)] #[command( name = "pikl", @@ -23,14 +32,69 @@ struct Cli { #[arg(long, default_value = "label")] label_key: String, - /// Shell command to run on selection (item JSON piped to stdin) + // -- Exec hooks (fire-and-forget subprocess per event) -- + /// Shell command to run on menu open #[arg(long)] - on_select: Option, + on_open_exec: Option, + + /// Shell command to run on menu close + #[arg(long)] + on_close_exec: Option, + + /// Shell command to run on cursor hover (item JSON on stdin) + #[arg(long)] + on_hover_exec: Option, + + /// Shell command to run on selection (item JSON on stdin) + #[arg(long)] + on_select_exec: Option, /// Shell command to run on cancel #[arg(long)] + on_cancel_exec: Option, + + /// Shell command to run on filter change + #[arg(long)] + on_filter_exec: Option, + + // -- Handler hooks (persistent bidirectional process) -- + /// Handler hook command for open events + #[arg(long)] + on_open: Option, + + /// Handler hook command for close events + #[arg(long)] + on_close: Option, + + /// Handler hook command for hover events + #[arg(long)] + on_hover: Option, + + /// Handler hook command for select events + #[arg(long)] + on_select: Option, + + /// Handler hook command for cancel events + #[arg(long)] on_cancel: Option, + /// Handler hook command for filter events + #[arg(long)] + on_filter: Option, + + // -- Debounce flags -- + /// Debounce delay in ms for hover hooks (default: 200) + #[arg(long, value_name = "MS")] + on_hover_debounce: Option, + + /// 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, + /// Read action script from this file descriptor (enables headless mode) #[arg(long, value_name = "FD")] action_fd: Option, @@ -42,9 +106,20 @@ struct Cli { /// Start in this input mode (insert or normal, default: insert) #[arg(long, value_name = "MODE", default_value = "insert")] start_mode: String, + + /// Comma-separated list of fields to search during filtering + #[arg(long, value_name = "FIELDS", value_delimiter = ',')] + filter_fields: Option>, + + /// Format template for display text (e.g. "{label} - {sublabel}") + #[arg(long, value_name = "TEMPLATE")] + format: Option, } fn main() { + // Initialize tracing from RUST_LOG env var + tracing_subscriber::fmt::init(); + let cli = Cli::parse(); // Install a panic hook that restores the terminal so a crash @@ -121,9 +196,6 @@ fn main() { }); // Reopen stdin from /dev/tty before entering async context. - // Both headless (show-ui branch) and interactive paths need this, - // so do it once here. Headless-only (no show-ui) doesn't need - // terminal input, but reopening is harmless. if script.is_none() && let Err(e) = reopen_stdin_from_tty() { @@ -145,27 +217,120 @@ fn main() { }; // STEP 4: Branch on headless vs interactive - let label_key = cli.label_key.clone(); let result = if let Some(script) = script { - rt.block_on(run_headless(items, label_key, script, start_mode)) + rt.block_on(run_headless(items, &cli, script, start_mode)) } else { - rt.block_on(run_interactive(items, label_key, start_mode)) + rt.block_on(run_interactive(items, &cli, start_mode)) }; // STEP 5: Handle result - handle_result(result, &cli, &rt); + handle_result(result, &cli); +} + +/// Build a JsonMenu with optional filter_fields and format template. +fn build_menu(items: Vec, 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, +) -> Option<(Arc, 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 = 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, +} + +impl HookHandler for CompositeHookHandler { + fn handle( + &self, + event: pikl_core::hook::HookEvent, + ) -> Result, PiklError> { + // Both fire. Exec is fire-and-forget, handler may + // send responses through action_tx. + let _ = self.exec.handle(event.clone()); + if let Some(ref h) = self.handler { + h.handle(event)?; + } + Ok(vec![]) + } } /// Run in headless mode: replay a script, optionally hand /// off to a TUI if the script ends with show-ui/show-tui/show-gui. async fn run_headless( items: Vec, - label_key: String, + cli: &Cli, script: Vec, start_mode: Mode, ) -> Result { - let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key)); + let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli)); menu.set_initial_mode(start_mode); + + if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) { + menu.set_dispatcher(dispatcher); + } + let event_rx = menu.subscribe(); // Default headless viewport @@ -182,7 +347,6 @@ async fn run_headless( match show_action { ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => { - // GUI doesn't exist yet. All show-* variants launch TUI for now. let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx)); let result = menu_handle .await @@ -204,11 +368,16 @@ async fn run_headless( /// pick from the menu. async fn run_interactive( items: Vec, - label_key: String, + cli: &Cli, start_mode: Mode, ) -> Result { - let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key)); + let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli)); menu.set_initial_mode(start_mode); + + if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) { + menu.set_dispatcher(dispatcher); + } + let event_rx = menu.subscribe(); // Handle SIGINT/SIGTERM: restore terminal and exit cleanly. @@ -216,7 +385,6 @@ async fn run_interactive( tokio::spawn(async move { if let Ok(()) = tokio::signal::ctrl_c().await { pikl_tui::restore_terminal(); - // Send cancel so the menu loop exits cleanly. let _ = signal_tx.send(Action::Cancel).await; } }); @@ -229,42 +397,19 @@ async fn run_interactive( result } -/// Serialize a value as JSON and write it to the given writer. -fn write_selected_json( - writer: &mut impl Write, - value: &serde_json::Value, -) -> Result<(), std::io::Error> { - let json = serde_json::to_string(value).unwrap_or_default(); - writeln!(writer, "{json}") -} - -/// Run a hook command if present. On failure, print the error -/// to stderr and exit. -fn run_result_hook( - rt: &tokio::runtime::Runtime, - hook_name: &str, - command: Option<&str>, - value: &serde_json::Value, -) { - if let Some(cmd) = command - && let Err(e) = rt.block_on(hook::run_hook(cmd, value)) - { - let _ = writeln!(std::io::stderr().lock(), "pikl: {hook_name} hook: {e}"); - std::process::exit(2); - } -} - /// Process the menu result: print selected item JSON to /// stdout, run hooks, or exit with the appropriate code. -fn handle_result(result: Result, cli: &Cli, rt: &tokio::runtime::Runtime) { +fn handle_result(result: Result, _cli: &Cli) { match result { - Ok(MenuResult::Selected(value)) => { - run_result_hook(rt, "on-select", cli.on_select.as_deref(), &value); - let _ = write_selected_json(&mut std::io::stdout().lock(), &value); + Ok(MenuResult::Selected { value, index }) => { + let output = OutputItem { + value: value.clone(), + action: OutputAction::Select, + index, + }; + let _ = write_output_json(&mut std::io::stdout().lock(), &output); } Ok(MenuResult::Cancelled) => { - let empty = serde_json::Value::String(String::new()); - run_result_hook(rt, "on-cancel", cli.on_cancel.as_deref(), &empty); std::process::exit(1); } Err(e) => { @@ -274,12 +419,15 @@ fn handle_result(result: Result, 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}") +} + /// Read items from stdin. If `timeout_secs` is non-zero, /// spawn a thread and bail if it doesn't finish in time. /// A timeout of 0 means no timeout (blocking read). -// TODO: The interactive path blocks on all of stdin before showing -// the menu. Switch to streaming items via Action::AddItems so the -// menu renders immediately and populates as lines arrive. fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result, PiklError> { if timeout_secs == 0 { return read_items_sync(std::io::stdin().lock(), label_key); @@ -315,17 +463,10 @@ fn reopen_stdin_from_tty() -> Result<(), PiklError> { { use std::os::unix::io::AsRawFd; let tty = std::fs::File::open("/dev/tty")?; - // SAFETY: dup2 is a standard POSIX call. We're - // redirecting stdin to the controlling tty so the - // TUI can read keyboard input after stdin was - // consumed for piped items. let r = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) }; if r < 0 { return Err(PiklError::Io(std::io::Error::last_os_error())); } - // SAFETY: tcflush is a standard POSIX call. Flush - // stale input that arrived between dup2 and raw - // mode so crossterm starts clean. unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) }; } Ok(()) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9815642..d9bcbd3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -213,17 +213,24 @@ them. The core manages debouncing and cancel-stale logic since that interacts with the event loop timing. ```rust -#[async_trait] pub trait HookHandler: Send + Sync { - async fn handle(&self, event: HookEvent) + fn handle(&self, event: HookEvent) -> Result, PiklError>; } ``` -The CLI binary provides `ShellHookHandler`, which maps -CLI flags to shell commands. Library consumers implement -their own handlers with whatever behaviour they want: -in-process closures, network calls, anything. +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`. + +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 diff --git a/docs/DEVPLAN.md b/docs/DEVPLAN.md index a24643f..c077ffa 100644 --- a/docs/DEVPLAN.md +++ b/docs/DEVPLAN.md @@ -14,7 +14,7 @@ when* we build it. - Ship something usable early, iterate from real usage - Don't optimize until there's a reason to -## Phase 1: Core Loop (TUI) +## Phase 1: Core Loop (TUI) ✓ The minimum thing that works end-to-end. @@ -35,7 +35,7 @@ The minimum thing that works end-to-end. **Done when:** `ls | pikl` works and prints the selected item. -## Phase 1.5: Action-fd (Headless Mode) +## Phase 1.5: Action-fd (Headless Mode) ✓ Scriptable, non-interactive mode for integration tests and automation. Small enough to slot in before phase 2. It's @@ -60,7 +60,7 @@ for `show-ui`. **Done when:** `echo -e "hello\nworld" | pikl --action-fd 3` with `confirm` on fd 3 prints `"hello"` to stdout. -## Phase 2: Navigation & Filtering +## Phase 2: Navigation & Filtering ✓ Make it feel like home for a vim user. The filter system is the real star here: strategy prefixes, pipeline @@ -112,7 +112,7 @@ chaining, incremental caching. with vim muscle memory. `'log | !temp | /[0-9]+/` works as a pipeline. -## Phase 3: Structured I/O & Hooks +## Phase 3: Structured I/O & Hooks ✓ The structured data pipeline and the full hook system. diff --git a/examples/demo.sh b/examples/demo.sh index 76c1ac9..42f708c 100755 --- a/examples/demo.sh +++ b/examples/demo.sh @@ -85,7 +85,7 @@ file_picker() { on_select_hook() { printf "one\ntwo\nthree\nfour\nfive\n" \ - | pikl --on-select 'echo "you picked: $(cat)"' + | pikl --on-select-exec 'echo "you picked: $(cat)" >&2' } mixed_input() { @@ -97,6 +97,184 @@ another plain string ITEMS } +# ── Phase 3 demos ──────────────────────────────────────── + +structured_items() { + echo "Items have sublabel, icon, and metadata fields." >&2 + echo "The output JSON includes action and index context." >&2 + echo "" >&2 + cat <<'ITEMS' | pikl +{"label": "Firefox", "sublabel": "Web Browser", "icon": "firefox", "meta": {"version": "125", "type": "browser"}} +{"label": "Neovim", "sublabel": "Text Editor", "icon": "nvim", "meta": {"version": "0.10", "type": "editor"}} +{"label": "Alacritty", "sublabel": "Terminal Emulator", "icon": "alacritty", "meta": {"version": "0.13", "type": "terminal"}} +{"label": "Thunar", "sublabel": "File Manager", "icon": "thunar", "meta": {"version": "4.18", "type": "filemanager"}} +{"label": "mpv", "sublabel": "Media Player", "icon": "mpv", "meta": {"version": "0.37", "type": "media"}} +ITEMS +} + +format_template() { + echo "Using --format to control display." >&2 + echo "Template: '{label} ({sublabel}) v{meta.version}'" >&2 + echo "" >&2 + cat <<'ITEMS' | pikl --format '{label} ({sublabel}) v{meta.version}' +{"label": "Firefox", "sublabel": "Web Browser", "meta": {"version": "125"}} +{"label": "Neovim", "sublabel": "Text Editor", "meta": {"version": "0.10"}} +{"label": "Alacritty", "sublabel": "Terminal Emulator", "meta": {"version": "0.13"}} +{"label": "Thunar", "sublabel": "File Manager", "meta": {"version": "4.18"}} +{"label": "mpv", "sublabel": "Media Player", "meta": {"version": "0.37"}} +ITEMS +} + +filter_fields_demo() { + echo "Using --filter-fields to search sublabel and meta fields." >&2 + echo "Try typing 'browser' or 'editor' to filter by sublabel." >&2 + echo "" >&2 + cat <<'ITEMS' | pikl --filter-fields label,sublabel --format '{label} - {sublabel}' +{"label": "Firefox", "sublabel": "Web Browser"} +{"label": "Neovim", "sublabel": "Text Editor"} +{"label": "Alacritty", "sublabel": "Terminal Emulator"} +{"label": "Thunar", "sublabel": "File Manager"} +{"label": "mpv", "sublabel": "Media Player"} +{"label": "GIMP", "sublabel": "Image Editor"} +{"label": "Inkscape", "sublabel": "Vector Graphics Editor"} +ITEMS +} + +field_filter_demo() { + echo "Field filters in the query: type meta.type:browser to match a field." >&2 + echo "Try: meta.type:browser or meta.type:editor or !meta.type:browser" >&2 + echo "" >&2 + cat <<'ITEMS' | pikl --format '{label} [{meta.type}] {meta.res}' +{"label": "Firefox", "meta": {"type": "browser", "res": "n/a"}} +{"label": "Chrome", "meta": {"type": "browser", "res": "n/a"}} +{"label": "Neovim", "meta": {"type": "editor", "res": "n/a"}} +{"label": "Helix", "meta": {"type": "editor", "res": "n/a"}} +{"label": "Alacritty", "meta": {"type": "terminal", "res": "n/a"}} +{"label": "kitty", "meta": {"type": "terminal", "res": "n/a"}} +{"label": "mpv", "meta": {"type": "media", "res": "3840x2160"}} +{"label": "vlc", "meta": {"type": "media", "res": "1920x1080"}} +ITEMS +} + +exec_hooks_demo() { + echo "Exec hooks: --on-hover-exec fires a command on each cursor move." >&2 + echo "Watch stderr for hover notifications as you navigate." >&2 + echo "" >&2 + cat <<'ITEMS' | pikl \ + --on-hover-exec 'jq -r ".item.label // empty" | xargs -I{} echo " hovering: {}" >&2' \ + --on-select-exec 'jq -r ".item.label // .value // empty" | xargs -I{} echo " selected: {}" >&2' +{"label": "Arch Linux", "sublabel": "rolling release"} +{"label": "NixOS", "sublabel": "declarative"} +{"label": "Void Linux", "sublabel": "runit-based"} +{"label": "Debian", "sublabel": "rock solid"} +{"label": "Alpine", "sublabel": "musl + busybox"} +ITEMS +} + +handler_hook_demo() { + echo "Handler hooks: a persistent process receives events on stdin" >&2 + echo "and can emit commands on stdout to modify the menu." >&2 + echo "" >&2 + echo "This demo logs hover events to stderr via a handler script." >&2 + echo "The handler stays alive for the menu's lifetime." >&2 + echo "" >&2 + + # Create a temporary handler script + local handler + handler=$(mktemp /tmp/pikl-handler-XXXXXX.sh) + cat > "$handler" <<'HANDLER' +#!/bin/bash +# Simple handler: logs events to stderr, demonstrates the protocol +while IFS= read -r event; do + event_type=$(echo "$event" | jq -r '.event // "unknown"') + case "$event_type" in + hover) + label=$(echo "$event" | jq -r '.item.label // "?"') + index=$(echo "$event" | jq -r '.index // "?"') + echo " handler got hover: $label (index $index)" >&2 + ;; + filter) + text=$(echo "$event" | jq -r '.text // ""') + echo " handler got filter: '$text'" >&2 + ;; + open) + echo " handler got open event" >&2 + ;; + close) + echo " handler got close event" >&2 + ;; + *) + echo " handler got: $event_type" >&2 + ;; + esac +done +HANDLER + chmod +x "$handler" + + cat <<'ITEMS' | pikl --on-hover "$handler" --on-hover-debounce 100 +{"label": "Maple", "sublabel": "Studio founder"} +{"label": "Cedar", "sublabel": "Backend dev"} +{"label": "Birch", "sublabel": "Frontend dev"} +{"label": "Pine", "sublabel": "DevOps"} +{"label": "Spruce", "sublabel": "QA"} +ITEMS + + rm -f "$handler" +} + +handler_add_items_demo() { + echo "Handler hooks can modify the menu by emitting commands." >&2 + echo "This demo adds items when you hover over specific entries." >&2 + echo "" >&2 + + local handler + handler=$(mktemp /tmp/pikl-handler-XXXXXX.sh) + cat > "$handler" <<'HANDLER' +#!/bin/bash +# Handler that adds related items on hover +while IFS= read -r event; do + event_type=$(echo "$event" | jq -r '.event // "unknown"') + if [ "$event_type" = "hover" ]; then + label=$(echo "$event" | jq -r '.item.label // ""') + case "$label" in + "Languages") + echo '{"action": "add_items", "items": [{"label": " Rust"}, {"label": " Go"}, {"label": " Python"}]}' + ;; + "Editors") + echo '{"action": "add_items", "items": [{"label": " Neovim"}, {"label": " Helix"}, {"label": " Emacs"}]}' + ;; + esac + fi +done +HANDLER + chmod +x "$handler" + + cat <<'ITEMS' | pikl --on-hover "$handler" --on-hover-debounce 300 +{"label": "Languages"} +{"label": "Editors"} +{"label": "Shells"} +ITEMS + + rm -f "$handler" +} + +pipeline_filter_demo() { + echo "Filter pipeline demo: chain filters with |" >&2 + echo "Try: 'rolling | !void (exact 'rolling', then exclude void)" >&2 + echo "Try: /sys/ (regex: items containing 'sys')" >&2 + echo "Try: meta.init:systemd (field filter on init system)" >&2 + echo "" >&2 + cat <<'ITEMS' | pikl --format '{label} ({meta.category}, {meta.init})' +{"label": "Arch Linux", "meta": {"category": "rolling", "init": "systemd"}} +{"label": "NixOS", "meta": {"category": "rolling", "init": "systemd"}} +{"label": "Void Linux", "meta": {"category": "rolling", "init": "runit"}} +{"label": "Debian", "meta": {"category": "stable", "init": "systemd"}} +{"label": "Alpine", "meta": {"category": "stable", "init": "openrc"}} +{"label": "Fedora", "meta": {"category": "semi-rolling", "init": "systemd"}} +{"label": "Gentoo", "meta": {"category": "rolling", "init": "openrc"}} +ITEMS +} + # ── Scenario menu ───────────────────────────────────────── scenarios=( @@ -107,22 +285,45 @@ scenarios=( "Git branches" "Git log (last 30)" "File picker" - "on-select hook" "Mixed input (plain + JSON)" + "---" + "Structured items (sublabel, meta)" + "Format template (--format)" + "Filter fields (--filter-fields)" + "Field filters (meta.type:browser)" + "Pipeline + field filters" + "---" + "Exec hooks (on-hover/select)" + "Handler hook (event logging)" + "Handler hook (add items on hover)" + "---" + "on-select-exec hook (legacy)" ) # Map display names to functions run_scenario() { case "$1" in - *"Plain text"*) plain_list ;; - *"Big list"*) big_list ;; - *"JSON objects"*) json_objects ;; - *"label-key"*) custom_label_key ;; - *"Git branches"*) git_branches ;; - *"Git log"*) git_log_picker ;; - *"File picker"*) file_picker ;; - *"on-select"*) on_select_hook ;; - *"Mixed input"*) mixed_input ;; + *"Plain text"*) plain_list ;; + *"Big list"*) big_list ;; + *"JSON objects"*) json_objects ;; + *"label-key"*) custom_label_key ;; + *"Git branches"*) git_branches ;; + *"Git log"*) git_log_picker ;; + *"File picker"*) file_picker ;; + *"Mixed input"*) mixed_input ;; + *"Structured items"*) structured_items ;; + *"Format template"*) format_template ;; + *"Filter fields"*) filter_fields_demo ;; + *"Field filters"*) field_filter_demo ;; + *"Pipeline + field"*) pipeline_filter_demo ;; + *"Exec hooks"*) exec_hooks_demo ;; + *"Handler hook (event"*) handler_hook_demo ;; + *"Handler hook (add"*) handler_add_items_demo ;; + *"on-select-exec"*) on_select_hook ;; + "---") + echo "that's a separator, not a scenario" >&2 + return 1 + ;; *) echo "unknown scenario" >&2 return 1 @@ -142,8 +343,8 @@ main() { exit 1 } - # pikl outputs JSON. Strip the quotes for matching. - choice=$(echo "$choice" | tr -d '"') + # pikl outputs JSON. Strip quotes and extract value for matching. + choice=$(echo "$choice" | jq -r '.value // .' 2>/dev/null || echo "$choice" | tr -d '"') echo "" >&2 echo "── running: $choice ──" >&2