Skip to main content

toddy_core/
engine.rs

1//! Pure state engine, decoupled from the iced runtime.
2//!
3//! [`Core`] owns the UI tree, widget caches, and subscription state.
4//! It processes [`IncomingMessage`]s and returns [`CoreEffect`]s that
5//! the host (the iced `App` or the headless runner) must execute.
6//! Core never touches iced directly -- it's pure state management.
7
8use std::collections::HashMap;
9
10use iced::Font;
11use serde_json::Value;
12
13use crate::effects;
14use crate::protocol::{EffectResponse, IncomingMessage, OutgoingEvent};
15use crate::theming;
16use crate::tree::Tree;
17use crate::widgets::{self, WidgetCaches};
18
19/// Side effects produced by [`Core::apply`] that the host must handle.
20#[derive(Debug)]
21pub enum CoreEffect {
22    /// The window set may have changed -- re-sync with renderer.
23    SyncWindows,
24    /// Emit an event to stdout.
25    EmitEvent(OutgoingEvent),
26    /// Emit an effect response to stdout.
27    EmitEffectResponse(EffectResponse),
28    /// Execute a widget operation (focus, scroll, etc.)
29    WidgetOp { op: String, payload: Value },
30    /// Execute a window operation (open, close, resize, etc.)
31    WindowOp {
32        op: String,
33        window_id: String,
34        settings: Value,
35    },
36    /// Theme changed (for the global/root theme only).
37    ThemeChanged(iced::Theme),
38    /// App-level theme should follow the system preference.
39    ThemeFollowsSystem,
40    /// Image operation (create/update/delete in-memory handles).
41    ImageOp {
42        op: String,
43        handle: String,
44        data: Option<Vec<u8>>,
45        pixels: Option<Vec<u8>>,
46        width: Option<u32>,
47        height: Option<u32>,
48    },
49    /// Extension configuration received from the host.
50    ExtensionConfig(Value),
51    /// Spawn an async effect (e.g. file dialogs) via Task::perform.
52    SpawnAsyncEffect {
53        request_id: String,
54        effect_type: String,
55        params: Value,
56    },
57}
58
59/// Pure state core, decoupled from the iced runtime.
60///
61/// Owns the retained UI tree, widget caches, active subscriptions, and
62/// global rendering defaults. The host calls [`apply`](Self::apply) with
63/// each incoming message and executes the returned [`CoreEffect`]s.
64pub struct Core {
65    /// The retained UI tree (snapshots replace it, patches update it).
66    pub tree: Tree,
67    /// Caches for stateful widgets (text_editor content, markdown items, etc.).
68    pub caches: WidgetCaches,
69    /// Active event subscriptions: kind -> tag.
70    pub active_subscriptions: HashMap<String, String>,
71    /// Global default text size from Settings.
72    pub default_text_size: Option<f32>,
73    /// Global default font from Settings.
74    pub default_font: Option<Font>,
75    /// Cached resolved theme from the root node's `theme` prop.
76    /// Only re-resolved when the raw JSON value changes.
77    pub cached_theme: Option<iced::Theme>,
78    /// Raw JSON of the last resolved theme prop, used for change detection.
79    cached_theme_json: Option<String>,
80    /// True after the first Settings message has been applied. Used to
81    /// suppress warnings about startup-only fields on the initial Settings.
82    settings_applied: bool,
83}
84
85impl Default for Core {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91impl Core {
92    pub fn new() -> Self {
93        Self {
94            tree: Tree::new(),
95            caches: WidgetCaches::new(),
96            active_subscriptions: HashMap::new(),
97            default_text_size: None,
98            default_font: None,
99            cached_theme: None,
100            cached_theme_json: None,
101            settings_applied: false,
102        }
103    }
104
105    /// Compute a SHA-256 hash of the current tree (serialized as JSON).
106    /// Returns the hex-encoded hash string, or an empty string if no tree.
107    pub fn tree_hash(&self) -> String {
108        use sha2::{Digest, Sha256};
109        match &self.tree.root() {
110            Some(root) => {
111                let json = serde_json::to_string(root).unwrap_or_default();
112                let hash = Sha256::digest(json.as_bytes());
113                format!("{:x}", hash)
114            }
115            None => String::new(),
116        }
117    }
118
119    /// Resolve and cache a theme from a JSON prop value. Only re-resolves
120    /// when the serialized JSON differs from the cached version.
121    fn resolve_and_cache_theme(
122        &mut self,
123        theme_val: &serde_json::Value,
124        effects: &mut Vec<CoreEffect>,
125    ) {
126        let json_str = theme_val.to_string();
127        if self.cached_theme_json.as_deref() == Some(&json_str) {
128            // Theme prop unchanged -- skip resolution.
129            return;
130        }
131        self.cached_theme_json = Some(json_str);
132        match theming::resolve_theme_only(theme_val) {
133            Some(theme) => {
134                self.cached_theme = Some(theme.clone());
135                effects.push(CoreEffect::ThemeChanged(theme));
136            }
137            None => {
138                self.cached_theme = None;
139                effects.push(CoreEffect::ThemeFollowsSystem);
140            }
141        }
142    }
143
144    /// Process an incoming message, mutate state, return effects.
145    pub fn apply(&mut self, message: IncomingMessage) -> Vec<CoreEffect> {
146        let mut effects = Vec::new();
147
148        match message {
149            IncomingMessage::Snapshot { tree } => {
150                log::debug!("snapshot received (root id={})", tree.id);
151                if let Some(theme_val) = tree.props.get("theme") {
152                    self.resolve_and_cache_theme(theme_val, &mut effects);
153                }
154                self.tree.snapshot(tree);
155                // Clear built-in caches but NOT extension caches. Extension
156                // cleanup callbacks run later via prepare_all() in the host,
157                // which needs the old cache entries to still be accessible.
158                self.caches.clear_builtin();
159                if let Some(root) = self.tree.root() {
160                    widgets::ensure_caches(root, &mut self.caches);
161                }
162                effects.push(CoreEffect::SyncWindows);
163            }
164            IncomingMessage::Patch { ops } => {
165                log::debug!("patch received ({} ops)", ops.len());
166                self.tree.apply_patch(ops);
167                // Re-check root theme prop in case a patch changed it.
168                if let Some(root) = self.tree.root()
169                    && let Some(theme_val) = root.props.get("theme")
170                {
171                    let theme_val = theme_val.clone();
172                    self.resolve_and_cache_theme(&theme_val, &mut effects);
173                }
174                if let Some(root) = self.tree.root() {
175                    widgets::ensure_caches(root, &mut self.caches);
176                }
177                effects.push(CoreEffect::SyncWindows);
178            }
179            IncomingMessage::Effect { id, kind, payload } => {
180                log::debug!("effect request: {kind} ({id})");
181                if effects::is_async_effect(&kind) {
182                    effects.push(CoreEffect::SpawnAsyncEffect {
183                        request_id: id,
184                        effect_type: kind,
185                        params: payload,
186                    });
187                } else {
188                    let response = effects::handle_effect(id, &kind, &payload);
189                    effects.push(CoreEffect::EmitEffectResponse(response));
190                }
191            }
192            IncomingMessage::WidgetOp { op, payload } => {
193                log::debug!("widget_op: {op}");
194                effects.push(CoreEffect::WidgetOp { op, payload });
195            }
196            IncomingMessage::Subscribe { kind, tag } => {
197                log::debug!("subscription register: {kind} -> {tag}");
198                if let Some(old_tag) = self.active_subscriptions.insert(kind.clone(), tag.clone())
199                    && old_tag != tag
200                {
201                    log::warn!(
202                        "subscription `{kind}` re-registered with tag `{tag}` \
203                         (was `{old_tag}`); previous handler replaced"
204                    );
205                }
206            }
207            IncomingMessage::Unsubscribe { kind } => {
208                log::debug!("subscription unregister: {kind}");
209                self.active_subscriptions.remove(&kind);
210            }
211            IncomingMessage::WindowOp {
212                op,
213                window_id,
214                settings,
215            } => {
216                log::debug!("window_op: {op} ({window_id})");
217                effects.push(CoreEffect::WindowOp {
218                    op,
219                    window_id,
220                    settings,
221                });
222            }
223            IncomingMessage::Settings { settings } => {
224                log::debug!("settings received");
225
226                // Protocol version check
227                if let Some(v) = settings.get("protocol_version").and_then(|v| v.as_u64()) {
228                    if v != u64::from(crate::protocol::PROTOCOL_VERSION) {
229                        log::error!(
230                            "protocol version mismatch: expected {}, got {}",
231                            crate::protocol::PROTOCOL_VERSION,
232                            v
233                        );
234                    }
235                } else {
236                    log::error!("no protocol_version in Settings, assuming compatible");
237                }
238
239                // Startup-only fields are extracted by run.rs before the
240                // daemon starts. Subsequent Settings messages can't change
241                // them -- warn so hosts notice the no-op.
242                if self.settings_applied {
243                    for field in &["antialiasing", "vsync", "fonts", "scale_factor"] {
244                        if settings.get(*field).is_some() {
245                            log::warn!(
246                                "Settings field `{field}` is startup-only; \
247                                 ignored after the daemon has started"
248                            );
249                        }
250                    }
251                }
252                self.settings_applied = true;
253
254                self.default_text_size = settings
255                    .get("default_text_size")
256                    .and_then(|v| v.as_f64())
257                    .map(|v| v as f32);
258                self.default_font = settings.get("default_font").map(|v| {
259                    let family = v.get("family").and_then(|f| f.as_str());
260                    match family {
261                        Some("monospace") => Font::MONOSPACE,
262                        Some(other) => {
263                            log::warn!(
264                                "unsupported default_font family `{other}`, \
265                                 using system default"
266                            );
267                            Font::DEFAULT
268                        }
269                        None => Font::DEFAULT,
270                    }
271                });
272                if let Some(ext_config) = settings.get("extension_config") {
273                    effects.push(CoreEffect::ExtensionConfig(ext_config.clone()));
274                }
275            }
276            IncomingMessage::ImageOp {
277                op,
278                handle,
279                data,
280                pixels,
281                width,
282                height,
283            } => {
284                log::debug!("image_op: {op} ({handle})");
285                effects.push(CoreEffect::ImageOp {
286                    op,
287                    handle,
288                    data,
289                    pixels,
290                    width,
291                    height,
292                });
293            }
294            // Scripting messages handled by the renderer binary (daemon /
295            // headless), not by Core. Listed explicitly so adding a new
296            // IncomingMessage variant produces a compile error here instead
297            // of silently falling through a catch-all `_` arm.
298            IncomingMessage::Query { .. } => {
299                log::debug!("Query message ignored by Core (handled by scripting layer)");
300            }
301            IncomingMessage::Interact { .. } => {
302                log::debug!("Interact message ignored by Core (handled by scripting layer)");
303            }
304            IncomingMessage::TreeHash { .. } => {
305                log::debug!("TreeHash message ignored by Core (handled by scripting layer)");
306            }
307            IncomingMessage::Screenshot { .. } => {
308                log::debug!("Screenshot message ignored by Core (handled by scripting layer)");
309            }
310            IncomingMessage::Reset { .. } => {
311                log::debug!("Reset message ignored by Core (handled by scripting layer)");
312            }
313            IncomingMessage::ExtensionCommand { .. } => {
314                log::debug!("ExtensionCommand message ignored by Core (handled by renderer App)");
315            }
316            IncomingMessage::AdvanceFrame { .. } => {
317                log::warn!(
318                    "AdvanceFrame is only supported in headless/test mode; ignored in daemon mode"
319                );
320            }
321            IncomingMessage::ExtensionCommands { .. } => {
322                log::debug!("ExtensionCommands message ignored by Core (handled by renderer App)");
323            }
324        }
325
326        effects
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use serde_json::Value;
333
334    use super::*;
335    use crate::protocol::{IncomingMessage, TreeNode};
336
337    fn make_node(id: &str, type_name: &str) -> TreeNode {
338        TreeNode {
339            id: id.to_string(),
340            type_name: type_name.to_string(),
341            props: serde_json::json!({}),
342            children: vec![],
343        }
344    }
345
346    fn make_node_with_props(id: &str, type_name: &str, props: Value) -> TreeNode {
347        TreeNode {
348            id: id.to_string(),
349            type_name: type_name.to_string(),
350            props,
351            children: vec![],
352        }
353    }
354
355    // -- Core::new() --
356
357    #[test]
358    fn new_returns_empty_tree() {
359        let core = Core::new();
360        assert!(core.tree.root().is_none());
361    }
362
363    #[test]
364    fn new_has_empty_active_subscriptions() {
365        let core = Core::new();
366        assert!(core.active_subscriptions.is_empty());
367    }
368
369    #[test]
370    fn new_has_no_default_text_size() {
371        let core = Core::new();
372        assert!(core.default_text_size.is_none());
373    }
374
375    #[test]
376    fn new_has_no_default_font() {
377        let core = Core::new();
378        assert!(core.default_font.is_none());
379    }
380
381    // -- Snapshot --
382
383    #[test]
384    fn snapshot_sets_tree_and_returns_sync_windows() {
385        let mut core = Core::new();
386        let msg = IncomingMessage::Snapshot {
387            tree: make_node("root", "column"),
388        };
389        let effects = core.apply(msg);
390        // Tree should be populated
391        assert!(core.tree.root().is_some());
392        assert_eq!(core.tree.root().unwrap().id, "root");
393        // Must include SyncWindows
394        let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
395        assert!(has_sync);
396    }
397
398    #[test]
399    fn snapshot_with_theme_prop_returns_theme_changed() {
400        let mut core = Core::new();
401        let msg = IncomingMessage::Snapshot {
402            tree: make_node_with_props("root", "column", serde_json::json!({"theme": "dark"})),
403        };
404        let effects = core.apply(msg);
405        let has_theme = effects
406            .iter()
407            .any(|e| matches!(e, CoreEffect::ThemeChanged(_)));
408        assert!(has_theme);
409    }
410
411    #[test]
412    fn snapshot_without_theme_prop_has_no_theme_changed() {
413        let mut core = Core::new();
414        let msg = IncomingMessage::Snapshot {
415            tree: make_node("root", "column"),
416        };
417        let effects = core.apply(msg);
418        let has_theme = effects
419            .iter()
420            .any(|e| matches!(e, CoreEffect::ThemeChanged(_)));
421        assert!(!has_theme);
422    }
423
424    // -- Patch --
425
426    #[test]
427    fn patch_with_no_ops_returns_sync_windows() {
428        let mut core = Core::new();
429        // First put a tree in place so patch has something to work with
430        let snapshot_msg = IncomingMessage::Snapshot {
431            tree: make_node("root", "column"),
432        };
433        core.apply(snapshot_msg);
434
435        let patch_msg = IncomingMessage::Patch { ops: vec![] };
436        let effects = core.apply(patch_msg);
437        let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
438        assert!(has_sync);
439    }
440
441    // -- Settings --
442
443    #[test]
444    fn settings_sets_default_text_size() {
445        let mut core = Core::new();
446        let msg = IncomingMessage::Settings {
447            settings: serde_json::json!({"default_text_size": 18.0}),
448        };
449        core.apply(msg);
450        assert_eq!(core.default_text_size, Some(18.0_f32));
451    }
452
453    #[test]
454    fn settings_sets_default_font_monospace() {
455        let mut core = Core::new();
456        let msg = IncomingMessage::Settings {
457            settings: serde_json::json!({"default_font": {"family": "monospace"}}),
458        };
459        core.apply(msg);
460        assert_eq!(core.default_font, Some(iced::Font::MONOSPACE));
461    }
462
463    #[test]
464    fn settings_sets_default_font_default_for_unknown_family() {
465        let mut core = Core::new();
466        let msg = IncomingMessage::Settings {
467            settings: serde_json::json!({"default_font": {"family": "sans-serif"}}),
468        };
469        core.apply(msg);
470        assert_eq!(core.default_font, Some(iced::Font::DEFAULT));
471    }
472
473    #[test]
474    fn settings_without_extension_config_returns_no_effects() {
475        let mut core = Core::new();
476        let msg = IncomingMessage::Settings {
477            settings: serde_json::json!({"default_text_size": 14.0}),
478        };
479        let effects = core.apply(msg);
480        assert!(effects.is_empty());
481    }
482
483    #[test]
484    fn settings_with_extension_config_emits_effect() {
485        let mut core = Core::new();
486        let msg = IncomingMessage::Settings {
487            settings: serde_json::json!({
488                "default_text_size": 14.0,
489                "extension_config": {
490                    "terminal": {"shell": "/bin/bash"}
491                }
492            }),
493        };
494        let effects = core.apply(msg);
495        let has_ext_config = effects
496            .iter()
497            .any(|e| matches!(e, CoreEffect::ExtensionConfig(_)));
498        assert!(has_ext_config);
499    }
500
501    #[test]
502    fn settings_with_extension_config_contains_correct_value() {
503        let mut core = Core::new();
504        let config_val = serde_json::json!({"terminal": {"shell": "/bin/zsh"}});
505        let msg = IncomingMessage::Settings {
506            settings: serde_json::json!({
507                "extension_config": config_val,
508            }),
509        };
510        let effects = core.apply(msg);
511        let ext_config = effects.iter().find_map(|e| match e {
512            CoreEffect::ExtensionConfig(v) => Some(v),
513            _ => None,
514        });
515        assert_eq!(
516            ext_config.unwrap(),
517            &serde_json::json!({"terminal": {"shell": "/bin/zsh"}})
518        );
519    }
520
521    // -- Subscribe / Unsubscribe --
522
523    #[test]
524    fn subscription_register_adds_to_active_subscriptions() {
525        let mut core = Core::new();
526        let msg = IncomingMessage::Subscribe {
527            kind: "time".to_string(),
528            tag: "tick".to_string(),
529        };
530        core.apply(msg);
531        assert_eq!(
532            core.active_subscriptions.get("time").map(|s| s.as_str()),
533            Some("tick")
534        );
535    }
536
537    #[test]
538    fn subscription_register_returns_no_effects() {
539        let mut core = Core::new();
540        let msg = IncomingMessage::Subscribe {
541            kind: "keyboard".to_string(),
542            tag: "key".to_string(),
543        };
544        let effects = core.apply(msg);
545        assert!(effects.is_empty());
546    }
547
548    #[test]
549    fn subscription_unregister_removes_from_active_subscriptions() {
550        let mut core = Core::new();
551        core.active_subscriptions
552            .insert("time".to_string(), "tick".to_string());
553        let msg = IncomingMessage::Unsubscribe {
554            kind: "time".to_string(),
555        };
556        core.apply(msg);
557        assert!(!core.active_subscriptions.contains_key("time"));
558    }
559
560    #[test]
561    fn subscription_unregister_returns_no_effects() {
562        let mut core = Core::new();
563        let msg = IncomingMessage::Unsubscribe {
564            kind: "time".to_string(),
565        };
566        let effects = core.apply(msg);
567        assert!(effects.is_empty());
568    }
569
570    // -- Unhandled message types --
571
572    #[test]
573    fn unhandled_message_returns_empty_effects() {
574        let mut core = Core::new();
575        // Query is handled by the scripting layer, not Core -- hits the catch-all
576        let msg = IncomingMessage::Query {
577            id: "q1".to_string(),
578            target: "tree".to_string(),
579            selector: Value::Null,
580        };
581        let effects = core.apply(msg);
582        assert!(effects.is_empty());
583    }
584
585    // -- Snapshot preserves extension caches for prepare_all --
586
587    #[test]
588    fn snapshot_preserves_extension_caches() {
589        let mut core = Core::new();
590
591        // Simulate extension storing data in extension caches.
592        core.caches.extension.insert("ext", "node-1", 42u32);
593
594        // Snapshot replaces the tree.
595        let msg = IncomingMessage::Snapshot {
596            tree: make_node("root", "column"),
597        };
598        core.apply(msg);
599
600        // Extension caches must survive -- clear_builtin() must NOT
601        // wipe them. The host calls prepare_all() after apply() to
602        // handle extension cleanup properly.
603        assert_eq!(core.caches.extension.get::<u32>("ext", "node-1"), Some(&42));
604    }
605
606    #[test]
607    fn snapshot_clears_builtin_caches() {
608        let mut core = Core::new();
609
610        // Populate a built-in cache by applying a snapshot with a text_editor.
611        let editor_node = make_node_with_props(
612            "ed1",
613            "text_editor",
614            serde_json::json!({"content": "hello"}),
615        );
616        let mut root = make_node("root", "column");
617        root.children.push(editor_node);
618        core.apply(IncomingMessage::Snapshot { tree: root });
619        assert!(core.caches.editor_contents.contains_key("ed1"));
620
621        // Second snapshot without the editor -- built-in caches should
622        // be cleared and repopulated (without the editor).
623        core.apply(IncomingMessage::Snapshot {
624            tree: make_node("root2", "column"),
625        });
626        assert!(!core.caches.editor_contents.contains_key("ed1"));
627    }
628
629    // -- Multi-window sequence -----------------------------------------------
630
631    fn make_window_node(id: &str) -> TreeNode {
632        TreeNode {
633            id: id.to_string(),
634            type_name: "window".to_string(),
635            props: serde_json::json!({}),
636            children: vec![],
637        }
638    }
639
640    #[test]
641    fn multi_window_snapshot_two_windows_produces_sync_windows() {
642        let mut core = Core::new();
643        let mut root = make_node("root", "column");
644        root.children.push(make_window_node("win-a"));
645        root.children.push(make_window_node("win-b"));
646
647        let effects = core.apply(IncomingMessage::Snapshot { tree: root });
648
649        let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
650        assert!(has_sync, "Snapshot with windows should produce SyncWindows");
651
652        // Verify the tree has both windows.
653        let ids = core.tree.window_ids();
654        assert_eq!(ids.len(), 2);
655        assert!(ids.contains(&"win-a".to_string()));
656        assert!(ids.contains(&"win-b".to_string()));
657    }
658
659    #[test]
660    fn multi_window_second_snapshot_removes_window() {
661        let mut core = Core::new();
662
663        // First snapshot: two windows.
664        let mut root1 = make_node("root", "column");
665        root1.children.push(make_window_node("win-a"));
666        root1.children.push(make_window_node("win-b"));
667        core.apply(IncomingMessage::Snapshot { tree: root1 });
668        assert_eq!(core.tree.window_ids().len(), 2);
669
670        // Second snapshot: only one window.
671        let mut root2 = make_node("root", "column");
672        root2.children.push(make_window_node("win-a"));
673        let effects = core.apply(IncomingMessage::Snapshot { tree: root2 });
674
675        let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
676        assert!(has_sync, "Second Snapshot should produce SyncWindows");
677
678        let ids = core.tree.window_ids();
679        assert_eq!(ids.len(), 1);
680        assert_eq!(ids[0], "win-a");
681    }
682
683    #[test]
684    fn multi_window_snapshot_then_add_window_via_second_snapshot() {
685        let mut core = Core::new();
686
687        // First: one window.
688        let mut root1 = make_node("root", "column");
689        root1.children.push(make_window_node("win-a"));
690        core.apply(IncomingMessage::Snapshot { tree: root1 });
691        assert_eq!(core.tree.window_ids().len(), 1);
692
693        // Second: three windows.
694        let mut root2 = make_node("root", "column");
695        root2.children.push(make_window_node("win-a"));
696        root2.children.push(make_window_node("win-b"));
697        root2.children.push(make_window_node("win-c"));
698        let effects = core.apply(IncomingMessage::Snapshot { tree: root2 });
699
700        let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
701        assert!(has_sync);
702
703        let ids = core.tree.window_ids();
704        assert_eq!(ids.len(), 3);
705    }
706}
707
708// ---------------------------------------------------------------------------
709// Extension dispatch + caches integration tests
710//
711// These verify that the EventResult::Consumed path correctly preserves
712// extension cache mutations -- the underlying mechanism that makes
713// Task::none() safe in the renderer's Message::Event handler.
714// ---------------------------------------------------------------------------
715#[cfg(test)]
716mod extension_event_tests {
717    use iced::{Element, Theme};
718    use serde_json::{Value, json};
719
720    use crate::extensions::{
721        EventResult, ExtensionCaches, ExtensionDispatcher, GenerationCounter, WidgetEnv,
722        WidgetExtension,
723    };
724    use crate::message::Message;
725    use crate::protocol::{OutgoingEvent, TreeNode};
726
727    /// A test extension that bumps a GenerationCounter and mutates a
728    /// cache entry on every Consumed event.
729    struct CountingExtension;
730
731    impl WidgetExtension for CountingExtension {
732        fn type_names(&self) -> &[&str] {
733            &["counter_widget"]
734        }
735
736        fn config_key(&self) -> &str {
737            "counting"
738        }
739
740        fn prepare(&mut self, node: &TreeNode, caches: &mut ExtensionCaches, _theme: &Theme) {
741            // Seed initial state if absent.
742            caches.get_or_insert(self.config_key(), &node.id, GenerationCounter::new);
743        }
744
745        fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
746            iced::widget::text("test").into()
747        }
748
749        fn handle_event(
750            &mut self,
751            node_id: &str,
752            _family: &str,
753            _data: &Value,
754            caches: &mut ExtensionCaches,
755        ) -> EventResult {
756            // Mutate caches and return Consumed with no events -- the
757            // scenario that was suspected of suppressing redraws.
758            if let Some(counter) = caches.get_mut::<GenerationCounter>(self.config_key(), node_id) {
759                counter.bump();
760            }
761            EventResult::Consumed(vec![])
762        }
763    }
764
765    /// Another test extension that returns Observed with synthetic events.
766    struct ObservingExtension;
767
768    impl WidgetExtension for ObservingExtension {
769        fn type_names(&self) -> &[&str] {
770            &["observer_widget"]
771        }
772
773        fn config_key(&self) -> &str {
774            "observing"
775        }
776
777        fn prepare(&mut self, node: &TreeNode, caches: &mut ExtensionCaches, _theme: &Theme) {
778            caches.get_or_insert(self.config_key(), &node.id, GenerationCounter::new);
779        }
780
781        fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
782            iced::widget::text("test").into()
783        }
784
785        fn handle_event(
786            &mut self,
787            node_id: &str,
788            _family: &str,
789            _data: &Value,
790            caches: &mut ExtensionCaches,
791        ) -> EventResult {
792            if let Some(counter) = caches.get_mut::<GenerationCounter>(self.config_key(), node_id) {
793                counter.bump();
794            }
795            EventResult::Observed(vec![OutgoingEvent::generic(
796                "viewport".to_string(),
797                node_id.to_string(),
798                Some(json!({"zoom": 1.5})),
799            )])
800        }
801    }
802
803    fn make_tree(id: &str, type_name: &str) -> TreeNode {
804        TreeNode {
805            id: id.to_string(),
806            type_name: type_name.to_string(),
807            props: json!({}),
808            children: vec![],
809        }
810    }
811
812    // -- Consumed with empty events mutates caches --------------------------
813
814    #[test]
815    fn consumed_empty_events_still_mutates_caches() {
816        let ext: Box<dyn WidgetExtension> = Box::new(CountingExtension);
817        let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
818        let mut caches = ExtensionCaches::new();
819        let root = make_tree("cw-1", "counter_widget");
820
821        // prepare registers the node and seeds the cache
822        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
823        assert_eq!(
824            caches
825                .get::<GenerationCounter>("counting", "cw-1")
826                .unwrap()
827                .get(),
828            0
829        );
830
831        // handle_event with Consumed(vec![]) modifies caches
832        let result = dispatcher.handle_event("cw-1", "click", &Value::Null, &mut caches);
833        assert!(matches!(result, EventResult::Consumed(ref v) if v.is_empty()));
834
835        // Cache mutation is visible -- generation was bumped
836        assert_eq!(
837            caches
838                .get::<GenerationCounter>("counting", "cw-1")
839                .unwrap()
840                .get(),
841            1
842        );
843    }
844
845    #[test]
846    fn consumed_caches_accumulate_across_multiple_events() {
847        let ext: Box<dyn WidgetExtension> = Box::new(CountingExtension);
848        let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
849        let mut caches = ExtensionCaches::new();
850        let root = make_tree("cw-1", "counter_widget");
851
852        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
853
854        for _ in 0..5 {
855            let _ = dispatcher.handle_event("cw-1", "click", &Value::Null, &mut caches);
856        }
857
858        assert_eq!(
859            caches
860                .get::<GenerationCounter>("counting", "cw-1")
861                .unwrap()
862                .get(),
863            5
864        );
865    }
866
867    // -- Observed returns events AND mutates caches -------------------------
868
869    #[test]
870    fn observed_mutates_caches_and_returns_events() {
871        let ext: Box<dyn WidgetExtension> = Box::new(ObservingExtension);
872        let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
873        let mut caches = ExtensionCaches::new();
874        let root = make_tree("ow-1", "observer_widget");
875
876        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
877
878        let result = dispatcher.handle_event("ow-1", "pan", &Value::Null, &mut caches);
879        match result {
880            EventResult::Observed(events) => {
881                assert_eq!(events.len(), 1);
882            }
883            other => panic!("expected Observed, got {:?}", variant_name(&other)),
884        }
885
886        assert_eq!(
887            caches
888                .get::<GenerationCounter>("observing", "ow-1")
889                .unwrap()
890                .get(),
891            1
892        );
893    }
894
895    // -- PassThrough for unknown nodes --------------------------------------
896
897    #[test]
898    fn unknown_node_returns_passthrough() {
899        let ext: Box<dyn WidgetExtension> = Box::new(CountingExtension);
900        let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
901        let mut caches = ExtensionCaches::new();
902
903        // Don't call prepare_all -- no node registered
904        let result = dispatcher.handle_event("nonexistent", "click", &Value::Null, &mut caches);
905        assert!(matches!(result, EventResult::PassThrough));
906    }
907
908    // -- GenerationCounter as redraw signal ---------------------------------
909
910    #[test]
911    fn generation_counter_detects_stale_state() {
912        let mut counter = GenerationCounter::new();
913        let saved = counter.get();
914        assert_eq!(saved, 0);
915
916        counter.bump();
917        assert_ne!(counter.get(), saved, "generation should differ after bump");
918
919        // Simulates the pattern in canvas::Program::draw -- compare saved
920        // value to current, clear cache if they differ.
921        let needs_redraw = counter.get() != saved;
922        assert!(needs_redraw);
923    }
924
925    fn variant_name(result: &EventResult) -> &'static str {
926        match result {
927            EventResult::PassThrough => "PassThrough",
928            EventResult::Consumed(_) => "Consumed",
929            EventResult::Observed(_) => "Observed",
930        }
931    }
932}