1use std::collections::BTreeMap;
2
3use thiserror::Error;
4
5use crate::{ComponentInstancePath, ScriptCallback, ScriptGeneration, UiValue};
6
7#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct EffectId {
10 component: ComponentInstancePath,
11 key: String,
12}
13
14impl EffectId {
15 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#[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}