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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user