Skip to main content

gpui_rhai/
timer.rs

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    /// Create a component-scoped declarative timer identity.
18    ///
19    /// # Errors
20    ///
21    /// Returns [`TimerError::InvalidKey`] for an empty, oversized, or unsafe key.
22    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    /// Create a bounded one-shot timer declaration.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`TimerError::InvalidDelay`] for zero or more than one day.
63    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    /// Reconcile one component subtree's timer declarations atomically.
187    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}