Skip to main content

flywheel/actor/
ticker.rs

1//! Ticker Actor: Dedicated thread for generating timing events.
2//!
3//! This actor provides a regular "tick" signal for animation and
4//! frame pacing. It decouples timing from the main thread, enabling
5//! async-friendly applications.
6
7use crossbeam_channel::{bounded, Receiver, Sender};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10use std::thread::{self, JoinHandle};
11use std::time::{Duration, Instant};
12
13/// A tick event sent at regular intervals.
14#[derive(Debug, Clone, Copy)]
15pub struct Tick {
16    /// Frame number (monotonically increasing).
17    pub frame: u64,
18    /// Time elapsed since the ticker was started.
19    pub elapsed: Duration,
20}
21
22/// Ticker actor that generates regular timing events.
23pub struct TickerActor {
24    /// Handle to the ticker thread.
25    handle: Option<JoinHandle<()>>,
26    /// Flag to signal shutdown.
27    shutdown: Arc<AtomicBool>,
28    /// Receiver for tick events.
29    tick_rx: Receiver<Tick>,
30}
31
32impl TickerActor {
33    /// Spawn a new ticker actor with the given interval.
34    ///
35    /// # Arguments
36    ///
37    /// * `interval` - Time between ticks (e.g., 16ms for ~60 FPS).
38    ///
39    /// # Returns
40    ///
41    /// The ticker actor with its tick receiver accessible.
42    ///
43    /// # Panics
44    ///
45    /// Panics if the OS fails to spawn the ticker thread.
46    #[allow(clippy::missing_panics_doc)]
47    pub fn spawn(interval: Duration) -> Self {
48        let shutdown = Arc::new(AtomicBool::new(false));
49        let shutdown_clone = shutdown.clone();
50
51        // Bounded channel with small buffer - we don't want ticks to queue up
52        let (tick_tx, tick_rx) = bounded(2);
53
54        let handle = thread::Builder::new()
55            .name("flywheel-ticker".to_string())
56            .spawn(move || {
57                Self::run_loop(&tick_tx, &shutdown_clone, interval);
58            })
59            .expect("Failed to spawn ticker thread");
60
61        Self {
62            handle: Some(handle),
63            shutdown,
64            tick_rx,
65        }
66    }
67
68    /// Get a reference to the tick receiver.
69    ///
70    /// Use this with `select!` for event-driven loops:
71    ///
72    /// ```ignore
73    /// loop {
74    ///     select! {
75    ///         recv(engine.input_receiver()) -> event => handle_input(event),
76    ///         recv(ticker.receiver()) -> tick => {
77    ///             generate_frame();
78    ///             engine.request_update();
79    ///         }
80    ///     }
81    /// }
82    /// ```
83    #[inline]
84    pub const fn receiver(&self) -> &Receiver<Tick> {
85        &self.tick_rx
86    }
87
88    /// Signal the ticker to shutdown.
89    pub fn shutdown(&self) {
90        self.shutdown.store(true, Ordering::Relaxed);
91    }
92
93    /// Wait for the ticker thread to finish.
94    pub fn join(mut self) {
95        self.shutdown();
96        if let Some(handle) = self.handle.take() {
97            let _ = handle.join();
98        }
99    }
100
101    /// Main ticker loop.
102    fn run_loop(tick_tx: &Sender<Tick>, shutdown: &Arc<AtomicBool>, interval: Duration) {
103        let start = Instant::now();
104        let mut frame = 0u64;
105        let mut next_tick = start + interval;
106
107        loop {
108            if shutdown.load(Ordering::Relaxed) {
109                break;
110            }
111
112            let now = Instant::now();
113            if now >= next_tick {
114                // Time to tick
115                let tick = Tick {
116                    frame,
117                    elapsed: now - start,
118                };
119
120                // Non-blocking send - if buffer is full, skip this tick
121                // (receiver is too slow, prevent queue buildup)
122                let _ = tick_tx.try_send(tick);
123
124                frame += 1;
125                next_tick += interval;
126
127                // Handle case where we're behind (catch up without queuing)
128                if next_tick < now {
129                    next_tick = now + interval;
130                }
131            } else {
132                // Sleep until next tick
133                let sleep_duration = next_tick - now;
134                thread::sleep(sleep_duration.min(Duration::from_millis(1)));
135            }
136        }
137    }
138}
139
140impl Drop for TickerActor {
141    fn drop(&mut self) {
142        self.shutdown();
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_ticker_basic() {
152        let ticker = TickerActor::spawn(Duration::from_millis(10));
153
154        // Should receive ticks
155        let tick = ticker.receiver().recv_timeout(Duration::from_millis(100));
156        assert!(tick.is_ok());
157        assert_eq!(tick.unwrap().frame, 0);
158
159        // Second tick
160        let tick2 = ticker.receiver().recv_timeout(Duration::from_millis(50));
161        assert!(tick2.is_ok());
162
163        ticker.join();
164    }
165
166    #[test]
167    fn test_ticker_shutdown() {
168        let ticker = TickerActor::spawn(Duration::from_millis(100));
169        ticker.shutdown();
170
171        // Should stop receiving ticks after shutdown
172        thread::sleep(Duration::from_millis(50));
173        ticker.join();
174    }
175}