StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 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 | |
| 100 | mod tracked; |
| 101 | |
| 102 | #[cfg(test)] |
| 103 | #[path = "autotracking_test.rs"] |
| 104 | mod tests; |
| 105 | |
| 106 | use itertools::Itertools as _; |
| 107 | pub use tracked::Tracked; |
| 108 | |
| 109 | use super::{EntityId, WindowId}; |
| 110 | use std::cell::UnsafeCell; |
| 111 | use std::collections::{hash_map::Entry, HashMap, HashSet}; |
| 112 | use std::mem; |
| 113 | use 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)] |
| 130 | struct 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)] |
| 140 | struct View { |
| 141 | window_id: WindowId, |
| 142 | view_id: EntityId, |
| 143 | } |
| 144 | |
| 145 | thread_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. |
| 150 | fn with_cache<F, R>(callback: F) -> R |
| 151 | where |
| 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` |
| 172 | pub(super) fn render_view<F, R>(window_id: WindowId, view_id: EntityId, render_callback: F) -> R |
| 173 | where |
| 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. |
| 194 | pub(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. |
| 209 | pub(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 |
| 227 | pub(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 |
| 249 | pub(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 | |
| 257 | fn 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 |
| 270 | fn 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 |
| 288 | fn 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 |