//! 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 tracing::Instrument; 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, _action_tx: mpsc::Sender, modes: HashMap, in_flight: HashMap>, } impl DebouncedDispatcher { pub fn new( handler: Arc, action_tx: mpsc::Sender, ) -> Self { Self { handler, _action_tx: 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); tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event"); 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 span = tracing::debug_span!("hook_fire", event_kind = ?event.kind()); tokio::spawn( async move { if let Err(e) = handler.handle(event) { tracing::warn!(error = %e, "hook handler error"); } } .instrument(span), ); } fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) { let kind = event.kind(); if cancel { self.cancel_in_flight(kind); } let delay_ms = delay.as_millis() as u64; tracing::debug!(delay_ms, cancel_stale = cancel, "debounced hook scheduled"); let handler = Arc::clone(&self.handler); let span = tracing::debug_span!("hook_fire", event_kind = ?kind); let handle = tokio::spawn( async move { tokio::time::sleep(delay).await; if let Err(e) = handler.handle(event) { tracing::warn!(error = %e, "hook handler error"); } } .instrument(span), ); self.in_flight.insert(kind, handle); } fn cancel_in_flight(&mut self, kind: HookEventKind) { if let Some(handle) = self.in_flight.remove(&kind) { tracing::debug!(event_kind = ?kind, "cancelled in-flight hook"); 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>>, } impl HookHandler for RecordingHandler { fn handle(&self, event: HookEvent) -> Result<(), PiklError> { if let Ok(mut events) = self.events.lock() { events.push(event.kind()); } Ok(()) } } #[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); } }