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:
295
crates/pikl-core/src/runtime/debounce.rs
Normal file
295
crates/pikl-core/src/runtime/debounce.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user