Skip to main content

toddy_core/widgets/
caches.rs

1//! Widget cache management.
2//!
3//! Several iced widgets (`text_editor`, `markdown`, `combo_box`, `canvas`,
4//! `pane_grid`) require mutable state that must persist across renders, but
5//! iced's `view()` only has `&self`. The solution: [`ensure_caches`] runs
6//! during `apply()` (mutable context) to populate [`WidgetCaches`], and
7//! `render()` in `view()` reads them immutably. No `RefCell` needed.
8//!
9//! Caches are keyed by node ID and automatically pruned when nodes leave
10//! the tree. Content-addressed hashing detects prop changes without
11//! clobbering user edits (e.g. a text_editor's cursor position).
12
13use std::collections::hash_map::DefaultHasher;
14use std::collections::{HashMap, HashSet};
15use std::hash::{Hash, Hasher};
16
17use iced::widget::canvas as iced_canvas;
18use iced::widget::{combo_box, markdown, pane_grid, text_editor};
19use serde_json::Value;
20
21use crate::protocol::TreeNode;
22
23/// Maximum recursion depth for tree walks (render, ensure_caches, prepare).
24/// Prevents stack overflow from pathologically nested trees. Normal UI trees
25/// rarely exceed 20-30 levels; 256 is generous.
26pub(crate) const MAX_TREE_DEPTH: usize = 256;
27
28/// Maximum recursion depth for [`hash_json_value`]. JSON values within
29/// props (e.g. canvas shapes) can be arbitrarily nested. Bounded to
30/// match [`MAX_TREE_DEPTH`] for consistency.
31const MAX_HASH_DEPTH: usize = 256;
32
33// ---------------------------------------------------------------------------
34// Widget caches
35// ---------------------------------------------------------------------------
36
37/// Generates the [`WidgetCaches`] struct with automatic `new()`,
38/// `clear_builtin()`, and `prune_stale()` implementations. Adding a
39/// new cache field only requires adding it to this macro invocation --
40/// clear and prune can never fall out of sync.
41macro_rules! define_caches {
42    ($($(#[$meta:meta])* $field:ident : $value:ty),* $(,)?) => {
43        /// Per-widget mutable state that persists across renders.
44        ///
45        /// Fields are `pub(crate)` to avoid leaking internal HashMap
46        /// structure to extension authors. The renderer binary accesses
47        /// specific entries through the accessor methods below.
48        pub struct WidgetCaches {
49            $($(#[$meta])* pub(crate) $field: HashMap<String, $value>,)*
50            /// Extension-owned caches. Public so extension authors can
51            /// access their own cached state during render/prepare/cleanup.
52            pub extension: crate::extensions::ExtensionCaches,
53        }
54
55        impl WidgetCaches {
56            pub fn new() -> Self {
57                Self {
58                    $($field: HashMap::new(),)*
59                    extension: crate::extensions::ExtensionCaches::new(),
60                }
61            }
62
63            /// Clear per-node widget caches without touching extension caches.
64            ///
65            /// Used by the Snapshot handler so that extension cleanup callbacks
66            /// (via `ExtensionDispatcher::prepare_all`) can run before the
67            /// extension cache entries are removed.
68            pub fn clear_builtin(&mut self) {
69                $(self.$field.clear();)*
70            }
71
72            /// Remove entries whose node IDs are no longer in the live set.
73            fn prune_stale(&mut self, live_ids: &HashSet<String>) {
74                $(self.$field.retain(|id, _| live_ids.contains(id));)*
75            }
76        }
77    };
78}
79
80define_caches! {
81    /// text_editor Content state (preserves cursor, undo history).
82    editor_contents: text_editor::Content,
83    /// Hash of last-synced "content" prop per text_editor. Detects
84    /// host-side prop changes without clobbering user edits.
85    editor_content_hashes: u64,
86    /// Parsed markdown items with content hash for invalidation.
87    markdown_items: (u64, Vec<markdown::Item>),
88    /// combo_box filter/selection state.
89    combo_states: combo_box::State<String>,
90    /// combo_box option lists for change detection.
91    combo_options: Vec<String>,
92    /// pane_grid layout state.
93    pane_grid_states: pane_grid::State<String>,
94    /// Per-canvas, per-layer geometry caches. Inner key is layer name,
95    /// u64 is content hash for invalidation.
96    canvas_caches: HashMap<String, (u64, iced_canvas::Cache)>,
97    /// Per-qr_code caches (content hash, canvas Cache).
98    qr_code_caches: (u64, iced_canvas::Cache),
99    /// Resolved themes for Themer widget nodes.
100    themer_themes: iced::Theme,
101}
102
103impl Default for WidgetCaches {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl WidgetCaches {
110    pub fn clear(&mut self) {
111        self.clear_builtin();
112        self.extension.clear();
113    }
114
115    // -- Accessor methods for renderer crate --
116    // Fields are pub(crate) to avoid leaking internal HashMap structure to
117    // extension authors, but the renderer binary needs access to a few.
118
119    /// Get a mutable reference to a text_editor Content by node ID.
120    pub fn editor_content_mut(&mut self, id: &str) -> Option<&mut text_editor::Content> {
121        self.editor_contents.get_mut(id)
122    }
123
124    /// Get a mutable reference to a pane_grid State by node ID.
125    pub fn pane_grid_state_mut(&mut self, id: &str) -> Option<&mut pane_grid::State<String>> {
126        self.pane_grid_states.get_mut(id)
127    }
128
129    /// Get an immutable reference to a pane_grid State by node ID.
130    pub fn pane_grid_state(&self, id: &str) -> Option<&pane_grid::State<String>> {
131        self.pane_grid_states.get(id)
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Cache pre-population
137// ---------------------------------------------------------------------------
138
139/// Walk the tree and ensure that every `text_editor`, `markdown`,
140/// `combo_box`, `pane_grid`, `canvas`, `qr_code`, and `themer` node has
141/// an entry in the corresponding cache. This must be called *before*
142/// `render` so that `render` can work with shared (`&`) references to
143/// the caches.
144///
145/// After populating caches, prunes stale entries for nodes no longer in the
146/// tree across all cache types.
147pub fn ensure_caches(node: &TreeNode, caches: &mut WidgetCaches) {
148    let mut live_ids = HashSet::new();
149    ensure_caches_walk(node, caches, &mut live_ids, 0);
150    caches.prune_stale(&live_ids);
151}
152
153/// Inner recursive walk: populate caches and collect live node IDs.
154fn ensure_caches_walk(
155    node: &TreeNode,
156    caches: &mut WidgetCaches,
157    live_ids: &mut HashSet<String>,
158    depth: usize,
159) {
160    if depth > MAX_TREE_DEPTH {
161        log::warn!(
162            "[id={}] ensure_caches depth exceeds {MAX_TREE_DEPTH}, skipping subtree",
163            node.id
164        );
165        return;
166    }
167    live_ids.insert(node.id.clone());
168
169    match node.type_name.as_str() {
170        "text_editor" => super::input::ensure_text_editor_cache(node, caches),
171        "markdown" => super::display::ensure_markdown_cache(node, caches),
172        "combo_box" => super::input::ensure_combo_box_cache(node, caches),
173        "pane_grid" => super::layout::ensure_pane_grid_cache(node, caches),
174        "canvas" => super::canvas::ensure_canvas_cache(node, caches),
175        "themer" => super::interactive::ensure_themer_cache(node, caches),
176        "qr_code" => super::display::ensure_qr_code_cache(node, caches),
177        _ => {}
178    }
179
180    for child in &node.children {
181        ensure_caches_walk(child, caches, live_ids, depth + 1);
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Cache helpers (used by ensure_* functions in widget modules)
187// ---------------------------------------------------------------------------
188
189/// Build a sorted layer map from canvas props. Supports two prop formats:
190/// - `"layers"`: a JSON object mapping layer_name -> array of shapes (preferred)
191/// - `"shapes"`: a flat JSON array of shapes (legacy, wrapped as a single "default" layer)
192///
193/// If both are present, `"layers"` wins. Returns a BTreeMap of references so
194/// layer order is deterministic (alphabetical by name) without allocating
195/// serialized strings.
196pub(crate) fn canvas_layer_map(
197    props: Option<&serde_json::Map<String, Value>>,
198) -> std::collections::BTreeMap<String, &Value> {
199    let mut map = std::collections::BTreeMap::new();
200
201    if let Some(layers_obj) = props
202        .and_then(|p| p.get("layers"))
203        .and_then(|v| v.as_object())
204    {
205        for (name, shapes_val) in layers_obj {
206            map.insert(name.clone(), shapes_val);
207        }
208    } else if let Some(shapes_arr) = props.and_then(|p| p.get("shapes")) {
209        map.insert("default".to_string(), shapes_arr);
210    }
211
212    map
213}
214
215/// Hash a serde_json::Value recursively without allocating a serialized string.
216/// Each variant is discriminated by a type tag byte to avoid collisions.
217/// Recursion is bounded by [`MAX_HASH_DEPTH`].
218///
219/// NOTE: DefaultHasher output is not stable across Rust versions or builds.
220/// These hashes must never be persisted or compared across process restarts.
221pub(crate) fn hash_json_value(v: &serde_json::Value, h: &mut impl std::hash::Hasher) {
222    hash_json_value_inner(v, h, 0);
223}
224
225fn hash_json_value_inner(v: &serde_json::Value, h: &mut impl std::hash::Hasher, depth: usize) {
226    if depth > MAX_HASH_DEPTH {
227        // Treat excessively nested values as opaque. This changes the
228        // hash (vs. recursing further) but is safe -- worst case is an
229        // unnecessary cache invalidation.
230        6u8.hash(h);
231        return;
232    }
233    match v {
234        serde_json::Value::Null => 0u8.hash(h),
235        serde_json::Value::Bool(b) => {
236            1u8.hash(h);
237            b.hash(h);
238        }
239        serde_json::Value::Number(n) => {
240            2u8.hash(h);
241            if let Some(f) = n.as_f64() {
242                f.to_bits().hash(h);
243            } else if let Some(i) = n.as_i64() {
244                i.hash(h);
245            } else if let Some(u) = n.as_u64() {
246                u.hash(h);
247            }
248        }
249        serde_json::Value::String(s) => {
250            3u8.hash(h);
251            s.hash(h);
252        }
253        serde_json::Value::Array(arr) => {
254            4u8.hash(h);
255            arr.len().hash(h);
256            for item in arr {
257                hash_json_value_inner(item, h, depth + 1);
258            }
259        }
260        serde_json::Value::Object(obj) => {
261            5u8.hash(h);
262            obj.len().hash(h);
263            for (k, v) in obj {
264                k.hash(h);
265                hash_json_value_inner(v, h, depth + 1);
266            }
267        }
268    }
269}
270
271/// Hash a string using DefaultHasher for same-process cache invalidation.
272/// NOTE: DefaultHasher output is not stable across Rust versions or builds.
273/// These hashes must never be persisted or compared across process restarts.
274pub(crate) fn hash_str(s: &str) -> u64 {
275    let mut hasher = DefaultHasher::new();
276    s.hash(&mut hasher);
277    hasher.finish()
278}
279
280// ---------------------------------------------------------------------------
281// Tests
282// ---------------------------------------------------------------------------
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    // -- WidgetCaches --
289
290    #[test]
291    fn widget_caches_new_is_empty() {
292        let c = WidgetCaches::new();
293        assert!(c.editor_contents.is_empty());
294        assert!(c.markdown_items.is_empty());
295        assert!(c.combo_states.is_empty());
296        assert!(c.combo_options.is_empty());
297        assert!(c.pane_grid_states.is_empty());
298    }
299
300    #[test]
301    fn widget_caches_clear_empties_maps() {
302        let mut c = WidgetCaches::new();
303        c.combo_options.insert("x".into(), vec!["a".into()]);
304        c.clear();
305        assert!(c.combo_options.is_empty());
306    }
307
308    // -- clear_builtin vs clear --
309
310    #[test]
311    fn clear_builtin_preserves_extension_caches() {
312        let mut caches = WidgetCaches::new();
313
314        // Add a built-in cache entry and an extension cache entry.
315        caches
316            .editor_contents
317            .insert("ed1".to_string(), iced::widget::text_editor::Content::new());
318        caches.extension.insert("ext", "key", 42u32);
319
320        caches.clear_builtin();
321
322        // Built-in caches should be empty.
323        assert!(caches.editor_contents.is_empty());
324        // Extension caches should survive.
325        assert_eq!(caches.extension.get::<u32>("ext", "key"), Some(&42));
326    }
327
328    #[test]
329    fn clear_wipes_both_builtin_and_extension() {
330        let mut caches = WidgetCaches::new();
331
332        caches
333            .editor_contents
334            .insert("ed1".to_string(), iced::widget::text_editor::Content::new());
335        caches.extension.insert("ext", "key", 42u32);
336
337        caches.clear();
338
339        assert!(caches.editor_contents.is_empty());
340        assert!(!caches.extension.contains("ext", "key"));
341    }
342
343    // -- hash_json_value --
344
345    #[test]
346    fn hash_json_value_same_input_same_hash() {
347        use std::collections::hash_map::DefaultHasher;
348
349        let val = serde_json::json!({"shapes": [{"type": "rect", "x": 0, "y": 0}]});
350        let h1 = {
351            let mut h = DefaultHasher::new();
352            hash_json_value(&val, &mut h);
353            h.finish()
354        };
355        let h2 = {
356            let mut h = DefaultHasher::new();
357            hash_json_value(&val, &mut h);
358            h.finish()
359        };
360        assert_eq!(h1, h2);
361    }
362
363    #[test]
364    fn hash_json_value_different_input_different_hash() {
365        use std::collections::hash_map::DefaultHasher;
366
367        let a = serde_json::json!({"type": "rect"});
368        let b = serde_json::json!({"type": "circle"});
369        let hash_a = {
370            let mut h = DefaultHasher::new();
371            hash_json_value(&a, &mut h);
372            h.finish()
373        };
374        let hash_b = {
375            let mut h = DefaultHasher::new();
376            hash_json_value(&b, &mut h);
377            h.finish()
378        };
379        assert_ne!(hash_a, hash_b);
380    }
381
382    #[test]
383    fn hash_json_value_type_discrimination() {
384        use std::collections::hash_map::DefaultHasher;
385
386        // null, false, and 0 should produce different hashes
387        let vals = [
388            serde_json::json!(null),
389            serde_json::json!(false),
390            serde_json::json!(0),
391            serde_json::json!(""),
392            serde_json::json!([]),
393            serde_json::json!({}),
394        ];
395        let hashes: Vec<u64> = vals
396            .iter()
397            .map(|v| {
398                let mut h = DefaultHasher::new();
399                hash_json_value(v, &mut h);
400                h.finish()
401            })
402            .collect();
403
404        // All should be distinct
405        for (i, h1) in hashes.iter().enumerate() {
406            for (j, h2) in hashes.iter().enumerate() {
407                if i != j {
408                    assert_ne!(h1, h2, "type {i} and {j} should hash differently");
409                }
410            }
411        }
412    }
413}