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    REGISTRY.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            IN_POLL.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. Must be
414/// called inside composition; outside a scope it degrades to [`delay`].
415pub fn scoped_delay_with_key<K: PartialEq + Clone + 'static>(
416    key: K,
417    duration: Duration,
418    cb: impl FnOnce() + 'static,
419) {
420    let cell: Rc<RefCell<ScopedSlot<K>>> = crate::remember(|| {
421        RefCell::new(ScopedSlot {
422            key: None,
423            alive: Rc::new(RefCell::new(true)),
424            installed: false,
425        })
426    });
427    let mut slot = cell.borrow_mut();
428    if !slot.installed {
429        slot.installed = true;
430        // Read the flag at dispose time: key changes replace it.
431        let cell_c = cell.clone();
432        crate::scoped_effect(move || {
433            crate::on_unmount(move || {
434                *cell_c.borrow().alive.borrow_mut() = false;
435            })
436        });
437    }
438    if slot.key.as_ref() != Some(&key) {
439        *slot.alive.borrow_mut() = false;
440        let alive = Rc::new(RefCell::new(true));
441        slot.alive = alive.clone();
442        slot.key = Some(key);
443        delay(duration, move || {
444            if *alive.borrow() {
445                cb();
446            }
447        })
448        .detach();
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use std::time::Duration as StdDuration;
456
457    fn sleep_ms(ms: u64) {
458        std::thread::sleep(StdDuration::from_millis(ms));
459    }
460
461    /// Clear the thread-local registry so reused test threads don't leak state.
462    fn reset() {
463        REGISTRY.with(|r| r.borrow_mut().clear());
464    }
465
466    #[test]
467    fn delay_fires_after_duration() {
468        reset();
469        let fired = Rc::new(RefCell::new(false));
470        let fired_c = fired.clone();
471        let _h = delay(Duration::from_millis(5), move || {
472            *fired_c.borrow_mut() = true;
473        });
474        poll();
475        assert!(!*fired.borrow(), "must not fire before the deadline");
476        sleep_ms(30);
477        poll();
478        assert!(*fired.borrow(), "must fire once the deadline passes");
479        sleep_ms(30);
480        poll();
481        assert!(next_deadline().is_none(), "one-shot must not reschedule");
482    }
483
484    #[test]
485    fn drop_cancels_delay() {
486        reset();
487        let fired = Rc::new(RefCell::new(false));
488        let fired_c = fired.clone();
489        let h = delay(Duration::from_millis(5), move || {
490            *fired_c.borrow_mut() = true;
491        });
492        drop(h);
493        sleep_ms(30);
494        poll();
495        assert!(!*fired.borrow(), "cancelled timer must not fire");
496    }
497
498    #[test]
499    fn interval_repeats_and_drop_stops() {
500        reset();
501        let count = Rc::new(RefCell::new(0u32));
502        let count_c = count.clone();
503        let h = interval(Duration::from_millis(5), move || {
504            *count_c.borrow_mut() += 1;
505        });
506        sleep_ms(30);
507        poll();
508        assert!(
509            *count.borrow() >= 1,
510            "must fire at least once, got {}",
511            *count.borrow()
512        );
513        let after_first = *count.borrow();
514        drop(h);
515        sleep_ms(30);
516        poll();
517        assert_eq!(*count.borrow(), after_first, "dropped interval must stop");
518    }
519
520    #[test]
521    fn interval_n_fires_exactly_n_times() {
522        reset();
523        let count = Rc::new(RefCell::new(0u32));
524        let count_c = count.clone();
525        let _h = interval_n(Duration::from_millis(5), 3, move || {
526            *count_c.borrow_mut() += 1;
527        });
528        for _ in 0..10 {
529            sleep_ms(15);
530            poll();
531        }
532        assert_eq!(*count.borrow(), 3);
533        assert!(next_deadline().is_none());
534    }
535
536    #[test]
537    fn delay_frames_counts_polls() {
538        reset();
539        let fired = Rc::new(RefCell::new(false));
540        let fired_c = fired.clone();
541        let start = frame_count();
542        let _h = delay_frames(3, move || {
543            *fired_c.borrow_mut() = true;
544        });
545        poll();
546        poll();
547        assert!(!*fired.borrow(), "must not fire before 3 polls");
548        poll();
549        assert!(*fired.borrow(), "must fire on the 3rd poll");
550        assert_eq!(frame_count(), start + 3);
551    }
552
553    #[test]
554    fn debouncer_coalesces_rapid_calls() {
555        reset();
556        let count = Rc::new(RefCell::new(0u32));
557        let deb = Debouncer::new(Duration::from_millis(10));
558        for _ in 0..5 {
559            let count_c = count.clone();
560            deb.call(move || {
561                *count_c.borrow_mut() += 1;
562            });
563        }
564        sleep_ms(40);
565        poll();
566        assert_eq!(
567            *count.borrow(),
568            1,
569            "rapid calls must coalesce into one firing"
570        );
571    }
572
573    #[test]
574    fn throttler_leads_and_trails_once() {
575        reset();
576        let count = Rc::new(RefCell::new(0u32));
577        let thro = Throttler::new(Duration::from_millis(50));
578        for _ in 0..5 {
579            let count_c = count.clone();
580            thro.call(move || {
581                *count_c.borrow_mut() += 1;
582            });
583        }
584        assert_eq!(*count.borrow(), 1, "first call fires immediately");
585        sleep_ms(80);
586        poll();
587        assert_eq!(
588            *count.borrow(),
589            2,
590            "the rest collapse into one trailing firing"
591        );
592    }
593
594    #[test]
595    fn same_batch_cancel_suppresses() {
596        // Due-buffer order follows HashMap iteration (nondeterministic), so
597        // retry until the canceller runs first; only then can suppression be
598        // observed. 32 misses in a row is effectively impossible.
599        for _ in 0..32 {
600            reset();
601            let events: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
602            let slot: Rc<RefCell<Option<TimerHandle>>> = Rc::new(RefCell::new(None));
603            let ev_c = events.clone();
604            let slot_c = slot.clone();
605            let _first = delay(Duration::from_millis(1), move || {
606                ev_c.borrow_mut().push("cancel");
607                *slot_c.borrow_mut() = None;
608            });
609            let ev_c = events.clone();
610            *slot.borrow_mut() = Some(delay(Duration::from_millis(1), move || {
611                ev_c.borrow_mut().push("second");
612            }));
613            sleep_ms(20);
614            poll();
615            let ev = events.borrow();
616            if *ev == ["cancel"] {
617                return;
618            }
619            assert_eq!(
620                *ev,
621                ["second", "cancel"],
622                "unexpected event sequence: {ev:?}"
623            );
624        }
625        panic!("canceller never ran first in 32 trials");
626    }
627
628    #[test]
629    fn reentrant_poll_is_ignored() {
630        reset();
631        let count = Rc::new(RefCell::new(0u32));
632        let count_c = count.clone();
633        let _h = delay(Duration::from_millis(1), move || {
634            *count_c.borrow_mut() += 1;
635            poll();
636        });
637        sleep_ms(20);
638        poll();
639        poll();
640        assert_eq!(*count.borrow(), 1, "nested poll must not refire");
641
642        reset();
643        let ticks = Rc::new(RefCell::new(0u32));
644        let ticks_c = ticks.clone();
645        let _i = interval(Duration::from_millis(5), move || {
646            *ticks_c.borrow_mut() += 1;
647            poll();
648        });
649        sleep_ms(30);
650        poll();
651        assert_eq!(
652            *ticks.borrow(),
653            1,
654            "nested poll must not double-fire intervals"
655        );
656    }
657
658    #[test]
659    fn interval_holds_phase_when_poll_late() {
660        reset();
661        let stamps = Rc::new(RefCell::new(Vec::new()));
662        let stamps_c = stamps.clone();
663        let period = Duration::from_millis(20);
664        let _h = interval(period, move || {
665            stamps_c.borrow_mut().push(Instant::now());
666        });
667        // Sleep through ~3 periods, then poll once: one firing at most, and
668        // the next deadline re-bases on the old schedule instead of drifting.
669        sleep_ms(70);
670        poll();
671        assert_eq!(stamps.borrow().len(), 1, "one poll fires once at most");
672        let first = stamps.borrow()[0];
673        let next = next_deadline().expect("interval must reschedule");
674        let gap = next.saturating_duration_since(first);
675        assert!(
676            gap < period + Duration::from_millis(15),
677            "reschedule must not drift by the full lateness, gap was {gap:?}"
678        );
679    }
680}