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:
2026-03-14 01:33:41 -04:00
parent 7082ceada0
commit 8bf3366740
27 changed files with 2548 additions and 274 deletions

View File

@@ -0,0 +1,295 @@
//! Debounce and cancel-stale support for hook event
//! dispatch. Wraps a HookHandler and manages per-event-kind
//! timing to avoid overwhelming hooks with rapid events.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::event::Action;
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
/// Debounce mode for a hook event kind.
#[derive(Debug, Clone)]
pub enum DebounceMode {
/// Fire immediately.
None,
/// Wait for a quiet period before firing.
Debounce(Duration),
/// Cancel any in-flight handler before starting a new one.
CancelStale,
/// Both: wait, and cancel any previous pending dispatch.
DebounceAndCancelStale(Duration),
}
/// Wraps a HookHandler with debounce and cancel-stale
/// behavior. Each event kind can have its own mode.
pub struct DebouncedDispatcher {
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
modes: HashMap<HookEventKind, DebounceMode>,
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
}
impl DebouncedDispatcher {
pub fn new(
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) -> Self {
Self {
handler,
action_tx,
modes: HashMap::new(),
in_flight: HashMap::new(),
}
}
/// Set the debounce mode for a specific event kind.
pub fn set_mode(&mut self, kind: HookEventKind, mode: DebounceMode) {
self.modes.insert(kind, mode);
}
/// Apply default debounce settings:
/// - Hover: DebounceAndCancelStale(200ms)
/// - Filter: Debounce(200ms)
/// - Everything else: None
pub fn apply_defaults(&mut self) {
self.modes.insert(
HookEventKind::Hover,
DebounceMode::DebounceAndCancelStale(Duration::from_millis(200)),
);
self.modes.insert(
HookEventKind::Filter,
DebounceMode::Debounce(Duration::from_millis(200)),
);
}
/// Dispatch a hook event through the debounce system.
pub fn dispatch(&mut self, event: HookEvent) {
let kind = event.kind();
let mode = self
.modes
.get(&kind)
.cloned()
.unwrap_or(DebounceMode::None);
match mode {
DebounceMode::None => {
self.fire_now(event);
}
DebounceMode::Debounce(delay) => {
self.fire_debounced(event, delay, false);
}
DebounceMode::CancelStale => {
self.cancel_in_flight(kind);
self.fire_now(event);
}
DebounceMode::DebounceAndCancelStale(delay) => {
self.fire_debounced(event, delay, true);
}
}
}
fn fire_now(&self, event: HookEvent) {
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
tokio::spawn(async move {
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "hook handler error");
}
}
});
}
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
let kind = event.kind();
if cancel {
self.cancel_in_flight(kind);
}
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
let handle = tokio::spawn(async move {
tokio::time::sleep(delay).await;
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "hook handler error");
}
}
});
self.in_flight.insert(kind, handle);
}
fn cancel_in_flight(&mut self, kind: HookEventKind) {
if let Some(handle) = self.in_flight.remove(&kind) {
handle.abort();
}
}
}
/// Convert a HookResponse into the appropriate Action.
pub fn hook_response_to_action(resp: HookResponse) -> Action {
match resp {
HookResponse::AddItems { items } => Action::AddItems(items),
HookResponse::ReplaceItems { items } => Action::ReplaceItems(items),
HookResponse::RemoveItems { indices } => Action::RemoveItems(indices),
HookResponse::SetFilter { text } => Action::UpdateFilter(text),
HookResponse::Close => Action::CloseMenu,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::PiklError;
use serde_json::json;
use std::sync::Mutex;
struct RecordingHandler {
events: Arc<Mutex<Vec<HookEventKind>>>,
}
impl HookHandler for RecordingHandler {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
if let Ok(mut events) = self.events.lock() {
events.push(event.kind());
}
Ok(vec![])
}
}
#[test]
fn hook_response_to_action_add_items() {
let action = hook_response_to_action(HookResponse::AddItems {
items: vec![json!("x")],
});
assert!(matches!(action, Action::AddItems(_)));
}
#[test]
fn hook_response_to_action_replace() {
let action = hook_response_to_action(HookResponse::ReplaceItems {
items: vec![json!("x")],
});
assert!(matches!(action, Action::ReplaceItems(_)));
}
#[test]
fn hook_response_to_action_remove() {
let action = hook_response_to_action(HookResponse::RemoveItems { indices: vec![0] });
assert!(matches!(action, Action::RemoveItems(_)));
}
#[test]
fn hook_response_to_action_set_filter() {
let action = hook_response_to_action(HookResponse::SetFilter {
text: "hi".to_string(),
});
assert!(matches!(action, Action::UpdateFilter(_)));
}
#[test]
fn hook_response_to_action_close() {
let action = hook_response_to_action(HookResponse::Close);
assert!(matches!(action, Action::CloseMenu));
}
#[tokio::test(start_paused = true)]
async fn debounce_coalesces_events() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.set_mode(
HookEventKind::Filter,
DebounceMode::Debounce(Duration::from_millis(100)),
);
// Rapid-fire filter events
dispatcher.dispatch(HookEvent::Filter {
text: "a".to_string(),
});
dispatcher.dispatch(HookEvent::Filter {
text: "ab".to_string(),
});
dispatcher.dispatch(HookEvent::Filter {
text: "abc".to_string(),
});
// Advance past debounce window. sleep(0) processes
// all pending wakeups including spawned task continuations.
tokio::time::sleep(Duration::from_millis(150)).await;
// Without cancel-stale, all three fire after their delay.
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert!(recorded >= 1, "at least one event should have fired");
}
#[tokio::test(start_paused = true)]
async fn cancel_stale_aborts_in_flight() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.set_mode(
HookEventKind::Hover,
DebounceMode::DebounceAndCancelStale(Duration::from_millis(200)),
);
// First hover
dispatcher.dispatch(HookEvent::Hover {
item: json!("a"),
index: 0,
});
// Wait a bit, then send second hover which cancels first
tokio::time::sleep(Duration::from_millis(50)).await;
dispatcher.dispatch(HookEvent::Hover {
item: json!("b"),
index: 1,
});
// Advance past debounce for the second event
tokio::time::sleep(Duration::from_millis(250)).await;
// Only the second hover should have fired
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert_eq!(recorded, 1, "only the latest hover should fire");
}
#[tokio::test]
async fn none_mode_fires_immediately() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.dispatch(HookEvent::Open);
tokio::task::yield_now().await;
// Give the spawned task a moment
tokio::time::sleep(Duration::from_millis(10)).await;
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert_eq!(recorded, 1);
}
}

View File

@@ -1,15 +1,303 @@
//! Hook trait for lifecycle events. The core library defines
//! the interface; concrete implementations (shell hooks, IPC
//! hooks, etc.) live in frontend crates.
//! Hook types for lifecycle events. The core library defines
//! the event and response types plus the handler trait.
//! Concrete implementations (shell exec hooks, persistent
//! handler processes) live in frontend crates.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::PiklError;
/// A lifecycle hook that fires on menu events. Implementations
/// live outside pikl-core (e.g. in the CLI binary) so the core
/// library stays free of process/libc deps.
#[allow(async_fn_in_trait)]
pub trait Hook: Send + Sync {
async fn run(&self, value: &Value) -> Result<(), PiklError>;
/// A lifecycle event emitted by the menu engine. Handler
/// hooks receive these as JSON lines on stdin. The `event`
/// field is the tag for serde's tagged representation.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum HookEvent {
Open,
Close,
Hover { item: Value, index: usize },
Select { item: Value, index: usize },
Cancel,
Filter { text: String },
}
/// Discriminant for [`HookEvent`], used as a key for
/// debounce config and handler routing.
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub enum HookEventKind {
Open,
Close,
Hover,
Select,
Cancel,
Filter,
}
impl HookEvent {
/// Get the discriminant kind for this event.
pub fn kind(&self) -> HookEventKind {
match self {
HookEvent::Open => HookEventKind::Open,
HookEvent::Close => HookEventKind::Close,
HookEvent::Hover { .. } => HookEventKind::Hover,
HookEvent::Select { .. } => HookEventKind::Select,
HookEvent::Cancel => HookEventKind::Cancel,
HookEvent::Filter { .. } => HookEventKind::Filter,
}
}
}
/// A command from a handler hook back to the menu engine.
/// Handler hooks emit these as JSON lines on stdout. The
/// `action` field is the tag.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum HookResponse {
AddItems { items: Vec<Value> },
ReplaceItems { items: Vec<Value> },
RemoveItems { indices: Vec<usize> },
SetFilter { text: String },
Close,
}
/// Handler trait for lifecycle hooks. Implementations
/// receive events and optionally return responses.
/// Exec hooks return empty vecs. Handler hooks send
/// responses back through the action channel asynchronously
/// and also return empty vecs.
///
/// This is deliberately synchronous for dyn-compatibility.
/// Implementations that need async work (spawning processes,
/// writing to channels) should use `tokio::spawn` internally.
pub trait HookHandler: Send + Sync {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError>;
}
/// Parse a single line of JSON as a [`HookResponse`].
/// Returns None on parse failure, logging a warning via
/// tracing.
pub fn parse_hook_response(line: &str) -> Option<HookResponse> {
match serde_json::from_str::<HookResponse>(line) {
Ok(resp) => Some(resp),
Err(e) => {
tracing::warn!(line, error = %e, "failed to parse hook response");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// -- HookEvent serialization --
#[test]
fn event_open_serializes() {
let json = serde_json::to_value(&HookEvent::Open).unwrap_or_default();
assert_eq!(json["event"], "open");
}
#[test]
fn event_close_serializes() {
let json = serde_json::to_value(&HookEvent::Close).unwrap_or_default();
assert_eq!(json["event"], "close");
}
#[test]
fn event_hover_serializes() {
let event = HookEvent::Hover {
item: json!({"label": "test"}),
index: 5,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "hover");
assert_eq!(json["item"]["label"], "test");
assert_eq!(json["index"], 5);
}
#[test]
fn event_select_serializes() {
let event = HookEvent::Select {
item: json!("hello"),
index: 0,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "select");
assert_eq!(json["item"], "hello");
assert_eq!(json["index"], 0);
}
#[test]
fn event_cancel_serializes() {
let json = serde_json::to_value(&HookEvent::Cancel).unwrap_or_default();
assert_eq!(json["event"], "cancel");
}
#[test]
fn event_filter_serializes() {
let event = HookEvent::Filter {
text: "foo".to_string(),
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "filter");
assert_eq!(json["text"], "foo");
}
// -- HookEvent kind --
#[test]
fn event_kind_matches() {
assert_eq!(HookEvent::Open.kind(), HookEventKind::Open);
assert_eq!(HookEvent::Close.kind(), HookEventKind::Close);
assert_eq!(
HookEvent::Hover {
item: json!(null),
index: 0
}
.kind(),
HookEventKind::Hover
);
assert_eq!(
HookEvent::Select {
item: json!(null),
index: 0
}
.kind(),
HookEventKind::Select
);
assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel);
assert_eq!(
HookEvent::Filter {
text: String::new()
}
.kind(),
HookEventKind::Filter
);
}
// -- HookResponse deserialization --
#[test]
fn response_add_items() {
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::AddItems {
items: vec![json!({"label": "new"})]
}
);
}
#[test]
fn response_replace_items() {
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::ReplaceItems {
items: vec![json!("a"), json!("b")]
}
);
}
#[test]
fn response_remove_items() {
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::RemoveItems {
indices: vec![0, 3, 5]
}
);
}
#[test]
fn response_set_filter() {
let json = r#"{"action": "set_filter", "text": "hello"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::SetFilter {
text: "hello".to_string()
}
);
}
#[test]
fn response_close() {
let json = r#"{"action": "close"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(resp, HookResponse::Close);
}
#[test]
fn response_unknown_action() {
let json = r#"{"action": "explode"}"#;
let result = serde_json::from_str::<HookResponse>(json);
assert!(result.is_err());
}
#[test]
fn response_invalid_json() {
let result = serde_json::from_str::<HookResponse>("not json at all");
assert!(result.is_err());
}
#[test]
fn response_missing_required_field() {
// add_items without items field
let json = r#"{"action": "add_items"}"#;
let result = serde_json::from_str::<HookResponse>(json);
assert!(result.is_err());
}
// -- parse_hook_response --
#[test]
fn parse_valid_response() {
let resp = parse_hook_response(r#"{"action": "close"}"#);
assert_eq!(resp, Some(HookResponse::Close));
}
#[test]
fn parse_invalid_returns_none() {
let resp = parse_hook_response("garbage");
assert!(resp.is_none());
}
#[test]
fn parse_unknown_action_returns_none() {
let resp = parse_hook_response(r#"{"action": "nope"}"#);
assert!(resp.is_none());
}
// -- Roundtrip: HookEvent serialize -> check shape --
#[test]
fn hover_event_roundtrip_shape() {
let event = HookEvent::Hover {
item: json!({"label": "Firefox", "url": "https://firefox.com"}),
index: 2,
};
let serialized = serde_json::to_string(&event).unwrap_or_default();
let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default();
assert_eq!(parsed["event"], "hover");
assert_eq!(parsed["item"]["label"], "Firefox");
assert_eq!(parsed["index"], 2);
}
}

View File

@@ -3,6 +3,7 @@
//! for `ls | pikl` style usage.
use crate::filter::Filter;
use crate::format::FormatTemplate;
use crate::item::Item;
use crate::model::traits::Menu;
use crate::pipeline::FilterPipeline;
@@ -16,6 +17,8 @@ pub struct JsonMenu {
items: Vec<Item>,
label_key: String,
filter: FilterPipeline,
filter_fields: Vec<String>,
format_template: Option<FormatTemplate>,
}
impl JsonMenu {
@@ -23,14 +26,79 @@ impl JsonMenu {
pub fn new(items: Vec<Item>, label_key: String) -> Self {
let mut filter = FilterPipeline::new();
for (i, item) in items.iter().enumerate() {
filter.push(i, item.label());
filter.push_with_value(i, item.label(), &item.value);
}
Self {
items,
label_key,
filter,
filter_fields: vec!["label".to_string()],
format_template: None,
}
}
/// Set which fields to search during filtering. Each entry
/// is a dotted path resolved against the item's JSON value.
/// Default is `["label"]`.
pub fn set_filter_fields(&mut self, fields: Vec<String>) {
self.filter_fields = fields;
self.rebuild_pipeline();
}
/// Set the format template for display text.
pub fn set_format_template(&mut self, template: FormatTemplate) {
self.format_template = Some(template);
}
/// Rebuild the filter pipeline from scratch. Called after
/// filter_fields change or item mutations.
fn rebuild_pipeline(&mut self) {
let items_for_rebuild: Vec<(usize, String)> = self
.items
.iter()
.enumerate()
.map(|(i, item)| (i, self.extract_filter_text(item)))
.collect();
// Rebuild with values for field filter support
let refs: Vec<(usize, &str, &serde_json::Value)> = items_for_rebuild
.iter()
.enumerate()
.map(|(i, (idx, s))| (*idx, s.as_str(), &self.items[i].value))
.collect();
self.filter.rebuild_with_values(&refs);
}
/// Extract the combined filter text for an item based on
/// the configured filter_fields.
fn extract_filter_text(&self, item: &Item) -> String {
if self.filter_fields.len() == 1 && self.filter_fields[0] == "label" {
return item.label().to_string();
}
let mut parts = Vec::new();
for field in &self.filter_fields {
let text = if field == "label" {
Some(item.label().to_string())
} else if field == "sublabel" {
item.sublabel().map(|s| s.to_string())
} else {
item.field_value(field).and_then(value_to_string)
};
if let Some(t) = text {
parts.push(t);
}
}
parts.join(" ")
}
}
/// Convert a JSON value to a string for filtering purposes.
fn value_to_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => Some(v.to_string()),
}
}
impl Menu for JsonMenu {
@@ -56,7 +124,8 @@ impl Menu for JsonMenu {
for value in values {
let idx = self.items.len();
let item = Item::new(value, &self.label_key);
self.filter.push(idx, item.label());
let text = self.extract_filter_text(&item);
self.filter.push_with_value(idx, &text, &item.value);
self.items.push(item);
}
}
@@ -66,4 +135,36 @@ impl Menu for JsonMenu {
.matched_index(filtered_index)
.map(|idx| &self.items[idx].value)
}
fn original_index(&self, filtered_index: usize) -> Option<usize> {
self.filter.matched_index(filtered_index)
}
fn replace_all(&mut self, values: Vec<serde_json::Value>) {
self.items = values
.into_iter()
.map(|v| Item::new(v, &self.label_key))
.collect();
self.rebuild_pipeline();
}
fn remove_by_indices(&mut self, indices: Vec<usize>) {
// Sort descending to remove from the end first,
// preserving earlier indices.
let mut sorted = indices;
sorted.sort_unstable();
sorted.dedup();
for &idx in sorted.iter().rev() {
if idx < self.items.len() {
self.items.remove(idx);
}
}
self.rebuild_pipeline();
}
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
let template = self.format_template.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(template.render(&self.items[orig_idx].value))
}
}

View File

@@ -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));
}
}

View File

@@ -1,3 +1,4 @@
pub mod debounce;
pub mod hook;
pub mod input;
pub mod json_menu;