ailloli_ui_runtime/app/
state_store.rs

1//! Type-erased, tree-scoped retained component state slots.
2
3use crate::component::reactive::ReactiveSource;
4use crate::component::signal::Signal;
5use crate::popup::ElementTreeId;
6use ailloli_ui_core::ids::ElementId;
7use std::any::Any;
8use std::cell::RefCell;
9use std::collections::HashMap;
10use std::rc::Rc;
11
12/// Public legacy slot identity within the root tree namespace.
13///
14/// Slot indices are caller-assigned `usize` values with no reserved sentinel.
15/// The same element and index must always be requested with the same concrete
16/// value type while mounted.
17///
18/// # Examples
19///
20/// ```
21/// use ailloli_ui_core::ElementId;
22/// use ailloli_ui_runtime::app::StateSlot;
23/// let slot = StateSlot { element_id: ElementId(3), slot_index: 1 };
24/// assert_eq!(slot.slot_index, 1);
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct StateSlot {
28    /// Tree-local retained element identity.
29    pub element_id: ElementId,
30    /// Positional state-hook slot within that element.
31    pub slot_index: usize,
32}
33
34/// Complete private key adding a retained-tree namespace.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36struct ScopedStateSlot {
37    /// Retained-tree namespace preventing cross-runtime aliasing.
38    element_tree_id: ElementTreeId,
39    /// Element and positional slot within the tree.
40    slot: StateSlot,
41}
42
43/// Type-erased retained signal storage keyed by tree, element, and slot index.
44///
45/// The store has no automatic capacity limit; reconciliation must remove
46/// unmounted element/tree slots. Stored values and returned signals are
47/// `Rc<RefCell<_>>` based, making this a single-UI-thread facility. The first
48/// concrete type used for a mounted key remains mandatory until removal.
49///
50/// # Examples
51///
52/// ```
53/// use ailloli_ui_runtime::app::StateStore;
54/// let _store = StateStore::default();
55/// ```
56#[derive(Default)]
57pub struct StateStore {
58    /// Sparse type-erased `(value, reactive source)` tuples.
59    values: HashMap<ScopedStateSlot, Box<dyn Any>>,
60}
61
62/// Provides the operations defined for StateStore.
63impl StateStore {
64    /// Returns a signal stored in the legacy root-tree namespace (`tree 0`).
65    ///
66    /// Retained runtimes use [`Self::signal_scoped`] so equal `ElementId`
67    /// values from independent element trees cannot alias one another.
68    ///
69    /// `initial` is evaluated by the caller before this method; it is discarded
70    /// if the slot already exists. Returned handles share value/source and keep
71    /// the invalidator installed when the slot was first created.
72    ///
73    /// # Panics
74    ///
75    /// Panics if this mounted tree-0/element/index key was first created with a
76    /// different concrete `T`.
77    ///
78    /// # Examples
79    ///
80    /// ```
81    /// use std::rc::Rc;
82    /// use ailloli_ui_core::ElementId;
83    /// use ailloli_ui_runtime::app::StateStore;
84    /// let mut store = StateStore::default();
85    /// let first = store.signal(ElementId(1), 0, 10, Rc::new(|| {}));
86    /// first.set(11);
87    /// let reused = store.signal(ElementId(1), 0, 99, Rc::new(|| {}));
88    /// assert_eq!((reused.read(), reused.revision()), (11, 1));
89    /// ```
90    pub fn signal<T: 'static>(
91        &mut self,
92        element_id: ElementId,
93        slot_index: usize,
94        initial: T,
95        invalidate: Rc<dyn Fn()>,
96    ) -> Signal<T> {
97        self.signal_scoped(
98            ElementTreeId::new(0),
99            element_id,
100            slot_index,
101            initial,
102            invalidate,
103        )
104    }
105
106    /// Returns a signal isolated to one retained element tree.
107    ///
108    /// Equal element IDs and slot indices in different `element_tree_id`
109    /// namespaces do not alias. As with [`Self::signal`], `initial` is eagerly
110    /// constructed before the method sees whether the slot exists.
111    ///
112    /// # Panics
113    ///
114    /// Panics when the existing scoped key contains a different concrete type.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use std::rc::Rc;
120    /// use ailloli_ui_core::ElementId;
121    /// use ailloli_ui_runtime::{app::StateStore, popup::ElementTreeId};
122    /// let mut store = StateStore::default();
123    /// let a = store.signal_scoped(ElementTreeId::new(1), ElementId(1), 0, 10, Rc::new(|| {}));
124    /// let b = store.signal_scoped(ElementTreeId::new(2), ElementId(1), 0, 20, Rc::new(|| {}));
125    /// a.set(11);
126    /// assert_eq!((a.read(), b.read()), (11, 20));
127    /// ```
128    pub fn signal_scoped<T: 'static>(
129        &mut self,
130        element_tree_id: ElementTreeId,
131        element_id: ElementId,
132        slot_index: usize,
133        initial: T,
134        invalidate: Rc<dyn Fn()>,
135    ) -> Signal<T> {
136        self.signal_scoped_with(
137            element_tree_id,
138            element_id,
139            slot_index,
140            || initial,
141            invalidate,
142        )
143    }
144
145    /// Lazily initializes a retained signal slot. The factory is never
146    /// evaluated again while the same typed slot remains mounted.
147    ///
148    /// Every returned handle shares the slot value, source revision, first
149    /// historical invalidator, and dynamic retained subscribers. The store
150    /// retains its `Rc`s until removal; outstanding handles keep old state alive
151    /// after removal, while a later lookup creates a fresh independent slot.
152    ///
153    /// # Panics
154    ///
155    /// Panics when an existing scoped key contains a different concrete `T`.
156    /// A panic from `initial` propagates and leaves the entry vacant.
157    ///
158    /// # Examples
159    ///
160    /// ```
161    /// use std::{cell::Cell, rc::Rc};
162    /// use ailloli_ui_core::ElementId;
163    /// use ailloli_ui_runtime::{app::StateStore, popup::ElementTreeId};
164    /// let mut store = StateStore::default();
165    /// let calls = Rc::new(Cell::new(0));
166    /// let counted = calls.clone();
167    /// let first = store.signal_scoped_with(ElementTreeId::new(1), ElementId(1), 0, move || { counted.set(counted.get() + 1); 7 }, Rc::new(|| {}));
168    /// let second = store.signal_scoped_with(ElementTreeId::new(1), ElementId(1), 0, || 99, Rc::new(|| {}));
169    /// assert_eq!((first.read(), second.read(), calls.get()), (7, 7, 1));
170    /// ```
171    pub fn signal_scoped_with<T: 'static>(
172        &mut self,
173        element_tree_id: ElementTreeId,
174        element_id: ElementId,
175        slot_index: usize,
176        initial: impl FnOnce() -> T,
177        invalidate: Rc<dyn Fn()>,
178    ) -> Signal<T> {
179        let slot = ScopedStateSlot {
180            element_tree_id,
181            slot: StateSlot {
182                element_id,
183                slot_index,
184            },
185        };
186
187        self.values.entry(slot).or_insert_with(|| {
188            Box::new((
189                Rc::new(RefCell::new(initial())),
190                ReactiveSource::new(invalidate),
191            )) as Box<dyn Any>
192        });
193
194        let (value, source) = self
195            .values
196            .get(&slot)
197            .expect("state slot must exist")
198            .downcast_ref::<(Rc<RefCell<T>>, Rc<ReactiveSource>)>()
199            .expect("state slot type mismatch");
200
201        Signal::with_source(value.clone(), source.clone())
202    }
203
204    /// Removes an element from the legacy root-tree namespace (`tree 0`).
205    ///
206    /// Every slot index and concrete type for the element is removed. Missing
207    /// elements are a no-op. Existing `Signal` handles continue to own and mutate
208    /// their detached values; the next lookup creates fresh state.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use std::rc::Rc;
214    /// use ailloli_ui_core::ElementId;
215    /// use ailloli_ui_runtime::app::StateStore;
216    /// let mut store = StateStore::default();
217    /// let old = store.signal(ElementId(1), 0, 4, Rc::new(|| {}));
218    /// store.remove_element(ElementId(1));
219    /// let fresh = store.signal(ElementId(1), 0, 9, Rc::new(|| {}));
220    /// assert_eq!((old.read(), fresh.read()), (4, 9));
221    /// ```
222    pub fn remove_element(&mut self, element_id: ElementId) {
223        self.remove_element_scoped(ElementTreeId::new(0), element_id);
224    }
225
226    /// Removes all state slots belonging to `element_id` in one tree only.
227    ///
228    /// This scans the full sparse store in O(total slots). Other trees (even with
229    /// the same element ID) remain intact.
230    ///
231    /// # Examples
232    ///
233    /// ```
234    /// use std::rc::Rc;
235    /// use ailloli_ui_core::ElementId;
236    /// use ailloli_ui_runtime::{app::StateStore, popup::ElementTreeId};
237    /// let mut store = StateStore::default();
238    /// let other = store.signal_scoped(ElementTreeId::new(2), ElementId(1), 0, 20, Rc::new(|| {}));
239    /// let _ = store.signal_scoped(ElementTreeId::new(1), ElementId(1), 0, 10, Rc::new(|| {}));
240    /// store.remove_element_scoped(ElementTreeId::new(1), ElementId(1));
241    /// assert_eq!(other.read(), 20);
242    /// ```
243    pub fn remove_element_scoped(&mut self, element_tree_id: ElementTreeId, element_id: ElementId) {
244        self.values.retain(|slot, _| {
245            slot.element_tree_id != element_tree_id || slot.slot.element_id != element_id
246        });
247    }
248
249    /// Removes every retained state slot belonging to one element-tree
250    /// namespace.
251    ///
252    /// This is intentionally tree-scoped: several retained runtimes can share
253    /// the same store while reusing identical [`ElementId`] values.
254    /// The operation scans all slots and leaves outstanding signal handles alive.
255    ///
256    /// # Examples
257    ///
258    /// Public callers can perform the narrower element-scoped removal:
259    ///
260    /// ```
261    /// use std::rc::Rc;
262    /// use ailloli_ui_core::ElementId;
263    /// use ailloli_ui_runtime::{app::StateStore, popup::ElementTreeId};
264    /// let mut store = StateStore::default();
265    /// let old = store.signal_scoped(ElementTreeId::new(1), ElementId(1), 0, 3, Rc::new(|| {}));
266    /// store.remove_element_scoped(ElementTreeId::new(1), ElementId(1));
267    /// assert_eq!(old.read(), 3);
268    /// ```
269    pub(crate) fn remove_tree_scoped(&mut self, element_tree_id: ElementTreeId) {
270        self.values
271            .retain(|slot, _| slot.element_tree_id != element_tree_id);
272    }
273}