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:
@@ -7,8 +7,10 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
|
||||
use crate::error::PiklError;
|
||||
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||
use crate::hook::{HookEvent, HookHandler};
|
||||
use crate::model::traits::Menu;
|
||||
use crate::navigation::Viewport;
|
||||
use serde_json::Value;
|
||||
@@ -20,9 +22,11 @@ pub enum ActionOutcome {
|
||||
/// State changed, broadcast to subscribers.
|
||||
Broadcast,
|
||||
/// User confirmed a selection.
|
||||
Selected(Value),
|
||||
Selected { value: Value, index: usize },
|
||||
/// User cancelled.
|
||||
Cancelled,
|
||||
/// Menu closed by hook command.
|
||||
Closed,
|
||||
/// Nothing happened (e.g. confirm on empty list).
|
||||
NoOp,
|
||||
}
|
||||
@@ -38,6 +42,8 @@ pub struct MenuRunner<M: Menu> {
|
||||
mode: Mode,
|
||||
action_rx: mpsc::Receiver<Action>,
|
||||
event_tx: broadcast::Sender<MenuEvent>,
|
||||
dispatcher: Option<DebouncedDispatcher>,
|
||||
previous_cursor: Option<usize>,
|
||||
}
|
||||
|
||||
impl<M: Menu> MenuRunner<M> {
|
||||
@@ -59,6 +65,8 @@ impl<M: Menu> MenuRunner<M> {
|
||||
mode: Mode::default(),
|
||||
action_rx,
|
||||
event_tx,
|
||||
dispatcher: None,
|
||||
previous_cursor: None,
|
||||
};
|
||||
(runner, action_tx)
|
||||
}
|
||||
@@ -69,6 +77,23 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Set a hook handler. Wraps it in a DebouncedDispatcher
|
||||
/// with no debounce (all events fire immediately). Use
|
||||
/// [`set_dispatcher`] for custom debounce settings.
|
||||
pub fn set_hook_handler(
|
||||
&mut self,
|
||||
handler: Arc<dyn HookHandler>,
|
||||
action_tx: mpsc::Sender<Action>,
|
||||
) {
|
||||
let dispatcher = DebouncedDispatcher::new(handler, action_tx);
|
||||
self.dispatcher = Some(dispatcher);
|
||||
}
|
||||
|
||||
/// Set a hook handler with a pre-configured dispatcher.
|
||||
pub fn set_dispatcher(&mut self, dispatcher: DebouncedDispatcher) {
|
||||
self.dispatcher = Some(dispatcher);
|
||||
}
|
||||
|
||||
/// Re-run the filter against all items with the current
|
||||
/// filter text. Updates the viewport with the new count.
|
||||
fn run_filter(&mut self) {
|
||||
@@ -83,9 +108,13 @@ impl<M: Menu> MenuRunner<M> {
|
||||
let visible_items: Vec<VisibleItem> = range
|
||||
.clone()
|
||||
.filter_map(|i| {
|
||||
self.menu.filtered_label(i).map(|label| VisibleItem {
|
||||
label: label.to_string(),
|
||||
index: i,
|
||||
self.menu.filtered_label(i).map(|label| {
|
||||
let formatted_text = self.menu.formatted_label(i);
|
||||
VisibleItem {
|
||||
label: label.to_string(),
|
||||
formatted_text,
|
||||
index: i,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -113,6 +142,35 @@ impl<M: Menu> MenuRunner<M> {
|
||||
.send(MenuEvent::StateChanged(self.build_view_state()));
|
||||
}
|
||||
|
||||
/// Emit a hook event through the dispatcher, if one is set.
|
||||
fn emit_hook(&mut self, event: HookEvent) {
|
||||
if let Some(dispatcher) = &mut self.dispatcher {
|
||||
dispatcher.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the cursor moved to a different item and
|
||||
/// emit a Hover event if so.
|
||||
fn check_cursor_hover(&mut self) {
|
||||
if self.menu.filtered_count() == 0 {
|
||||
self.previous_cursor = None;
|
||||
return;
|
||||
}
|
||||
let current = self.viewport.cursor();
|
||||
let current_orig = self.menu.original_index(current);
|
||||
if current_orig != self.previous_cursor {
|
||||
self.previous_cursor = current_orig;
|
||||
if let Some(value) = self.menu.serialize_filtered(current).cloned()
|
||||
&& let Some(orig_idx) = current_orig
|
||||
{
|
||||
self.emit_hook(HookEvent::Hover {
|
||||
item: value,
|
||||
index: orig_idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a single action to the menu state. Pure state
|
||||
/// transition: no channels, no async. Testable in isolation.
|
||||
pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
|
||||
@@ -151,8 +209,12 @@ impl<M: Menu> MenuRunner<M> {
|
||||
return ActionOutcome::NoOp;
|
||||
}
|
||||
let cursor = self.viewport.cursor();
|
||||
let index = self.menu.original_index(cursor).unwrap_or(0);
|
||||
match self.menu.serialize_filtered(cursor) {
|
||||
Some(value) => ActionOutcome::Selected(value.clone()),
|
||||
Some(value) => ActionOutcome::Selected {
|
||||
value: value.clone(),
|
||||
index,
|
||||
},
|
||||
None => ActionOutcome::NoOp,
|
||||
}
|
||||
}
|
||||
@@ -178,6 +240,41 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.run_filter();
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::ReplaceItems(values) => {
|
||||
// Smart cursor: try to keep selection on the same original item.
|
||||
let cursor = self.viewport.cursor();
|
||||
let old_value = self.menu.serialize_filtered(cursor).cloned();
|
||||
self.menu.replace_all(values);
|
||||
self.run_filter();
|
||||
// Try to find the old item in the new set
|
||||
if let Some(ref old_val) = old_value {
|
||||
let mut found = false;
|
||||
for i in 0..self.menu.filtered_count() {
|
||||
if self.menu.serialize_filtered(i) == Some(old_val) {
|
||||
self.viewport.set_cursor(i);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
self.viewport.clamp();
|
||||
}
|
||||
} else {
|
||||
self.viewport.clamp();
|
||||
}
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::RemoveItems(indices) => {
|
||||
self.menu.remove_by_indices(indices);
|
||||
self.run_filter();
|
||||
self.viewport.clamp();
|
||||
ActionOutcome::Broadcast
|
||||
}
|
||||
Action::ProcessHookResponse(resp) => {
|
||||
let action = hook_response_to_action(resp);
|
||||
self.apply_action(action)
|
||||
}
|
||||
Action::CloseMenu => ActionOutcome::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,14 +302,47 @@ impl<M: Menu> MenuRunner<M> {
|
||||
self.run_filter();
|
||||
self.broadcast_state();
|
||||
|
||||
// Emit Open event
|
||||
self.emit_hook(HookEvent::Open);
|
||||
|
||||
while let Some(action) = self.action_rx.recv().await {
|
||||
let is_filter_update = matches!(&action, Action::UpdateFilter(_));
|
||||
|
||||
match self.apply_action(action) {
|
||||
ActionOutcome::Broadcast => self.broadcast_state(),
|
||||
ActionOutcome::Selected(value) => {
|
||||
ActionOutcome::Broadcast => {
|
||||
self.broadcast_state();
|
||||
|
||||
// Emit Filter event if the filter changed
|
||||
if is_filter_update {
|
||||
let text = self.filter_text.to_string();
|
||||
self.emit_hook(HookEvent::Filter { text });
|
||||
}
|
||||
|
||||
// Check for cursor movement -> Hover
|
||||
self.check_cursor_hover();
|
||||
}
|
||||
ActionOutcome::Selected { value, index } => {
|
||||
// Emit Select event
|
||||
self.emit_hook(HookEvent::Select {
|
||||
item: value.clone(),
|
||||
index,
|
||||
});
|
||||
// Emit Close event
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
|
||||
return Ok(MenuResult::Selected(value));
|
||||
return Ok(MenuResult::Selected { value, index });
|
||||
}
|
||||
ActionOutcome::Cancelled => {
|
||||
self.emit_hook(HookEvent::Cancel);
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Cancelled);
|
||||
return Ok(MenuResult::Cancelled);
|
||||
}
|
||||
ActionOutcome::Closed => {
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self.event_tx.send(MenuEvent::Cancelled);
|
||||
return Ok(MenuResult::Cancelled);
|
||||
}
|
||||
@@ -221,6 +351,7 @@ impl<M: Menu> MenuRunner<M> {
|
||||
}
|
||||
|
||||
// Sender dropped
|
||||
self.emit_hook(HookEvent::Close);
|
||||
Ok(MenuResult::Cancelled)
|
||||
}
|
||||
}
|
||||
@@ -285,7 +416,7 @@ mod tests {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
let outcome = m.apply_action(Action::Confirm);
|
||||
assert!(matches!(&outcome, ActionOutcome::Selected(v) if v.as_str() == Some("beta")));
|
||||
assert!(matches!(&outcome, ActionOutcome::Selected { value, .. } if value.as_str() == Some("beta")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,7 +544,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(_))));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -474,7 +605,7 @@ mod tests {
|
||||
assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha")));
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("alpha")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("alpha")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -496,7 +627,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("gamma")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("gamma")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -521,7 +652,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -556,7 +687,7 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("epsilon")));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("epsilon")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -608,7 +739,7 @@ mod tests {
|
||||
// Must get "banana". Filter was applied before confirm ran.
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("banana")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -634,7 +765,7 @@ mod tests {
|
||||
// Cursor at index 3 -> "delta"
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -730,7 +861,129 @@ mod tests {
|
||||
// Must find "zephyr". It was added before the filter ran.
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("zephyr")
|
||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr")
|
||||
));
|
||||
}
|
||||
|
||||
// -- Replace/Remove/Close action tests --
|
||||
|
||||
#[test]
|
||||
fn apply_replace_items() {
|
||||
let mut m = ready_menu();
|
||||
assert_eq!(m.menu.total(), 4);
|
||||
let outcome = m.apply_action(Action::ReplaceItems(vec![
|
||||
serde_json::Value::String("x".to_string()),
|
||||
serde_json::Value::String("y".to_string()),
|
||||
]));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 2);
|
||||
assert_eq!(m.menu.filtered_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_replace_items_preserves_cursor() {
|
||||
let mut m = ready_menu();
|
||||
// Move to "beta" (index 1)
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
// Replace items, keeping "beta" in the new set
|
||||
m.apply_action(Action::ReplaceItems(vec![
|
||||
serde_json::Value::String("alpha".to_string()),
|
||||
serde_json::Value::String("beta".to_string()),
|
||||
serde_json::Value::String("zeta".to_string()),
|
||||
]));
|
||||
// Cursor should still be on "beta"
|
||||
let vs = m.build_view_state();
|
||||
assert_eq!(vs.visible_items[vs.cursor].label, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_remove_items() {
|
||||
let mut m = ready_menu();
|
||||
assert_eq!(m.menu.total(), 4);
|
||||
let outcome = m.apply_action(Action::RemoveItems(vec![1, 3]));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 2);
|
||||
// alpha and gamma should remain
|
||||
assert_eq!(m.menu.filtered_label(0), Some("alpha"));
|
||||
assert_eq!(m.menu.filtered_label(1), Some("gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_close_menu() {
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::CloseMenu);
|
||||
assert!(matches!(outcome, ActionOutcome::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_hook_response_close() {
|
||||
use crate::hook::HookResponse;
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::Close));
|
||||
assert!(matches!(outcome, ActionOutcome::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_hook_response_add_items() {
|
||||
use crate::hook::HookResponse;
|
||||
let mut m = ready_menu();
|
||||
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::AddItems {
|
||||
items: vec![serde_json::json!("new")],
|
||||
}));
|
||||
assert!(matches!(outcome, ActionOutcome::Broadcast));
|
||||
assert_eq!(m.menu.total(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_returns_original_index() {
|
||||
let mut m = ready_menu();
|
||||
// Filter to narrow results, then confirm
|
||||
m.apply_action(Action::UpdateFilter("del".to_string()));
|
||||
assert!(m.menu.filtered_count() >= 1);
|
||||
let outcome = m.apply_action(Action::Confirm);
|
||||
// "delta" is at original index 3
|
||||
assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. }));
|
||||
}
|
||||
|
||||
// -- Hook event tests --
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_events_fire_on_lifecycle() {
|
||||
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct Recorder(Mutex<Vec<HookEventKind>>);
|
||||
impl HookHandler for Recorder {
|
||||
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
|
||||
if let Ok(mut v) = self.0.lock() {
|
||||
v.push(event.kind());
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
let recorder = Arc::new(Recorder(Mutex::new(Vec::new())));
|
||||
let (mut m, action_tx) = test_menu();
|
||||
m.set_hook_handler(Arc::clone(&recorder) as Arc<dyn HookHandler>, action_tx);
|
||||
m.run_filter();
|
||||
m.apply_action(Action::Resize { height: 10 });
|
||||
|
||||
// Simulate lifecycle: the Open event is emitted in run(),
|
||||
// but we can test Filter/Hover/Cancel manually
|
||||
m.emit_hook(HookEvent::Open);
|
||||
m.apply_action(Action::UpdateFilter("al".to_string()));
|
||||
m.emit_hook(HookEvent::Filter {
|
||||
text: "al".to_string(),
|
||||
});
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
m.check_cursor_hover();
|
||||
|
||||
// Give spawned tasks a chance to complete
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
let events = recorder.0.lock().map(|v| v.clone()).unwrap_or_default();
|
||||
assert!(events.contains(&HookEventKind::Open));
|
||||
assert!(events.contains(&HookEventKind::Filter));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user