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}