egui_cha/helpers/
debounce.rs

1//! Debounce helper for TEA applications
2//!
3//! Debouncing delays action until input stops for a specified duration.
4//! Useful for search inputs, form validation, auto-save, etc.
5
6use super::clock::Clock;
7#[cfg(feature = "tokio")]
8use crate::Cmd;
9use std::time::{Duration, Instant};
10
11/// Debouncer - delays action until input stops
12///
13/// # How it works
14/// Each call to `trigger()` resets the timer. The message is only
15/// delivered after the specified delay has passed without any new triggers.
16///
17/// # Example
18/// ```ignore
19/// use egui_cha::helpers::Debouncer;
20/// use std::time::Duration;
21///
22/// struct Model {
23///     search_query: String,
24///     search_debouncer: Debouncer,
25/// }
26///
27/// enum Msg {
28///     SearchInput(String),
29///     DoSearch,           // Debounced trigger
30///     SearchComplete,     // Actual search execution
31/// }
32///
33/// fn update(model: &mut Model, msg: Msg) -> Cmd<Msg> {
34///     match msg {
35///         Msg::SearchInput(text) => {
36///             model.search_query = text;
37///             // Returns Cmd::delay - resets on each keystroke
38///             model.search_debouncer.trigger(
39///                 Duration::from_millis(300),
40///                 Msg::DoSearch,
41///             )
42///         }
43///         Msg::DoSearch => {
44///             // Only fire if this is the latest trigger
45///             if model.search_debouncer.should_fire() {
46///                 // Perform actual search
47///                 Cmd::task(async { Msg::SearchComplete })
48///             } else {
49///                 Cmd::none()
50///             }
51///         }
52///         _ => Cmd::none()
53///     }
54/// }
55/// ```
56#[derive(Debug, Clone)]
57pub struct Debouncer {
58    pending_until: Option<Instant>,
59}
60
61impl Default for Debouncer {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl Debouncer {
68    /// Create a new debouncer
69    pub fn new() -> Self {
70        Self {
71            pending_until: None,
72        }
73    }
74
75    /// Trigger a debounced action
76    ///
77    /// Returns `Cmd::delay` that will deliver the message after the specified delay.
78    /// Each call resets the internal timer, so rapid calls will keep delaying
79    /// until input stops.
80    ///
81    /// When the delayed message arrives in `update()`, call `should_fire()`
82    /// to check if this is the latest trigger.
83    ///
84    /// Requires the `tokio` feature.
85    #[cfg(feature = "tokio")]
86    pub fn trigger<Msg>(&mut self, delay: Duration, msg: Msg) -> Cmd<Msg>
87    where
88        Msg: Clone + Send + 'static,
89    {
90        let fire_at = Instant::now() + delay;
91        self.pending_until = Some(fire_at);
92        Cmd::delay(delay, msg)
93    }
94
95    /// Mark trigger time without returning a Cmd
96    ///
97    /// Use this when you want to manage the delay yourself (e.g., with a different async runtime).
98    /// Call `should_fire()` after the delay to check if it should fire.
99    pub fn mark_trigger(&mut self, delay: Duration) {
100        let fire_at = Instant::now() + delay;
101        self.pending_until = Some(fire_at);
102    }
103
104    /// Check if the debounced action should fire
105    ///
106    /// Call this when the delayed message arrives to verify it's the latest trigger.
107    /// Returns `true` if enough time has passed since the last `trigger()` call.
108    ///
109    /// This also clears the pending state if firing.
110    pub fn should_fire(&mut self) -> bool {
111        match self.pending_until {
112            Some(until) if Instant::now() >= until => {
113                self.pending_until = None;
114                true
115            }
116            Some(_) => false, // Not yet time (newer trigger exists)
117            None => false,    // No pending trigger
118        }
119    }
120
121    /// Check if there's a pending debounce (without firing)
122    pub fn is_pending(&self) -> bool {
123        self.pending_until.is_some()
124    }
125
126    /// Cancel any pending debounced action
127    ///
128    /// The next delayed message will be ignored by `should_fire()`.
129    pub fn cancel(&mut self) {
130        self.pending_until = None;
131    }
132
133    /// Reset the debouncer state
134    ///
135    /// Same as `cancel()`, but semantically for cleanup.
136    pub fn reset(&mut self) {
137        self.pending_until = None;
138    }
139}
140
141// ============================================
142// DebouncerWithClock - testable version
143// ============================================
144
145/// A debouncer with pluggable clock for testing
146///
147/// Like [`Debouncer`], but uses a [`Clock`] trait for time access,
148/// enabling deterministic testing with [`FakeClock`](crate::testing::FakeClock).
149///
150/// # Example
151/// ```ignore
152/// use egui_cha::testing::FakeClock;
153/// use egui_cha::helpers::DebouncerWithClock;
154/// use std::time::Duration;
155///
156/// let clock = FakeClock::new();
157/// let mut debouncer = DebouncerWithClock::new(clock.clone());
158///
159/// debouncer.trigger(Duration::from_millis(500), Msg::Search);
160/// assert!(!debouncer.should_fire()); // Not yet
161///
162/// clock.advance(Duration::from_millis(600));
163/// assert!(debouncer.should_fire()); // Now it fires
164/// ```
165#[derive(Debug, Clone)]
166pub struct DebouncerWithClock<C: Clock> {
167    clock: C,
168    pending_until: Option<Duration>,
169}
170
171impl<C: Clock> DebouncerWithClock<C> {
172    /// Create a new debouncer with the given clock
173    pub fn new(clock: C) -> Self {
174        Self {
175            clock,
176            pending_until: None,
177        }
178    }
179
180    /// Trigger a debounced action
181    ///
182    /// Returns `Cmd::delay` that will deliver the message after the specified delay.
183    /// Each call resets the internal timer.
184    ///
185    /// Requires the `tokio` feature.
186    #[cfg(feature = "tokio")]
187    pub fn trigger<Msg>(&mut self, delay: Duration, msg: Msg) -> Cmd<Msg>
188    where
189        Msg: Clone + Send + 'static,
190    {
191        let fire_at = self.clock.now() + delay;
192        self.pending_until = Some(fire_at);
193        Cmd::delay(delay, msg)
194    }
195
196    /// Mark trigger time without returning a Cmd
197    ///
198    /// Use this when you want to manage the delay yourself.
199    /// Call `should_fire()` after the delay to check if it should fire.
200    pub fn mark_trigger(&mut self, delay: Duration) {
201        let fire_at = self.clock.now() + delay;
202        self.pending_until = Some(fire_at);
203    }
204
205    /// Check if the debounced action should fire
206    ///
207    /// Returns `true` if enough time has passed since the last `trigger()` call.
208    pub fn should_fire(&mut self) -> bool {
209        match self.pending_until {
210            Some(until) if self.clock.now() >= until => {
211                self.pending_until = None;
212                true
213            }
214            Some(_) => false,
215            None => false,
216        }
217    }
218
219    /// Check if there's a pending debounce
220    pub fn is_pending(&self) -> bool {
221        self.pending_until.is_some()
222    }
223
224    /// Cancel any pending debounced action
225    pub fn cancel(&mut self) {
226        self.pending_until = None;
227    }
228
229    /// Reset the debouncer state
230    pub fn reset(&mut self) {
231        self.pending_until = None;
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::thread;
239
240    #[test]
241    #[cfg(feature = "tokio")]
242    fn test_debouncer_basic() {
243        let mut debouncer = Debouncer::new();
244
245        // Trigger with short delay
246        let _cmd = debouncer.trigger::<()>(Duration::from_millis(10), ());
247        assert!(debouncer.is_pending());
248
249        // Wait for delay
250        thread::sleep(Duration::from_millis(15));
251        assert!(debouncer.should_fire());
252        assert!(!debouncer.is_pending());
253    }
254
255    #[test]
256    #[cfg(feature = "tokio")]
257    fn test_debouncer_reset_on_trigger() {
258        let mut debouncer = Debouncer::new();
259
260        // First trigger
261        let _cmd = debouncer.trigger::<()>(Duration::from_millis(50), ());
262
263        // Wait partial time
264        thread::sleep(Duration::from_millis(30));
265        assert!(!debouncer.should_fire()); // Not yet
266
267        // Trigger again (resets timer)
268        let _cmd = debouncer.trigger::<()>(Duration::from_millis(50), ());
269
270        // Wait partial time again
271        thread::sleep(Duration::from_millis(30));
272        assert!(!debouncer.should_fire()); // Still not yet (timer was reset)
273
274        // Wait remaining time
275        thread::sleep(Duration::from_millis(25));
276        assert!(debouncer.should_fire()); // Now it fires
277    }
278
279    #[test]
280    #[cfg(feature = "tokio")]
281    fn test_debouncer_cancel() {
282        let mut debouncer = Debouncer::new();
283
284        let _cmd = debouncer.trigger::<()>(Duration::from_millis(10), ());
285        debouncer.cancel();
286
287        thread::sleep(Duration::from_millis(15));
288        assert!(!debouncer.should_fire()); // Cancelled, won't fire
289    }
290
291    #[test]
292    #[cfg(feature = "tokio")]
293    fn test_debouncer_double_fire_protection() {
294        let mut debouncer = Debouncer::new();
295
296        let _cmd = debouncer.trigger::<()>(Duration::from_millis(10), ());
297        thread::sleep(Duration::from_millis(15));
298
299        assert!(debouncer.should_fire()); // First call fires
300        assert!(!debouncer.should_fire()); // Second call doesn't
301    }
302
303    // Non-tokio tests using mark_trigger
304    #[test]
305    fn test_debouncer_mark_trigger_basic() {
306        let mut debouncer = Debouncer::new();
307
308        debouncer.mark_trigger(Duration::from_millis(10));
309        assert!(debouncer.is_pending());
310
311        // Wait for delay
312        thread::sleep(Duration::from_millis(15));
313        assert!(debouncer.should_fire());
314        assert!(!debouncer.is_pending());
315    }
316
317    #[test]
318    fn test_debouncer_cancel_without_tokio() {
319        let mut debouncer = Debouncer::new();
320
321        debouncer.mark_trigger(Duration::from_millis(10));
322        debouncer.cancel();
323
324        thread::sleep(Duration::from_millis(15));
325        assert!(!debouncer.should_fire()); // Cancelled
326    }
327}