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:
2026-03-14 01:33:41 -04:00
parent 7082ceada0
commit 8bf3366740
27 changed files with 2548 additions and 274 deletions

View File

@@ -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,
}

View File

@@ -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());
}
}

View File

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

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

View File

@@ -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
}
}