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).
This commit is contained in:
@@ -12,10 +12,11 @@ workspace = true
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt"] }
|
||||
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] }
|
||||
tracing = "0.1"
|
||||
nucleo-matcher = "0.3.1"
|
||||
fancy-regex = "0.14"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
|
||||
pikl-test-macros = { path = "../pikl-test-macros" }
|
||||
|
||||
154
crates/pikl-core/src/format.rs
Normal file
154
crates/pikl-core/src/format.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Display format templates. Parses `{field.path}`
|
||||
//! placeholders in a template string and renders them
|
||||
//! against item JSON values.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::item::resolve_field_path;
|
||||
|
||||
/// A compiled format template. Segments are either literal
|
||||
/// text or field path references that get resolved against
|
||||
/// item values at render time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FormatTemplate {
|
||||
segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Segment {
|
||||
Literal(String),
|
||||
Field(String),
|
||||
}
|
||||
|
||||
impl FormatTemplate {
|
||||
/// Parse a format string like `"{label} - {sublabel}"`.
|
||||
/// Unmatched `{` or `}` are treated as literals.
|
||||
pub fn parse(template: &str) -> Self {
|
||||
let mut segments = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = template.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '{' {
|
||||
// Look for closing brace
|
||||
let mut field = String::new();
|
||||
let mut found_close = false;
|
||||
for c2 in chars.by_ref() {
|
||||
if c2 == '}' {
|
||||
found_close = true;
|
||||
break;
|
||||
}
|
||||
field.push(c2);
|
||||
}
|
||||
if found_close && !field.is_empty() {
|
||||
if !current.is_empty() {
|
||||
segments.push(Segment::Literal(std::mem::take(&mut current)));
|
||||
}
|
||||
segments.push(Segment::Field(field));
|
||||
} else {
|
||||
// Malformed: treat as literal
|
||||
current.push('{');
|
||||
current.push_str(&field);
|
||||
if found_close {
|
||||
current.push('}');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
segments.push(Segment::Literal(current));
|
||||
}
|
||||
|
||||
Self { segments }
|
||||
}
|
||||
|
||||
/// Render this template against a JSON value. Missing
|
||||
/// fields produce empty strings.
|
||||
pub fn render(&self, value: &Value) -> String {
|
||||
let mut out = String::new();
|
||||
for seg in &self.segments {
|
||||
match seg {
|
||||
Segment::Literal(s) => out.push_str(s),
|
||||
Segment::Field(path) => {
|
||||
if let Some(v) = resolve_field_path(value, path) {
|
||||
match v {
|
||||
Value::String(s) => out.push_str(s),
|
||||
Value::Number(n) => out.push_str(&n.to_string()),
|
||||
Value::Bool(b) => out.push_str(&b.to_string()),
|
||||
Value::Null => {}
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn simple_field() {
|
||||
let t = FormatTemplate::parse("{label}");
|
||||
assert_eq!(t.render(&json!({"label": "Firefox"})), "Firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_and_fields() {
|
||||
let t = FormatTemplate::parse("{label} - {sublabel}");
|
||||
let v = json!({"label": "Firefox", "sublabel": "Web Browser"});
|
||||
assert_eq!(t.render(&v), "Firefox - Web Browser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_field_renders_empty() {
|
||||
let t = FormatTemplate::parse("{label} ({version})");
|
||||
let v = json!({"label": "Firefox"});
|
||||
assert_eq!(t.render(&v), "Firefox ()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_dotted_path() {
|
||||
let t = FormatTemplate::parse("{meta.resolution.width}x{meta.resolution.height}");
|
||||
let v = json!({"meta": {"resolution": {"width": 3840, "height": 2160}}});
|
||||
assert_eq!(t.render(&v), "3840x2160");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_only() {
|
||||
let t = FormatTemplate::parse("just text");
|
||||
assert_eq!(t.render(&json!({})), "just text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_template() {
|
||||
let t = FormatTemplate::parse("");
|
||||
assert_eq!(t.render(&json!({"label": "x"})), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_brace_is_literal() {
|
||||
let t = FormatTemplate::parse("hello {world");
|
||||
assert_eq!(t.render(&json!({})), "hello {world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_braces_are_literal() {
|
||||
let t = FormatTemplate::parse("hello {}");
|
||||
assert_eq!(t.render(&json!({})), "hello {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_value_item() {
|
||||
let t = FormatTemplate::parse("{label}");
|
||||
// String values don't have object fields
|
||||
assert_eq!(t.render(&json!("hello")), "");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! deps. Frontends are separate crates that talk to this
|
||||
//! through channels.
|
||||
|
||||
pub mod format;
|
||||
mod model;
|
||||
mod query;
|
||||
mod runtime;
|
||||
@@ -13,6 +14,7 @@ pub mod error;
|
||||
// Re-export submodules at crate root so the public API stays flat.
|
||||
pub use model::event;
|
||||
pub use model::item;
|
||||
pub use model::output;
|
||||
pub use model::traits;
|
||||
pub use query::exact;
|
||||
pub use query::filter;
|
||||
@@ -20,6 +22,7 @@ pub use query::navigation;
|
||||
pub use query::pipeline;
|
||||
pub use query::regex_filter;
|
||||
pub use query::strategy;
|
||||
pub use runtime::debounce;
|
||||
pub use runtime::hook;
|
||||
pub use runtime::input;
|
||||
pub use runtime::json_menu;
|
||||
|
||||
@@ -11,6 +11,8 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::hook::HookResponse;
|
||||
|
||||
/// Input mode. Insert mode sends keystrokes to the filter,
|
||||
/// normal mode uses vim-style navigation keybinds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -39,6 +41,10 @@ pub enum Action {
|
||||
Cancel,
|
||||
Resize { height: u16 },
|
||||
AddItems(Vec<Value>),
|
||||
ReplaceItems(Vec<Value>),
|
||||
RemoveItems(Vec<usize>),
|
||||
ProcessHookResponse(HookResponse),
|
||||
CloseMenu,
|
||||
}
|
||||
|
||||
/// Broadcast from the menu loop to all subscribers
|
||||
@@ -73,6 +79,7 @@ pub struct ViewState {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VisibleItem {
|
||||
pub label: String,
|
||||
pub formatted_text: Option<String>,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
@@ -81,6 +88,6 @@ pub struct VisibleItem {
|
||||
#[must_use]
|
||||
#[derive(Debug)]
|
||||
pub enum MenuResult {
|
||||
Selected(Value),
|
||||
Selected { value: Value, index: usize },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use serde_json::Value;
|
||||
/// as `Value::String`, structured entries as
|
||||
/// `Value::Object`. The label is extracted and cached at
|
||||
/// construction time so display calls are just a pointer
|
||||
/// dereference.
|
||||
/// dereference. Sublabel, icon, and group are also cached
|
||||
/// for structured items.
|
||||
///
|
||||
/// Serializes as the inner Value (transparent for output)
|
||||
/// but construction always requires label extraction.
|
||||
@@ -16,23 +17,40 @@ use serde_json::Value;
|
||||
pub struct Item {
|
||||
pub value: Value,
|
||||
label_cache: String,
|
||||
sublabel_cache: Option<String>,
|
||||
icon_cache: Option<String>,
|
||||
group_cache: Option<String>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Create an Item from a JSON value, extracting the display
|
||||
/// label from the given key. String values use the string
|
||||
/// itself. Object values look up the key.
|
||||
/// itself. Object values look up the key. Also extracts
|
||||
/// sublabel, icon, and group from known keys on objects.
|
||||
pub fn new(value: Value, label_key: &str) -> Self {
|
||||
let label_cache = extract_label(&value, label_key).to_string();
|
||||
Self { value, label_cache }
|
||||
let sublabel_cache = extract_optional(&value, "sublabel");
|
||||
let icon_cache = extract_optional(&value, "icon");
|
||||
let group_cache = extract_optional(&value, "group");
|
||||
Self {
|
||||
value,
|
||||
label_cache,
|
||||
sublabel_cache,
|
||||
icon_cache,
|
||||
group_cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a plain-text string as an Item. Stored internally
|
||||
/// as `Value::String` with the label cached.
|
||||
/// as `Value::String` with the label cached. Optional
|
||||
/// fields are left as None.
|
||||
pub fn from_plain_text(line: &str) -> Self {
|
||||
Self {
|
||||
value: Value::String(line.to_string()),
|
||||
label_cache: line.to_string(),
|
||||
sublabel_cache: None,
|
||||
icon_cache: None,
|
||||
group_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +59,38 @@ impl Item {
|
||||
pub fn label(&self) -> &str {
|
||||
&self.label_cache
|
||||
}
|
||||
|
||||
/// Get the sublabel for this item, if present.
|
||||
pub fn sublabel(&self) -> Option<&str> {
|
||||
self.sublabel_cache.as_deref()
|
||||
}
|
||||
|
||||
/// Get the icon for this item, if present.
|
||||
pub fn icon(&self) -> Option<&str> {
|
||||
self.icon_cache.as_deref()
|
||||
}
|
||||
|
||||
/// Get the group for this item, if present.
|
||||
pub fn group(&self) -> Option<&str> {
|
||||
self.group_cache.as_deref()
|
||||
}
|
||||
|
||||
/// Get the `meta` field from the underlying value, if it
|
||||
/// exists and the value is an object.
|
||||
pub fn meta(&self) -> Option<&Value> {
|
||||
match &self.value {
|
||||
Value::Object(map) => map.get("meta"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a dotted field path against this item's value.
|
||||
/// For example, `field_value("meta.resolution")` on
|
||||
/// `{"meta": {"resolution": "1080p"}}` returns
|
||||
/// `Some(Value::String("1080p"))`.
|
||||
pub fn field_value(&self, path: &str) -> Option<&Value> {
|
||||
resolve_field_path(&self.value, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the display label from a JSON value.
|
||||
@@ -52,6 +102,30 @@ fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract an optional string field from a JSON object value.
|
||||
fn extract_optional(value: &Value, key: &str) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(map) => map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a dotted path (e.g. `"meta.tags.0"`) through nested
|
||||
/// JSON objects. Returns None if any intermediate is not an
|
||||
/// object or the key is missing.
|
||||
pub fn resolve_field_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
for segment in path.split('.') {
|
||||
match current {
|
||||
Value::Object(map) => {
|
||||
current = map.get(segment)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
impl serde::Serialize for Item {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.value.serialize(serializer)
|
||||
@@ -113,6 +187,9 @@ mod tests {
|
||||
let item = Item::from_plain_text("hello world");
|
||||
assert_eq!(item.label(), "hello world");
|
||||
assert!(item.value.is_string());
|
||||
assert!(item.sublabel().is_none());
|
||||
assert!(item.icon().is_none());
|
||||
assert!(item.group().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,4 +205,107 @@ mod tests {
|
||||
let json = serde_json::to_string(&item).unwrap_or_default();
|
||||
assert_eq!(json, r#"{"label":"foo"}"#);
|
||||
}
|
||||
|
||||
// -- New accessor tests --
|
||||
|
||||
#[test]
|
||||
fn sublabel_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "Firefox", "sublabel": "Web Browser"}),
|
||||
"label",
|
||||
);
|
||||
assert_eq!(item.sublabel(), Some("Web Browser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sublabel_missing() {
|
||||
let item = Item::new(json!({"label": "Firefox"}), "label");
|
||||
assert!(item.sublabel().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "Firefox", "icon": "firefox.png"}),
|
||||
"label",
|
||||
);
|
||||
assert_eq!(item.icon(), Some("firefox.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "Firefox", "group": "browsers"}),
|
||||
"label",
|
||||
);
|
||||
assert_eq!(item.group(), Some("browsers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "test", "meta": {"res": 1080}}),
|
||||
"label",
|
||||
);
|
||||
let meta = item.meta();
|
||||
assert!(meta.is_some());
|
||||
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_from_plain_text() {
|
||||
let item = Item::from_plain_text("hello");
|
||||
assert!(item.meta().is_none());
|
||||
}
|
||||
|
||||
// -- Field path resolution tests --
|
||||
|
||||
#[test]
|
||||
fn resolve_simple_path() {
|
||||
let value = json!({"label": "test"});
|
||||
assert_eq!(resolve_field_path(&value, "label"), Some(&json!("test")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_nested_path() {
|
||||
let value = json!({"meta": {"resolution": {"width": 3840}}});
|
||||
assert_eq!(
|
||||
resolve_field_path(&value, "meta.resolution.width"),
|
||||
Some(&json!(3840))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_path() {
|
||||
let value = json!({"label": "test"});
|
||||
assert!(resolve_field_path(&value, "nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_nested() {
|
||||
let value = json!({"meta": {"a": 1}});
|
||||
assert!(resolve_field_path(&value, "meta.b").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_non_object_intermediate() {
|
||||
let value = json!({"meta": "not an object"});
|
||||
assert!(resolve_field_path(&value, "meta.foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_on_string_value() {
|
||||
let value = json!("hello");
|
||||
assert!(resolve_field_path(&value, "anything").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_value_on_item() {
|
||||
let item = Item::new(
|
||||
json!({"label": "test", "meta": {"tags": ["a", "b"]}}),
|
||||
"label",
|
||||
);
|
||||
assert_eq!(item.field_value("meta.tags"), Some(&json!(["a", "b"])));
|
||||
assert!(item.field_value("meta.nope").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod event;
|
||||
pub mod item;
|
||||
pub mod output;
|
||||
pub mod traits;
|
||||
|
||||
110
crates/pikl-core/src/model/output.rs
Normal file
110
crates/pikl-core/src/model/output.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
//! Structured output for selected items. Wraps the original
|
||||
//! item value with action context and original index so
|
||||
//! hooks and downstream tools know what happened.
|
||||
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// What the user did to produce this output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputAction {
|
||||
Select,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// A selected item wrapped with output context. For object
|
||||
/// values, the original fields are merged at the top level
|
||||
/// alongside `action` and `index`. For non-object values
|
||||
/// (plain strings, numbers), the value appears as a `value`
|
||||
/// field.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutputItem {
|
||||
pub value: Value,
|
||||
pub action: OutputAction,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
impl Serialize for OutputItem {
|
||||
fn serialize<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_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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,25 @@ pub trait Menu: Send + 'static {
|
||||
/// Get the JSON value of a filtered item for output.
|
||||
/// Returns a reference to the stored value.
|
||||
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>;
|
||||
|
||||
/// Get the original index of a filtered item. Used to
|
||||
/// provide the item's position in the unfiltered list
|
||||
/// for output and hook events.
|
||||
fn original_index(&self, filtered_index: usize) -> Option<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,9 +165,17 @@ impl Viewport {
|
||||
self.scroll_offset..end
|
||||
}
|
||||
|
||||
/// Set the cursor to a specific position. Does NOT clamp
|
||||
/// or update scroll. Call [`clamp`](Self::clamp) after if
|
||||
/// the position may be out of bounds.
|
||||
pub fn set_cursor(&mut self, position: usize) {
|
||||
self.cursor = position;
|
||||
}
|
||||
|
||||
/// Clamp cursor and scroll offset to valid positions after
|
||||
/// a height or count change.
|
||||
fn clamp(&mut self) {
|
||||
/// a height or count change, item removal, or manual
|
||||
/// cursor set.
|
||||
pub fn clamp(&mut self) {
|
||||
if self.filtered_count == 0 {
|
||||
self.cursor = 0;
|
||||
self.scroll_offset = 0;
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
//! and chains results through stages. Supports incremental
|
||||
//! caching: unchanged stages keep their results.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::filter::{Filter, FuzzyFilter};
|
||||
use super::strategy::{self, FilterKind};
|
||||
use crate::item::resolve_field_path;
|
||||
|
||||
/// A multi-stage filter pipeline. Each `|` in the query
|
||||
/// creates a new stage that filters the previous stage's
|
||||
@@ -13,6 +16,9 @@ use super::strategy::{self, FilterKind};
|
||||
pub struct FilterPipeline {
|
||||
/// Master item list: (original index, label).
|
||||
items: Vec<(usize, String)>,
|
||||
/// Optional item values for field filter resolution.
|
||||
/// Stored separately since most pipelines don't need them.
|
||||
item_values: Vec<Option<Value>>,
|
||||
/// Pipeline stages, one per `|`-separated segment.
|
||||
stages: Vec<PipelineStage>,
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Convert a JSON value to a string for field filter matching.
|
||||
fn value_to_filter_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FilterPipeline {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -109,6 +126,7 @@ impl FilterPipeline {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
item_values: Vec::new(),
|
||||
stages: Vec::new(),
|
||||
last_raw_query: String::new(),
|
||||
}
|
||||
@@ -130,7 +148,7 @@ impl FilterPipeline {
|
||||
|
||||
let stage = &mut self.stages[stage_idx];
|
||||
|
||||
let result = match stage.kind {
|
||||
let result = match &stage.kind {
|
||||
FilterKind::Fuzzy => Self::eval_fuzzy(stage, &input_indices, stage_idx),
|
||||
FilterKind::Exact => {
|
||||
Self::eval_simple(stage, &input_indices, &self.items, |label, query| {
|
||||
@@ -146,6 +164,14 @@ impl FilterPipeline {
|
||||
}
|
||||
})
|
||||
}
|
||||
FilterKind::Field { path } => {
|
||||
Self::eval_field(
|
||||
stage,
|
||||
&input_indices,
|
||||
&self.item_values,
|
||||
path,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
self.stages[stage_idx].cached_indices = result;
|
||||
@@ -184,6 +210,41 @@ impl FilterPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_field(
|
||||
stage: &PipelineStage,
|
||||
input_indices: &[usize],
|
||||
item_values: &[Option<Value>],
|
||||
path: &str,
|
||||
) -> Vec<usize> {
|
||||
if stage.query_text.is_empty() {
|
||||
return input_indices.to_vec();
|
||||
}
|
||||
let query_lower = stage.query_text.to_lowercase();
|
||||
let matches = |idx: usize| -> bool {
|
||||
let value = item_values.get(idx).and_then(|v| v.as_ref());
|
||||
if let Some(val) = value
|
||||
&& let Some(field_val) = resolve_field_path(val, path)
|
||||
{
|
||||
let text = value_to_filter_string(field_val);
|
||||
return text.to_lowercase().contains(&query_lower);
|
||||
}
|
||||
false
|
||||
};
|
||||
if stage.inverse {
|
||||
input_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&idx| !matches(idx))
|
||||
.collect()
|
||||
} else {
|
||||
input_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&idx| matches(idx))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_simple(
|
||||
stage: &PipelineStage,
|
||||
input_indices: &[usize],
|
||||
@@ -209,9 +270,49 @@ impl FilterPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
impl FilterPipeline {
|
||||
/// Clear all items and stages, then re-push the given
|
||||
/// items. Used after replace_all or remove_by_indices
|
||||
/// to rebuild the pipeline from scratch.
|
||||
pub fn rebuild(&mut self, items: &[(usize, &str)]) {
|
||||
self.items.clear();
|
||||
self.item_values.clear();
|
||||
self.stages.clear();
|
||||
self.last_raw_query.clear();
|
||||
for &(index, label) in items {
|
||||
self.items.push((index, label.to_string()));
|
||||
self.item_values.push(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all items and stages, then re-push with values.
|
||||
/// Used when field filters need access to item JSON.
|
||||
pub fn rebuild_with_values(&mut self, items: &[(usize, &str, &Value)]) {
|
||||
self.items.clear();
|
||||
self.item_values.clear();
|
||||
self.stages.clear();
|
||||
self.last_raw_query.clear();
|
||||
for &(index, label, value) in items {
|
||||
self.items.push((index, label.to_string()));
|
||||
self.item_values.push(Some(value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a single item with its JSON value for field
|
||||
/// filter support.
|
||||
pub fn push_with_value(&mut self, index: usize, label: &str, value: &Value) {
|
||||
self.push(index, label);
|
||||
// push() already added a None to item_values, replace it
|
||||
if let Some(last) = self.item_values.last_mut() {
|
||||
*last = Some(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter for FilterPipeline {
|
||||
fn push(&mut self, index: usize, label: &str) {
|
||||
self.items.push((index, label.to_string()));
|
||||
self.item_values.push(None);
|
||||
// Push to any existing fuzzy filters in stages
|
||||
for stage in &mut self.stages {
|
||||
if let Some(ref mut fuzzy) = stage.fuzzy {
|
||||
@@ -614,4 +715,95 @@ mod tests {
|
||||
assert!(result.contains(&"cherry"));
|
||||
assert!(!result.contains(&"banana"));
|
||||
}
|
||||
|
||||
// -- Field filter tests --
|
||||
|
||||
#[test]
|
||||
fn field_filter_matches() {
|
||||
use serde_json::json;
|
||||
let mut p = FilterPipeline::new();
|
||||
let items = vec![
|
||||
json!({"label": "monitor1", "meta": {"res": "3840"}}),
|
||||
json!({"label": "monitor2", "meta": {"res": "1920"}}),
|
||||
json!({"label": "monitor3", "meta": {"res": "3840"}}),
|
||||
];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let label = item["label"].as_str().unwrap_or("");
|
||||
p.push_with_value(i, label, item);
|
||||
}
|
||||
p.set_query("meta.res:3840");
|
||||
assert_eq!(p.matched_count(), 2);
|
||||
let indices: Vec<usize> = (0..p.matched_count())
|
||||
.filter_map(|i| p.matched_index(i))
|
||||
.collect();
|
||||
assert!(indices.contains(&0));
|
||||
assert!(indices.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_inverse() {
|
||||
use serde_json::json;
|
||||
let mut p = FilterPipeline::new();
|
||||
let items = vec![
|
||||
json!({"label": "a", "meta": {"res": "3840"}}),
|
||||
json!({"label": "b", "meta": {"res": "1920"}}),
|
||||
];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let label = item["label"].as_str().unwrap_or("");
|
||||
p.push_with_value(i, label, item);
|
||||
}
|
||||
p.set_query("!meta.res:3840");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
assert_eq!(p.matched_index(0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_missing_field() {
|
||||
use serde_json::json;
|
||||
let mut p = FilterPipeline::new();
|
||||
let items = vec![
|
||||
json!({"label": "a", "meta": {"res": "3840"}}),
|
||||
json!({"label": "b"}),
|
||||
];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let label = item["label"].as_str().unwrap_or("");
|
||||
p.push_with_value(i, label, item);
|
||||
}
|
||||
p.set_query("meta.res:3840");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
assert_eq!(p.matched_index(0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_in_pipeline_with_other_stages() {
|
||||
use serde_json::json;
|
||||
let mut p = FilterPipeline::new();
|
||||
let items = vec![
|
||||
json!({"label": "Firefox", "meta": {"type": "browser"}}),
|
||||
json!({"label": "Chrome", "meta": {"type": "browser"}}),
|
||||
json!({"label": "Vim", "meta": {"type": "editor"}}),
|
||||
];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let label = item["label"].as_str().unwrap_or("");
|
||||
p.push_with_value(i, label, item);
|
||||
}
|
||||
// First stage: field filter for browsers, second stage: fuzzy for "fire"
|
||||
p.set_query("meta.type:browser | fire");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
assert_eq!(p.matched_index(0), Some(0)); // Firefox
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_pipeline() {
|
||||
let mut p = FilterPipeline::new();
|
||||
push_items(&mut p, &["apple", "banana"]);
|
||||
p.set_query("ban");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
|
||||
// Rebuild with new items
|
||||
p.rebuild(&[(0, "cherry"), (1, "date")]);
|
||||
p.set_query("dat");
|
||||
assert_eq!(p.matched_count(), 1);
|
||||
assert_eq!(p.matched_index(0), Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
//! strategy to use based on the query prefix.
|
||||
|
||||
/// The type of filter to apply for a query segment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FilterKind {
|
||||
Fuzzy,
|
||||
Exact,
|
||||
Regex,
|
||||
/// Field-specific filter: `meta.res:3840` matches
|
||||
/// items where the dotted path resolves to a value
|
||||
/// containing the query text.
|
||||
Field { path: String },
|
||||
}
|
||||
|
||||
/// A parsed filter segment with its kind, inversion flag,
|
||||
@@ -75,6 +79,28 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for inverse field filter: !field.path:value
|
||||
// Must check before generic inverse fuzzy to avoid
|
||||
// treating it as fuzzy with query "field.path:value".
|
||||
if let Some(rest) = segment.strip_prefix('!')
|
||||
&& let Some((path, value)) = try_parse_field_filter(rest)
|
||||
{
|
||||
return ParsedSegment {
|
||||
kind: FilterKind::Field { path: path.to_string() },
|
||||
inverse: true,
|
||||
query: value,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for field filter: field.path:value
|
||||
if let Some((path, value)) = try_parse_field_filter(segment) {
|
||||
return ParsedSegment {
|
||||
kind: FilterKind::Field { path: path.to_string() },
|
||||
inverse: false,
|
||||
query: value,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for inverse fuzzy: !query
|
||||
if let Some(rest) = segment.strip_prefix('!') {
|
||||
return ParsedSegment {
|
||||
@@ -101,6 +127,36 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to parse a `field.path:value` pattern. Returns
|
||||
/// `(path, value)` if the segment matches. The path must
|
||||
/// consist of word chars and dots, with no spaces before
|
||||
/// the colon.
|
||||
fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> {
|
||||
let colon_pos = segment.find(':')?;
|
||||
let path = &segment[..colon_pos];
|
||||
let value = &segment[colon_pos + 1..];
|
||||
|
||||
// Path must be non-empty and look like a dotted identifier
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Must contain at least one dot or look like a field name
|
||||
// (not just a single word which could be a typo)
|
||||
if !path.contains('.') {
|
||||
return None;
|
||||
}
|
||||
// No spaces in the path
|
||||
if path.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
// Path chars must be alphanumeric, underscore, or dot
|
||||
if !path.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((path, value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -228,4 +284,52 @@ mod tests {
|
||||
assert!(p.inverse);
|
||||
assert_eq!(p.query, "[0-9]");
|
||||
}
|
||||
|
||||
// -- Field filter tests --
|
||||
|
||||
#[test]
|
||||
fn field_filter_dotted_path() {
|
||||
let p = parse_segment("meta.res:3840");
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
FilterKind::Field {
|
||||
path: "meta.res".to_string()
|
||||
}
|
||||
);
|
||||
assert!(!p.inverse);
|
||||
assert_eq!(p.query, "3840");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_inverse() {
|
||||
let p = parse_segment("!meta.res:3840");
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
FilterKind::Field {
|
||||
path: "meta.res".to_string()
|
||||
}
|
||||
);
|
||||
assert!(p.inverse);
|
||||
assert_eq!(p.query, "3840");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_word_colon_is_not_field_filter() {
|
||||
// No dot means it's not treated as a field path
|
||||
let p = parse_segment("foo:bar");
|
||||
assert_eq!(p.kind, FilterKind::Fuzzy);
|
||||
assert_eq!(p.query, "foo:bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_filter_empty_value() {
|
||||
let p = parse_segment("meta.tag:");
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
FilterKind::Field {
|
||||
path: "meta.tag".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(p.query, "");
|
||||
}
|
||||
}
|
||||
|
||||
295
crates/pikl-core/src/runtime/debounce.rs
Normal file
295
crates/pikl-core/src/runtime/debounce.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,303 @@
|
||||
//! Hook trait for lifecycle events. The core library defines
|
||||
//! the interface; concrete implementations (shell hooks, IPC
|
||||
//! hooks, etc.) live in frontend crates.
|
||||
//! Hook types for lifecycle events. The core library defines
|
||||
//! the event and response types plus the handler trait.
|
||||
//! Concrete implementations (shell exec hooks, persistent
|
||||
//! handler processes) live in frontend crates.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::PiklError;
|
||||
|
||||
/// A lifecycle hook that fires on menu events. Implementations
|
||||
/// live outside pikl-core (e.g. in the CLI binary) so the core
|
||||
/// library stays free of process/libc deps.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait Hook: Send + Sync {
|
||||
async fn run(&self, value: &Value) -> Result<(), PiklError>;
|
||||
/// A lifecycle event emitted by the menu engine. Handler
|
||||
/// hooks receive these as JSON lines on stdin. The `event`
|
||||
/// field is the tag for serde's tagged representation.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
Open,
|
||||
Close,
|
||||
Hover { item: Value, index: usize },
|
||||
Select { item: Value, index: usize },
|
||||
Cancel,
|
||||
Filter { text: String },
|
||||
}
|
||||
|
||||
/// Discriminant for [`HookEvent`], used as a key for
|
||||
/// debounce config and handler routing.
|
||||
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
|
||||
pub enum HookEventKind {
|
||||
Open,
|
||||
Close,
|
||||
Hover,
|
||||
Select,
|
||||
Cancel,
|
||||
Filter,
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
/// Get the discriminant kind for this event.
|
||||
pub fn kind(&self) -> HookEventKind {
|
||||
match self {
|
||||
HookEvent::Open => HookEventKind::Open,
|
||||
HookEvent::Close => HookEventKind::Close,
|
||||
HookEvent::Hover { .. } => HookEventKind::Hover,
|
||||
HookEvent::Select { .. } => HookEventKind::Select,
|
||||
HookEvent::Cancel => HookEventKind::Cancel,
|
||||
HookEvent::Filter { .. } => HookEventKind::Filter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command from a handler hook back to the menu engine.
|
||||
/// Handler hooks emit these as JSON lines on stdout. The
|
||||
/// `action` field is the tag.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum HookResponse {
|
||||
AddItems { items: Vec<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());
|
||||
}
|
||||
|
||||
// -- 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! for `ls | pikl` style usage.
|
||||
|
||||
use crate::filter::Filter;
|
||||
use crate::format::FormatTemplate;
|
||||
use crate::item::Item;
|
||||
use crate::model::traits::Menu;
|
||||
use crate::pipeline::FilterPipeline;
|
||||
@@ -16,6 +17,8 @@ pub struct JsonMenu {
|
||||
items: Vec<Item>,
|
||||
label_key: String,
|
||||
filter: FilterPipeline,
|
||||
filter_fields: Vec<String>,
|
||||
format_template: Option<FormatTemplate>,
|
||||
}
|
||||
|
||||
impl JsonMenu {
|
||||
@@ -23,14 +26,79 @@ impl JsonMenu {
|
||||
pub fn new(items: Vec<Item>, label_key: String) -> Self {
|
||||
let mut filter = FilterPipeline::new();
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
filter.push(i, item.label());
|
||||
filter.push_with_value(i, item.label(), &item.value);
|
||||
}
|
||||
Self {
|
||||
items,
|
||||
label_key,
|
||||
filter,
|
||||
filter_fields: vec!["label".to_string()],
|
||||
format_template: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set which fields to search during filtering. Each entry
|
||||
/// is a dotted path resolved against the item's JSON value.
|
||||
/// Default is `["label"]`.
|
||||
pub fn set_filter_fields(&mut self, fields: Vec<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 {
|
||||
@@ -56,7 +124,8 @@ impl Menu for JsonMenu {
|
||||
for value in values {
|
||||
let idx = self.items.len();
|
||||
let item = Item::new(value, &self.label_key);
|
||||
self.filter.push(idx, item.label());
|
||||
let text = self.extract_filter_text(&item);
|
||||
self.filter.push_with_value(idx, &text, &item.value);
|
||||
self.items.push(item);
|
||||
}
|
||||
}
|
||||
@@ -66,4 +135,36 @@ impl Menu for JsonMenu {
|
||||
.matched_index(filtered_index)
|
||||
.map(|idx| &self.items[idx].value)
|
||||
}
|
||||
|
||||
fn original_index(&self, filtered_index: usize) -> Option<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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
|
||||
use crate::error::PiklError;
|
||||
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||
use crate::hook::{HookEvent, HookHandler};
|
||||
use crate::model::traits::Menu;
|
||||
use crate::navigation::Viewport;
|
||||
use serde_json::Value;
|
||||
@@ -20,9 +22,11 @@ pub enum ActionOutcome {
|
||||
/// State changed, broadcast to subscribers.
|
||||
Broadcast,
|
||||
/// User confirmed a selection.
|
||||
Selected(Value),
|
||||
Selected { value: Value, index: usize },
|
||||
/// User cancelled.
|
||||
Cancelled,
|
||||
/// Menu closed by hook command.
|
||||
Closed,
|
||||
/// Nothing happened (e.g. confirm on empty list).
|
||||
NoOp,
|
||||
}
|
||||
@@ -38,6 +42,8 @@ pub struct MenuRunner<M: Menu> {
|
||||
mode: Mode,
|
||||
action_rx: mpsc::Receiver<Action>,
|
||||
event_tx: broadcast::Sender<MenuEvent>,
|
||||
dispatcher: Option<DebouncedDispatcher>,
|
||||
previous_cursor: Option<usize>,
|
||||
}
|
||||
|
||||
impl<M: Menu> MenuRunner<M> {
|
||||
@@ -59,6 +65,8 @@ impl<M: Menu> MenuRunner<M> {
|
||||
mode: Mode::default(),
|
||||
action_rx,
|
||||
event_tx,
|
||||
dispatcher: None,
|
||||
previous_cursor: None,
|
||||
};
|
||||
(runner, action_tx)
|
||||
}
|
||||
@@ -69,6 +77,23 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Set a hook handler. Wraps it in a DebouncedDispatcher
|
||||
/// with no debounce (all events fire immediately). Use
|
||||
/// [`set_dispatcher`] for custom debounce settings.
|
||||
pub fn set_hook_handler(
|
||||
&mut self,
|
||||
handler: Arc<dyn HookHandler>,
|
||||
action_tx: mpsc::Sender<Action>,
|
||||
) {
|
||||
let dispatcher = DebouncedDispatcher::new(handler, action_tx);
|
||||
self.dispatcher = Some(dispatcher);
|
||||
}
|
||||
|
||||
/// Set a hook handler with a pre-configured dispatcher.
|
||||
pub fn set_dispatcher(&mut self, dispatcher: DebouncedDispatcher) {
|
||||
self.dispatcher = Some(dispatcher);
|
||||
}
|
||||
|
||||
/// Re-run the filter against all items with the current
|
||||
/// filter text. Updates the viewport with the new count.
|
||||
fn run_filter(&mut self) {
|
||||
@@ -83,9 +108,13 @@ impl<M: Menu> MenuRunner<M> {
|
||||
let visible_items: Vec<VisibleItem> = range
|
||||
.clone()
|
||||
.filter_map(|i| {
|
||||
self.menu.filtered_label(i).map(|label| VisibleItem {
|
||||
label: label.to_string(),
|
||||
index: i,
|
||||
self.menu.filtered_label(i).map(|label| {
|
||||
let formatted_text = self.menu.formatted_label(i);
|
||||
VisibleItem {
|
||||
label: label.to_string(),
|
||||
formatted_text,
|
||||
index: i,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -113,6 +142,35 @@ impl<M: Menu> MenuRunner<M> {
|
||||
.send(MenuEvent::StateChanged(self.build_view_state()));
|
||||
}
|
||||
|
||||
/// Emit a hook event through the dispatcher, if one is set.
|
||||
fn emit_hook(&mut self, event: HookEvent) {
|
||||
if let Some(dispatcher) = &mut self.dispatcher {
|
||||
dispatcher.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the cursor moved to a different item and
|
||||
/// emit a Hover event if so.
|
||||
fn check_cursor_hover(&mut self) {
|
||||
if self.menu.filtered_count() == 0 {
|
||||
self.previous_cursor = None;
|
||||
return;
|
||||
}
|
||||
let current = self.viewport.cursor();
|
||||
let current_orig = self.menu.original_index(current);
|
||||
if current_orig != self.previous_cursor {
|
||||
self.previous_cursor = current_orig;
|
||||
if let Some(value) = self.menu.serialize_filtered(current).cloned()
|
||||
&& let Some(orig_idx) = current_orig
|
||||
{
|
||||
self.emit_hook(HookEvent::Hover {
|
||||
item: value,
|
||||
index: orig_idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a single action to the menu state. Pure state
|
||||
/// transition: no channels, no async. Testable in isolation.
|
||||
pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
|
||||
@@ -151,8 +209,12 @@ impl<M: Menu> MenuRunner<M> {
|
||||
return ActionOutcome::NoOp;
|
||||
}
|
||||
let cursor = self.viewport.cursor();
|
||||
let index = self.menu.original_index(cursor).unwrap_or(0);
|
||||
match self.menu.serialize_filtered(cursor) {
|
||||
Some(value) => ActionOutcome::Selected(value.clone()),
|
||||
Some(value) => ActionOutcome::Selected {
|
||||
value: value.clone(),
|
||||
index,
|
||||
},
|
||||
None => ActionOutcome::NoOp,
|
||||
}
|
||||
}
|
||||
@@ -178,6 +240,41 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.run_filter();
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::ReplaceItems(values) => {
|
||||
// Smart cursor: try to keep selection on the same original item.
|
||||
let cursor = self.viewport.cursor();
|
||||
let old_value = self.menu.serialize_filtered(cursor).cloned();
|
||||
self.menu.replace_all(values);
|
||||
self.run_filter();
|
||||
// Try to find the old item in the new set
|
||||
if let Some(ref old_val) = old_value {
|
||||
let mut found = false;
|
||||
for i in 0..self.menu.filtered_count() {
|
||||
if self.menu.serialize_filtered(i) == Some(old_val) {
|
||||
self.viewport.set_cursor(i);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
self.viewport.clamp();
|
||||
}
|
||||
} else {
|
||||
self.viewport.clamp();
|
||||
}
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::RemoveItems(indices) => {
|
||||
self.menu.remove_by_indices(indices);
|
||||
self.run_filter();
|
||||
self.viewport.clamp();
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::ProcessHookResponse(resp) => {
|
||||
let action = hook_response_to_action(resp);
|
||||
self.apply_action(action)
|
||||
}
|
||||
Action::CloseMenu => ActionOutcome::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,14 +302,47 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.run_filter();
|
||||
self.broadcast_state();
|
||||
|
||||
// Emit Open event
|
||||
self.emit_hook(HookEvent::Open);
|
||||
|
||||
while let Some(action) = self.action_rx.recv().await {
|
||||
let is_filter_update = matches!(&action, Action::UpdateFilter(_));
|
||||
|
||||
match self.apply_action(action) {
|
||||
ActionOutcome::Broadcast => self.broadcast_state(),
|
||||
ActionOutcome::Selected(value) => {
|
||||
ActionOutcome::Broadcast => {
|
||||
self.broadcast_state();
|
||||
|
||||
// Emit Filter event if the filter changed
|
||||
if is_filter_update {
|
||||
let text = self.filter_text.to_string();
|
||||
self.emit_hook(HookEvent::Filter { text });
|
||||
}
|
||||
|
||||
// Check for cursor movement -> Hover
|
||||
self.check_cursor_hover();
|
||||
}
|
||||
ActionOutcome::Selected { value, index } => {
|
||||
// Emit Select event
|
||||
self.emit_hook(HookEvent::Select {
|
||||
item: value.clone(),
|
||||
index,
|
||||
});
|
||||
// Emit Close event
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
|
||||
return Ok(MenuResult::Selected(value));
|
||||
return Ok(MenuResult::Selected { value, index });
|
||||
}
|
||||
ActionOutcome::Cancelled => {
|
||||
self.emit_hook(HookEvent::Cancel);
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Cancelled);
|
||||
return Ok(MenuResult::Cancelled);
|
||||
}
|
||||
ActionOutcome::Closed => {
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Cancelled);
|
||||
return Ok(MenuResult::Cancelled);
|
||||
}
|
||||
@@ -221,6 +351,7 @@ impl<M: Menu> MenuRunner<M> {
|
||||
}
|
||||
|
||||
// Sender dropped
|
||||
self.emit_hook(HookEvent::Close);
|
||||
Ok(MenuResult::Cancelled)
|
||||
}
|
||||
}
|
||||
@@ -285,7 +416,7 @@ mod tests {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
let outcome = m.apply_action(Action::Confirm);
|
||||
assert!(matches!(&outcome, ActionOutcome::Selected(v) if v.as_str() == Some("beta")));
|
||||
assert!(matches!(&outcome, ActionOutcome::Selected { value, .. } if value.as_str() == Some("beta")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,7 +544,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(_))));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -474,7 +605,7 @@ mod tests {
|
||||
assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha")));
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("alpha")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("alpha")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -496,7 +627,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("gamma")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("gamma")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -521,7 +652,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -556,7 +687,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("epsilon")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("epsilon")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -608,7 +739,7 @@ mod tests {
|
||||
// Must get "banana". Filter was applied before confirm ran.
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("banana")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -634,7 +765,7 @@ mod tests {
|
||||
// Cursor at index 3 -> "delta"
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -730,7 +861,129 @@ mod tests {
|
||||
// Must find "zephyr". It was added before the filter ran.
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("zephyr")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr")
|
||||
));
|
||||
}
|
||||
|
||||
// -- Replace/Remove/Close action tests --
|
||||
|
||||
#[test]
|
||||
fn apply_replace_items() {
|
||||
let mut m = ready_menu();
|
||||
assert_eq!(m.menu.total(), 4);
|
||||
let outcome = m.apply_action(Action::ReplaceItems(vec![
|
||||
serde_json::Value::String("x".to_string()),
|
||||
serde_json::Value::String("y".to_string()),
|
||||
]));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 2);
|
||||
assert_eq!(m.menu.filtered_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_replace_items_preserves_cursor() {
|
||||
let mut m = ready_menu();
|
||||
// Move to "beta" (index 1)
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
// Replace items, keeping "beta" in the new set
|
||||
m.apply_action(Action::ReplaceItems(vec![
|
||||
serde_json::Value::String("alpha".to_string()),
|
||||
serde_json::Value::String("beta".to_string()),
|
||||
serde_json::Value::String("zeta".to_string()),
|
||||
]));
|
||||
// Cursor should still be on "beta"
|
||||
let vs = m.build_view_state();
|
||||
assert_eq!(vs.visible_items[vs.cursor].label, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_remove_items() {
|
||||
let mut m = ready_menu();
|
||||
assert_eq!(m.menu.total(), 4);
|
||||
let outcome = m.apply_action(Action::RemoveItems(vec![1, 3]));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 2);
|
||||
// alpha and gamma should remain
|
||||
assert_eq!(m.menu.filtered_label(0), Some("alpha"));
|
||||
assert_eq!(m.menu.filtered_label(1), Some("gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_close_menu() {
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::CloseMenu);
|
||||
assert!(matches!(outcome, ActionOutcome::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_hook_response_close() {
|
||||
use crate::hook::HookResponse;
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::Close));
|
||||
assert!(matches!(outcome, ActionOutcome::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_hook_response_add_items() {
|
||||
use crate::hook::HookResponse;
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::AddItems {
|
||||
items: vec![serde_json::json!("new")],
|
||||
}));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_returns_original_index() {
|
||||
let mut m = ready_menu();
|
||||
// Filter to narrow results, then confirm
|
||||
m.apply_action(Action::UpdateFilter("del".to_string()));
|
||||
assert!(m.menu.filtered_count() >= 1);
|
||||
let outcome = m.apply_action(Action::Confirm);
|
||||
// "delta" is at original index 3
|
||||
assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. }));
|
||||
}
|
||||
|
||||
// -- Hook event tests --
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_events_fire_on_lifecycle() {
|
||||
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct Recorder(Mutex<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod debounce;
|
||||
pub mod hook;
|
||||
pub mod input;
|
||||
pub mod json_menu;
|
||||
|
||||
Reference in New Issue
Block a user