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