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

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