use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use thiserror::Error;
use crate::{
AsyncDelivery, AsyncScope, ComponentInstancePath, ScriptCallback, ScriptGeneration, UiValue,
};
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TimerId {
component: ComponentInstancePath,
key: String,
}
impl TimerId {
pub fn new(
component: ComponentInstancePath,
key: impl Into<String>,
) -> Result<Self, TimerError> {
let key = key.into();
if !(1..=128).contains(&key.len())
|| !key.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | ':' | '.')
})
{
return Err(TimerError::InvalidKey(key));
}
Ok(Self { component, key })
}
#[must_use]
pub const fn component(&self) -> &ComponentInstancePath {
&self.component
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct TimerDescriptor {
id: TimerId,
delay: Duration,
paused: bool,
callback: ScriptCallback,
payload: UiValue,
}
impl TimerDescriptor {
pub fn new(
id: TimerId,
delay: Duration,
paused: bool,
callback: ScriptCallback,
payload: UiValue,
) -> Result<Self, TimerError> {
if delay.is_zero() || delay > Duration::from_hours(24) {
return Err(TimerError::InvalidDelay(delay));
}
Ok(Self {
id,
delay,
paused,
callback,
payload,
})
}
#[must_use]
pub const fn id(&self) -> &TimerId {
&self.id
}
#[must_use]
pub const fn delay(&self) -> Duration {
self.delay
}
#[must_use]
pub const fn paused(&self) -> bool {
self.paused
}
}
#[derive(Clone, Debug, PartialEq)]
struct TimerSignature {
delay: Duration,
callback: ScriptCallback,
payload: UiValue,
}
impl From<&TimerDescriptor> for TimerSignature {
fn from(descriptor: &TimerDescriptor) -> Self {
Self {
delay: descriptor.delay,
callback: descriptor.callback.clone(),
payload: descriptor.payload.clone(),
}
}
}
#[derive(Clone, Debug)]
struct TimerEntry {
descriptor: TimerDescriptor,
signature: TimerSignature,
deadline: Instant,
remaining: Option<Duration>,
declaration_paused: bool,
interaction_paused: bool,
view_paused: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TimerSnapshot {
pub id: TimerId,
pub delay: Duration,
pub remaining: Duration,
pub declaration_paused: bool,
pub interaction_paused: bool,
pub view_paused: bool,
pub callback: String,
pub generation: ScriptGeneration,
}
impl TimerEntry {
fn new(descriptor: TimerDescriptor, now: Instant) -> Self {
let signature = TimerSignature::from(&descriptor);
let declaration_paused = descriptor.paused;
let remaining = descriptor.paused.then_some(descriptor.delay);
Self {
deadline: now + descriptor.delay,
descriptor,
signature,
remaining,
declaration_paused,
interaction_paused: false,
view_paused: false,
}
}
fn synchronize(&mut self, descriptor: TimerDescriptor, now: Instant) {
let was_paused = self.is_paused();
self.declaration_paused = descriptor.paused;
self.transition_pause(was_paused, self.is_paused(), now);
self.descriptor = descriptor;
}
fn is_paused(&self) -> bool {
self.declaration_paused || self.interaction_paused || self.view_paused
}
fn transition_pause(&mut self, was_paused: bool, paused: bool, now: Instant) {
if !was_paused && paused {
self.remaining = Some(self.deadline.saturating_duration_since(now));
} else if was_paused && !paused {
self.deadline = now + self.remaining.take().unwrap_or_default();
}
}
}
#[derive(Clone, Debug, Default)]
pub struct TimerRegistry {
entries: BTreeMap<TimerId, TimerEntry>,
completed: BTreeMap<TimerId, TimerSignature>,
}
impl TimerRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn reconcile(
&mut self,
root: &ComponentInstancePath,
declarations: BTreeMap<TimerId, TimerDescriptor>,
now: Instant,
) {
self.entries
.retain(|id, _| !id.component.is_within(root) || declarations.contains_key(id));
self.completed
.retain(|id, _| !id.component.is_within(root) || declarations.contains_key(id));
for (id, descriptor) in declarations {
let signature = TimerSignature::from(&descriptor);
if self.completed.get(&id) == Some(&signature) {
continue;
}
self.completed.remove(&id);
match self.entries.get_mut(&id) {
Some(entry) if entry.signature == signature => entry.synchronize(descriptor, now),
Some(entry) => *entry = TimerEntry::new(descriptor, now),
None => {
self.entries.insert(id, TimerEntry::new(descriptor, now));
}
}
}
}
#[must_use]
pub fn pause(&mut self, id: &TimerId, now: Instant) -> bool {
let Some(entry) = self.entries.get_mut(id) else {
return false;
};
let was_paused = entry.is_paused();
entry.interaction_paused = true;
entry.transition_pause(was_paused, entry.is_paused(), now);
true
}
#[must_use]
pub fn resume(&mut self, id: &TimerId, now: Instant) -> bool {
let Some(entry) = self.entries.get_mut(id) else {
return false;
};
let was_paused = entry.is_paused();
entry.interaction_paused = false;
entry.transition_pause(was_paused, entry.is_paused(), now);
true
}
#[must_use]
pub fn cancel(&mut self, id: &TimerId) -> bool {
let Some(entry) = self.entries.remove(id) else {
return false;
};
self.completed.insert(id.clone(), entry.signature);
true
}
#[must_use]
pub fn drain(&mut self, now: Instant, generation: ScriptGeneration) -> Vec<AsyncDelivery> {
let due = self
.entries
.iter()
.filter(|(_, entry)| entry.remaining.is_none() && entry.deadline <= now)
.map(|(id, _)| id.clone())
.collect::<Vec<_>>();
due.into_iter()
.filter_map(|id| {
let entry = self.entries.remove(&id)?;
self.completed.insert(id, entry.signature);
(entry.descriptor.callback.generation() == generation).then(|| AsyncDelivery {
callback: entry.descriptor.callback,
payload: entry.descriptor.payload,
scope: AsyncScope::Component(entry.descriptor.id.component),
})
})
.collect()
}
pub fn cancel_component_scope(&mut self, component: &ComponentInstancePath) {
self.entries
.retain(|id, _| !id.component.is_within(component));
self.completed
.retain(|id, _| !id.component.is_within(component));
}
pub fn pause_component_scope(&mut self, component: &ComponentInstancePath, now: Instant) {
for (id, entry) in &mut self.entries {
if id.component.is_within(component) {
let was_paused = entry.is_paused();
entry.view_paused = true;
entry.transition_pause(was_paused, entry.is_paused(), now);
}
}
}
pub fn resume_component_scope(&mut self, component: &ComponentInstancePath, now: Instant) {
for (id, entry) in &mut self.entries {
if id.component.is_within(component) {
let was_paused = entry.is_paused();
entry.view_paused = false;
entry.transition_pause(was_paused, entry.is_paused(), now);
}
}
}
#[must_use]
pub fn active_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn inspect(&self, now: Instant) -> Vec<TimerSnapshot> {
self.entries
.iter()
.map(|(id, entry)| TimerSnapshot {
id: id.clone(),
delay: entry.descriptor.delay,
remaining: entry
.remaining
.unwrap_or_else(|| entry.deadline.saturating_duration_since(now)),
declaration_paused: entry.declaration_paused,
interaction_paused: entry.interaction_paused,
view_paused: entry.view_paused,
callback: entry.descriptor.callback.name().to_owned(),
generation: entry.descriptor.callback.generation(),
})
.collect()
}
}
#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum TimerError {
#[error("timer key `{0}` must be 1-128 safe identifier characters")]
InvalidKey(String),
#[error("timer delay must be between 1ms and 24h, got {0:?}")]
InvalidDelay(Duration),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EventSchema, ScriptGeneration};
use rhai::FnPtr;
fn descriptor(
component: &ComponentInstancePath,
key: &str,
delay: Duration,
paused: bool,
) -> TimerDescriptor {
let mut callback = ScriptCallback::try_from_fn_ptr(
FnPtr::new("fired").unwrap(),
ScriptGeneration::initial(),
)
.unwrap();
callback.bind_component_if_unset(component.clone(), BTreeMap::<String, EventSchema>::new());
TimerDescriptor::new(
TimerId::new(component.clone(), key).unwrap(),
delay,
paused,
callback,
UiValue::String(key.to_owned()),
)
.unwrap()
}
#[test]
fn reconcile_preserves_deadline_pause_and_one_shot_completion() {
let root = ComponentInstancePath::root("App", "root");
let now = Instant::now();
let mut timers = TimerRegistry::new();
let running = descriptor(&root, "toast:a", Duration::from_millis(100), false);
timers.reconcile(
&root,
BTreeMap::from([(running.id.clone(), running.clone())]),
now,
);
assert!(timers.pause(running.id(), now + Duration::from_millis(40)));
let snapshot = timers.inspect(now + Duration::from_millis(90));
assert_eq!(snapshot[0].remaining, Duration::from_millis(60));
assert!(snapshot[0].interaction_paused);
assert!(timers.resume(running.id(), now + Duration::from_millis(90)));
assert!(
timers
.drain(
now + Duration::from_millis(149),
ScriptGeneration::initial(),
)
.is_empty()
);
assert_eq!(
timers
.drain(
now + Duration::from_millis(150),
ScriptGeneration::initial(),
)
.len(),
1
);
timers.reconcile(&root, BTreeMap::from([(running.id.clone(), running)]), now);
assert_eq!(
timers.active_count(),
0,
"completed declaration stays one-shot"
);
}
#[test]
fn changing_signature_restarts_and_removal_cleans_scope() {
let root = ComponentInstancePath::root("App", "root");
let now = Instant::now();
let mut timers = TimerRegistry::new();
let first = descriptor(&root, "one", Duration::from_millis(10), false);
timers.reconcile(&root, BTreeMap::from([(first.id.clone(), first)]), now);
assert_eq!(timers.active_count(), 1);
let changed = descriptor(&root, "one", Duration::from_millis(20), false);
timers.reconcile(&root, BTreeMap::from([(changed.id.clone(), changed)]), now);
assert!(
timers
.drain(now + Duration::from_millis(19), ScriptGeneration::initial(),)
.is_empty()
);
timers.reconcile(&root, BTreeMap::new(), now);
assert_eq!(timers.active_count(), 0);
}
#[test]
fn view_resume_does_not_clear_an_explicit_pause() {
let root = ComponentInstancePath::root("App", "root");
let now = Instant::now();
let mut timers = TimerRegistry::new();
let timer = descriptor(&root, "one", Duration::from_millis(100), false);
timers.reconcile(&root, BTreeMap::from([(timer.id.clone(), timer)]), now);
assert!(timers.pause(
&TimerId::new(root.clone(), "one").unwrap(),
now + Duration::from_millis(10)
));
timers.pause_component_scope(&root, now + Duration::from_millis(20));
timers.resume_component_scope(&root, now + Duration::from_secs(1));
let snapshot = timers.inspect(now + Duration::from_secs(1));
assert!(snapshot[0].interaction_paused);
assert!(!snapshot[0].view_paused);
assert!(
timers
.drain(now + Duration::from_secs(2), ScriptGeneration::initial())
.is_empty()
);
}
}