1use std::collections::BTreeMap;
2use std::time::{Duration, Instant};
3
4use thiserror::Error;
5
6use crate::{
7 AsyncDelivery, AsyncScope, ComponentInstancePath, ScriptCallback, ScriptGeneration, UiValue,
8};
9
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct TimerId {
12 component: ComponentInstancePath,
13 key: String,
14}
15
16impl TimerId {
17 pub fn new(
23 component: ComponentInstancePath,
24 key: impl Into<String>,
25 ) -> Result<Self, TimerError> {
26 let key = key.into();
27 if !(1..=128).contains(&key.len())
28 || !key.chars().all(|character| {
29 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | ':' | '.')
30 })
31 {
32 return Err(TimerError::InvalidKey(key));
33 }
34 Ok(Self { component, key })
35 }
36
37 #[must_use]
38 pub const fn component(&self) -> &ComponentInstancePath {
39 &self.component
40 }
41
42 #[must_use]
43 pub fn key(&self) -> &str {
44 &self.key
45 }
46}
47
48#[derive(Clone, Debug, PartialEq)]
49pub struct TimerDescriptor {
50 id: TimerId,
51 delay: Duration,
52 paused: bool,
53 callback: ScriptCallback,
54 payload: UiValue,
55}
56
57impl TimerDescriptor {
58 pub fn new(
64 id: TimerId,
65 delay: Duration,
66 paused: bool,
67 callback: ScriptCallback,
68 payload: UiValue,
69 ) -> Result<Self, TimerError> {
70 if delay.is_zero() || delay > Duration::from_hours(24) {
71 return Err(TimerError::InvalidDelay(delay));
72 }
73 Ok(Self {
74 id,
75 delay,
76 paused,
77 callback,
78 payload,
79 })
80 }
81
82 #[must_use]
83 pub const fn id(&self) -> &TimerId {
84 &self.id
85 }
86
87 #[must_use]
88 pub const fn delay(&self) -> Duration {
89 self.delay
90 }
91
92 #[must_use]
93 pub const fn paused(&self) -> bool {
94 self.paused
95 }
96}
97
98#[derive(Clone, Debug, PartialEq)]
99struct TimerSignature {
100 delay: Duration,
101 callback: ScriptCallback,
102 payload: UiValue,
103}
104
105impl From<&TimerDescriptor> for TimerSignature {
106 fn from(descriptor: &TimerDescriptor) -> Self {
107 Self {
108 delay: descriptor.delay,
109 callback: descriptor.callback.clone(),
110 payload: descriptor.payload.clone(),
111 }
112 }
113}
114
115#[derive(Clone, Debug)]
116struct TimerEntry {
117 descriptor: TimerDescriptor,
118 signature: TimerSignature,
119 deadline: Instant,
120 remaining: Option<Duration>,
121 declaration_paused: bool,
122 interaction_paused: bool,
123 view_paused: bool,
124}
125
126#[derive(Clone, Debug, PartialEq)]
127pub struct TimerSnapshot {
128 pub id: TimerId,
129 pub delay: Duration,
130 pub remaining: Duration,
131 pub declaration_paused: bool,
132 pub interaction_paused: bool,
133 pub view_paused: bool,
134 pub callback: String,
135 pub generation: ScriptGeneration,
136}
137
138impl TimerEntry {
139 fn new(descriptor: TimerDescriptor, now: Instant) -> Self {
140 let signature = TimerSignature::from(&descriptor);
141 let declaration_paused = descriptor.paused;
142 let remaining = descriptor.paused.then_some(descriptor.delay);
143 Self {
144 deadline: now + descriptor.delay,
145 descriptor,
146 signature,
147 remaining,
148 declaration_paused,
149 interaction_paused: false,
150 view_paused: false,
151 }
152 }
153
154 fn synchronize(&mut self, descriptor: TimerDescriptor, now: Instant) {
155 let was_paused = self.is_paused();
156 self.declaration_paused = descriptor.paused;
157 self.transition_pause(was_paused, self.is_paused(), now);
158 self.descriptor = descriptor;
159 }
160
161 fn is_paused(&self) -> bool {
162 self.declaration_paused || self.interaction_paused || self.view_paused
163 }
164
165 fn transition_pause(&mut self, was_paused: bool, paused: bool, now: Instant) {
166 if !was_paused && paused {
167 self.remaining = Some(self.deadline.saturating_duration_since(now));
168 } else if was_paused && !paused {
169 self.deadline = now + self.remaining.take().unwrap_or_default();
170 }
171 }
172}
173
174#[derive(Clone, Debug, Default)]
175pub struct TimerRegistry {
176 entries: BTreeMap<TimerId, TimerEntry>,
177 completed: BTreeMap<TimerId, TimerSignature>,
178}
179
180impl TimerRegistry {
181 #[must_use]
182 pub fn new() -> Self {
183 Self::default()
184 }
185
186 pub fn reconcile(
188 &mut self,
189 root: &ComponentInstancePath,
190 declarations: BTreeMap<TimerId, TimerDescriptor>,
191 now: Instant,
192 ) {
193 self.entries
194 .retain(|id, _| !id.component.is_within(root) || declarations.contains_key(id));
195 self.completed
196 .retain(|id, _| !id.component.is_within(root) || declarations.contains_key(id));
197 for (id, descriptor) in declarations {
198 let signature = TimerSignature::from(&descriptor);
199 if self.completed.get(&id) == Some(&signature) {
200 continue;
201 }
202 self.completed.remove(&id);
203 match self.entries.get_mut(&id) {
204 Some(entry) if entry.signature == signature => entry.synchronize(descriptor, now),
205 Some(entry) => *entry = TimerEntry::new(descriptor, now),
206 None => {
207 self.entries.insert(id, TimerEntry::new(descriptor, now));
208 }
209 }
210 }
211 }
212
213 #[must_use]
214 pub fn pause(&mut self, id: &TimerId, now: Instant) -> bool {
215 let Some(entry) = self.entries.get_mut(id) else {
216 return false;
217 };
218 let was_paused = entry.is_paused();
219 entry.interaction_paused = true;
220 entry.transition_pause(was_paused, entry.is_paused(), now);
221 true
222 }
223
224 #[must_use]
225 pub fn resume(&mut self, id: &TimerId, now: Instant) -> bool {
226 let Some(entry) = self.entries.get_mut(id) else {
227 return false;
228 };
229 let was_paused = entry.is_paused();
230 entry.interaction_paused = false;
231 entry.transition_pause(was_paused, entry.is_paused(), now);
232 true
233 }
234
235 #[must_use]
236 pub fn cancel(&mut self, id: &TimerId) -> bool {
237 let Some(entry) = self.entries.remove(id) else {
238 return false;
239 };
240 self.completed.insert(id.clone(), entry.signature);
241 true
242 }
243
244 #[must_use]
245 pub fn drain(&mut self, now: Instant, generation: ScriptGeneration) -> Vec<AsyncDelivery> {
246 let due = self
247 .entries
248 .iter()
249 .filter(|(_, entry)| entry.remaining.is_none() && entry.deadline <= now)
250 .map(|(id, _)| id.clone())
251 .collect::<Vec<_>>();
252 due.into_iter()
253 .filter_map(|id| {
254 let entry = self.entries.remove(&id)?;
255 self.completed.insert(id, entry.signature);
256 (entry.descriptor.callback.generation() == generation).then(|| AsyncDelivery {
257 callback: entry.descriptor.callback,
258 payload: entry.descriptor.payload,
259 scope: AsyncScope::Component(entry.descriptor.id.component),
260 })
261 })
262 .collect()
263 }
264
265 pub fn cancel_component_scope(&mut self, component: &ComponentInstancePath) {
266 self.entries
267 .retain(|id, _| !id.component.is_within(component));
268 self.completed
269 .retain(|id, _| !id.component.is_within(component));
270 }
271
272 pub fn pause_component_scope(&mut self, component: &ComponentInstancePath, now: Instant) {
273 for (id, entry) in &mut self.entries {
274 if id.component.is_within(component) {
275 let was_paused = entry.is_paused();
276 entry.view_paused = true;
277 entry.transition_pause(was_paused, entry.is_paused(), now);
278 }
279 }
280 }
281
282 pub fn resume_component_scope(&mut self, component: &ComponentInstancePath, now: Instant) {
283 for (id, entry) in &mut self.entries {
284 if id.component.is_within(component) {
285 let was_paused = entry.is_paused();
286 entry.view_paused = false;
287 entry.transition_pause(was_paused, entry.is_paused(), now);
288 }
289 }
290 }
291
292 #[must_use]
293 pub fn active_count(&self) -> usize {
294 self.entries.len()
295 }
296
297 #[must_use]
298 pub fn inspect(&self, now: Instant) -> Vec<TimerSnapshot> {
299 self.entries
300 .iter()
301 .map(|(id, entry)| TimerSnapshot {
302 id: id.clone(),
303 delay: entry.descriptor.delay,
304 remaining: entry
305 .remaining
306 .unwrap_or_else(|| entry.deadline.saturating_duration_since(now)),
307 declaration_paused: entry.declaration_paused,
308 interaction_paused: entry.interaction_paused,
309 view_paused: entry.view_paused,
310 callback: entry.descriptor.callback.name().to_owned(),
311 generation: entry.descriptor.callback.generation(),
312 })
313 .collect()
314 }
315}
316
317#[derive(Clone, Debug, Error, Eq, PartialEq)]
318pub enum TimerError {
319 #[error("timer key `{0}` must be 1-128 safe identifier characters")]
320 InvalidKey(String),
321 #[error("timer delay must be between 1ms and 24h, got {0:?}")]
322 InvalidDelay(Duration),
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::{EventSchema, ScriptGeneration};
329 use rhai::FnPtr;
330
331 fn descriptor(
332 component: &ComponentInstancePath,
333 key: &str,
334 delay: Duration,
335 paused: bool,
336 ) -> TimerDescriptor {
337 let mut callback = ScriptCallback::try_from_fn_ptr(
338 FnPtr::new("fired").unwrap(),
339 ScriptGeneration::initial(),
340 )
341 .unwrap();
342 callback.bind_component_if_unset(component.clone(), BTreeMap::<String, EventSchema>::new());
343 TimerDescriptor::new(
344 TimerId::new(component.clone(), key).unwrap(),
345 delay,
346 paused,
347 callback,
348 UiValue::String(key.to_owned()),
349 )
350 .unwrap()
351 }
352
353 #[test]
354 fn reconcile_preserves_deadline_pause_and_one_shot_completion() {
355 let root = ComponentInstancePath::root("App", "root");
356 let now = Instant::now();
357 let mut timers = TimerRegistry::new();
358 let running = descriptor(&root, "toast:a", Duration::from_millis(100), false);
359 timers.reconcile(
360 &root,
361 BTreeMap::from([(running.id.clone(), running.clone())]),
362 now,
363 );
364 assert!(timers.pause(running.id(), now + Duration::from_millis(40)));
365 let snapshot = timers.inspect(now + Duration::from_millis(90));
366 assert_eq!(snapshot[0].remaining, Duration::from_millis(60));
367 assert!(snapshot[0].interaction_paused);
368 assert!(timers.resume(running.id(), now + Duration::from_millis(90)));
369 assert!(
370 timers
371 .drain(
372 now + Duration::from_millis(149),
373 ScriptGeneration::initial(),
374 )
375 .is_empty()
376 );
377 assert_eq!(
378 timers
379 .drain(
380 now + Duration::from_millis(150),
381 ScriptGeneration::initial(),
382 )
383 .len(),
384 1
385 );
386 timers.reconcile(&root, BTreeMap::from([(running.id.clone(), running)]), now);
387 assert_eq!(
388 timers.active_count(),
389 0,
390 "completed declaration stays one-shot"
391 );
392 }
393
394 #[test]
395 fn changing_signature_restarts_and_removal_cleans_scope() {
396 let root = ComponentInstancePath::root("App", "root");
397 let now = Instant::now();
398 let mut timers = TimerRegistry::new();
399 let first = descriptor(&root, "one", Duration::from_millis(10), false);
400 timers.reconcile(&root, BTreeMap::from([(first.id.clone(), first)]), now);
401 assert_eq!(timers.active_count(), 1);
402 let changed = descriptor(&root, "one", Duration::from_millis(20), false);
403 timers.reconcile(&root, BTreeMap::from([(changed.id.clone(), changed)]), now);
404 assert!(
405 timers
406 .drain(now + Duration::from_millis(19), ScriptGeneration::initial(),)
407 .is_empty()
408 );
409 timers.reconcile(&root, BTreeMap::new(), now);
410 assert_eq!(timers.active_count(), 0);
411 }
412
413 #[test]
414 fn view_resume_does_not_clear_an_explicit_pause() {
415 let root = ComponentInstancePath::root("App", "root");
416 let now = Instant::now();
417 let mut timers = TimerRegistry::new();
418 let timer = descriptor(&root, "one", Duration::from_millis(100), false);
419 timers.reconcile(&root, BTreeMap::from([(timer.id.clone(), timer)]), now);
420 assert!(timers.pause(
421 &TimerId::new(root.clone(), "one").unwrap(),
422 now + Duration::from_millis(10)
423 ));
424 timers.pause_component_scope(&root, now + Duration::from_millis(20));
425 timers.resume_component_scope(&root, now + Duration::from_secs(1));
426 let snapshot = timers.inspect(now + Duration::from_secs(1));
427 assert!(snapshot[0].interaction_paused);
428 assert!(!snapshot[0].view_paused);
429 assert!(
430 timers
431 .drain(now + Duration::from_secs(2), ScriptGeneration::initial())
432 .is_empty()
433 );
434 }
435}