Files
pikl/crates/pikl-core/src/model/item.rs
J. Champagne 8bf3366740 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).
2026-03-14 01:42:11 -04:00

312 lines
9.0 KiB
Rust

//! The core data unit. An [`Item`] is a single entry in the
//! menu, either a plain-text string or a JSON object with
//! structured fields.
use serde_json::Value;
/// A menu entry wrapping a JSON value. Plain text is stored
/// 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. Sublabel, icon, and group are also cached
/// for structured items.
///
/// Serializes as the inner Value (transparent for output)
/// but construction always requires label extraction.
#[derive(Debug, Clone)]
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. 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();
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. 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,
}
}
/// Get the display label for this item. Cached at
/// construction time, so this is just a borrow.
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.
fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str {
match value {
Value::String(s) => s.as_str(),
Value::Object(map) => map.get(key).and_then(|v| v.as_str()).unwrap_or(""),
_ => "",
}
}
/// 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)
}
}
impl<'de> serde::Deserialize<'de> for Item {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
// Default to "label" key when deserializing. Callers that need
// a different key should construct via Item::new() instead.
Ok(Item::new(value, "label"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn label_from_string() {
let item = Item::new(json!("hello"), "label");
assert_eq!(item.label(), "hello");
}
#[test]
fn label_from_object_with_key() {
let item = Item::new(json!({"label": "foo", "value": 42}), "label");
assert_eq!(item.label(), "foo");
}
#[test]
fn label_from_object_missing_key() {
let item = Item::new(json!({"name": "bar"}), "label");
assert_eq!(item.label(), "");
}
#[test]
fn label_from_object_custom_key() {
let item = Item::new(json!({"name": "bar", "label": "ignored"}), "name");
assert_eq!(item.label(), "bar");
}
#[test]
fn label_from_number() {
let item = Item::new(json!(42), "label");
assert_eq!(item.label(), "");
}
#[test]
fn label_from_null() {
let item = Item::new(json!(null), "label");
assert_eq!(item.label(), "");
}
#[test]
fn from_plain_text() {
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]
fn serialize_string_item() {
let item = Item::from_plain_text("test");
let json = serde_json::to_string(&item).unwrap_or_default();
assert_eq!(json, "\"test\"");
}
#[test]
fn serialize_object_item() {
let item = Item::new(json!({"label": "foo"}), "label");
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());
}
}