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}