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:
@@ -11,6 +11,8 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::hook::HookResponse;
|
||||
|
||||
/// Input mode. Insert mode sends keystrokes to the filter,
|
||||
/// normal mode uses vim-style navigation keybinds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -39,6 +41,10 @@ pub enum Action {
|
||||
Cancel,
|
||||
Resize { height: u16 },
|
||||
AddItems(Vec<Value>),
|
||||
ReplaceItems(Vec<Value>),
|
||||
RemoveItems(Vec<usize>),
|
||||
ProcessHookResponse(HookResponse),
|
||||
CloseMenu,
|
||||
}
|
||||
|
||||
/// Broadcast from the menu loop to all subscribers
|
||||
@@ -73,6 +79,7 @@ pub struct ViewState {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VisibleItem {
|
||||
pub label: String,
|
||||
pub formatted_text: Option<String>,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
@@ -81,6 +88,6 @@ pub struct VisibleItem {
|
||||
#[must_use]
|
||||
#[derive(Debug)]
|
||||
pub enum MenuResult {
|
||||
Selected(Value),
|
||||
Selected { value: Value, index: usize },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod event;
|
||||
pub mod item;
|
||||
pub mod output;
|
||||
pub mod traits;
|
||||
|
||||
110
crates/pikl-core/src/model/output.rs
Normal file
110
crates/pikl-core/src/model/output.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
//! Structured output for selected items. Wraps the original
|
||||
//! item value with action context and original index so
|
||||
//! hooks and downstream tools know what happened.
|
||||
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// What the user did to produce this output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputAction {
|
||||
Select,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// A selected item wrapped with output context. For object
|
||||
/// values, the original fields are merged at the top level
|
||||
/// alongside `action` and `index`. For non-object values
|
||||
/// (plain strings, numbers), the value appears as a `value`
|
||||
/// field.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutputItem {
|
||||
pub value: Value,
|
||||
pub action: OutputAction,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
impl Serialize for OutputItem {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match &self.value {
|
||||
Value::Object(map) => {
|
||||
// Flatten: merge object fields with action/index
|
||||
let mut s = serializer.serialize_map(Some(map.len() + 2))?;
|
||||
for (k, v) in map {
|
||||
s.serialize_entry(k, v)?;
|
||||
}
|
||||
s.serialize_entry("action", &self.action)?;
|
||||
s.serialize_entry("index", &self.index)?;
|
||||
s.end()
|
||||
}
|
||||
_ => {
|
||||
// Non-object: put value in a "value" field
|
||||
let mut s = serializer.serialize_map(Some(3))?;
|
||||
s.serialize_entry("value", &self.value)?;
|
||||
s.serialize_entry("action", &self.action)?;
|
||||
s.serialize_entry("index", &self.index)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn output_item_flattens_object() {
|
||||
let item = OutputItem {
|
||||
value: json!({"label": "Firefox", "url": "https://firefox.com"}),
|
||||
action: OutputAction::Select,
|
||||
index: 3,
|
||||
};
|
||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||
assert_eq!(json["label"], "Firefox");
|
||||
assert_eq!(json["url"], "https://firefox.com");
|
||||
assert_eq!(json["action"], "select");
|
||||
assert_eq!(json["index"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_item_string_value() {
|
||||
let item = OutputItem {
|
||||
value: json!("hello"),
|
||||
action: OutputAction::Select,
|
||||
index: 0,
|
||||
};
|
||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||
assert_eq!(json["value"], "hello");
|
||||
assert_eq!(json["action"], "select");
|
||||
assert_eq!(json["index"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_item_cancel_action() {
|
||||
let item = OutputItem {
|
||||
value: json!(null),
|
||||
action: OutputAction::Cancel,
|
||||
index: 0,
|
||||
};
|
||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||
assert_eq!(json["action"], "cancel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_item_string_contains_value_text() {
|
||||
let item = OutputItem {
|
||||
value: json!("alpha"),
|
||||
action: OutputAction::Select,
|
||||
index: 0,
|
||||
};
|
||||
let serialized = serde_json::to_string(&item).unwrap_or_default();
|
||||
assert!(
|
||||
serialized.contains("alpha"),
|
||||
"output should contain the value text: {serialized}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,25 @@ pub trait Menu: Send + 'static {
|
||||
/// Get the JSON value of a filtered item for output.
|
||||
/// Returns a reference to the stored value.
|
||||
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>;
|
||||
|
||||
/// Get the original index of a filtered item. Used to
|
||||
/// provide the item's position in the unfiltered list
|
||||
/// for output and hook events.
|
||||
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
||||
|
||||
/// Replace all items with a new set of values. Used by
|
||||
/// handler hook `replace_items` responses.
|
||||
fn replace_all(&mut self, values: Vec<serde_json::Value>);
|
||||
|
||||
/// Remove items at the given original indices. Used by
|
||||
/// handler hook `remove_items` responses.
|
||||
fn remove_by_indices(&mut self, indices: Vec<usize>);
|
||||
|
||||
/// Get the formatted display text for a filtered item,
|
||||
/// if a format template is configured. Returns None if
|
||||
/// no template is set, in which case the raw label is
|
||||
/// used.
|
||||
fn formatted_label(&self, _filtered_index: usize) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user