Skip to main content

repose_core/
timer.rs

1//! Wall-clock and frame-based timers without an async runtime.
2//!
3//! Schedule a callback for an [`Instant`] or a frame count; the platform
4//! wakes the event loop exactly then via `ReposeRuntime::next_wakeup_deadline`
5//! (`ControlFlow::WaitUntil`). No executor, no threads, no per-frame cost
6//! when idle.
7//!
8//! Compose parallels (approximate):
9//!
10//! | Compose                           | Here                              |
11//! |-----------------------------------|-----------------------------------|
12//! | `delay(d)` half of `LaunchedEffect` | [`delay`] (unscoped; hold handle) |
13//! | `LaunchedEffect` without keys     | [`scoped_delay`] (unmount only)   |
14//! | fixed-rate repetition             | [`interval`] (drift-free)         |
15//! | run-after-deadline                | [`timeout`] ([`delay`] alias)      |
16//! | flow `debounce` (trailing edge)   | [`Debouncer`] / `debounced_signal`|
17//! | sequencing over redraws           | [`delay_frames`]                  |
18//!
19//! Caveats vs coroutines: handles and flags suppress only *pending* firings.
20//! There is no counterpart for cancelling *running* work (`LaunchedEffect`
21//! key-change/leave, `withTimeout` aborting in-flight work, `collectLatest`):
22//! a firing callback runs to completion, `timeout` is not `withTimeout`, and
23//! `delay_frames` counts redraw polls (no vsync timestamp, refresh-rate
24//! dependent) rather than subscribing to frames.
25//!
26//! Rules of thumb: wall-clock waiting goes through [`delay`]/[`interval`];
27//! sequencing after animations or redraws through [`delay_frames`]; reactive
28//! signal shaping through `debounced_signal`.
29
30use std::cell::{Cell, RefCell};
31use std::collections::HashMap;
32use std::rc::Rc;
33
34use web_time::{Duration, Instant};
35
36use crate::{request_frame, unique_component_id};
37
38/// Minimum period for repeating timers. Zero would re-fire every poll and
39/// busy-loop the event loop; clamp instead of panicking.
40const MIN_PERIOD: Duration = Duration::from_millis(1);
41
42thread_local! {
43    static REGISTRY: RefCell<HashMap<u64, Entry>> = RefCell::new(HashMap::new());
44    /// Redraw counter, advanced by [`poll`]. Basis for [`delay_frames`].
45    static FRAME: RefCell<u64> = const { RefCell::new(0) };
46    /// Reentrancy flag: [`poll`] ignores reentrant calls (e.g. from inside
47    /// a timer callback), where a nested pass would fire due timers twice.
48    static IN_POLL: Cell<bool> = const { Cell::new(false) };
49}
50
51type Callback = Rc<RefCell<Box<dyn FnMut()>>>;
52
53#[derive(Clone, Copy)]
54enum Due {
55    /// Fire when wall-clock reaches this instant.
56    At(Instant),
57    /// Fire once [`FRAME`] reaches this count.
58    Frame(u64),
59}
60
61#[derive(Clone, Copy)]
62enum Repeat {
63    Once,
64    Every { period: Duration },
65    Times { period: Duration, left: u32 },
66}
67
68struct Entry {
69    due: Due,
70    repeat: Repeat,
71    callback: Callback,
72}
73
74/// Owner of a scheduled timer. Dropping cancels it (no-op if already fired).
75#[must_use = "dropping the handle cancels the timer"]
76pub struct TimerHandle {
77    id: Option<u64>,
78}
79
80impl TimerHandle {
81    /// Cancel now instead of at drop. Consuming makes double-cancel impossible.
82    pub fn cancel(mut self) {
83        self.cancel_now();
84    }
85
86    /// Detach without cancelling: the timer fires even though no handle owns
87    /// it anymore. Prefer holding the handle so timers cancel with their
88    /// owner. Never detach a repeating timer unless it is truly global:
89    /// nothing will stop it afterwards.
90    pub fn detach(self) {
91        std::mem::forget(self);
92    }
93
94    fn cancel_now(&mut self) {
95        if let Some(id) = self.id.take() {
96            cancel(id);
97        }
98    }
99}
100
101impl Drop for TimerHandle {
102    fn drop(&mut self) {
103        self.cancel_now();
104    }
105}
106
107fn insert(due: Due, repeat: Repeat, callback: Callback) -> TimerHandle {
108    let id = unique_component_id();
109    REGISTRY.with(|r| {
110        r.borrow_mut().insert(
111            id,
112            Entry {
113                due,
114                repeat,
115                callback,
116            },
117        );
118    });
119    request_frame();
120    TimerHandle { id: Some(id) }
121}
122
123fn cancel(id: u64) {
124    let _ = REGISTRY.try_with(|r| {
125        r.borrow_mut().remove(&id);
126    });
127}
128
129fn wrap_once(cb: impl FnOnce() + 'static) -> Callback {
130    let mut cb = Some(cb);
131    Rc::new(RefCell::new(Box::new(move || {
132        if let Some(f) = cb.take() {
133            f();
134        }
135    }) as Box<dyn FnMut()>))
136}
137
138/// Run `cb` once after `duration`. Returns a handle; dropping it cancels.
139///
140/// The platform sleeps until the deadline (`ControlFlow::WaitUntil`), so an
141/// idle timer costs nothing per frame.
142pub fn delay(duration: Duration, cb: impl FnOnce() + 'static) -> TimerHandle {
143    insert(
144        Due::At(saturating_add(Instant::now(), duration)),
145        Repeat::Once,
146        wrap_once(cb),
147    )
148}
149
150/// Run `cb` once after `duration`. A [`delay`] alias naming the
151/// run-after-deadline intent. This is not `withTimeout`: it cannot abort
152/// in-flight work, it only starts `cb` once the duration elapses.
153pub fn timeout(duration: Duration, cb: impl FnOnce() + 'static) -> TimerHandle {
154    delay(duration, cb)
155}
156
157/// Run `cb` once after `frames` redraws have been polled. Counts polls, not
158/// vsync frames: no timestamp, refresh-rate dependent. While frame entries
159/// are pending [`poll`] keeps requesting frames. A `0` count fires on the
160/// next poll.
161pub fn delay_frames(frames: u32, cb: impl FnOnce() + 'static) -> TimerHandle {
162    let at = FRAME.with(|f| f.borrow().wrapping_add(frames as u64));
163    insert(Due::Frame(at), Repeat::Once, wrap_once(cb))
164}
165
166/// Run `cb` every `period`, drift-free (next fire = last scheduled fire +
167/// `period`, so slow frames skip beats instead of bunching). Dropping the
168/// handle stops the timer. Periods below 1ms are clamped to 1ms.
169pub fn interval(period: Duration, cb: impl FnMut() + 'static) -> TimerHandle {
170    let period = period.max(MIN_PERIOD);
171    insert(
172        Due::At(saturating_add(Instant::now(), period)),
173        Repeat::Every { period },
174        Rc::new(RefCell::new(Box::new(cb) as Box<dyn FnMut()>)),
175    )
176}
177
178/// Like [`interval`], but stops on its own after `times` firings. A `0` count
179/// schedules nothing and returns a disarmed handle.
180pub fn interval_n(period: Duration, times: u32, cb: impl FnMut() + 'static) -> TimerHandle {
181    if times == 0 {
182        return TimerHandle { id: None };
183    }
184    let period = period.max(MIN_PERIOD);
185    insert(
186        Due::At(saturating_add(Instant::now(), period)),
187        Repeat::Times {
188            period,
189            left: times,
190        },
191        Rc::new(RefCell::new(Box::new(cb) as Box<dyn FnMut()>)),
192    )
193}
194
195/// Current redraw count (advanced by [`poll`]). Basis for [`delay_frames`].
196pub fn frame_count() -> u64 {
197    FRAME.with(|f| *f.borrow())
198}
199
200/// Earliest wall-clock deadline, if any. Fed into
201/// `ReposeRuntime::next_wakeup_deadline` so the platform sleeps until a timer
202/// is due.
203pub fn next_deadline() -> Option<Instant> {
204    REGISTRY.with(|r| {
205        r.borrow()
206            .values()
207            .filter_map(|e| match e.due {
208                Due::At(t) => Some(t),
209                Due::Frame(_) => None,
210            })
211            .min()
212    })
213}
214
215/// Saturating `Instant + Duration`: absurd durations fire immediately
216/// instead of panicking on overflow.
217fn saturating_add(t: Instant, d: Duration) -> Instant {
218    t.checked_add(d).unwrap_or(t)
219}
220
221/// Advance the frame counter and fire due timers. Called once per redraw
222/// (from `ReposeRuntime::tick_overlays`). Reentrant calls, e.g. from inside
223/// a timer callback, are ignored: the outer pass already collected the due
224/// timers, so a nested pass would fire them twice.
225pub fn poll() {
226    if IN_POLL.with(|f| f.replace(true)) {
227        return;
228    }
229    struct Guard;
230    impl Drop for Guard {
231        fn drop(&mut self) {
232            let _ = IN_POLL.try_with(|f| f.set(false));
233        }
234    }
235    let _guard = Guard;
236    let frame = FRAME.with(|f| {
237        let mut f = f.borrow_mut();
238        *f = f.wrapping_add(1);
239        *f
240    });
241    let now = Instant::now();
242    // Ids are never reused, so rechecking membership before firing lets a
243    // same-batch cancel actually suppress the timer.
244    let mut due: Vec<(u64, Callback)> = Vec::new();
245    let mut remove: Vec<u64> = Vec::new();
246    let mut need_frames = false;
247    REGISTRY.with(|r| {
248        let mut reg = r.borrow_mut();
249        for (id, entry) in reg.iter_mut() {
250            let is_due = match entry.due {
251                Due::At(t) => t <= now,
252                Due::Frame(f) => {
253                    if frame >= f {
254                        true
255                    } else {
256                        need_frames = true;
257                        false
258                    }
259                }
260            };
261            if !is_due {
262                continue;
263            }
264            due.push((*id, entry.callback.clone()));
265            // Re-base on the old schedule, not now: keeps fixed-rate phase.
266            let base = match entry.due {
267                Due::At(t) => t,
268                Due::Frame(_) => now,
269            };
270            match entry.repeat {
271                Repeat::Once => remove.push(*id),
272                Repeat::Every { period } => {
273                    entry.due = Due::At(skip_ahead(base, period, now));
274                }
275                Repeat::Times { period, left } => {
276                    if left <= 1 {
277                        remove.push(*id);
278                    } else {
279                        entry.repeat = Repeat::Times {
280                            period,
281                            left: left - 1,
282                        };
283                        entry.due = Due::At(skip_ahead(base, period, now));
284                    }
285                }
286            }
287        }
288    });
289    for (id, cb) in due.iter() {
290        let live = REGISTRY.with(|r| r.borrow().contains_key(id));
291        if live {
292            cb.borrow_mut()();
293        }
294    }
295    if !remove.is_empty() {
296        REGISTRY.with(|r| {
297            let mut reg = r.borrow_mut();
298            for id in remove {
299                reg.remove(&id);
300            }
301            need_frames = need_frames || reg.values().any(|e| matches!(e.due, Due::Frame(_)));
302        });
303    }
304    if need_frames {
305        request_frame();
306    }
307}
308
309/// Skip missed beats so sleepers resume without bursting.
310fn skip_ahead(mut next: Instant, period: Duration, now: Instant) -> Instant {
311    let mut guard = 0u32;
312    while next <= now && guard < 1024 {
313        next = saturating_add(next, period);
314        guard += 1;
315    }
316    if next <= now {
317        saturating_add(now, period)
318    } else {
319        next
320    }
321}
322
323/// Trailing-edge debouncer: each call reschedules the single pending firing.
324/// Cloneable (shared slot); dropping all clones cancels it.
325#[derive(Clone)]
326pub struct Debouncer {
327    delay: Duration,
328    pending: Rc<RefCell<Option<TimerHandle>>>,
329}
330
331impl Default for Debouncer {
332    /// 300 ms trailing debounce, the conventional UI default.
333    fn default() -> Self {
334        Self::new(Duration::from_millis(300))
335    }
336}
337
338impl Debouncer {
339    /// Debounce firings by `delay` of inactivity.
340    pub fn new(delay: Duration) -> Self {
341        Self {
342            delay: delay.max(MIN_PERIOD),
343            pending: Rc::new(RefCell::new(None)),
344        }
345    }
346
347    /// Schedule `cb` after a quiet `delay`, replacing any pending firing.
348    pub fn call(&self, cb: impl FnOnce() + 'static) {
349        *self.pending.borrow_mut() = Some(delay(self.delay, cb));
350    }
351
352    /// Drop the pending firing, if any.
353    pub fn cancel_pending(&self) {
354        *self.pending.borrow_mut() = None;
355    }
356}
357
358/// Leading-edge throttler with one coalesced trailing firing per period.
359#[derive(Clone)]
360pub struct Throttler {
361    period: Duration,
362    last_fire: Rc<RefCell<Option<Instant>>>,
363    pending: Rc<RefCell<Option<TimerHandle>>>,
364}
365
366impl Throttler {
367    /// Throttle firings to at most one leading plus one trailing per `period`.
368    pub fn new(period: Duration) -> Self {
369        Self {
370            period: period.max(MIN_PERIOD),
371            last_fire: Rc::new(RefCell::new(None)),
372            pending: Rc::new(RefCell::new(None)),
373        }
374    }
375
376    /// Run `cb` now if the period elapsed since the last firing, else
377    /// coalesce it into the single trailing firing at the period edge.
378    /// Trailing windows slide from the actual fire time, not the edge.
379    pub fn call(&self, cb: impl FnOnce() + 'static) {
380        let now = Instant::now();
381        let edge = self
382            .last_fire
383            .borrow()
384            .map(|t| saturating_add(t, self.period))
385            .unwrap_or(now);
386        if now >= edge {
387            *self.last_fire.borrow_mut() = Some(now);
388            cb();
389        } else {
390            let last_fire = self.last_fire.clone();
391            *self.pending.borrow_mut() = Some(delay(edge - now, move || {
392                *last_fire.borrow_mut() = Some(Instant::now());
393                cb();
394            }));
395        }
396    }
397}
398
399/// [`delay`] tied to the current composition scope: if the scope disposes
400/// before the deadline, the callback is suppressed. Schedules once per mount;
401/// use [`scoped_delay_with_key`] to restart on change.
402pub fn scoped_delay(duration: Duration, cb: impl FnOnce() + 'static) {
403    scoped_delay_with_key((), duration, cb);
404}
405
406struct ScopedSlot<K> {
407    key: Option<K>,
408    alive: Rc<RefCell<bool>>,
409    installed: bool,
410}
411
412/// Keyed [`scoped_delay`]: reschedules when `key` changes (cancelling the
413/// previous generation via its flag) and suppresses on unmount. Storage is
414/// callsite-keyed, so branches above the call site cannot shift slots.
415#[track_caller]
416pub fn scoped_delay_with_key<K: PartialEq + Clone + 'static>(
417    key: K,
418    duration: Duration,
419    cb: impl FnOnce() + 'static,
420) {
421    let loc = std::panic::Location::caller();
422    let callsite = format!(
423        "scoped_delay:{}:{}:{}",
424        loc.file(),
425        loc.line(),
426        loc.column()
427    );
428    let cell: Rc<RefCell<ScopedSlot<K>>> = crate::remember_with_key(callsite, || {
429        RefCell::new(ScopedSlot {
430            key: None,
431            alive: Rc::new(RefCell::new(true)),
432            installed: false,
433        })
434    });
435    let mut slot = cell.borrow_mut();
436    if !slot.installed {
437        slot.installed = true;
438        // Read the flag at dispose time: key changes replace it.
439        let cell_c = cell.clone();
440        crate::scoped_effect(move || {
441            crate::on_unmount(move || {
442                *cell_c.borrow().alive.borrow_mut() = false;
443            })
444        });
445    }
446    if slot.key.as_ref() != Some(&key) {
447        *slot.alive.borrow_mut() = false;
448        let alive = Rc::new(RefCell::new(true));
449        slot.alive = alive.clone();
450        slot.key = Some(key);
451        delay(duration, move || {
452            if *alive.borrow() {
453                cb();
454            }
455        })
456        .detach();
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use std::time::Duration as StdDuration;
464
465    fn sleep_ms(ms: u64) {
466        std::thread::sleep(StdDuration::from_millis(ms));
467    }
468
469    /// Clear the thread-local registry so reused test threads don't leak state.
470    fn reset() {
471        REGISTRY.with(|r| r.borrow_mut().clear());
472    }
473
474    #[test]
475    fn delay_fires_after_duration() {
476        reset();
477        let fired = Rc::new(RefCell::new(false));
478        let fired_c = fired.clone();
479        let _h = delay(Duration::from_millis(5), move || {
480            *fired_c.borrow_mut() = true;
481        });
482        poll();
483        assert!(!*fired.borrow(), "must not fire before the deadline");
484        sleep_ms(30);
485        poll();
486        assert!(*fired.borrow(), "must fire once the deadline passes");
487        sleep_ms(30);
488        poll();
489        assert!(next_deadline().is_none(), "one-shot must not reschedule");
490    }
491
492    #[test]
493    fn drop_cancels_delay() {
494        reset();
495        let fired = Rc::new(RefCell::new(false));
496        let fired_c = fired.clone();
497        let h = delay(Duration::from_millis(5), move || {
498            *fired_c.borrow_mut() = true;
499        });
500        drop(h);
501        sleep_ms(30);
502        poll();
503        assert!(!*fired.borrow(), "cancelled timer must not fire");
504    }
505
506    #[test]
507    fn interval_repeats_and_drop_stops() {
508        reset();
509        let count = Rc::new(RefCell::new(0u32));
510        let count_c = count.clone();
511        let h = interval(Duration::from_millis(5), move || {
512            *count_c.borrow_mut() += 1;
513        });
514        sleep_ms(30);
515        poll();
516        assert!(
517            *count.borrow() >= 1,
518            "must fire at least once, got {}",
519            *count.borrow()
520        );
521        let after_first = *count.borrow();
522        drop(h);
523        sleep_ms(30);
524        poll();
525        assert_eq!(*count.borrow(), after_first, "dropped interval must stop");
526    }
527
528    #[test]
529    fn interval_n_fires_exactly_n_times() {
530        reset();
531        let count = Rc::new(RefCell::new(0u32));
532        let count_c = count.clone();
533        let _h = interval_n(Duration::from_millis(5), 3, move || {
534            *count_c.borrow_mut() += 1;
535        });
536        for _ in 0..10 {
537            sleep_ms(15);
538            poll();
539        }
540        assert_eq!(*count.borrow(), 3);
541        assert!(next_deadline().is_none());
542    }
543
544    #[test]
545    fn delay_frames_counts_polls() {
546        reset();
547        let fired = Rc::new(RefCell::new(false));
548        let fired_c = fired.clone();
549        let start = frame_count();
550        let _h = delay_frames(3, move || {
551            *fired_c.borrow_mut() = true;
552        });
553        poll();
554        poll();
555        assert!(!*fired.borrow(), "must not fire before 3 polls");
556        poll();
557        assert!(*fired.borrow(), "must fire on the 3rd poll");
558        assert_eq!(frame_count(), start + 3);
559    }
560
561    #[test]
562    fn debouncer_coalesces_rapid_calls() {
563        reset();
564        let count = Rc::new(RefCell::new(0u32));
565        let deb = Debouncer::new(Duration::from_millis(10));
566        for _ in 0..5 {
567            let count_c = count.clone();
568            deb.call(move || {
569                *count_c.borrow_mut() += 1;
570            });
571        }
572        sleep_ms(40);
573        poll();
574        assert_eq!(
575            *count.borrow(),
576            1,
577            "rapid calls must coalesce into one firing"
578        );
579    }
580
581    #[test]
582    fn throttler_leads_and_trails_once() {
583        reset();
584        let count = Rc::new(RefCell::new(0u32));
585        let thro = Throttler::new(Duration::from_millis(50));
586        for _ in 0..5 {
587            let count_c = count.clone();
588            thro.call(move || {
589                *count_c.borrow_mut() += 1;
590            });
591        }
592        assert_eq!(*count.borrow(), 1, "first call fires immediately");
593        sleep_ms(80);
594        poll();
595        assert_eq!(
596            *count.borrow(),
597            2,
598            "the rest collapse into one trailing firing"
599        );
600    }
601
602    #[test]
603    fn same_batch_cancel_suppresses() {
604        // Due-buffer order follows HashMap iteration (nondeterministic), so
605        // retry until the canceller runs first; only then can suppression be
606        // observed. 32 misses in a row is effectively impossible.
607        for _ in 0..32 {
608            reset();
609            let events: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
610            let slot: Rc<RefCell<Option<TimerHandle>>> = Rc::new(RefCell::new(None));
611            let ev_c = events.clone();
612            let slot_c = slot.clone();
613            let _first = delay(Duration::from_millis(1), move || {
614                ev_c.borrow_mut().push("cancel");
615                *slot_c.borrow_mut() = None;
616            });
617            let ev_c = events.clone();
618            *slot.borrow_mut() = Some(delay(Duration::from_millis(1), move || {
619                ev_c.borrow_mut().push("second");
620            }));
621            sleep_ms(20);
622            poll();
623            let ev = events.borrow();
624            if *ev == ["cancel"] {
625                return;
626            }
627            assert_eq!(
628                *ev,
629                ["second", "cancel"],
630                "unexpected event sequence: {ev:?}"
631            );
632        }
633        panic!("canceller never ran first in 32 trials");
634    }
635
636    #[test]
637    fn reentrant_poll_is_ignored() {
638        reset();
639        let count = Rc::new(RefCell::new(0u32));
640        let count_c = count.clone();
641        let _h = delay(Duration::from_millis(1), move || {
642            *count_c.borrow_mut() += 1;
643            poll();
644        });
645        sleep_ms(20);
646        poll();
647        poll();
648        assert_eq!(*count.borrow(), 1, "nested poll must not refire");
649
650        reset();
651        let ticks = Rc::new(RefCell::new(0u32));
652        let ticks_c = ticks.clone();
653        let _i = interval(Duration::from_millis(5), move || {
654            *ticks_c.borrow_mut() += 1;
655            poll();
656        });
657        sleep_ms(30);
658        poll();
659        assert_eq!(
660            *ticks.borrow(),
661            1,
662            "nested poll must not double-fire intervals"
663        );
664    }
665
666    #[test]
667    fn interval_holds_phase_when_poll_late() {
668        reset();
669        let stamps = Rc::new(RefCell::new(Vec::new()));
670        let stamps_c = stamps.clone();
671        let period = Duration::from_millis(20);
672        let _h = interval(period, move || {
673            stamps_c.borrow_mut().push(Instant::now());
674        });
675        // Sleep through ~3 periods, then poll once: one firing at most, and
676        // the next deadline re-bases on the old schedule instead of drifting.
677        sleep_ms(70);
678        poll();
679        assert_eq!(stamps.borrow().len(), 1, "one poll fires once at most");
680        let first = stamps.borrow()[0];
681        let next = next_deadline().expect("interval must reschedule");
682        let gap = next.saturating_duration_since(first);
683        assert!(
684            gap < period + Duration::from_millis(15),
685            "reschedule must not drift by the full lateness, gap was {gap:?}"
686        );
687    }
688}