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}