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).
312 lines
9.0 KiB
Rust
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());
|
|
}
|
|
}
|