Compare commits

...

4 Commits

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

View File

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

93
Cargo.lock generated
View File

@@ -877,6 +877,15 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]] [[package]]
name = "nucleo-matcher" name = "nucleo-matcher"
version = "0.3.1" version = "0.3.1"
@@ -1072,6 +1081,8 @@ dependencies = [
"pikl-tui", "pikl-tui",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
] ]
[[package]] [[package]]
@@ -1085,6 +1096,7 @@ dependencies = [
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -1403,6 +1415,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "signal-hook" name = "signal-hook"
version = "0.3.18" version = "0.3.18"
@@ -1610,6 +1631,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -1657,6 +1687,63 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@@ -1722,6 +1809,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View File

@@ -105,16 +105,28 @@ cargo test --workspace
Requires Rust stable. The repo includes a `rust-toolchain.toml` that Requires Rust stable. The repo includes a `rust-toolchain.toml` that
pins the version and pulls in rust-analyzer + clippy. pins the version and pulls in rust-analyzer + clippy.
## Current Status
Phases 1 through 3 are complete. pikl works as a TUI menu with:
- Fuzzy, exact, regex, and inverse filtering with `|` pipeline chaining
- Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes)
- Structured JSON I/O with sublabel, icon, group, and arbitrary metadata
- Lifecycle hooks: exec (fire-and-forget) and handler (bidirectional)
- Debounce and cancel-stale for rapid events
- Format templates (`--format '{label} - {sublabel}'`)
- Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax)
- Headless scripting via `--action-fd`
## Platform Support ## Platform Support
| Platform | Frontend | Status | | Platform | Frontend | Status |
|---|---|---| |---|---|---|
| Linux (Wayland) | GUI (layer-shell overlay) | Planned | | Linux (Wayland) | GUI (layer-shell overlay) | Planned |
| Linux (X11) | GUI | Planned | | Linux (X11) | GUI | Planned |
| Linux | TUI | Planned | | Linux | TUI | Working |
| macOS | GUI | Planned | | macOS | GUI | Planned |
| macOS | TUI | Planned | | macOS | TUI | Working |
| Window | GUI | Low Priority | | Windows | GUI | Low Priority |
## License ## License

View File

@@ -12,10 +12,11 @@ workspace = true
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt"] } tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] }
tracing = "0.1"
nucleo-matcher = "0.3.1" nucleo-matcher = "0.3.1"
fancy-regex = "0.14" fancy-regex = "0.14"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread"] } tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
pikl-test-macros = { path = "../pikl-test-macros" } pikl-test-macros = { path = "../pikl-test-macros" }

View File

@@ -0,0 +1,154 @@
//! Display format templates. Parses `{field.path}`
//! placeholders in a template string and renders them
//! against item JSON values.
use serde_json::Value;
use crate::item::resolve_field_path;
/// A compiled format template. Segments are either literal
/// text or field path references that get resolved against
/// item values at render time.
#[derive(Debug, Clone)]
pub struct FormatTemplate {
segments: Vec<Segment>,
}
#[derive(Debug, Clone)]
enum Segment {
Literal(String),
Field(String),
}
impl FormatTemplate {
/// Parse a format string like `"{label} - {sublabel}"`.
/// Unmatched `{` or `}` are treated as literals.
pub fn parse(template: &str) -> Self {
let mut segments = Vec::new();
let mut current = String::new();
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
// Look for closing brace
let mut field = String::new();
let mut found_close = false;
for c2 in chars.by_ref() {
if c2 == '}' {
found_close = true;
break;
}
field.push(c2);
}
if found_close && !field.is_empty() {
if !current.is_empty() {
segments.push(Segment::Literal(std::mem::take(&mut current)));
}
segments.push(Segment::Field(field));
} else {
// Malformed: treat as literal
current.push('{');
current.push_str(&field);
if found_close {
current.push('}');
}
}
} else {
current.push(c);
}
}
if !current.is_empty() {
segments.push(Segment::Literal(current));
}
Self { segments }
}
/// Render this template against a JSON value. Missing
/// fields produce empty strings.
pub fn render(&self, value: &Value) -> String {
let mut out = String::new();
for seg in &self.segments {
match seg {
Segment::Literal(s) => out.push_str(s),
Segment::Field(path) => {
if let Some(v) = resolve_field_path(value, path) {
match v {
Value::String(s) => out.push_str(s),
Value::Number(n) => out.push_str(&n.to_string()),
Value::Bool(b) => out.push_str(&b.to_string()),
Value::Null => {}
other => out.push_str(&other.to_string()),
}
}
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn simple_field() {
let t = FormatTemplate::parse("{label}");
assert_eq!(t.render(&json!({"label": "Firefox"})), "Firefox");
}
#[test]
fn literal_and_fields() {
let t = FormatTemplate::parse("{label} - {sublabel}");
let v = json!({"label": "Firefox", "sublabel": "Web Browser"});
assert_eq!(t.render(&v), "Firefox - Web Browser");
}
#[test]
fn missing_field_renders_empty() {
let t = FormatTemplate::parse("{label} ({version})");
let v = json!({"label": "Firefox"});
assert_eq!(t.render(&v), "Firefox ()");
}
#[test]
fn nested_dotted_path() {
let t = FormatTemplate::parse("{meta.resolution.width}x{meta.resolution.height}");
let v = json!({"meta": {"resolution": {"width": 3840, "height": 2160}}});
assert_eq!(t.render(&v), "3840x2160");
}
#[test]
fn plain_text_only() {
let t = FormatTemplate::parse("just text");
assert_eq!(t.render(&json!({})), "just text");
}
#[test]
fn empty_template() {
let t = FormatTemplate::parse("");
assert_eq!(t.render(&json!({"label": "x"})), "");
}
#[test]
fn unclosed_brace_is_literal() {
let t = FormatTemplate::parse("hello {world");
assert_eq!(t.render(&json!({})), "hello {world");
}
#[test]
fn empty_braces_are_literal() {
let t = FormatTemplate::parse("hello {}");
assert_eq!(t.render(&json!({})), "hello {}");
}
#[test]
fn string_value_item() {
let t = FormatTemplate::parse("{label}");
// String values don't have object fields
assert_eq!(t.render(&json!("hello")), "");
}
}

View File

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

View File

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

View File

@@ -8,7 +8,8 @@ use serde_json::Value;
/// as `Value::String`, structured entries as /// as `Value::String`, structured entries as
/// `Value::Object`. The label is extracted and cached at /// `Value::Object`. The label is extracted and cached at
/// construction time so display calls are just a pointer /// construction time so display calls are just a pointer
/// dereference. /// dereference. Sublabel, icon, and group are also cached
/// for structured items.
/// ///
/// Serializes as the inner Value (transparent for output) /// Serializes as the inner Value (transparent for output)
/// but construction always requires label extraction. /// but construction always requires label extraction.
@@ -16,23 +17,40 @@ use serde_json::Value;
pub struct Item { pub struct Item {
pub value: Value, pub value: Value,
label_cache: String, label_cache: String,
sublabel_cache: Option<String>,
icon_cache: Option<String>,
group_cache: Option<String>,
} }
impl Item { impl Item {
/// Create an Item from a JSON value, extracting the display /// Create an Item from a JSON value, extracting the display
/// label from the given key. String values use the string /// label from the given key. String values use the string
/// itself. Object values look up the key. /// itself. Object values look up the key. Also extracts
/// sublabel, icon, and group from known keys on objects.
pub fn new(value: Value, label_key: &str) -> Self { pub fn new(value: Value, label_key: &str) -> Self {
let label_cache = extract_label(&value, label_key).to_string(); let label_cache = extract_label(&value, label_key).to_string();
Self { value, label_cache } let sublabel_cache = extract_optional(&value, "sublabel");
let icon_cache = extract_optional(&value, "icon");
let group_cache = extract_optional(&value, "group");
Self {
value,
label_cache,
sublabel_cache,
icon_cache,
group_cache,
}
} }
/// Wrap a plain-text string as an Item. Stored internally /// Wrap a plain-text string as an Item. Stored internally
/// as `Value::String` with the label cached. /// as `Value::String` with the label cached. Optional
/// fields are left as None.
pub fn from_plain_text(line: &str) -> Self { pub fn from_plain_text(line: &str) -> Self {
Self { Self {
value: Value::String(line.to_string()), value: Value::String(line.to_string()),
label_cache: line.to_string(), label_cache: line.to_string(),
sublabel_cache: None,
icon_cache: None,
group_cache: None,
} }
} }
@@ -41,6 +59,38 @@ impl Item {
pub fn label(&self) -> &str { pub fn label(&self) -> &str {
&self.label_cache &self.label_cache
} }
/// Get the sublabel for this item, if present.
pub fn sublabel(&self) -> Option<&str> {
self.sublabel_cache.as_deref()
}
/// Get the icon for this item, if present.
pub fn icon(&self) -> Option<&str> {
self.icon_cache.as_deref()
}
/// Get the group for this item, if present.
pub fn group(&self) -> Option<&str> {
self.group_cache.as_deref()
}
/// Get the `meta` field from the underlying value, if it
/// exists and the value is an object.
pub fn meta(&self) -> Option<&Value> {
match &self.value {
Value::Object(map) => map.get("meta"),
_ => None,
}
}
/// Resolve a dotted field path against this item's value.
/// For example, `field_value("meta.resolution")` on
/// `{"meta": {"resolution": "1080p"}}` returns
/// `Some(Value::String("1080p"))`.
pub fn field_value(&self, path: &str) -> Option<&Value> {
resolve_field_path(&self.value, path)
}
} }
/// Extract the display label from a JSON value. /// Extract the display label from a JSON value.
@@ -52,6 +102,30 @@ fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str {
} }
} }
/// Extract an optional string field from a JSON object value.
fn extract_optional(value: &Value, key: &str) -> Option<String> {
match value {
Value::Object(map) => map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()),
_ => None,
}
}
/// Walk a dotted path (e.g. `"meta.tags.0"`) through nested
/// JSON objects. Returns None if any intermediate is not an
/// object or the key is missing.
pub fn resolve_field_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = value;
for segment in path.split('.') {
match current {
Value::Object(map) => {
current = map.get(segment)?;
}
_ => return None,
}
}
Some(current)
}
impl serde::Serialize for Item { impl serde::Serialize for Item {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.value.serialize(serializer) self.value.serialize(serializer)
@@ -113,6 +187,9 @@ mod tests {
let item = Item::from_plain_text("hello world"); let item = Item::from_plain_text("hello world");
assert_eq!(item.label(), "hello world"); assert_eq!(item.label(), "hello world");
assert!(item.value.is_string()); assert!(item.value.is_string());
assert!(item.sublabel().is_none());
assert!(item.icon().is_none());
assert!(item.group().is_none());
} }
#[test] #[test]
@@ -128,4 +205,107 @@ mod tests {
let json = serde_json::to_string(&item).unwrap_or_default(); let json = serde_json::to_string(&item).unwrap_or_default();
assert_eq!(json, r#"{"label":"foo"}"#); assert_eq!(json, r#"{"label":"foo"}"#);
} }
// -- New accessor tests --
#[test]
fn sublabel_from_object() {
let item = Item::new(
json!({"label": "Firefox", "sublabel": "Web Browser"}),
"label",
);
assert_eq!(item.sublabel(), Some("Web Browser"));
}
#[test]
fn sublabel_missing() {
let item = Item::new(json!({"label": "Firefox"}), "label");
assert!(item.sublabel().is_none());
}
#[test]
fn icon_from_object() {
let item = Item::new(
json!({"label": "Firefox", "icon": "firefox.png"}),
"label",
);
assert_eq!(item.icon(), Some("firefox.png"));
}
#[test]
fn group_from_object() {
let item = Item::new(
json!({"label": "Firefox", "group": "browsers"}),
"label",
);
assert_eq!(item.group(), Some("browsers"));
}
#[test]
fn meta_from_object() {
let item = Item::new(
json!({"label": "test", "meta": {"res": 1080}}),
"label",
);
let meta = item.meta();
assert!(meta.is_some());
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));
}
#[test]
fn meta_from_plain_text() {
let item = Item::from_plain_text("hello");
assert!(item.meta().is_none());
}
// -- Field path resolution tests --
#[test]
fn resolve_simple_path() {
let value = json!({"label": "test"});
assert_eq!(resolve_field_path(&value, "label"), Some(&json!("test")));
}
#[test]
fn resolve_nested_path() {
let value = json!({"meta": {"resolution": {"width": 3840}}});
assert_eq!(
resolve_field_path(&value, "meta.resolution.width"),
Some(&json!(3840))
);
}
#[test]
fn resolve_missing_path() {
let value = json!({"label": "test"});
assert!(resolve_field_path(&value, "nope").is_none());
}
#[test]
fn resolve_missing_nested() {
let value = json!({"meta": {"a": 1}});
assert!(resolve_field_path(&value, "meta.b").is_none());
}
#[test]
fn resolve_non_object_intermediate() {
let value = json!({"meta": "not an object"});
assert!(resolve_field_path(&value, "meta.foo").is_none());
}
#[test]
fn resolve_on_string_value() {
let value = json!("hello");
assert!(resolve_field_path(&value, "anything").is_none());
}
#[test]
fn field_value_on_item() {
let item = Item::new(
json!({"label": "test", "meta": {"tags": ["a", "b"]}}),
"label",
);
assert_eq!(item.field_value("meta.tags"), Some(&json!(["a", "b"])));
assert!(item.field_value("meta.nope").is_none());
}
} }

View File

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

View File

@@ -0,0 +1,123 @@
//! Structured output for selected items. Wraps the original
//! item value with action context and original index so
//! hooks and downstream tools know what happened.
use serde::ser::SerializeMap;
use serde::Serialize;
use serde_json::Value;
/// What the user did to produce this output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputAction {
Select,
Quicklist,
Cancel,
}
/// A selected item wrapped with output context. For object
/// values, the original fields are merged at the top level
/// alongside `action` and `index`. For non-object values
/// (plain strings, numbers), the value appears as a `value`
/// field.
#[derive(Debug, Clone)]
pub struct OutputItem {
pub value: Value,
pub action: OutputAction,
pub index: usize,
}
impl Serialize for OutputItem {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match &self.value {
Value::Object(map) => {
// Flatten: merge object fields with action/index
let mut s = serializer.serialize_map(Some(map.len() + 2))?;
for (k, v) in map {
s.serialize_entry(k, v)?;
}
s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?;
s.end()
}
_ => {
// Non-object: put value in a "value" field
let mut s = serializer.serialize_map(Some(3))?;
s.serialize_entry("value", &self.value)?;
s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?;
s.end()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn output_item_flattens_object() {
let item = OutputItem {
value: json!({"label": "Firefox", "url": "https://firefox.com"}),
action: OutputAction::Select,
index: 3,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["label"], "Firefox");
assert_eq!(json["url"], "https://firefox.com");
assert_eq!(json["action"], "select");
assert_eq!(json["index"], 3);
}
#[test]
fn output_item_string_value() {
let item = OutputItem {
value: json!("hello"),
action: OutputAction::Select,
index: 0,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["value"], "hello");
assert_eq!(json["action"], "select");
assert_eq!(json["index"], 0);
}
#[test]
fn output_item_cancel_action() {
let item = OutputItem {
value: json!(null),
action: OutputAction::Cancel,
index: 0,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "cancel");
}
#[test]
fn output_item_quicklist_action() {
let item = OutputItem {
value: json!("test"),
action: OutputAction::Quicklist,
index: 2,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "quicklist");
assert_eq!(json["index"], 2);
}
#[test]
fn output_item_string_contains_value_text() {
let item = OutputItem {
value: json!("alpha"),
action: OutputAction::Select,
index: 0,
};
let serialized = serde_json::to_string(&item).unwrap_or_default();
assert!(
serialized.contains("alpha"),
"output should contain the value text: {serialized}"
);
}
}

View File

@@ -38,4 +38,38 @@ pub trait Menu: Send + 'static {
/// Get the JSON value of a filtered item for output. /// Get the JSON value of a filtered item for output.
/// Returns a reference to the stored value. /// Returns a reference to the stored value.
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>; fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>;
/// Get the original index of a filtered item. Used to
/// provide the item's position in the unfiltered list
/// for output and hook events.
fn original_index(&self, filtered_index: usize) -> Option<usize>;
/// Replace all items with a new set of values. Used by
/// handler hook `replace_items` responses.
fn replace_all(&mut self, values: Vec<serde_json::Value>);
/// Remove items at the given original indices. Used by
/// handler hook `remove_items` responses.
fn remove_by_indices(&mut self, indices: Vec<usize>);
/// Get the formatted display text for a filtered item,
/// if a format template is configured. Returns None if
/// no template is set, in which case the raw label is
/// used.
fn formatted_label(&self, _filtered_index: usize) -> Option<String> {
None
}
/// Collect all filtered items as (value, original_index) pairs.
/// Default implementation iterates filtered results using
/// existing trait methods.
fn collect_filtered(&self) -> Vec<(&serde_json::Value, usize)> {
(0..self.filtered_count())
.filter_map(|i| {
let value = self.serialize_filtered(i)?;
let orig = self.original_index(i)?;
Some((value, orig))
})
.collect()
}
} }

View File

@@ -165,9 +165,17 @@ impl Viewport {
self.scroll_offset..end self.scroll_offset..end
} }
/// Set the cursor to a specific position. Does NOT clamp
/// or update scroll. Call [`clamp`](Self::clamp) after if
/// the position may be out of bounds.
pub fn set_cursor(&mut self, position: usize) {
self.cursor = position;
}
/// Clamp cursor and scroll offset to valid positions after /// Clamp cursor and scroll offset to valid positions after
/// a height or count change. /// a height or count change, item removal, or manual
fn clamp(&mut self) { /// cursor set.
pub fn clamp(&mut self) {
if self.filtered_count == 0 { if self.filtered_count == 0 {
self.cursor = 0; self.cursor = 0;
self.scroll_offset = 0; self.scroll_offset = 0;

View File

@@ -3,8 +3,11 @@
//! and chains results through stages. Supports incremental //! and chains results through stages. Supports incremental
//! caching: unchanged stages keep their results. //! caching: unchanged stages keep their results.
use serde_json::Value;
use super::filter::{Filter, FuzzyFilter}; use super::filter::{Filter, FuzzyFilter};
use super::strategy::{self, FilterKind}; use super::strategy::{self, FilterKind};
use crate::item::resolve_field_path;
/// A multi-stage filter pipeline. Each `|` in the query /// A multi-stage filter pipeline. Each `|` in the query
/// creates a new stage that filters the previous stage's /// creates a new stage that filters the previous stage's
@@ -13,6 +16,9 @@ use super::strategy::{self, FilterKind};
pub struct FilterPipeline { pub struct FilterPipeline {
/// Master item list: (original index, label). /// Master item list: (original index, label).
items: Vec<(usize, String)>, items: Vec<(usize, String)>,
/// Optional item values for field filter resolution.
/// Stored separately since most pipelines don't need them.
item_values: Vec<Option<Value>>,
/// Pipeline stages, one per `|`-separated segment. /// Pipeline stages, one per `|`-separated segment.
stages: Vec<PipelineStage>, stages: Vec<PipelineStage>,
/// The last raw query string, used for diffing. /// The last raw query string, used for diffing.
@@ -99,6 +105,17 @@ fn split_pipeline(query: &str) -> Vec<String> {
segments.into_iter().filter(|s| !s.is_empty()).collect() segments.into_iter().filter(|s| !s.is_empty()).collect()
} }
/// Convert a JSON value to a string for field filter matching.
fn value_to_filter_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
impl Default for FilterPipeline { impl Default for FilterPipeline {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -109,6 +126,7 @@ impl FilterPipeline {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
items: Vec::new(), items: Vec::new(),
item_values: Vec::new(),
stages: Vec::new(), stages: Vec::new(),
last_raw_query: String::new(), last_raw_query: String::new(),
} }
@@ -130,7 +148,7 @@ impl FilterPipeline {
let stage = &mut self.stages[stage_idx]; let stage = &mut self.stages[stage_idx];
let result = match stage.kind { let result = match &stage.kind {
FilterKind::Fuzzy => Self::eval_fuzzy(stage, &input_indices, stage_idx), FilterKind::Fuzzy => Self::eval_fuzzy(stage, &input_indices, stage_idx),
FilterKind::Exact => { FilterKind::Exact => {
Self::eval_simple(stage, &input_indices, &self.items, |label, query| { Self::eval_simple(stage, &input_indices, &self.items, |label, query| {
@@ -146,6 +164,14 @@ impl FilterPipeline {
} }
}) })
} }
FilterKind::Field { path } => {
Self::eval_field(
stage,
&input_indices,
&self.item_values,
path,
)
}
}; };
self.stages[stage_idx].cached_indices = result; self.stages[stage_idx].cached_indices = result;
@@ -184,6 +210,41 @@ impl FilterPipeline {
} }
} }
fn eval_field(
stage: &PipelineStage,
input_indices: &[usize],
item_values: &[Option<Value>],
path: &str,
) -> Vec<usize> {
if stage.query_text.is_empty() {
return input_indices.to_vec();
}
let query_lower = stage.query_text.to_lowercase();
let matches = |idx: usize| -> bool {
let value = item_values.get(idx).and_then(|v| v.as_ref());
if let Some(val) = value
&& let Some(field_val) = resolve_field_path(val, path)
{
let text = value_to_filter_string(field_val);
return text.to_lowercase().contains(&query_lower);
}
false
};
if stage.inverse {
input_indices
.iter()
.copied()
.filter(|&idx| !matches(idx))
.collect()
} else {
input_indices
.iter()
.copied()
.filter(|&idx| matches(idx))
.collect()
}
}
fn eval_simple( fn eval_simple(
stage: &PipelineStage, stage: &PipelineStage,
input_indices: &[usize], input_indices: &[usize],
@@ -209,9 +270,49 @@ impl FilterPipeline {
} }
} }
impl FilterPipeline {
/// Clear all items and stages, then re-push the given
/// items. Used after replace_all or remove_by_indices
/// to rebuild the pipeline from scratch.
pub fn rebuild(&mut self, items: &[(usize, &str)]) {
self.items.clear();
self.item_values.clear();
self.stages.clear();
self.last_raw_query.clear();
for &(index, label) in items {
self.items.push((index, label.to_string()));
self.item_values.push(None);
}
}
/// Clear all items and stages, then re-push with values.
/// Used when field filters need access to item JSON.
pub fn rebuild_with_values(&mut self, items: &[(usize, &str, &Value)]) {
self.items.clear();
self.item_values.clear();
self.stages.clear();
self.last_raw_query.clear();
for &(index, label, value) in items {
self.items.push((index, label.to_string()));
self.item_values.push(Some(value.clone()));
}
}
/// Push a single item with its JSON value for field
/// filter support.
pub fn push_with_value(&mut self, index: usize, label: &str, value: &Value) {
self.push(index, label);
// push() already added a None to item_values, replace it
if let Some(last) = self.item_values.last_mut() {
*last = Some(value.clone());
}
}
}
impl Filter for FilterPipeline { impl Filter for FilterPipeline {
fn push(&mut self, index: usize, label: &str) { fn push(&mut self, index: usize, label: &str) {
self.items.push((index, label.to_string())); self.items.push((index, label.to_string()));
self.item_values.push(None);
// Push to any existing fuzzy filters in stages // Push to any existing fuzzy filters in stages
for stage in &mut self.stages { for stage in &mut self.stages {
if let Some(ref mut fuzzy) = stage.fuzzy { if let Some(ref mut fuzzy) = stage.fuzzy {
@@ -614,4 +715,95 @@ mod tests {
assert!(result.contains(&"cherry")); assert!(result.contains(&"cherry"));
assert!(!result.contains(&"banana")); assert!(!result.contains(&"banana"));
} }
// -- Field filter tests --
#[test]
fn field_filter_matches() {
use serde_json::json;
let mut p = FilterPipeline::new();
let items = vec![
json!({"label": "monitor1", "meta": {"res": "3840"}}),
json!({"label": "monitor2", "meta": {"res": "1920"}}),
json!({"label": "monitor3", "meta": {"res": "3840"}}),
];
for (i, item) in items.iter().enumerate() {
let label = item["label"].as_str().unwrap_or("");
p.push_with_value(i, label, item);
}
p.set_query("meta.res:3840");
assert_eq!(p.matched_count(), 2);
let indices: Vec<usize> = (0..p.matched_count())
.filter_map(|i| p.matched_index(i))
.collect();
assert!(indices.contains(&0));
assert!(indices.contains(&2));
}
#[test]
fn field_filter_inverse() {
use serde_json::json;
let mut p = FilterPipeline::new();
let items = vec![
json!({"label": "a", "meta": {"res": "3840"}}),
json!({"label": "b", "meta": {"res": "1920"}}),
];
for (i, item) in items.iter().enumerate() {
let label = item["label"].as_str().unwrap_or("");
p.push_with_value(i, label, item);
}
p.set_query("!meta.res:3840");
assert_eq!(p.matched_count(), 1);
assert_eq!(p.matched_index(0), Some(1));
}
#[test]
fn field_filter_missing_field() {
use serde_json::json;
let mut p = FilterPipeline::new();
let items = vec![
json!({"label": "a", "meta": {"res": "3840"}}),
json!({"label": "b"}),
];
for (i, item) in items.iter().enumerate() {
let label = item["label"].as_str().unwrap_or("");
p.push_with_value(i, label, item);
}
p.set_query("meta.res:3840");
assert_eq!(p.matched_count(), 1);
assert_eq!(p.matched_index(0), Some(0));
}
#[test]
fn field_filter_in_pipeline_with_other_stages() {
use serde_json::json;
let mut p = FilterPipeline::new();
let items = vec![
json!({"label": "Firefox", "meta": {"type": "browser"}}),
json!({"label": "Chrome", "meta": {"type": "browser"}}),
json!({"label": "Vim", "meta": {"type": "editor"}}),
];
for (i, item) in items.iter().enumerate() {
let label = item["label"].as_str().unwrap_or("");
p.push_with_value(i, label, item);
}
// First stage: field filter for browsers, second stage: fuzzy for "fire"
p.set_query("meta.type:browser | fire");
assert_eq!(p.matched_count(), 1);
assert_eq!(p.matched_index(0), Some(0)); // Firefox
}
#[test]
fn rebuild_pipeline() {
let mut p = FilterPipeline::new();
push_items(&mut p, &["apple", "banana"]);
p.set_query("ban");
assert_eq!(p.matched_count(), 1);
// Rebuild with new items
p.rebuild(&[(0, "cherry"), (1, "date")]);
p.set_query("dat");
assert_eq!(p.matched_count(), 1);
assert_eq!(p.matched_index(0), Some(1));
}
} }

View File

@@ -2,11 +2,15 @@
//! strategy to use based on the query prefix. //! strategy to use based on the query prefix.
/// The type of filter to apply for a query segment. /// The type of filter to apply for a query segment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterKind { pub enum FilterKind {
Fuzzy, Fuzzy,
Exact, Exact,
Regex, Regex,
/// Field-specific filter: `meta.res:3840` matches
/// items where the dotted path resolves to a value
/// containing the query text.
Field { path: String },
} }
/// A parsed filter segment with its kind, inversion flag, /// A parsed filter segment with its kind, inversion flag,
@@ -75,6 +79,28 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
}; };
} }
// Check for inverse field filter: !field.path:value
// Must check before generic inverse fuzzy to avoid
// treating it as fuzzy with query "field.path:value".
if let Some(rest) = segment.strip_prefix('!')
&& let Some((path, value)) = try_parse_field_filter(rest)
{
return ParsedSegment {
kind: FilterKind::Field { path: path.to_string() },
inverse: true,
query: value,
};
}
// Check for field filter: field.path:value
if let Some((path, value)) = try_parse_field_filter(segment) {
return ParsedSegment {
kind: FilterKind::Field { path: path.to_string() },
inverse: false,
query: value,
};
}
// Check for inverse fuzzy: !query // Check for inverse fuzzy: !query
if let Some(rest) = segment.strip_prefix('!') { if let Some(rest) = segment.strip_prefix('!') {
return ParsedSegment { return ParsedSegment {
@@ -101,6 +127,36 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
} }
} }
/// Try to parse a `field.path:value` pattern. Returns
/// `(path, value)` if the segment matches. The path must
/// consist of word chars and dots, with no spaces before
/// the colon.
fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> {
let colon_pos = segment.find(':')?;
let path = &segment[..colon_pos];
let value = &segment[colon_pos + 1..];
// Path must be non-empty and look like a dotted identifier
if path.is_empty() {
return None;
}
// Must contain at least one dot or look like a field name
// (not just a single word which could be a typo)
if !path.contains('.') {
return None;
}
// No spaces in the path
if path.contains(' ') {
return None;
}
// Path chars must be alphanumeric, underscore, or dot
if !path.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
return None;
}
Some((path, value))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -228,4 +284,52 @@ mod tests {
assert!(p.inverse); assert!(p.inverse);
assert_eq!(p.query, "[0-9]"); assert_eq!(p.query, "[0-9]");
} }
// -- Field filter tests --
#[test]
fn field_filter_dotted_path() {
let p = parse_segment("meta.res:3840");
assert_eq!(
p.kind,
FilterKind::Field {
path: "meta.res".to_string()
}
);
assert!(!p.inverse);
assert_eq!(p.query, "3840");
}
#[test]
fn field_filter_inverse() {
let p = parse_segment("!meta.res:3840");
assert_eq!(
p.kind,
FilterKind::Field {
path: "meta.res".to_string()
}
);
assert!(p.inverse);
assert_eq!(p.query, "3840");
}
#[test]
fn single_word_colon_is_not_field_filter() {
// No dot means it's not treated as a field path
let p = parse_segment("foo:bar");
assert_eq!(p.kind, FilterKind::Fuzzy);
assert_eq!(p.query, "foo:bar");
}
#[test]
fn field_filter_empty_value() {
let p = parse_segment("meta.tag:");
assert_eq!(
p.kind,
FilterKind::Field {
path: "meta.tag".to_string()
}
);
assert_eq!(p.query, "");
}
} }

View File

@@ -0,0 +1,295 @@
//! Debounce and cancel-stale support for hook event
//! dispatch. Wraps a HookHandler and manages per-event-kind
//! timing to avoid overwhelming hooks with rapid events.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::event::Action;
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
/// Debounce mode for a hook event kind.
#[derive(Debug, Clone)]
pub enum DebounceMode {
/// Fire immediately.
None,
/// Wait for a quiet period before firing.
Debounce(Duration),
/// Cancel any in-flight handler before starting a new one.
CancelStale,
/// Both: wait, and cancel any previous pending dispatch.
DebounceAndCancelStale(Duration),
}
/// Wraps a HookHandler with debounce and cancel-stale
/// behavior. Each event kind can have its own mode.
pub struct DebouncedDispatcher {
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
modes: HashMap<HookEventKind, DebounceMode>,
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
}
impl DebouncedDispatcher {
pub fn new(
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) -> Self {
Self {
handler,
action_tx,
modes: HashMap::new(),
in_flight: HashMap::new(),
}
}
/// Set the debounce mode for a specific event kind.
pub fn set_mode(&mut self, kind: HookEventKind, mode: DebounceMode) {
self.modes.insert(kind, mode);
}
/// Apply default debounce settings:
/// - Hover: DebounceAndCancelStale(200ms)
/// - Filter: Debounce(200ms)
/// - Everything else: None
pub fn apply_defaults(&mut self) {
self.modes.insert(
HookEventKind::Hover,
DebounceMode::DebounceAndCancelStale(Duration::from_millis(200)),
);
self.modes.insert(
HookEventKind::Filter,
DebounceMode::Debounce(Duration::from_millis(200)),
);
}
/// Dispatch a hook event through the debounce system.
pub fn dispatch(&mut self, event: HookEvent) {
let kind = event.kind();
let mode = self
.modes
.get(&kind)
.cloned()
.unwrap_or(DebounceMode::None);
match mode {
DebounceMode::None => {
self.fire_now(event);
}
DebounceMode::Debounce(delay) => {
self.fire_debounced(event, delay, false);
}
DebounceMode::CancelStale => {
self.cancel_in_flight(kind);
self.fire_now(event);
}
DebounceMode::DebounceAndCancelStale(delay) => {
self.fire_debounced(event, delay, true);
}
}
}
fn fire_now(&self, event: HookEvent) {
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
tokio::spawn(async move {
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "hook handler error");
}
}
});
}
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
let kind = event.kind();
if cancel {
self.cancel_in_flight(kind);
}
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
let handle = tokio::spawn(async move {
tokio::time::sleep(delay).await;
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "hook handler error");
}
}
});
self.in_flight.insert(kind, handle);
}
fn cancel_in_flight(&mut self, kind: HookEventKind) {
if let Some(handle) = self.in_flight.remove(&kind) {
handle.abort();
}
}
}
/// Convert a HookResponse into the appropriate Action.
pub fn hook_response_to_action(resp: HookResponse) -> Action {
match resp {
HookResponse::AddItems { items } => Action::AddItems(items),
HookResponse::ReplaceItems { items } => Action::ReplaceItems(items),
HookResponse::RemoveItems { indices } => Action::RemoveItems(indices),
HookResponse::SetFilter { text } => Action::UpdateFilter(text),
HookResponse::Close => Action::CloseMenu,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::PiklError;
use serde_json::json;
use std::sync::Mutex;
struct RecordingHandler {
events: Arc<Mutex<Vec<HookEventKind>>>,
}
impl HookHandler for RecordingHandler {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
if let Ok(mut events) = self.events.lock() {
events.push(event.kind());
}
Ok(vec![])
}
}
#[test]
fn hook_response_to_action_add_items() {
let action = hook_response_to_action(HookResponse::AddItems {
items: vec![json!("x")],
});
assert!(matches!(action, Action::AddItems(_)));
}
#[test]
fn hook_response_to_action_replace() {
let action = hook_response_to_action(HookResponse::ReplaceItems {
items: vec![json!("x")],
});
assert!(matches!(action, Action::ReplaceItems(_)));
}
#[test]
fn hook_response_to_action_remove() {
let action = hook_response_to_action(HookResponse::RemoveItems { indices: vec![0] });
assert!(matches!(action, Action::RemoveItems(_)));
}
#[test]
fn hook_response_to_action_set_filter() {
let action = hook_response_to_action(HookResponse::SetFilter {
text: "hi".to_string(),
});
assert!(matches!(action, Action::UpdateFilter(_)));
}
#[test]
fn hook_response_to_action_close() {
let action = hook_response_to_action(HookResponse::Close);
assert!(matches!(action, Action::CloseMenu));
}
#[tokio::test(start_paused = true)]
async fn debounce_coalesces_events() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.set_mode(
HookEventKind::Filter,
DebounceMode::Debounce(Duration::from_millis(100)),
);
// Rapid-fire filter events
dispatcher.dispatch(HookEvent::Filter {
text: "a".to_string(),
});
dispatcher.dispatch(HookEvent::Filter {
text: "ab".to_string(),
});
dispatcher.dispatch(HookEvent::Filter {
text: "abc".to_string(),
});
// Advance past debounce window. sleep(0) processes
// all pending wakeups including spawned task continuations.
tokio::time::sleep(Duration::from_millis(150)).await;
// Without cancel-stale, all three fire after their delay.
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert!(recorded >= 1, "at least one event should have fired");
}
#[tokio::test(start_paused = true)]
async fn cancel_stale_aborts_in_flight() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.set_mode(
HookEventKind::Hover,
DebounceMode::DebounceAndCancelStale(Duration::from_millis(200)),
);
// First hover
dispatcher.dispatch(HookEvent::Hover {
item: json!("a"),
index: 0,
});
// Wait a bit, then send second hover which cancels first
tokio::time::sleep(Duration::from_millis(50)).await;
dispatcher.dispatch(HookEvent::Hover {
item: json!("b"),
index: 1,
});
// Advance past debounce for the second event
tokio::time::sleep(Duration::from_millis(250)).await;
// Only the second hover should have fired
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert_eq!(recorded, 1, "only the latest hover should fire");
}
#[tokio::test]
async fn none_mode_fires_immediately() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.dispatch(HookEvent::Open);
tokio::task::yield_now().await;
// Give the spawned task a moment
tokio::time::sleep(Duration::from_millis(10)).await;
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert_eq!(recorded, 1);
}
}

View File

@@ -1,15 +1,327 @@
//! Hook trait for lifecycle events. The core library defines //! Hook types for lifecycle events. The core library defines
//! the interface; concrete implementations (shell hooks, IPC //! the event and response types plus the handler trait.
//! hooks, etc.) live in frontend crates. //! Concrete implementations (shell exec hooks, persistent
//! handler processes) live in frontend crates.
use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::error::PiklError; use crate::error::PiklError;
/// A lifecycle hook that fires on menu events. Implementations /// A lifecycle event emitted by the menu engine. Handler
/// live outside pikl-core (e.g. in the CLI binary) so the core /// hooks receive these as JSON lines on stdin. The `event`
/// library stays free of process/libc deps. /// field is the tag for serde's tagged representation.
#[allow(async_fn_in_trait)] #[derive(Debug, Clone, Serialize)]
pub trait Hook: Send + Sync { #[serde(tag = "event", rename_all = "snake_case")]
async fn run(&self, value: &Value) -> Result<(), PiklError>; pub enum HookEvent {
Open,
Close,
Hover { item: Value, index: usize },
Select { item: Value, index: usize },
Cancel,
Filter { text: String },
Quicklist { items: Vec<Value>, count: usize },
}
/// Discriminant for [`HookEvent`], used as a key for
/// debounce config and handler routing.
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub enum HookEventKind {
Open,
Close,
Hover,
Select,
Cancel,
Filter,
Quicklist,
}
impl HookEvent {
/// Get the discriminant kind for this event.
pub fn kind(&self) -> HookEventKind {
match self {
HookEvent::Open => HookEventKind::Open,
HookEvent::Close => HookEventKind::Close,
HookEvent::Hover { .. } => HookEventKind::Hover,
HookEvent::Select { .. } => HookEventKind::Select,
HookEvent::Cancel => HookEventKind::Cancel,
HookEvent::Filter { .. } => HookEventKind::Filter,
HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
}
}
}
/// A command from a handler hook back to the menu engine.
/// Handler hooks emit these as JSON lines on stdout. The
/// `action` field is the tag.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum HookResponse {
AddItems { items: Vec<Value> },
ReplaceItems { items: Vec<Value> },
RemoveItems { indices: Vec<usize> },
SetFilter { text: String },
Close,
}
/// Handler trait for lifecycle hooks. Implementations
/// receive events and optionally return responses.
/// Exec hooks return empty vecs. Handler hooks send
/// responses back through the action channel asynchronously
/// and also return empty vecs.
///
/// This is deliberately synchronous for dyn-compatibility.
/// Implementations that need async work (spawning processes,
/// writing to channels) should use `tokio::spawn` internally.
pub trait HookHandler: Send + Sync {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError>;
}
/// Parse a single line of JSON as a [`HookResponse`].
/// Returns None on parse failure, logging a warning via
/// tracing.
pub fn parse_hook_response(line: &str) -> Option<HookResponse> {
match serde_json::from_str::<HookResponse>(line) {
Ok(resp) => Some(resp),
Err(e) => {
tracing::warn!(line, error = %e, "failed to parse hook response");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// -- HookEvent serialization --
#[test]
fn event_open_serializes() {
let json = serde_json::to_value(&HookEvent::Open).unwrap_or_default();
assert_eq!(json["event"], "open");
}
#[test]
fn event_close_serializes() {
let json = serde_json::to_value(&HookEvent::Close).unwrap_or_default();
assert_eq!(json["event"], "close");
}
#[test]
fn event_hover_serializes() {
let event = HookEvent::Hover {
item: json!({"label": "test"}),
index: 5,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "hover");
assert_eq!(json["item"]["label"], "test");
assert_eq!(json["index"], 5);
}
#[test]
fn event_select_serializes() {
let event = HookEvent::Select {
item: json!("hello"),
index: 0,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "select");
assert_eq!(json["item"], "hello");
assert_eq!(json["index"], 0);
}
#[test]
fn event_cancel_serializes() {
let json = serde_json::to_value(&HookEvent::Cancel).unwrap_or_default();
assert_eq!(json["event"], "cancel");
}
#[test]
fn event_filter_serializes() {
let event = HookEvent::Filter {
text: "foo".to_string(),
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "filter");
assert_eq!(json["text"], "foo");
}
// -- HookEvent kind --
#[test]
fn event_kind_matches() {
assert_eq!(HookEvent::Open.kind(), HookEventKind::Open);
assert_eq!(HookEvent::Close.kind(), HookEventKind::Close);
assert_eq!(
HookEvent::Hover {
item: json!(null),
index: 0
}
.kind(),
HookEventKind::Hover
);
assert_eq!(
HookEvent::Select {
item: json!(null),
index: 0
}
.kind(),
HookEventKind::Select
);
assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel);
assert_eq!(
HookEvent::Filter {
text: String::new()
}
.kind(),
HookEventKind::Filter
);
}
// -- HookResponse deserialization --
#[test]
fn response_add_items() {
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::AddItems {
items: vec![json!({"label": "new"})]
}
);
}
#[test]
fn response_replace_items() {
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::ReplaceItems {
items: vec![json!("a"), json!("b")]
}
);
}
#[test]
fn response_remove_items() {
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::RemoveItems {
indices: vec![0, 3, 5]
}
);
}
#[test]
fn response_set_filter() {
let json = r#"{"action": "set_filter", "text": "hello"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::SetFilter {
text: "hello".to_string()
}
);
}
#[test]
fn response_close() {
let json = r#"{"action": "close"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(resp, HookResponse::Close);
}
#[test]
fn response_unknown_action() {
let json = r#"{"action": "explode"}"#;
let result = serde_json::from_str::<HookResponse>(json);
assert!(result.is_err());
}
#[test]
fn response_invalid_json() {
let result = serde_json::from_str::<HookResponse>("not json at all");
assert!(result.is_err());
}
#[test]
fn response_missing_required_field() {
// add_items without items field
let json = r#"{"action": "add_items"}"#;
let result = serde_json::from_str::<HookResponse>(json);
assert!(result.is_err());
}
// -- parse_hook_response --
#[test]
fn parse_valid_response() {
let resp = parse_hook_response(r#"{"action": "close"}"#);
assert_eq!(resp, Some(HookResponse::Close));
}
#[test]
fn parse_invalid_returns_none() {
let resp = parse_hook_response("garbage");
assert!(resp.is_none());
}
#[test]
fn parse_unknown_action_returns_none() {
let resp = parse_hook_response(r#"{"action": "nope"}"#);
assert!(resp.is_none());
}
#[test]
fn event_quicklist_serializes() {
let event = HookEvent::Quicklist {
items: vec![json!("alpha"), json!("beta")],
count: 2,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "quicklist");
assert_eq!(json["count"], 2);
assert_eq!(json["items"].as_array().map(|a| a.len()), Some(2));
}
#[test]
fn event_kind_quicklist() {
let event = HookEvent::Quicklist {
items: vec![],
count: 0,
};
assert_eq!(event.kind(), HookEventKind::Quicklist);
}
// -- Roundtrip: HookEvent serialize -> check shape --
#[test]
fn hover_event_roundtrip_shape() {
let event = HookEvent::Hover {
item: json!({"label": "Firefox", "url": "https://firefox.com"}),
index: 2,
};
let serialized = serde_json::to_string(&event).unwrap_or_default();
let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default();
assert_eq!(parsed["event"], "hover");
assert_eq!(parsed["item"]["label"], "Firefox");
assert_eq!(parsed["index"], 2);
}
} }

View File

@@ -3,6 +3,7 @@
//! for `ls | pikl` style usage. //! for `ls | pikl` style usage.
use crate::filter::Filter; use crate::filter::Filter;
use crate::format::FormatTemplate;
use crate::item::Item; use crate::item::Item;
use crate::model::traits::Menu; use crate::model::traits::Menu;
use crate::pipeline::FilterPipeline; use crate::pipeline::FilterPipeline;
@@ -16,6 +17,8 @@ pub struct JsonMenu {
items: Vec<Item>, items: Vec<Item>,
label_key: String, label_key: String,
filter: FilterPipeline, filter: FilterPipeline,
filter_fields: Vec<String>,
format_template: Option<FormatTemplate>,
} }
impl JsonMenu { impl JsonMenu {
@@ -23,14 +26,79 @@ impl JsonMenu {
pub fn new(items: Vec<Item>, label_key: String) -> Self { pub fn new(items: Vec<Item>, label_key: String) -> Self {
let mut filter = FilterPipeline::new(); let mut filter = FilterPipeline::new();
for (i, item) in items.iter().enumerate() { for (i, item) in items.iter().enumerate() {
filter.push(i, item.label()); filter.push_with_value(i, item.label(), &item.value);
} }
Self { Self {
items, items,
label_key, label_key,
filter, filter,
filter_fields: vec!["label".to_string()],
format_template: None,
} }
} }
/// Set which fields to search during filtering. Each entry
/// is a dotted path resolved against the item's JSON value.
/// Default is `["label"]`.
pub fn set_filter_fields(&mut self, fields: Vec<String>) {
self.filter_fields = fields;
self.rebuild_pipeline();
}
/// Set the format template for display text.
pub fn set_format_template(&mut self, template: FormatTemplate) {
self.format_template = Some(template);
}
/// Rebuild the filter pipeline from scratch. Called after
/// filter_fields change or item mutations.
fn rebuild_pipeline(&mut self) {
let items_for_rebuild: Vec<(usize, String)> = self
.items
.iter()
.enumerate()
.map(|(i, item)| (i, self.extract_filter_text(item)))
.collect();
// Rebuild with values for field filter support
let refs: Vec<(usize, &str, &serde_json::Value)> = items_for_rebuild
.iter()
.enumerate()
.map(|(i, (idx, s))| (*idx, s.as_str(), &self.items[i].value))
.collect();
self.filter.rebuild_with_values(&refs);
}
/// Extract the combined filter text for an item based on
/// the configured filter_fields.
fn extract_filter_text(&self, item: &Item) -> String {
if self.filter_fields.len() == 1 && self.filter_fields[0] == "label" {
return item.label().to_string();
}
let mut parts = Vec::new();
for field in &self.filter_fields {
let text = if field == "label" {
Some(item.label().to_string())
} else if field == "sublabel" {
item.sublabel().map(|s| s.to_string())
} else {
item.field_value(field).and_then(value_to_string)
};
if let Some(t) = text {
parts.push(t);
}
}
parts.join(" ")
}
}
/// Convert a JSON value to a string for filtering purposes.
fn value_to_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => Some(v.to_string()),
}
} }
impl Menu for JsonMenu { impl Menu for JsonMenu {
@@ -56,7 +124,8 @@ impl Menu for JsonMenu {
for value in values { for value in values {
let idx = self.items.len(); let idx = self.items.len();
let item = Item::new(value, &self.label_key); let item = Item::new(value, &self.label_key);
self.filter.push(idx, item.label()); let text = self.extract_filter_text(&item);
self.filter.push_with_value(idx, &text, &item.value);
self.items.push(item); self.items.push(item);
} }
} }
@@ -66,4 +135,36 @@ impl Menu for JsonMenu {
.matched_index(filtered_index) .matched_index(filtered_index)
.map(|idx| &self.items[idx].value) .map(|idx| &self.items[idx].value)
} }
fn original_index(&self, filtered_index: usize) -> Option<usize> {
self.filter.matched_index(filtered_index)
}
fn replace_all(&mut self, values: Vec<serde_json::Value>) {
self.items = values
.into_iter()
.map(|v| Item::new(v, &self.label_key))
.collect();
self.rebuild_pipeline();
}
fn remove_by_indices(&mut self, indices: Vec<usize>) {
// Sort descending to remove from the end first,
// preserving earlier indices.
let mut sorted = indices;
sorted.sort_unstable();
sorted.dedup();
for &idx in sorted.iter().rev() {
if idx < self.items.len() {
self.items.remove(idx);
}
}
self.rebuild_pipeline();
}
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
let template = self.format_template.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(template.render(&self.items[orig_idx].value))
}
} }

View File

@@ -7,8 +7,10 @@ use std::sync::Arc;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem}; use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
use crate::hook::{HookEvent, HookHandler};
use crate::model::traits::Menu; use crate::model::traits::Menu;
use crate::navigation::Viewport; use crate::navigation::Viewport;
use serde_json::Value; use serde_json::Value;
@@ -20,9 +22,13 @@ pub enum ActionOutcome {
/// State changed, broadcast to subscribers. /// State changed, broadcast to subscribers.
Broadcast, Broadcast,
/// User confirmed a selection. /// User confirmed a selection.
Selected(Value), Selected { value: Value, index: usize },
/// User cancelled. /// User cancelled.
Cancelled, Cancelled,
/// Menu closed by hook command.
Closed,
/// User quicklisted the filtered items.
Quicklist { items: Vec<(Value, usize)> },
/// Nothing happened (e.g. confirm on empty list). /// Nothing happened (e.g. confirm on empty list).
NoOp, NoOp,
} }
@@ -38,6 +44,8 @@ pub struct MenuRunner<M: Menu> {
mode: Mode, mode: Mode,
action_rx: mpsc::Receiver<Action>, action_rx: mpsc::Receiver<Action>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>,
} }
impl<M: Menu> MenuRunner<M> { impl<M: Menu> MenuRunner<M> {
@@ -59,6 +67,8 @@ impl<M: Menu> MenuRunner<M> {
mode: Mode::default(), mode: Mode::default(),
action_rx, action_rx,
event_tx, event_tx,
dispatcher: None,
previous_cursor: None,
}; };
(runner, action_tx) (runner, action_tx)
} }
@@ -69,6 +79,23 @@ impl<M: Menu> MenuRunner<M> {
self.event_tx.subscribe() self.event_tx.subscribe()
} }
/// Set a hook handler. Wraps it in a DebouncedDispatcher
/// with no debounce (all events fire immediately). Use
/// [`set_dispatcher`] for custom debounce settings.
pub fn set_hook_handler(
&mut self,
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) {
let dispatcher = DebouncedDispatcher::new(handler, action_tx);
self.dispatcher = Some(dispatcher);
}
/// Set a hook handler with a pre-configured dispatcher.
pub fn set_dispatcher(&mut self, dispatcher: DebouncedDispatcher) {
self.dispatcher = Some(dispatcher);
}
/// Re-run the filter against all items with the current /// Re-run the filter against all items with the current
/// filter text. Updates the viewport with the new count. /// filter text. Updates the viewport with the new count.
fn run_filter(&mut self) { fn run_filter(&mut self) {
@@ -83,9 +110,13 @@ impl<M: Menu> MenuRunner<M> {
let visible_items: Vec<VisibleItem> = range let visible_items: Vec<VisibleItem> = range
.clone() .clone()
.filter_map(|i| { .filter_map(|i| {
self.menu.filtered_label(i).map(|label| VisibleItem { self.menu.filtered_label(i).map(|label| {
let formatted_text = self.menu.formatted_label(i);
VisibleItem {
label: label.to_string(), label: label.to_string(),
formatted_text,
index: i, index: i,
}
}) })
}) })
.collect(); .collect();
@@ -113,6 +144,35 @@ impl<M: Menu> MenuRunner<M> {
.send(MenuEvent::StateChanged(self.build_view_state())); .send(MenuEvent::StateChanged(self.build_view_state()));
} }
/// Emit a hook event through the dispatcher, if one is set.
fn emit_hook(&mut self, event: HookEvent) {
if let Some(dispatcher) = &mut self.dispatcher {
dispatcher.dispatch(event);
}
}
/// Check if the cursor moved to a different item and
/// emit a Hover event if so.
fn check_cursor_hover(&mut self) {
if self.menu.filtered_count() == 0 {
self.previous_cursor = None;
return;
}
let current = self.viewport.cursor();
let current_orig = self.menu.original_index(current);
if current_orig != self.previous_cursor {
self.previous_cursor = current_orig;
if let Some(value) = self.menu.serialize_filtered(current).cloned()
&& let Some(orig_idx) = current_orig
{
self.emit_hook(HookEvent::Hover {
item: value,
index: orig_idx,
});
}
}
}
/// Apply a single action to the menu state. Pure state /// Apply a single action to the menu state. Pure state
/// transition: no channels, no async. Testable in isolation. /// transition: no channels, no async. Testable in isolation.
pub fn apply_action(&mut self, action: Action) -> ActionOutcome { pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
@@ -151,11 +211,27 @@ impl<M: Menu> MenuRunner<M> {
return ActionOutcome::NoOp; return ActionOutcome::NoOp;
} }
let cursor = self.viewport.cursor(); let cursor = self.viewport.cursor();
let index = self.menu.original_index(cursor).unwrap_or(0);
match self.menu.serialize_filtered(cursor) { match self.menu.serialize_filtered(cursor) {
Some(value) => ActionOutcome::Selected(value.clone()), Some(value) => ActionOutcome::Selected {
value: value.clone(),
index,
},
None => ActionOutcome::NoOp, None => ActionOutcome::NoOp,
} }
} }
Action::Quicklist => {
if self.menu.filtered_count() == 0 {
return ActionOutcome::NoOp;
}
let items: Vec<(Value, usize)> = self
.menu
.collect_filtered()
.into_iter()
.map(|(v, idx)| (v.clone(), idx))
.collect();
ActionOutcome::Quicklist { items }
}
Action::Cancel => ActionOutcome::Cancelled, Action::Cancel => ActionOutcome::Cancelled,
Action::Resize { height } => { Action::Resize { height } => {
self.viewport.set_height(height as usize); self.viewport.set_height(height as usize);
@@ -178,6 +254,41 @@ impl<M: Menu> MenuRunner<M> {
self.run_filter(); self.run_filter();
ActionOutcome::Broadcast ActionOutcome::Broadcast
} }
Action::ReplaceItems(values) => {
// Smart cursor: try to keep selection on the same original item.
let cursor = self.viewport.cursor();
let old_value = self.menu.serialize_filtered(cursor).cloned();
self.menu.replace_all(values);
self.run_filter();
// Try to find the old item in the new set
if let Some(ref old_val) = old_value {
let mut found = false;
for i in 0..self.menu.filtered_count() {
if self.menu.serialize_filtered(i) == Some(old_val) {
self.viewport.set_cursor(i);
found = true;
break;
}
}
if !found {
self.viewport.clamp();
}
} else {
self.viewport.clamp();
}
ActionOutcome::Broadcast
}
Action::RemoveItems(indices) => {
self.menu.remove_by_indices(indices);
self.run_filter();
self.viewport.clamp();
ActionOutcome::Broadcast
}
Action::ProcessHookResponse(resp) => {
let action = hook_response_to_action(resp);
self.apply_action(action)
}
Action::CloseMenu => ActionOutcome::Closed,
} }
} }
@@ -205,14 +316,62 @@ impl<M: Menu> MenuRunner<M> {
self.run_filter(); self.run_filter();
self.broadcast_state(); self.broadcast_state();
// Emit Open event
self.emit_hook(HookEvent::Open);
while let Some(action) = self.action_rx.recv().await { while let Some(action) = self.action_rx.recv().await {
let is_filter_update = matches!(&action, Action::UpdateFilter(_));
match self.apply_action(action) { match self.apply_action(action) {
ActionOutcome::Broadcast => self.broadcast_state(), ActionOutcome::Broadcast => {
ActionOutcome::Selected(value) => { self.broadcast_state();
// Emit Filter event if the filter changed
if is_filter_update {
let text = self.filter_text.to_string();
self.emit_hook(HookEvent::Filter { text });
}
// Check for cursor movement -> Hover
self.check_cursor_hover();
}
ActionOutcome::Selected { value, index } => {
// Emit Select event
self.emit_hook(HookEvent::Select {
item: value.clone(),
index,
});
// Emit Close event
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Selected(value.clone())); let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
return Ok(MenuResult::Selected(value)); return Ok(MenuResult::Selected { value, index });
}
ActionOutcome::Quicklist { items } => {
let values: Vec<Value> =
items.iter().map(|(v, _)| v.clone()).collect();
let count = values.len();
self.emit_hook(HookEvent::Quicklist {
items: values.clone(),
count,
});
self.emit_hook(HookEvent::Close);
let _ = self
.event_tx
.send(MenuEvent::Quicklist(values));
return Ok(MenuResult::Quicklist { items });
} }
ActionOutcome::Cancelled => { ActionOutcome::Cancelled => {
self.emit_hook(HookEvent::Cancel);
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
}
ActionOutcome::Closed => {
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled); let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled); return Ok(MenuResult::Cancelled);
} }
@@ -221,6 +380,7 @@ impl<M: Menu> MenuRunner<M> {
} }
// Sender dropped // Sender dropped
self.emit_hook(HookEvent::Close);
Ok(MenuResult::Cancelled) Ok(MenuResult::Cancelled)
} }
} }
@@ -285,7 +445,7 @@ mod tests {
let mut m = ready_menu(); let mut m = ready_menu();
m.apply_action(Action::MoveDown(1)); m.apply_action(Action::MoveDown(1));
let outcome = m.apply_action(Action::Confirm); let outcome = m.apply_action(Action::Confirm);
assert!(matches!(&outcome, ActionOutcome::Selected(v) if v.as_str() == Some("beta"))); assert!(matches!(&outcome, ActionOutcome::Selected { value, .. } if value.as_str() == Some("beta")));
} }
#[test] #[test]
@@ -413,7 +573,7 @@ mod tests {
} }
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(_)))); assert!(matches!(result, Ok(MenuResult::Selected { .. })));
} }
#[tokio::test] #[tokio::test]
@@ -474,7 +634,7 @@ mod tests {
assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha"))); assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha")));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("alpha"))); assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("alpha")));
} }
#[tokio::test] #[tokio::test]
@@ -496,7 +656,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("gamma"))); assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("gamma")));
} }
#[tokio::test] #[tokio::test]
@@ -521,7 +681,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta"))); assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")));
} }
#[tokio::test] #[tokio::test]
@@ -556,7 +716,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("epsilon"))); assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("epsilon")));
} }
#[tokio::test] #[tokio::test]
@@ -608,7 +768,7 @@ mod tests {
// Must get "banana". Filter was applied before confirm ran. // Must get "banana". Filter was applied before confirm ran.
assert!(matches!( assert!(matches!(
result, result,
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("banana") Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana")
)); ));
} }
@@ -634,7 +794,7 @@ mod tests {
// Cursor at index 3 -> "delta" // Cursor at index 3 -> "delta"
assert!(matches!( assert!(matches!(
result, result,
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta") Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")
)); ));
} }
@@ -730,7 +890,250 @@ mod tests {
// Must find "zephyr". It was added before the filter ran. // Must find "zephyr". It was added before the filter ran.
assert!(matches!( assert!(matches!(
result, result,
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("zephyr") Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr")
)); ));
} }
// -- Replace/Remove/Close action tests --
#[test]
fn apply_replace_items() {
let mut m = ready_menu();
assert_eq!(m.menu.total(), 4);
let outcome = m.apply_action(Action::ReplaceItems(vec![
serde_json::Value::String("x".to_string()),
serde_json::Value::String("y".to_string()),
]));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 2);
assert_eq!(m.menu.filtered_count(), 2);
}
#[test]
fn apply_replace_items_preserves_cursor() {
let mut m = ready_menu();
// Move to "beta" (index 1)
m.apply_action(Action::MoveDown(1));
// Replace items, keeping "beta" in the new set
m.apply_action(Action::ReplaceItems(vec![
serde_json::Value::String("alpha".to_string()),
serde_json::Value::String("beta".to_string()),
serde_json::Value::String("zeta".to_string()),
]));
// Cursor should still be on "beta"
let vs = m.build_view_state();
assert_eq!(vs.visible_items[vs.cursor].label, "beta");
}
#[test]
fn apply_remove_items() {
let mut m = ready_menu();
assert_eq!(m.menu.total(), 4);
let outcome = m.apply_action(Action::RemoveItems(vec![1, 3]));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 2);
// alpha and gamma should remain
assert_eq!(m.menu.filtered_label(0), Some("alpha"));
assert_eq!(m.menu.filtered_label(1), Some("gamma"));
}
#[test]
fn apply_close_menu() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::CloseMenu);
assert!(matches!(outcome, ActionOutcome::Closed));
}
#[test]
fn apply_hook_response_close() {
use crate::hook::HookResponse;
let mut m = ready_menu();
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::Close));
assert!(matches!(outcome, ActionOutcome::Closed));
}
#[test]
fn apply_hook_response_add_items() {
use crate::hook::HookResponse;
let mut m = ready_menu();
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::AddItems {
items: vec![serde_json::json!("new")],
}));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 5);
}
#[test]
fn confirm_returns_original_index() {
let mut m = ready_menu();
// Filter to narrow results, then confirm
m.apply_action(Action::UpdateFilter("del".to_string()));
assert!(m.menu.filtered_count() >= 1);
let outcome = m.apply_action(Action::Confirm);
// "delta" is at original index 3
assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. }));
}
// -- Quicklist tests --
#[test]
fn apply_quicklist_returns_all_filtered() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
assert_eq!(items.len(), 4);
let labels: Vec<&str> = items
.iter()
.filter_map(|(v, _)| v.as_str())
.collect();
assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]);
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[test]
fn apply_quicklist_respects_filter() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("a".to_string()));
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
// "alpha", "gamma", "delta" all contain 'a'
assert!(items.len() >= 2);
for (v, _) in &items {
let label = v.as_str().unwrap_or("");
assert!(
label.contains('a'),
"filtered item should contain 'a': {label}"
);
}
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[test]
fn apply_quicklist_on_empty_is_noop() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("zzzzz".to_string()));
assert_eq!(m.menu.filtered_count(), 0);
let outcome = m.apply_action(Action::Quicklist);
assert!(matches!(outcome, ActionOutcome::NoOp));
}
#[test]
fn apply_quicklist_preserves_original_indices() {
let mut m = ready_menu();
// Filter to "del" -> only "delta" at original index 3
m.apply_action(Action::UpdateFilter("del".to_string()));
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].0.as_str(), Some("delta"));
assert_eq!(items[0].1, 3);
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[tokio::test]
async fn quicklist_event_broadcast() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
// Skip initial state
let _ = rx.recv().await;
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
let _ = tx.send(Action::Quicklist).await;
if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await {
assert_eq!(values.len(), 4);
} else {
panic!("expected Quicklist event");
}
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Quicklist { .. })));
}
#[tokio::test]
async fn quicklist_after_filter() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Filter then quicklist back-to-back
let _ = tx.send(Action::UpdateFilter("al".to_string())).await;
let _ = tx.send(Action::Quicklist).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
match result {
Ok(MenuResult::Quicklist { items }) => {
// "alpha" matches "al"
assert!(!items.is_empty());
for (v, _) in &items {
let label = v.as_str().unwrap_or("");
assert!(
label.contains("al"),
"quicklist item should match filter: {label}"
);
}
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
// -- Hook event tests --
#[tokio::test]
async fn hook_events_fire_on_lifecycle() {
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
use std::sync::Mutex;
struct Recorder(Mutex<Vec<HookEventKind>>);
impl HookHandler for Recorder {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
if let Ok(mut v) = self.0.lock() {
v.push(event.kind());
}
Ok(vec![])
}
}
let recorder = Arc::new(Recorder(Mutex::new(Vec::new())));
let (mut m, action_tx) = test_menu();
m.set_hook_handler(Arc::clone(&recorder) as Arc<dyn HookHandler>, action_tx);
m.run_filter();
m.apply_action(Action::Resize { height: 10 });
// Simulate lifecycle: the Open event is emitted in run(),
// but we can test Filter/Hover/Cancel manually
m.emit_hook(HookEvent::Open);
m.apply_action(Action::UpdateFilter("al".to_string()));
m.emit_hook(HookEvent::Filter {
text: "al".to_string(),
});
m.apply_action(Action::MoveDown(1));
m.check_cursor_hover();
// Give spawned tasks a chance to complete
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let events = recorder.0.lock().map(|v| v.clone()).unwrap_or_default();
assert!(events.contains(&HookEventKind::Open));
assert!(events.contains(&HookEventKind::Filter));
}
} }

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,170 @@
//! Bidirectional handler hooks. Persistent subprocess that
//! receives events as JSON lines on stdin and emits
//! responses as JSON lines on stdout. Responses are parsed
//! and sent back to the menu engine through the action
//! channel.
use std::collections::HashMap;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use pikl_core::error::PiklError;
use pikl_core::event::Action;
use pikl_core::hook::{
parse_hook_response, HookEvent, HookEventKind, HookHandler, HookResponse,
};
/// A persistent handler hook process. Spawns a child process,
/// writes events to its stdin as JSON lines, reads responses
/// from its stdout as JSON lines. Responses are converted to
/// Actions and sent through the action channel.
pub struct ShellHandlerHook {
/// Event senders, one per active handler process.
event_txs: HashMap<HookEventKind, mpsc::Sender<HookEvent>>,
}
impl ShellHandlerHook {
/// Build from CLI flags. Returns None if no handler hooks are configured.
pub fn from_cli(
cli: &crate::Cli,
action_tx: mpsc::Sender<Action>,
) -> Option<Self> {
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
if let Some(ref cmd) = cli.on_open {
handlers.push((HookEventKind::Open, cmd));
}
if let Some(ref cmd) = cli.on_close {
handlers.push((HookEventKind::Close, cmd));
}
if let Some(ref cmd) = cli.on_hover {
handlers.push((HookEventKind::Hover, cmd));
}
if let Some(ref cmd) = cli.on_select {
handlers.push((HookEventKind::Select, cmd));
}
if let Some(ref cmd) = cli.on_cancel {
handlers.push((HookEventKind::Cancel, cmd));
}
if let Some(ref cmd) = cli.on_filter {
handlers.push((HookEventKind::Filter, cmd));
}
if handlers.is_empty() {
return None;
}
let mut event_txs = HashMap::new();
for (kind, cmd) in handlers {
let (event_tx, event_rx) = mpsc::channel::<HookEvent>(64);
let cmd = cmd.to_string();
let atx = action_tx.clone();
tokio::spawn(async move {
if let Err(e) = run_handler_process(&cmd, event_rx, atx).await {
tracing::warn!(error = %e, command = %cmd, "handler hook process failed");
}
});
event_txs.insert(kind, event_tx);
}
Some(Self { event_txs })
}
}
impl HookHandler for ShellHandlerHook {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
let kind = event.kind();
if let Some(tx) = self.event_txs.get(&kind) {
// Non-blocking send. If the channel is full, drop the event.
let _ = tx.try_send(event);
}
Ok(vec![])
}
}
/// Run a persistent handler process. Reads events from the
/// channel, writes them as JSON lines to the child's stdin.
/// Reads JSON lines from the child's stdout and converts
/// them to Actions sent through action_tx.
async fn run_handler_process(
command: &str,
mut event_rx: mpsc::Receiver<HookEvent>,
action_tx: mpsc::Sender<Action>,
) -> Result<(), PiklError> {
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.spawn()?;
let child_stdin = child
.stdin
.take()
.ok_or_else(|| PiklError::Io(std::io::Error::other("failed to open handler stdin")))?;
let child_stdout = child
.stdout
.take()
.ok_or_else(|| PiklError::Io(std::io::Error::other("failed to open handler stdout")))?;
let mut stdin_writer = child_stdin;
let stdout_reader = BufReader::new(child_stdout);
// Spawn reader task: reads JSON lines from child stdout
let reader_action_tx = action_tx.clone();
let reader_handle = tokio::spawn(async move {
let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(resp) = parse_hook_response(&line) {
let action = Action::ProcessHookResponse(resp);
if reader_action_tx.send(action).await.is_err() {
break;
}
}
}
});
// Writer loop: reads events from channel, writes to stdin
while let Some(event) = event_rx.recv().await {
let json = serde_json::to_string(&event).unwrap_or_default();
if stdin_writer
.write_all(json.as_bytes())
.await
.is_err()
{
break;
}
if stdin_writer
.write_all(b"\n")
.await
.is_err()
{
break;
}
if stdin_writer.flush().await.is_err() {
break;
}
}
// Close stdin to signal the child
drop(stdin_writer);
// Wait briefly for the reader to finish
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
reader_handle,
)
.await;
// Kill child if still running
let _ = child.kill().await;
Ok(())
}

View File

@@ -1,15 +1,15 @@
//! Shell hook execution. Hooks are shell commands that fire //! Shell exec hooks. Fire-and-forget subprocess per event.
//! on menu events (selection, cancellation). The selected //! The hook's stdin receives the event JSON, stdout is
//! item's JSON is piped to the hook's stdin. //! redirected to stderr to keep pikl's output clean.
//!
//! Hook stdout is redirected to stderr so it doesn't end up use std::collections::HashMap;
//! mixed into pikl's structured output on stdout.
use serde_json::Value; use serde_json::Value;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::process::Command; use tokio::process::Command;
use pikl_core::error::PiklError; use pikl_core::error::PiklError;
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
/// Duplicate stderr as a [`Stdio`] handle for use as a /// Duplicate stderr as a [`Stdio`] handle for use as a
/// child process's stdout. Keeps hook output on stderr /// child process's stdout. Keeps hook output on stderr
@@ -26,7 +26,7 @@ fn stderr_as_stdio() -> std::process::Stdio {
std::process::Stdio::inherit() std::process::Stdio::inherit()
} }
/// Run a shell hook, piping the value as JSON to stdin. /// Run a shell command, piping the value as JSON to stdin.
/// Hook stdout goes to stderr (see module docs). Returns /// Hook stdout goes to stderr (see module docs). Returns
/// an error if the command exits non-zero. /// an error if the command exits non-zero.
pub async fn run_hook(command: &str, value: &Value) -> Result<(), PiklError> { pub async fn run_hook(command: &str, value: &Value) -> Result<(), PiklError> {
@@ -48,8 +48,7 @@ async fn write_json_stdin(
Ok(()) Ok(())
} }
/// Run a shell hook with a custom stdout handle. Used by /// Run a shell hook with a custom stdout handle.
/// [`run_hook`] to redirect hook output to stderr.
async fn run_hook_with_stdout( async fn run_hook_with_stdout(
command: &str, command: &str,
value: &Value, value: &Value,
@@ -75,6 +74,61 @@ async fn run_hook_with_stdout(
Ok(()) Ok(())
} }
/// Fire-and-forget shell hook handler. Spawns a subprocess
/// for each event that has a registered command. Always
/// returns Ok(vec![]) since exec hooks don't send responses.
pub struct ShellExecHandler {
commands: HashMap<HookEventKind, String>,
}
impl ShellExecHandler {
/// Build from CLI flags.
pub fn from_cli(cli: &crate::Cli) -> Self {
let mut commands = HashMap::new();
if let Some(ref cmd) = cli.on_open_exec {
commands.insert(HookEventKind::Open, cmd.clone());
}
if let Some(ref cmd) = cli.on_close_exec {
commands.insert(HookEventKind::Close, cmd.clone());
}
if let Some(ref cmd) = cli.on_hover_exec {
commands.insert(HookEventKind::Hover, cmd.clone());
}
if let Some(ref cmd) = cli.on_select_exec {
commands.insert(HookEventKind::Select, cmd.clone());
}
if let Some(ref cmd) = cli.on_cancel_exec {
commands.insert(HookEventKind::Cancel, cmd.clone());
}
if let Some(ref cmd) = cli.on_filter_exec {
commands.insert(HookEventKind::Filter, cmd.clone());
}
Self { commands }
}
pub fn has_hooks(&self) -> bool {
!self.commands.is_empty()
}
}
impl HookHandler for ShellExecHandler {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
let kind = event.kind();
if let Some(cmd) = self.commands.get(&kind) {
let cmd = cmd.clone();
// Serialize event as JSON for the hook's stdin
let event_json =
serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
tokio::spawn(async move {
if let Err(e) = run_hook(&cmd, &event_json).await {
tracing::warn!(error = %e, command = %cmd, "exec hook failed");
}
});
}
Ok(vec![])
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -94,35 +148,6 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
} }
// -- Hook stdin verification --
/// Helper: run `cat` with piped stdout so we can capture what it echoes back
/// from the value JSON written to stdin.
async fn capture_hook_stdin(value: &Value) -> String {
let child = Command::new("sh")
.arg("-c")
.arg("cat")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.spawn();
let Ok(mut child) = child else {
return String::new();
};
let _ = write_json_stdin(&mut child, value).await;
let output = child
.wait_with_output()
.await
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(),
stdout: Vec::new(),
stderr: Vec::new(),
});
String::from_utf8(output.stdout).unwrap_or_default()
}
#[tokio::test] #[tokio::test]
async fn write_json_stdin_sends_correct_data() { async fn write_json_stdin_sends_correct_data() {
let value = json!({"key": "value"}); let value = json!({"key": "value"});
@@ -141,50 +166,17 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn hook_receives_plain_text_json() { async fn hook_receives_plain_text_json() {
let value = json!("hello"); let value = json!("hello");
let got = capture_hook_stdin(&value).await;
assert_eq!(got, r#""hello""#);
}
#[tokio::test]
async fn hook_receives_object_json() {
let value = json!({"label": "foo", "value": 42});
let got = capture_hook_stdin(&value).await;
let parsed: Value = serde_json::from_str(&got).unwrap_or_default();
assert_eq!(parsed["label"], "foo");
assert_eq!(parsed["value"], 42);
}
#[tokio::test]
async fn hook_receives_special_chars() {
let value = json!("he said \"hi\"\nand left");
let got = capture_hook_stdin(&value).await;
let parsed: Value = serde_json::from_str(&got).unwrap_or_default();
assert_eq!(
parsed.as_str().unwrap_or_default(),
"he said \"hi\"\nand left"
);
}
// -- Hook stdout-to-stderr redirection --
#[tokio::test]
async fn hook_stdout_not_on_piped_stdout() {
// With piped stdout, `echo hello` output is capturable:
let value = json!("test");
let child = Command::new("sh") let child = Command::new("sh")
.arg("-c") .arg("-c")
.arg("echo hello") .arg("cat")
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit())
.spawn(); .spawn();
assert!(child.is_ok(), "should be able to spawn echo"); let Ok(mut child) = child else {
if let Ok(mut child) = child { return;
if let Some(mut stdin) = child.stdin.take() { };
let json = serde_json::to_string(&value).unwrap_or_default(); let _ = write_json_stdin(&mut child, &value).await;
let _ = stdin.write_all(json.as_bytes()).await;
drop(stdin);
}
let output = child let output = child
.wait_with_output() .wait_with_output()
.await .await
@@ -193,47 +185,10 @@ mod tests {
stdout: Vec::new(), stdout: Vec::new(),
stderr: Vec::new(), stderr: Vec::new(),
}); });
let piped_out = String::from_utf8(output.stdout).unwrap_or_default(); let got = String::from_utf8(output.stdout).unwrap_or_default();
assert_eq!(piped_out.trim(), "hello"); assert_eq!(got, r#""hello""#);
} }
// With stderr_as_stdio(), hook stdout is redirected away from stdout.
// Verify the hook still succeeds (output goes to stderr instead).
let result = run_hook("echo hello", &value).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn stderr_as_stdio_returns_valid_fd() {
// Verify stderr_as_stdio() produces a usable Stdio.
// A child process using it should spawn and exit cleanly.
let child = Command::new("sh")
.arg("-c")
.arg("echo ok >&1")
.stdin(std::process::Stdio::null())
.stdout(stderr_as_stdio())
.stderr(std::process::Stdio::inherit())
.spawn();
assert!(child.is_ok());
let output = child
.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()
);
}
// -- Hook error propagation --
#[tokio::test] #[tokio::test]
async fn hook_nonzero_exit() { async fn hook_nonzero_exit() {
let value = json!("test"); let value = json!("test");
@@ -245,34 +200,4 @@ mod tests {
assert_eq!(status.code(), Some(42)); assert_eq!(status.code(), Some(42));
} }
} }
#[tokio::test]
async fn hook_missing_command() {
let value = json!("test");
let result = run_hook("/nonexistent_binary_that_does_not_exist_12345", &value).await;
// sh -c will fail with 127 (command not found)
assert!(result.is_err());
}
#[tokio::test]
async fn hook_empty_command() {
let value = json!("test");
// Empty string passed to sh -c is a no-op, exits 0
let result = run_hook("", &value).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn hook_with_stdout_uses_custom_stdio() {
let value = json!("custom");
let result = run_hook_with_stdout("echo ok", &value, std::process::Stdio::piped()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn hook_with_stdout_propagates_failure() {
let value = json!("test");
let result = run_hook_with_stdout("exit 1", &value, std::process::Stdio::piped()).await;
assert!(matches!(result, Err(PiklError::HookFailed { .. })));
}
} }

View File

@@ -1,17 +1,27 @@
mod handler;
mod hook; mod hook;
use std::io::{BufReader, IsTerminal, Write}; use std::io::{BufReader, IsTerminal, Write};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use clap::Parser; use clap::Parser;
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
use pikl_core::error::PiklError; use pikl_core::error::PiklError;
use pikl_core::event::{Action, MenuResult, Mode}; use pikl_core::event::{Action, MenuResult, Mode};
use pikl_core::format::FormatTemplate;
use pikl_core::hook::{HookEventKind, HookHandler};
use pikl_core::input::read_items_sync; use pikl_core::input::read_items_sync;
use pikl_core::item::Item; use pikl_core::item::Item;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
use pikl_core::menu::MenuRunner; use pikl_core::menu::MenuRunner;
use pikl_core::output::{OutputAction, OutputItem};
use pikl_core::script::action_fd::{self, ScriptAction, ShowAction}; use pikl_core::script::action_fd::{self, ScriptAction, ShowAction};
use serde_json::Value;
use handler::ShellHandlerHook;
use hook::ShellExecHandler;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@@ -23,14 +33,69 @@ struct Cli {
#[arg(long, default_value = "label")] #[arg(long, default_value = "label")]
label_key: String, label_key: String,
/// Shell command to run on selection (item JSON piped to stdin) // -- Exec hooks (fire-and-forget subprocess per event) --
/// Shell command to run on menu open
#[arg(long)] #[arg(long)]
on_select: Option<String>, on_open_exec: Option<String>,
/// Shell command to run on menu close
#[arg(long)]
on_close_exec: Option<String>,
/// Shell command to run on cursor hover (item JSON on stdin)
#[arg(long)]
on_hover_exec: Option<String>,
/// Shell command to run on selection (item JSON on stdin)
#[arg(long)]
on_select_exec: Option<String>,
/// Shell command to run on cancel /// Shell command to run on cancel
#[arg(long)] #[arg(long)]
on_cancel_exec: Option<String>,
/// Shell command to run on filter change
#[arg(long)]
on_filter_exec: Option<String>,
// -- Handler hooks (persistent bidirectional process) --
/// Handler hook command for open events
#[arg(long)]
on_open: Option<String>,
/// Handler hook command for close events
#[arg(long)]
on_close: Option<String>,
/// Handler hook command for hover events
#[arg(long)]
on_hover: Option<String>,
/// Handler hook command for select events
#[arg(long)]
on_select: Option<String>,
/// Handler hook command for cancel events
#[arg(long)]
on_cancel: Option<String>, on_cancel: Option<String>,
/// Handler hook command for filter events
#[arg(long)]
on_filter: Option<String>,
// -- Debounce flags --
/// Debounce delay in ms for hover hooks (default: 200)
#[arg(long, value_name = "MS")]
on_hover_debounce: Option<u64>,
/// Cancel in-flight hover hooks when a new hover fires
#[arg(long)]
on_hover_cancel_stale: bool,
/// Debounce delay in ms for filter hooks (default: 200)
#[arg(long, value_name = "MS")]
on_filter_debounce: Option<u64>,
/// Read action script from this file descriptor (enables headless mode) /// Read action script from this file descriptor (enables headless mode)
#[arg(long, value_name = "FD")] #[arg(long, value_name = "FD")]
action_fd: Option<i32>, action_fd: Option<i32>,
@@ -42,9 +107,24 @@ struct Cli {
/// Start in this input mode (insert or normal, default: insert) /// Start in this input mode (insert or normal, default: insert)
#[arg(long, value_name = "MODE", default_value = "insert")] #[arg(long, value_name = "MODE", default_value = "insert")]
start_mode: String, start_mode: String,
/// Comma-separated list of fields to search during filtering
#[arg(long, value_name = "FIELDS", value_delimiter = ',')]
filter_fields: Option<Vec<String>>,
/// Format template for display text (e.g. "{label} - {sublabel}")
#[arg(long, value_name = "TEMPLATE")]
format: Option<String>,
/// Wrap output in structured JSON with action metadata
#[arg(long)]
structured: bool,
} }
fn main() { fn main() {
// Initialize tracing from RUST_LOG env var
tracing_subscriber::fmt::init();
let cli = Cli::parse(); let cli = Cli::parse();
// Install a panic hook that restores the terminal so a crash // Install a panic hook that restores the terminal so a crash
@@ -121,9 +201,6 @@ fn main() {
}); });
// Reopen stdin from /dev/tty before entering async context. // Reopen stdin from /dev/tty before entering async context.
// Both headless (show-ui branch) and interactive paths need this,
// so do it once here. Headless-only (no show-ui) doesn't need
// terminal input, but reopening is harmless.
if script.is_none() if script.is_none()
&& let Err(e) = reopen_stdin_from_tty() && let Err(e) = reopen_stdin_from_tty()
{ {
@@ -145,27 +222,120 @@ fn main() {
}; };
// STEP 4: Branch on headless vs interactive // STEP 4: Branch on headless vs interactive
let label_key = cli.label_key.clone();
let result = if let Some(script) = script { let result = if let Some(script) = script {
rt.block_on(run_headless(items, label_key, script, start_mode)) rt.block_on(run_headless(items, &cli, script, start_mode))
} else { } else {
rt.block_on(run_interactive(items, label_key, start_mode)) rt.block_on(run_interactive(items, &cli, start_mode))
}; };
// STEP 5: Handle result // STEP 5: Handle result
handle_result(result, &cli, &rt); handle_result(result, &cli);
}
/// Build a JsonMenu with optional filter_fields and format template.
fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
let mut menu = JsonMenu::new(items, cli.label_key.clone());
if let Some(ref fields) = cli.filter_fields {
menu.set_filter_fields(fields.clone());
}
if let Some(ref template) = cli.format {
menu.set_format_template(FormatTemplate::parse(template));
}
menu
}
/// Build the composite hook handler from CLI flags, if any hooks are specified.
fn build_hook_handler(
cli: &Cli,
action_tx: &tokio::sync::mpsc::Sender<Action>,
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
let exec_handler = ShellExecHandler::from_cli(cli);
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
let has_exec = exec_handler.has_hooks();
let has_handler = handler_hook.is_some();
if !has_exec && !has_handler {
return None;
}
let handler: Arc<dyn HookHandler> = if has_exec && has_handler {
Arc::new(CompositeHookHandler {
exec: exec_handler,
handler: handler_hook,
})
} else if has_handler {
if let Some(h) = handler_hook {
Arc::new(h)
} else {
Arc::new(exec_handler)
}
} else {
Arc::new(exec_handler)
};
let mut dispatcher = DebouncedDispatcher::new(Arc::clone(&handler), action_tx.clone());
dispatcher.apply_defaults();
// Apply custom debounce settings
if let Some(ms) = cli.on_hover_debounce {
let mode = if cli.on_hover_cancel_stale {
DebounceMode::DebounceAndCancelStale(Duration::from_millis(ms))
} else {
DebounceMode::Debounce(Duration::from_millis(ms))
};
dispatcher.set_mode(HookEventKind::Hover, mode);
} else if cli.on_hover_cancel_stale {
dispatcher.set_mode(HookEventKind::Hover, DebounceMode::CancelStale);
}
if let Some(ms) = cli.on_filter_debounce {
dispatcher.set_mode(
HookEventKind::Filter,
DebounceMode::Debounce(Duration::from_millis(ms)),
);
}
Some((handler, dispatcher))
}
/// Composite handler that delegates to exec and handler hooks
/// based on whether they have a command for the event kind.
struct CompositeHookHandler {
exec: ShellExecHandler,
handler: Option<ShellHandlerHook>,
}
impl HookHandler for CompositeHookHandler {
fn handle(
&self,
event: pikl_core::hook::HookEvent,
) -> Result<Vec<pikl_core::hook::HookResponse>, PiklError> {
// Both fire. Exec is fire-and-forget, handler may
// send responses through action_tx.
let _ = self.exec.handle(event.clone());
if let Some(ref h) = self.handler {
h.handle(event)?;
}
Ok(vec![])
}
} }
/// Run in headless mode: replay a script, optionally hand /// Run in headless mode: replay a script, optionally hand
/// off to a TUI if the script ends with show-ui/show-tui/show-gui. /// off to a TUI if the script ends with show-ui/show-tui/show-gui.
async fn run_headless( async fn run_headless(
items: Vec<Item>, items: Vec<Item>,
label_key: String, cli: &Cli,
script: Vec<ScriptAction>, script: Vec<ScriptAction>,
start_mode: Mode, start_mode: Mode,
) -> Result<MenuResult, PiklError> { ) -> Result<MenuResult, PiklError> {
let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key)); let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
menu.set_initial_mode(start_mode); menu.set_initial_mode(start_mode);
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
menu.set_dispatcher(dispatcher);
}
let event_rx = menu.subscribe(); let event_rx = menu.subscribe();
// Default headless viewport // Default headless viewport
@@ -182,7 +352,6 @@ async fn run_headless(
match show_action { match show_action {
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => { ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => {
// GUI doesn't exist yet. All show-* variants launch TUI for now.
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx)); let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx));
let result = menu_handle let result = menu_handle
.await .await
@@ -204,11 +373,16 @@ async fn run_headless(
/// pick from the menu. /// pick from the menu.
async fn run_interactive( async fn run_interactive(
items: Vec<Item>, items: Vec<Item>,
label_key: String, cli: &Cli,
start_mode: Mode, start_mode: Mode,
) -> Result<MenuResult, PiklError> { ) -> Result<MenuResult, PiklError> {
let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key)); let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
menu.set_initial_mode(start_mode); menu.set_initial_mode(start_mode);
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
menu.set_dispatcher(dispatcher);
}
let event_rx = menu.subscribe(); let event_rx = menu.subscribe();
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly. // Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
@@ -216,7 +390,6 @@ async fn run_interactive(
tokio::spawn(async move { tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await { if let Ok(()) = tokio::signal::ctrl_c().await {
pikl_tui::restore_terminal(); pikl_tui::restore_terminal();
// Send cancel so the menu loop exits cleanly.
let _ = signal_tx.send(Action::Cancel).await; let _ = signal_tx.send(Action::Cancel).await;
} }
}); });
@@ -229,42 +402,48 @@ async fn run_interactive(
result result
} }
/// Serialize a value as JSON and write it to the given writer. /// Process the menu result: print output to stdout and
fn write_selected_json( /// exit with the appropriate code.
writer: &mut impl Write, fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
value: &serde_json::Value, let mut out = std::io::stdout().lock();
) -> Result<(), std::io::Error> {
let json = serde_json::to_string(value).unwrap_or_default();
writeln!(writer, "{json}")
}
/// Run a hook command if present. On failure, print the error
/// to stderr and exit.
fn run_result_hook(
rt: &tokio::runtime::Runtime,
hook_name: &str,
command: Option<&str>,
value: &serde_json::Value,
) {
if let Some(cmd) = command
&& let Err(e) = rt.block_on(hook::run_hook(cmd, value))
{
let _ = writeln!(std::io::stderr().lock(), "pikl: {hook_name} hook: {e}");
std::process::exit(2);
}
}
/// Process the menu result: print selected item JSON to
/// stdout, run hooks, or exit with the appropriate code.
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli, rt: &tokio::runtime::Runtime) {
match result { match result {
Ok(MenuResult::Selected(value)) => { Ok(MenuResult::Selected { value, index }) => {
run_result_hook(rt, "on-select", cli.on_select.as_deref(), &value); if cli.structured {
let _ = write_selected_json(&mut std::io::stdout().lock(), &value); let output = OutputItem {
value,
action: OutputAction::Select,
index,
};
let _ = write_output_json(&mut out, &output);
} else {
let _ = write_plain_value(&mut out, &value);
}
}
Ok(MenuResult::Quicklist { items }) => {
if cli.structured {
for (value, index) in items {
let output = OutputItem {
value,
action: OutputAction::Quicklist,
index,
};
let _ = write_output_json(&mut out, &output);
}
} else {
for (value, _) in &items {
let _ = write_plain_value(&mut out, value);
}
}
} }
Ok(MenuResult::Cancelled) => { Ok(MenuResult::Cancelled) => {
let empty = serde_json::Value::String(String::new()); if cli.structured {
run_result_hook(rt, "on-cancel", cli.on_cancel.as_deref(), &empty); let output = OutputItem {
value: Value::Null,
action: OutputAction::Cancel,
index: 0,
};
let _ = write_output_json(&mut out, &output);
}
std::process::exit(1); std::process::exit(1);
} }
Err(e) => { Err(e) => {
@@ -274,12 +453,27 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli, rt: &tokio::r
} }
} }
/// Serialize an OutputItem as JSON and write it to the given writer.
fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), std::io::Error> {
let json = serde_json::to_string(output).unwrap_or_default();
writeln!(writer, "{json}")
}
/// Write a plain value: strings without quotes, everything
/// else as JSON.
fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::io::Error> {
match value {
Value::String(s) => writeln!(writer, "{s}"),
_ => {
let json = serde_json::to_string(value).unwrap_or_default();
writeln!(writer, "{json}")
}
}
}
/// Read items from stdin. If `timeout_secs` is non-zero, /// Read items from stdin. If `timeout_secs` is non-zero,
/// spawn a thread and bail if it doesn't finish in time. /// spawn a thread and bail if it doesn't finish in time.
/// A timeout of 0 means no timeout (blocking read). /// A timeout of 0 means no timeout (blocking read).
// TODO: The interactive path blocks on all of stdin before showing
// the menu. Switch to streaming items via Action::AddItems so the
// menu renders immediately and populates as lines arrive.
fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Item>, PiklError> { fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Item>, PiklError> {
if timeout_secs == 0 { if timeout_secs == 0 {
return read_items_sync(std::io::stdin().lock(), label_key); return read_items_sync(std::io::stdin().lock(), label_key);
@@ -315,17 +509,10 @@ fn reopen_stdin_from_tty() -> Result<(), PiklError> {
{ {
use std::os::unix::io::AsRawFd; use std::os::unix::io::AsRawFd;
let tty = std::fs::File::open("/dev/tty")?; let tty = std::fs::File::open("/dev/tty")?;
// SAFETY: dup2 is a standard POSIX call. We're
// redirecting stdin to the controlling tty so the
// TUI can read keyboard input after stdin was
// consumed for piped items.
let r = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) }; let r = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) };
if r < 0 { if r < 0 {
return Err(PiklError::Io(std::io::Error::last_os_error())); return Err(PiklError::Io(std::io::Error::last_os_error()));
} }
// SAFETY: tcflush is a standard POSIX call. Flush
// stale input that arrived between dup2 and raw
// mode so crossterm starts clean.
unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) }; unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) };
} }
Ok(()) Ok(())

View File

@@ -213,17 +213,24 @@ them. The core manages debouncing and cancel-stale logic
since that interacts with the event loop timing. since that interacts with the event loop timing.
```rust ```rust
#[async_trait]
pub trait HookHandler: Send + Sync { pub trait HookHandler: Send + Sync {
async fn handle(&self, event: HookEvent) fn handle(&self, event: HookEvent)
-> Result<Vec<HookResponse>, PiklError>; -> Result<Vec<HookResponse>, PiklError>;
} }
``` ```
The CLI binary provides `ShellHookHandler`, which maps The trait is deliberately synchronous for dyn-compatibility.
CLI flags to shell commands. Library consumers implement Implementations that need async work (spawning processes,
their own handlers with whatever behaviour they want: writing to channels) use `tokio::spawn` internally. This
in-process closures, network calls, anything. keeps the trait object-safe so the core can hold
`Arc<dyn HookHandler>`.
The CLI binary provides `ShellExecHandler` and
`ShellHandlerHook`, which map CLI flags to shell commands.
A `CompositeHookHandler` dispatches to both based on event
kind. Library consumers implement their own handlers with
whatever behaviour they want: in-process closures, network
calls, anything.
## Filtering ## Filtering

View File

@@ -14,7 +14,7 @@ when* we build it.
- Ship something usable early, iterate from real usage - Ship something usable early, iterate from real usage
- Don't optimize until there's a reason to - Don't optimize until there's a reason to
## Phase 1: Core Loop (TUI) ## Phase 1: Core Loop (TUI)
The minimum thing that works end-to-end. The minimum thing that works end-to-end.
@@ -35,7 +35,7 @@ The minimum thing that works end-to-end.
**Done when:** `ls | pikl` works and prints the selected **Done when:** `ls | pikl` works and prints the selected
item. item.
## Phase 1.5: Action-fd (Headless Mode) ## Phase 1.5: Action-fd (Headless Mode)
Scriptable, non-interactive mode for integration tests and Scriptable, non-interactive mode for integration tests and
automation. Small enough to slot in before phase 2. It's automation. Small enough to slot in before phase 2. It's
@@ -60,7 +60,7 @@ for `show-ui`.
**Done when:** `echo -e "hello\nworld" | pikl --action-fd 3` **Done when:** `echo -e "hello\nworld" | pikl --action-fd 3`
with `confirm` on fd 3 prints `"hello"` to stdout. with `confirm` on fd 3 prints `"hello"` to stdout.
## Phase 2: Navigation & Filtering ## Phase 2: Navigation & Filtering
Make it feel like home for a vim user. The filter system Make it feel like home for a vim user. The filter system
is the real star here: strategy prefixes, pipeline is the real star here: strategy prefixes, pipeline
@@ -112,7 +112,7 @@ chaining, incremental caching.
with vim muscle memory. with vim muscle memory.
`'log | !temp | /[0-9]+/` works as a pipeline. `'log | !temp | /[0-9]+/` works as a pipeline.
## Phase 3: Structured I/O & Hooks ## Phase 3: Structured I/O & Hooks
The structured data pipeline and the full hook system. The structured data pipeline and the full hook system.

View File

@@ -0,0 +1,64 @@
# Lesson: async fn in traits vs dyn-compatibility
## The problem
The `HookHandler` trait needs to be stored as
`Arc<dyn HookHandler>` so the menu engine can hold a
type-erased handler without knowing whether it's an exec
hook, a handler hook, or a composite. But `async fn` in
traits (even with edition 2024's native support) makes the
trait not dyn-compatible. The compiler can't build a vtable
for an async method because the return type (an opaque
`impl Future`) varies per implementation.
## What we tried
Official documentation of edition 2024 supports async fn
in traits natively, async-trait is NOT needed." That's true
for static dispatch (generics), but wrong for `dyn` dispatch.
## The fix
Made `handle()` a regular sync `fn`. The trait is
deliberately synchronous:
```rust
pub trait HookHandler: Send + Sync {
fn handle(&self, event: HookEvent)
-> Result<Vec<HookResponse>, PiklError>;
}
```
This works because handlers don't actually need to `await`
anything at the call site:
- **Exec hooks** spawn a subprocess via `tokio::spawn` and
return immediately. The process runs in the background.
- **Handler hooks** write to an internal channel via
`try_send` (non-blocking) and return. A background task
handles the actual I/O.
Both return `Ok(vec![])` instantly. Responses from handler
hooks flow back through the action channel asynchronously.
## Takeaway
`async fn in trait` (edition 2024) is for generic/static
dispatch. If you need `dyn Trait`, you still need either:
1. A sync method (if the work can be spawned internally)
2. A boxed future return type
3. The `async-trait` crate
Option 1 is the simplest when the handler doesn't need to
return data from async work. If responses are needed, they
go through a separate channel rather than the return value.
## Related: tokio test-util for debounce tests
Tests that verify debounce timing need `tokio::time::pause`
and `tokio::time::advance`, which require the `test-util`
feature on tokio in dev-dependencies. Use
`#[tokio::test(start_paused = true)]` and
`tokio::time::sleep()` (which auto-advances in paused mode)
rather than manual `pause()` + `advance()` + `yield_now()`
chains, which are fragile with spawned tasks.

View File

@@ -85,7 +85,7 @@ file_picker() {
on_select_hook() { on_select_hook() {
printf "one\ntwo\nthree\nfour\nfive\n" \ printf "one\ntwo\nthree\nfour\nfive\n" \
| pikl --on-select 'echo "you picked: $(cat)"' | pikl --on-select-exec 'echo "you picked: $(cat)" >&2'
} }
mixed_input() { mixed_input() {
@@ -97,6 +97,184 @@ another plain string
ITEMS ITEMS
} }
# ── Phase 3 demos ────────────────────────────────────────
structured_items() {
echo "Items have sublabel, icon, and metadata fields." >&2
echo "The output JSON includes action and index context." >&2
echo "" >&2
cat <<'ITEMS' | pikl
{"label": "Firefox", "sublabel": "Web Browser", "icon": "firefox", "meta": {"version": "125", "type": "browser"}}
{"label": "Neovim", "sublabel": "Text Editor", "icon": "nvim", "meta": {"version": "0.10", "type": "editor"}}
{"label": "Alacritty", "sublabel": "Terminal Emulator", "icon": "alacritty", "meta": {"version": "0.13", "type": "terminal"}}
{"label": "Thunar", "sublabel": "File Manager", "icon": "thunar", "meta": {"version": "4.18", "type": "filemanager"}}
{"label": "mpv", "sublabel": "Media Player", "icon": "mpv", "meta": {"version": "0.37", "type": "media"}}
ITEMS
}
format_template() {
echo "Using --format to control display." >&2
echo "Template: '{label} ({sublabel}) v{meta.version}'" >&2
echo "" >&2
cat <<'ITEMS' | pikl --format '{label} ({sublabel}) v{meta.version}'
{"label": "Firefox", "sublabel": "Web Browser", "meta": {"version": "125"}}
{"label": "Neovim", "sublabel": "Text Editor", "meta": {"version": "0.10"}}
{"label": "Alacritty", "sublabel": "Terminal Emulator", "meta": {"version": "0.13"}}
{"label": "Thunar", "sublabel": "File Manager", "meta": {"version": "4.18"}}
{"label": "mpv", "sublabel": "Media Player", "meta": {"version": "0.37"}}
ITEMS
}
filter_fields_demo() {
echo "Using --filter-fields to search sublabel and meta fields." >&2
echo "Try typing 'browser' or 'editor' to filter by sublabel." >&2
echo "" >&2
cat <<'ITEMS' | pikl --filter-fields label,sublabel --format '{label} - {sublabel}'
{"label": "Firefox", "sublabel": "Web Browser"}
{"label": "Neovim", "sublabel": "Text Editor"}
{"label": "Alacritty", "sublabel": "Terminal Emulator"}
{"label": "Thunar", "sublabel": "File Manager"}
{"label": "mpv", "sublabel": "Media Player"}
{"label": "GIMP", "sublabel": "Image Editor"}
{"label": "Inkscape", "sublabel": "Vector Graphics Editor"}
ITEMS
}
field_filter_demo() {
echo "Field filters in the query: type meta.type:browser to match a field." >&2
echo "Try: meta.type:browser or meta.type:editor or !meta.type:browser" >&2
echo "" >&2
cat <<'ITEMS' | pikl --format '{label} [{meta.type}] {meta.res}'
{"label": "Firefox", "meta": {"type": "browser", "res": "n/a"}}
{"label": "Chrome", "meta": {"type": "browser", "res": "n/a"}}
{"label": "Neovim", "meta": {"type": "editor", "res": "n/a"}}
{"label": "Helix", "meta": {"type": "editor", "res": "n/a"}}
{"label": "Alacritty", "meta": {"type": "terminal", "res": "n/a"}}
{"label": "kitty", "meta": {"type": "terminal", "res": "n/a"}}
{"label": "mpv", "meta": {"type": "media", "res": "3840x2160"}}
{"label": "vlc", "meta": {"type": "media", "res": "1920x1080"}}
ITEMS
}
exec_hooks_demo() {
echo "Exec hooks: --on-hover-exec fires a command on each cursor move." >&2
echo "Watch stderr for hover notifications as you navigate." >&2
echo "" >&2
cat <<'ITEMS' | pikl \
--on-hover-exec 'jq -r ".item.label // empty" | xargs -I{} echo " hovering: {}" >&2' \
--on-select-exec 'jq -r ".item.label // .value // empty" | xargs -I{} echo " selected: {}" >&2'
{"label": "Arch Linux", "sublabel": "rolling release"}
{"label": "NixOS", "sublabel": "declarative"}
{"label": "Void Linux", "sublabel": "runit-based"}
{"label": "Debian", "sublabel": "rock solid"}
{"label": "Alpine", "sublabel": "musl + busybox"}
ITEMS
}
handler_hook_demo() {
echo "Handler hooks: a persistent process receives events on stdin" >&2
echo "and can emit commands on stdout to modify the menu." >&2
echo "" >&2
echo "This demo logs hover events to stderr via a handler script." >&2
echo "The handler stays alive for the menu's lifetime." >&2
echo "" >&2
# Create a temporary handler script
local handler
handler=$(mktemp /tmp/pikl-handler-XXXXXX.sh)
cat > "$handler" <<'HANDLER'
#!/bin/bash
# Simple handler: logs events to stderr, demonstrates the protocol
while IFS= read -r event; do
event_type=$(echo "$event" | jq -r '.event // "unknown"')
case "$event_type" in
hover)
label=$(echo "$event" | jq -r '.item.label // "?"')
index=$(echo "$event" | jq -r '.index // "?"')
echo " handler got hover: $label (index $index)" >&2
;;
filter)
text=$(echo "$event" | jq -r '.text // ""')
echo " handler got filter: '$text'" >&2
;;
open)
echo " handler got open event" >&2
;;
close)
echo " handler got close event" >&2
;;
*)
echo " handler got: $event_type" >&2
;;
esac
done
HANDLER
chmod +x "$handler"
cat <<'ITEMS' | pikl --on-hover "$handler" --on-hover-debounce 100
{"label": "Maple", "sublabel": "Studio founder"}
{"label": "Cedar", "sublabel": "Backend dev"}
{"label": "Birch", "sublabel": "Frontend dev"}
{"label": "Pine", "sublabel": "DevOps"}
{"label": "Spruce", "sublabel": "QA"}
ITEMS
rm -f "$handler"
}
handler_add_items_demo() {
echo "Handler hooks can modify the menu by emitting commands." >&2
echo "This demo adds items when you hover over specific entries." >&2
echo "" >&2
local handler
handler=$(mktemp /tmp/pikl-handler-XXXXXX.sh)
cat > "$handler" <<'HANDLER'
#!/bin/bash
# Handler that adds related items on hover
while IFS= read -r event; do
event_type=$(echo "$event" | jq -r '.event // "unknown"')
if [ "$event_type" = "hover" ]; then
label=$(echo "$event" | jq -r '.item.label // ""')
case "$label" in
"Languages")
echo '{"action": "add_items", "items": [{"label": " Rust"}, {"label": " Go"}, {"label": " Python"}]}'
;;
"Editors")
echo '{"action": "add_items", "items": [{"label": " Neovim"}, {"label": " Helix"}, {"label": " Emacs"}]}'
;;
esac
fi
done
HANDLER
chmod +x "$handler"
cat <<'ITEMS' | pikl --on-hover "$handler" --on-hover-debounce 300
{"label": "Languages"}
{"label": "Editors"}
{"label": "Shells"}
ITEMS
rm -f "$handler"
}
pipeline_filter_demo() {
echo "Filter pipeline demo: chain filters with |" >&2
echo "Try: 'rolling | !void (exact 'rolling', then exclude void)" >&2
echo "Try: /sys/ (regex: items containing 'sys')" >&2
echo "Try: meta.init:systemd (field filter on init system)" >&2
echo "" >&2
cat <<'ITEMS' | pikl --format '{label} ({meta.category}, {meta.init})'
{"label": "Arch Linux", "meta": {"category": "rolling", "init": "systemd"}}
{"label": "NixOS", "meta": {"category": "rolling", "init": "systemd"}}
{"label": "Void Linux", "meta": {"category": "rolling", "init": "runit"}}
{"label": "Debian", "meta": {"category": "stable", "init": "systemd"}}
{"label": "Alpine", "meta": {"category": "stable", "init": "openrc"}}
{"label": "Fedora", "meta": {"category": "semi-rolling", "init": "systemd"}}
{"label": "Gentoo", "meta": {"category": "rolling", "init": "openrc"}}
ITEMS
}
# ── Scenario menu ───────────────────────────────────────── # ── Scenario menu ─────────────────────────────────────────
scenarios=( scenarios=(
@@ -107,8 +285,19 @@ scenarios=(
"Git branches" "Git branches"
"Git log (last 30)" "Git log (last 30)"
"File picker" "File picker"
"on-select hook"
"Mixed input (plain + JSON)" "Mixed input (plain + JSON)"
"---"
"Structured items (sublabel, meta)"
"Format template (--format)"
"Filter fields (--filter-fields)"
"Field filters (meta.type:browser)"
"Pipeline + field filters"
"---"
"Exec hooks (on-hover/select)"
"Handler hook (event logging)"
"Handler hook (add items on hover)"
"---"
"on-select-exec hook (legacy)"
) )
# Map display names to functions # Map display names to functions
@@ -121,8 +310,20 @@ run_scenario() {
*"Git branches"*) git_branches ;; *"Git branches"*) git_branches ;;
*"Git log"*) git_log_picker ;; *"Git log"*) git_log_picker ;;
*"File picker"*) file_picker ;; *"File picker"*) file_picker ;;
*"on-select"*) on_select_hook ;;
*"Mixed input"*) mixed_input ;; *"Mixed input"*) mixed_input ;;
*"Structured items"*) structured_items ;;
*"Format template"*) format_template ;;
*"Filter fields"*) filter_fields_demo ;;
*"Field filters"*) field_filter_demo ;;
*"Pipeline + field"*) pipeline_filter_demo ;;
*"Exec hooks"*) exec_hooks_demo ;;
*"Handler hook (event"*) handler_hook_demo ;;
*"Handler hook (add"*) handler_add_items_demo ;;
*"on-select-exec"*) on_select_hook ;;
"---")
echo "that's a separator, not a scenario" >&2
return 1
;;
*) *)
echo "unknown scenario" >&2 echo "unknown scenario" >&2
return 1 return 1
@@ -142,8 +343,8 @@ main() {
exit 1 exit 1
} }
# pikl outputs JSON. Strip the quotes for matching. # pikl outputs JSON. Strip quotes and extract value for matching.
choice=$(echo "$choice" | tr -d '"') choice=$(echo "$choice" | jq -r '.value // .' 2>/dev/null || echo "$choice" | tr -d '"')
echo "" >&2 echo "" >&2
echo "── running: $choice ──" >&2 echo "── running: $choice ──" >&2