Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/core/autotracking/mod.rs
1//! Automatic change tracking (autotracking) system to reduce the need to call `ctx.notify()`
2//!
3//! This module provides a wrapper type `Tracked`, which automatically tracks changes to the
4//! underlying data and invalidates any Views that depend on that data (the equivalent of calling
5//! `ctx.notify()` directly).
6//!
7//! ## Use
8//!
9//! The `Tracked` type is intended to be straightforward to use: Any data that is wrapped in a
10//! `Tracked` will be hooked into the Autotracking system and will not need any calls to `notify`
11//! from any views that depend on the data for rendering. Creating a `Tracked` can be done in two
12//! ways:
13//!
14//! 1. Directly using the `Tracked::new` constructor, e.g. `Tracked::new(true)`
15//! 2. Via `Into`, e.g. `true.into()`
16//!
17//! `Tracked` implements `Deref` and `DerefMut` for the underlying type, so in most cases you
18//! should be able to use it directly wherever the underlying data would be expected. It may
19//! require an explicit dereference (e.g. `*my_value`) in some cases, but other than that the
20//! intent is for it to be mostly transparent.
21//!
22//! See the `autotracking` example in the `examples/` directory for more.
23//!
24//! ## Limitations
25//!
26//! `Tracked` is currently single-threaded, meaning that all of the values you want to have
27//! automatically tracked must be on the main thread of the app. This should apply to anything
28//! stored in a Model or View, as those are owned by the main thread already. To ensure that
29//! autotracked data is not incorrectly shared between threads, `Tracked` explicitly does not
30//! implement `Send` or `Sync`.
31//!
32//! Additionally, `Tracked` currently detects any mutable _access_ to the data as an update. It
33//! explicitly does not do any diff of the data to determine if there was an actual change made.
34//! This means it could generate false positive updates if you update it to the same values or
35//! otherwise take mutable access without modifying the data.
36//!
37//! Similar to the above, since `Tracked` relies on _mutable_ access, it will not detect changes
38//! in a type that uses interior mutability to make changes through a shared reference (`&self`
39//! instead of `&mut self`).
40//!
41//! ## Granularity
42//!
43//! Since `Tracked` can wrap any type, it is up to the user to determine how granular you want the
44//! tracking to be. It can wrap each individual value to give you very granular updates when only
45//! specific dependencies are changed, or it can wrap an entire model and give coarse updates
46//! whenever the model is modified.
47//!
48//! ## Details
49//!
50//! Internally, the Autotracking system works by tracking access in two ways:
51//!
52//! 1. While a View is rendering, any _reads_ of `Tracked` data are cached as dependencies for that
53//! view.
54//! 2. When any `Tracked` data is _updated_, all Views that depended on that data are marked for
55//! invalidation.
56//!
57//! ### `Tracked`
58//!
59//! To track reads and updates, each instance of a `Tracked` includes a unique identifier used by
60//! the autotracking system. The `Deref` and `DerefMut` implementations for `Tracked` send that
61//! identifier to the Autotracking system indicating a read or update, respectively. The fact that
62//! the tracking is tied to `Deref` and `DerefMut` is the reason behind the limitation listed above
63//! that it could generate false-positive results.
64//!
65//! ### Reads
66//!
67//! In order to track reads and cache dependencies, whenever the UI Framework begins rendering a
68//! View (i.e. calling `View::render` on it), it first notifies the Autotracking system that a
69//! render is starting. The autotracking system clears the cache for that View and holds onto the
70//! `WindowId` and `ViewId` for the duration of the render. During that time, any reads of
71//! `Tracked` data result in the autotracking cache being updated to list that tracked data as a
72//! dependency of the rendering view.
73//!
74//! When the call to `View::render` is complete, the UI Framework notifies the Autotracking system
75//! that it's over and it stops associating reads with a View dependency.
76//!
77//! ### Updates
78//!
79//! Whenever a `Tracked` data is updated, the Autotracking system adds all views that depend on
80//! that data to a set of invalidations. Then, every time the UI Framework collects the manual
81//! invalidations (e.g. those created by calls to `ctx.notify()`), it also drains the stored
82//! invalidations from the autotracking system. From that point forward, they are treated exactly
83//! the same as if you had called `ctx.notify()` for the relevant views.
84//!
85//! ### Removing Views
86//!
87//! When the UI Framework removes a view or window, it notifies the Autotracking system of that
88//! removal and any Views that no longer exist are removed from the dependency cache. This ensures
89//! that we aren't wasting resources trying to invalidate views that no longer exist.
90//!
91//! ### Cache
92//!
93//! All of the Autotracking cached data is stored in a thread-local static on the main thread. This
94//! removes the need for synchronization (e.g. `Mutex` or `RwLock`), as the data will only ever be
95//! accessed by a single thread. This also allows the `Tracked` instances to notify about any reads
96//! or updates without having to maintain a reference to the `AppContext` or similar app
97//! state. However, this is also the source of the limitation that all data using `Tracked` must
98//! be on the main thread and the lack of support for multithreaded change tracking.
99 
100mod tracked;
101 
102#[cfg(test)]
103#[path = "autotracking_test.rs"]
104mod tests;
105 
106use itertools::Itertools as _;
107pub use tracked::Tracked;
108 
109use super::{EntityId, WindowId};
110use std::cell::UnsafeCell;
111use std::collections::{hash_map::Entry, HashMap, HashSet};
112use std::mem;
113use tracked::TrackedId;
114 
115/// Internal state cache used for autotracking changes
116///
117/// Rendering dependencies are stored in two maps, one from `TrackedId` -> Set of `View`s that
118/// depend on that value; and the other from `View` -> Set of `TrackedId`s that View depends on.
119/// This double-map allows us to insert and retrieve dependencies in O(1) time while also limiting
120/// the time it takes to remove a view from the cache.
121///
122/// When we start rendering a view, we first clear that view's dependencies from the existing
123/// cache, then we track that view in `rendering_view`. Subsequently, when a `Tracked` value is
124/// read, we update the maps to reflect that dependency.
125///
126/// When a `Tracked` value is updated, we refer back to the cached set of Views that depend on that
127/// value and add them all to the `invalidations` list, so that those views will be considered
128/// invalidated on the next render.
129#[derive(Default)]
130struct Cache {
131 rendering_view: Option<View>,
132 view_dependencies: HashMap<View, HashSet<TrackedId>>,
133 value_dependencies: HashMap<TrackedId, HashSet<View>>,
134 invalidations: HashSet<View>,
135}
136 
137/// Helper struct to encapsulate a View with its associated Window, necessary for properly tracking
138/// invalidations by window.
139#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
140struct View {
141 window_id: WindowId,
142 view_id: EntityId,
143}
144 
145thread_local! {
146 static CACHE: UnsafeCell<Cache> = UnsafeCell::new(Cache::default())
147}
148 
149/// Helper method for dereferencing the cache value and providing it to the caller via a callback.
150fn with_cache<F, R>(callback: F) -> R
151where
152 F: FnOnce(&mut Cache) -> R,
153{
154 CACHE.with(|cache_cell| {
155 // Safety: The cache is thread-local and only ever accessed by functions in this module.
156 // Therefore, there is only ever one reference active at a time.
157 let cache = unsafe { &mut *cache_cell.get() };
158 
159 callback(cache)
160 })
161}
162 
163/// Render a View using the provided callback while tracking any reads of `Tracked` values.
164///
165/// While the render is performed, any reads of `Tracked` values will be stored as dependencies for
166/// the provided View.
167///
168/// ## Invariants
169///
170/// This function requires that only one view is rendered at a time, so the callback cannot result
171/// in a recursive call to `render`
172pub(super) fn render_view<F, R>(window_id: WindowId, view_id: EntityId, render_callback: F) -> R
173where
174 F: FnOnce() -> R,
175{
176 with_cache(|cache| {
177 debug_assert!(cache.rendering_view.is_none());
178 
179 let view = View { window_id, view_id };
180 // Clear the dependency cache for this view as it is being rendered again
181 remove_view_internal(view, cache);
182 cache.rendering_view = Some(view);
183 
184 let return_value = render_callback();
185 
186 cache.rendering_view = None;
187 
188 return_value
189 })
190}
191 
192/// Returns the list of windows that have invalidations caused by the
193/// Autotracking system.
194pub(super) fn windows_with_invalidations() -> Vec<WindowId> {
195 with_cache(|cache| {
196 cache
197 .invalidations
198 .iter()
199 .map(|view| view.window_id)
200 .unique()
201 .collect_vec()
202 })
203}
204 
205/// Retrieves any invalidations for the given window caused by the Autotracking
206/// system.
207///
208/// Note: This will clear the cache of invalidations for this window.
209pub(super) fn take_invalidations_for_window(window_id: WindowId) -> HashSet<EntityId> {
210 with_cache(|cache| {
211 let (matching, remainder) = mem::take(&mut cache.invalidations)
212 .into_iter()
213 .partition(|view| view.window_id == window_id);
214 cache.invalidations = remainder;
215 matching.into_iter().map(|view| view.view_id).collect()
216 })
217}
218 
219/// Notify the Autotracking system that a Window is being closed
220///
221/// This will remove all Views associated with that Window from the dependencies cache to make
222/// sure that we don't invalidate views from closed windows.
223///
224/// ## Invariants
225///
226/// This should not be called during the rendering of a View
227pub(super) fn close_window(window_id: WindowId) {
228 with_cache(|cache| {
229 debug_assert!(cache.rendering_view.is_none());
230 
231 let removed_views = cache
232 .view_dependencies
233 .keys()
234 .filter(|view| view.window_id == window_id)
235 .copied()
236 .collect::<Vec<_>>();
237 
238 for removed_view in removed_views {
239 remove_view_internal(removed_view, cache);
240 }
241 })
242}
243 
244/// Remove a view from the dependency cache and any existing invalidations
245///
246/// ## Invariants
247///
248/// Should not be called during the rendering of a View
249pub(super) fn remove_view(window_id: WindowId, view_id: EntityId) {
250 with_cache(|cache| {
251 debug_assert!(cache.rendering_view.is_none());
252 
253 remove_view_internal(View { window_id, view_id }, cache);
254 });
255}
256 
257fn remove_view_internal(view: View, cache: &mut Cache) {
258 for tracked_id in cache.view_dependencies.remove(&view).into_iter().flatten() {
259 if let Entry::Occupied(mut entry) = cache.value_dependencies.entry(tracked_id) {
260 entry.get_mut().remove(&view);
261 
262 if entry.get().is_empty() {
263 entry.remove();
264 }
265 }
266 }
267}
268 
269/// Notify the Autotracking system that a given `Tracked` value was read
270fn track_read(field: TrackedId) {
271 with_cache(|cache| {
272 if let Some(view) = cache.rendering_view {
273 cache
274 .view_dependencies
275 .entry(view)
276 .or_default()
277 .insert(field);
278 cache
279 .value_dependencies
280 .entry(field)
281 .or_default()
282 .insert(view);
283 }
284 });
285}
286 
287/// Notify the Autotracking system that a given `Tracked` value was updated
288fn track_update(field: TrackedId) {
289 with_cache(|cache| {
290 cache
291 .invalidations
292 .extend(cache.value_dependencies.get(&field).into_iter().flatten());
293 })
294}
295