Skip to main content

gpui_rhai/
effect.rs

1use std::collections::BTreeMap;
2
3use thiserror::Error;
4
5use crate::{ComponentInstancePath, ScriptCallback, ScriptGeneration, UiValue};
6
7/// Stable identity of one declarative component effect.
8#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct EffectId {
10    component: ComponentInstancePath,
11    key: String,
12}
13
14impl EffectId {
15    /// Construct a component-scoped effect identity.
16    ///
17    /// # Errors
18    ///
19    /// Returns [`EffectError::InvalidKey`] for an unsafe or unstable key.
20    pub fn new(
21        component: ComponentInstancePath,
22        key: impl Into<String>,
23    ) -> Result<Self, EffectError> {
24        let key = key.into();
25        if key.is_empty()
26            || key.len() > 128
27            || !key.chars().all(|character| {
28                character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ':')
29            })
30        {
31            return Err(EffectError::InvalidKey(key));
32        }
33        Ok(Self { component, key })
34    }
35
36    #[must_use]
37    pub const fn component(&self) -> &ComponentInstancePath {
38        &self.component
39    }
40
41    #[must_use]
42    pub fn key(&self) -> &str {
43        &self.key
44    }
45}
46
47/// One pure-render effect declaration retained until its dependencies change.
48#[derive(Clone, Debug, PartialEq)]
49pub struct EffectDescriptor {
50    id: EffectId,
51    dependencies: UiValue,
52    start: ScriptCallback,
53    cleanup: ScriptCallback,
54}
55
56impl EffectDescriptor {
57    #[must_use]
58    pub fn new(
59        id: EffectId,
60        dependencies: UiValue,
61        start: ScriptCallback,
62        cleanup: ScriptCallback,
63    ) -> Self {
64        Self {
65            id,
66            dependencies,
67            start,
68            cleanup,
69        }
70    }
71
72    #[must_use]
73    pub const fn id(&self) -> &EffectId {
74        &self.id
75    }
76
77    #[must_use]
78    pub const fn dependencies(&self) -> &UiValue {
79        &self.dependencies
80    }
81
82    #[must_use]
83    pub const fn start(&self) -> &ScriptCallback {
84        &self.start
85    }
86
87    #[must_use]
88    pub const fn cleanup(&self) -> &ScriptCallback {
89        &self.cleanup
90    }
91
92    #[must_use]
93    pub fn generation(&self) -> ScriptGeneration {
94        self.start.generation()
95    }
96}
97
98#[derive(Clone, Debug, Default)]
99pub struct EffectRegistry {
100    active: BTreeMap<EffectId, ActiveEffect>,
101    next_activation: u64,
102}
103
104#[derive(Clone, Debug)]
105struct ActiveEffect {
106    descriptor: EffectDescriptor,
107    activation: u64,
108}
109
110impl ActiveEffect {
111    fn scope(&self) -> crate::AsyncScope {
112        crate::AsyncScope::Effect {
113            component: self.descriptor.id.component.clone(),
114            key: self.descriptor.id.key.clone(),
115            activation: self.activation,
116        }
117    }
118}
119
120impl EffectRegistry {
121    #[must_use]
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&EffectId, &EffectDescriptor)> {
127        self.active
128            .iter()
129            .map(|(id, active)| (id, &active.descriptor))
130    }
131
132    pub(crate) fn iter_active(
133        &self,
134    ) -> impl ExactSizeIterator<Item = (&EffectId, &EffectDescriptor, u64)> {
135        self.active
136            .iter()
137            .map(|(id, active)| (id, &active.descriptor, active.activation))
138    }
139
140    pub(crate) fn contains_scope(&self, scope: &crate::AsyncScope) -> bool {
141        let crate::AsyncScope::Effect {
142            component,
143            key,
144            activation,
145        } = scope
146        else {
147            return true;
148        };
149        self.active
150            .get(&EffectId {
151                component: component.clone(),
152                key: key.clone(),
153            })
154            .is_some_and(|active| active.activation == *activation)
155    }
156
157    #[must_use]
158    pub fn len(&self) -> usize {
159        self.active.len()
160    }
161
162    #[must_use]
163    pub fn is_empty(&self) -> bool {
164        self.active.is_empty()
165    }
166
167    pub(crate) fn plan(
168        &self,
169        root: &ComponentInstancePath,
170        candidate: BTreeMap<EffectId, EffectDescriptor>,
171    ) -> EffectPlan {
172        let mut next = self.active.clone();
173        next.retain(|id, _| !id.component.is_within(root));
174
175        let mut cleanup = self
176            .active
177            .iter()
178            .filter(|(id, active)| {
179                id.component.is_within(root) && candidate.get(*id) != Some(&active.descriptor)
180            })
181            .map(|(_, active)| active.clone())
182            .collect::<Vec<_>>();
183        cleanup.sort_by(|left, right| right.descriptor.id.cmp(&left.descriptor.id));
184
185        let mut activation = self.next_activation.max(1);
186        let mut start = Vec::new();
187        for (id, descriptor) in candidate {
188            if let Some(active) = self
189                .active
190                .get(&id)
191                .filter(|active| active.descriptor == descriptor)
192            {
193                next.insert(id, active.clone());
194            } else {
195                let active = ActiveEffect {
196                    descriptor,
197                    activation,
198                };
199                activation = activation.saturating_add(1);
200                start.push(active.clone());
201                next.insert(id, active);
202            }
203        }
204        start.sort_by(|left, right| left.descriptor.id.cmp(&right.descriptor.id));
205
206        EffectPlan {
207            cleanup,
208            start,
209            next,
210            next_activation: activation,
211        }
212    }
213
214    pub(crate) fn commit(&mut self, plan: EffectPlan) {
215        self.active = plan.next;
216        self.next_activation = plan.next_activation;
217    }
218
219    pub(crate) fn remove_scope(&mut self, root: &ComponentInstancePath) {
220        self.active.retain(|id, _| !id.component.is_within(root));
221    }
222}
223
224#[derive(Clone, Debug)]
225pub(crate) struct EffectPlan {
226    cleanup: Vec<ActiveEffect>,
227    start: Vec<ActiveEffect>,
228    next: BTreeMap<EffectId, ActiveEffect>,
229    next_activation: u64,
230}
231
232impl EffectPlan {
233    pub(crate) fn transition_count(&self) -> usize {
234        self.cleanup.len().saturating_add(self.start.len())
235    }
236
237    pub(crate) fn has_cleanup(&self) -> bool {
238        !self.cleanup.is_empty()
239    }
240
241    pub(crate) fn cleanup_descriptors(
242        &self,
243    ) -> impl ExactSizeIterator<Item = (&EffectDescriptor, crate::AsyncScope)> {
244        self.cleanup
245            .iter()
246            .map(|active| (&active.descriptor, active.scope()))
247    }
248
249    pub(crate) fn start_descriptors(
250        &self,
251    ) -> impl ExactSizeIterator<Item = (&EffectDescriptor, crate::AsyncScope)> {
252        self.start
253            .iter()
254            .map(|active| (&active.descriptor, active.scope()))
255    }
256}
257
258#[derive(Clone, Debug, Error, Eq, PartialEq)]
259pub enum EffectError {
260    #[error("effect key `{0}` must be 1-128 ASCII letters, digits, `_`, `-`, `.`, or `:`")]
261    InvalidKey(String),
262    #[error("effect `{key}` is not declared by component `{component}`")]
263    Undeclared {
264        component: ComponentInstancePath,
265        key: String,
266    },
267    #[error("effect `{key}` is declared more than once by component `{component}`")]
268    Duplicate {
269        component: ComponentInstancePath,
270        key: String,
271    },
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::RuntimeEngine;
278
279    fn callback(name: &str) -> ScriptCallback {
280        let mut engine = RuntimeEngine::new();
281        let compiled = engine
282            .compile(&format!(
283                "fn {name}(ctx, dependencies) {{}} fn view(ctx) {{ text(\"ok\") }}"
284            ))
285            .unwrap();
286        engine.callback(&compiled, name).unwrap()
287    }
288
289    fn descriptor(component: &ComponentInstancePath, key: &str, value: i64) -> EffectDescriptor {
290        EffectDescriptor::new(
291            EffectId::new(component.clone(), key).unwrap(),
292            UiValue::Integer(value),
293            callback("start"),
294            callback("cleanup"),
295        )
296    }
297
298    #[test]
299    fn unchanged_effects_do_not_restart_and_removed_effects_cleanup() {
300        let root = ComponentInstancePath::root("View", "main");
301        let child = root.child("Panel", "primary");
302        let active = descriptor(&child, "load", 1);
303        let mut registry = EffectRegistry::new();
304        let initial = registry.plan(
305            &root,
306            BTreeMap::from([(active.id().clone(), active.clone())]),
307        );
308        assert!(!initial.has_cleanup());
309        assert_eq!(
310            initial
311                .start_descriptors()
312                .map(|(descriptor, _)| descriptor)
313                .collect::<Vec<_>>(),
314            vec![&active]
315        );
316        registry.commit(initial);
317
318        let unchanged = registry.plan(
319            &root,
320            BTreeMap::from([(active.id().clone(), active.clone())]),
321        );
322        assert_eq!(unchanged.transition_count(), 0);
323
324        let removed = registry.plan(&root, BTreeMap::new());
325        assert_eq!(
326            removed
327                .cleanup_descriptors()
328                .map(|(descriptor, _)| descriptor)
329                .collect::<Vec<_>>(),
330            vec![&active]
331        );
332        assert_eq!(removed.start_descriptors().len(), 0);
333    }
334
335    #[test]
336    fn restarted_effect_uses_a_distinct_async_activation_scope() {
337        let root = ComponentInstancePath::root("View", "main");
338        let old = descriptor(&root, "watch", 1);
339        let mut registry = EffectRegistry::new();
340        let initial = registry.plan(&root, BTreeMap::from([(old.id().clone(), old.clone())]));
341        let old_scope = initial.start_descriptors().next().unwrap().1;
342        registry.commit(initial);
343
344        let changed = descriptor(&root, "watch", 2);
345        let restart = registry.plan(&root, BTreeMap::from([(changed.id().clone(), changed)]));
346        let cleanup_scope = restart.cleanup_descriptors().next().unwrap().1;
347        let start_scope = restart.start_descriptors().next().unwrap().1;
348        assert_eq!(cleanup_scope, old_scope);
349        assert_ne!(start_scope, old_scope);
350        registry.commit(restart);
351        assert!(!registry.contains_scope(&old_scope));
352        assert!(registry.contains_scope(&start_scope));
353    }
354}