oxiui_core/scheduler.rs
1//! Frame-aligned callback scheduling plus debounce/throttle helpers.
2//!
3//! [`Scheduler`] is a deterministic, virtual-clock scheduler: the caller drives
4//! it by calling [`Scheduler::tick`] once per frame with the elapsed `dt`
5//! (seconds). It maintains a monotonically-increasing virtual `now`, runs any
6//! callbacks whose fire time has arrived, and supports one-shot timers
7//! (`after`), repeating timers (`every`), and next-frame callbacks
8//! (`request_frame`, the `requestAnimationFrame` analogue).
9//!
10//! Keeping time virtual (rather than reading a wall clock) makes the scheduler
11//! testable and frame-rate independent, and lets the same logic run under wasm,
12//! native, and headless test harnesses.
13//!
14//! [`Debounce`] and [`Throttle`] are standalone rate-limiters usable with the
15//! same virtual clock or any `f32` timestamp source.
16
17/// An opaque handle to a scheduled callback, usable for cancellation.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub struct TimerId(u64);
20
21/// What a fired timer should do next.
22enum Repeat {
23 /// Fire once and drop.
24 Once,
25 /// Re-arm to fire again every `interval` seconds.
26 Every(f32),
27}
28
29struct Timer {
30 id: TimerId,
31 /// Virtual time at which this timer next fires.
32 fire_at: f32,
33 repeat: Repeat,
34 callback: Box<dyn FnMut()>,
35}
36
37/// A virtual-clock scheduler for frame-aligned callbacks.
38#[derive(Default)]
39pub struct Scheduler {
40 now: f32,
41 next_id: u64,
42 timers: Vec<Timer>,
43 /// Callbacks to run on the next `tick`, regardless of time (rAF analogue).
44 frame_callbacks: Vec<(TimerId, Box<dyn FnMut()>)>,
45}
46
47impl Scheduler {
48 /// Create a scheduler with its virtual clock at zero.
49 pub fn new() -> Self {
50 Self::default()
51 }
52
53 /// The current virtual time in seconds.
54 pub fn now(&self) -> f32 {
55 self.now
56 }
57
58 /// Number of pending timers (excluding next-frame callbacks).
59 pub fn pending(&self) -> usize {
60 self.timers.len()
61 }
62
63 /// Number of pending next-frame callbacks.
64 pub fn pending_frames(&self) -> usize {
65 self.frame_callbacks.len()
66 }
67
68 fn alloc_id(&mut self) -> TimerId {
69 let id = TimerId(self.next_id);
70 self.next_id += 1;
71 id
72 }
73
74 /// Schedule `callback` to fire once, `delay` seconds from now. A `delay`
75 /// of `0` (or negative) fires on the next [`tick`](Scheduler::tick).
76 pub fn after(&mut self, delay: f32, callback: impl FnMut() + 'static) -> TimerId {
77 let id = self.alloc_id();
78 self.timers.push(Timer {
79 id,
80 fire_at: self.now + delay.max(0.0),
81 repeat: Repeat::Once,
82 callback: Box::new(callback),
83 });
84 id
85 }
86
87 /// Schedule `callback` to fire every `interval` seconds, beginning
88 /// `interval` seconds from now. `interval` is clamped to a tiny positive
89 /// value to avoid a zero-period busy loop.
90 pub fn every(&mut self, interval: f32, callback: impl FnMut() + 'static) -> TimerId {
91 let interval = interval.max(1e-4);
92 let id = self.alloc_id();
93 self.timers.push(Timer {
94 id,
95 fire_at: self.now + interval,
96 repeat: Repeat::Every(interval),
97 callback: Box::new(callback),
98 });
99 id
100 }
101
102 /// Schedule `callback` to run on the next [`tick`](Scheduler::tick), once.
103 /// This is the `requestAnimationFrame` analogue.
104 pub fn request_frame(&mut self, callback: impl FnMut() + 'static) -> TimerId {
105 let id = self.alloc_id();
106 self.frame_callbacks.push((id, Box::new(callback)));
107 id
108 }
109
110 /// Cancel a pending timer or next-frame callback. Returns `true` if found.
111 pub fn cancel(&mut self, id: TimerId) -> bool {
112 let before = self.timers.len() + self.frame_callbacks.len();
113 self.timers.retain(|t| t.id != id);
114 self.frame_callbacks.retain(|(fid, _)| *fid != id);
115 self.timers.len() + self.frame_callbacks.len() != before
116 }
117
118 /// Advance the virtual clock by `dt` seconds and run every callback that is
119 /// now due. Returns the number of callbacks fired.
120 ///
121 /// Repeating timers may fire multiple times within one large `dt`, and are
122 /// re-armed relative to their *scheduled* fire time (so they don't drift).
123 /// Next-frame callbacks always fire exactly once, before timers.
124 pub fn tick(&mut self, dt: f32) -> usize {
125 self.now += dt.max(0.0);
126 let mut fired = 0;
127
128 // Next-frame callbacks fire first, exactly once each.
129 let frames = std::mem::take(&mut self.frame_callbacks);
130 for (_, mut cb) in frames {
131 cb();
132 fired += 1;
133 }
134
135 // Timers: collect due ones (in fire-time order) and run them. Re-arm
136 // repeats; this loop handles a single `dt` that spans several periods.
137 loop {
138 // Find the earliest due timer not yet past `now`.
139 let due_idx = self
140 .timers
141 .iter()
142 .enumerate()
143 .filter(|(_, t)| t.fire_at <= self.now)
144 .min_by(|(_, a), (_, b)| {
145 a.fire_at
146 .partial_cmp(&b.fire_at)
147 .unwrap_or(std::cmp::Ordering::Equal)
148 })
149 .map(|(i, _)| i);
150
151 let Some(idx) = due_idx else { break };
152
153 match self.timers[idx].repeat {
154 Repeat::Once => {
155 let mut timer = self.timers.remove(idx);
156 (timer.callback)();
157 fired += 1;
158 }
159 Repeat::Every(interval) => {
160 (self.timers[idx].callback)();
161 fired += 1;
162 // Re-arm relative to the scheduled time to avoid drift.
163 self.timers[idx].fire_at += interval;
164 }
165 }
166 }
167 fired
168 }
169}
170
171/// Trailing-edge debouncer: an action only fires once input has been quiet for
172/// `delay` seconds. Each [`Debounce::signal`] resets the quiet timer; only when
173/// [`Debounce::poll`] is called after `delay` of silence does it report ready.
174#[derive(Clone, Copy, Debug)]
175pub struct Debounce {
176 delay: f32,
177 /// Virtual time of the most recent signal, or `None` if idle/already fired.
178 last_signal: Option<f32>,
179}
180
181impl Debounce {
182 /// Create a debouncer with the given quiet-period `delay` in seconds.
183 pub fn new(delay: f32) -> Self {
184 Self {
185 delay: delay.max(0.0),
186 last_signal: None,
187 }
188 }
189
190 /// Register activity at virtual time `now`, resetting the quiet timer.
191 pub fn signal(&mut self, now: f32) {
192 self.last_signal = Some(now);
193 }
194
195 /// If a signal is pending and `now` is at least `delay` past the last
196 /// signal, consume it and return `true` (the action should fire). Otherwise
197 /// `false`.
198 pub fn poll(&mut self, now: f32) -> bool {
199 match self.last_signal {
200 Some(t) if now - t >= self.delay => {
201 self.last_signal = None;
202 true
203 }
204 _ => false,
205 }
206 }
207
208 /// Whether a signal is currently waiting to fire.
209 pub fn is_pending(&self) -> bool {
210 self.last_signal.is_some()
211 }
212}
213
214/// Leading-edge throttler: an action may fire at most once per `interval`
215/// seconds. The first attempt fires immediately; subsequent attempts within the
216/// interval are suppressed.
217#[derive(Clone, Copy, Debug)]
218pub struct Throttle {
219 interval: f32,
220 /// Virtual time the action last fired, or `None` if it never has.
221 last_fire: Option<f32>,
222}
223
224impl Throttle {
225 /// Create a throttle allowing one fire per `interval` seconds.
226 pub fn new(interval: f32) -> Self {
227 Self {
228 interval: interval.max(0.0),
229 last_fire: None,
230 }
231 }
232
233 /// Attempt to fire at virtual time `now`. Returns `true` if allowed (and
234 /// records the time); `false` if still within the cool-down window.
235 pub fn try_fire(&mut self, now: f32) -> bool {
236 match self.last_fire {
237 Some(t) if now - t < self.interval => false,
238 _ => {
239 self.last_fire = Some(now);
240 true
241 }
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use std::cell::Cell;
250 use std::rc::Rc;
251
252 #[test]
253 fn after_fires_once_when_due() {
254 let mut s = Scheduler::new();
255 let n = Rc::new(Cell::new(0u32));
256 let n_c = Rc::clone(&n);
257 s.after(1.0, move || n_c.set(n_c.get() + 1));
258 // Not due yet.
259 assert_eq!(s.tick(0.5), 0);
260 assert_eq!(n.get(), 0);
261 // Now due.
262 assert_eq!(s.tick(0.6), 1);
263 assert_eq!(n.get(), 1);
264 // Does not fire again.
265 assert_eq!(s.tick(5.0), 0);
266 assert_eq!(n.get(), 1);
267 assert_eq!(s.pending(), 0);
268 }
269
270 #[test]
271 fn every_repeats_and_handles_large_dt() {
272 let mut s = Scheduler::new();
273 let n = Rc::new(Cell::new(0u32));
274 let n_c = Rc::clone(&n);
275 s.every(1.0, move || n_c.set(n_c.get() + 1));
276 // A single 3.5s tick spans three full intervals (1,2,3).
277 let fired = s.tick(3.5);
278 assert_eq!(fired, 3);
279 assert_eq!(n.get(), 3);
280 // Next interval at t=4.
281 s.tick(0.6); // now = 4.1
282 assert_eq!(n.get(), 4);
283 }
284
285 #[test]
286 fn request_frame_fires_next_tick_only() {
287 let mut s = Scheduler::new();
288 let n = Rc::new(Cell::new(0u32));
289 let n_c = Rc::clone(&n);
290 s.request_frame(move || n_c.set(n_c.get() + 1));
291 assert_eq!(s.pending_frames(), 1);
292 assert_eq!(s.tick(0.0), 1);
293 assert_eq!(n.get(), 1);
294 // Gone after one tick.
295 assert_eq!(s.tick(0.0), 0);
296 assert_eq!(n.get(), 1);
297 }
298
299 #[test]
300 fn cancel_prevents_fire() {
301 let mut s = Scheduler::new();
302 let n = Rc::new(Cell::new(0u32));
303 let n_c = Rc::clone(&n);
304 let id = s.after(1.0, move || n_c.set(n_c.get() + 1));
305 assert!(s.cancel(id));
306 assert!(!s.cancel(id));
307 s.tick(2.0);
308 assert_eq!(n.get(), 0);
309 }
310
311 #[test]
312 fn debounce_fires_only_after_quiet_period() {
313 let mut d = Debounce::new(0.3);
314 d.signal(0.0);
315 assert!(d.is_pending());
316 // Re-signalled before the quiet period elapses -> resets.
317 assert!(!d.poll(0.2));
318 d.signal(0.2);
319 assert!(!d.poll(0.4)); // only 0.2s since last signal
320 // 0.3s of quiet -> fires.
321 assert!(d.poll(0.5));
322 assert!(!d.is_pending());
323 // Nothing pending -> no fire.
324 assert!(!d.poll(1.0));
325 }
326
327 #[test]
328 fn throttle_limits_rate_leading_edge() {
329 let mut t = Throttle::new(1.0);
330 assert!(t.try_fire(0.0)); // first fires
331 assert!(!t.try_fire(0.5)); // within cooldown
332 assert!(!t.try_fire(0.99));
333 assert!(t.try_fire(1.0)); // cooldown elapsed
334 assert!(!t.try_fire(1.5));
335 }
336}