295 lines
9.4 KiB
Rust
295 lines
9.4 KiB
Rust
//! 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<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: 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<Mutex<Vec<HookEventKind>>>,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|