Skip to main content

toddy_core/
extensions.rs

1//! Widget extension system.
2//!
3//! Extensions let Rust crates add custom widget types to the toddy
4//! renderer. Each extension implements [`WidgetExtension`] and is
5//! registered at startup via [`ToddyAppBuilder`](crate::app::ToddyAppBuilder).
6//! The [`ExtensionDispatcher`] routes incoming messages and render
7//! calls to the correct extension based on node type names.
8//!
9//! State is managed through [`ExtensionCaches`], a type-erased
10//! key-value store namespaced by extension. Mutation happens in
11//! `prepare()` / `handle_event()` / `handle_command()` (mutable
12//! phase), reads happen in `render()` (immutable phase), matching
13//! iced's `update()`/`view()` split.
14
15use std::any::Any;
16use std::collections::HashMap;
17use std::panic::{AssertUnwindSafe, catch_unwind};
18use std::sync::atomic::{AtomicU32, Ordering};
19
20use iced::{Element, Theme};
21use serde_json::Value;
22
23use crate::image_registry::ImageRegistry;
24use crate::message::Message;
25use crate::protocol::{OutgoingEvent, TreeNode};
26use crate::widgets::WidgetCaches;
27
28/// Check if panic isolation is disabled via the TODDY_NO_CATCH_UNWIND env var.
29/// When true, extension panics propagate normally, preserving stack traces for
30/// debugging. Only use during development -- in production, catch_unwind
31/// prevents one extension from crashing the entire renderer.
32pub(crate) fn catch_unwind_enabled() -> bool {
33    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
34    *ENABLED.get_or_init(|| std::env::var("TODDY_NO_CATCH_UNWIND").is_err())
35}
36
37// ---------------------------------------------------------------------------
38// WidgetExtension trait
39// ---------------------------------------------------------------------------
40
41/// Trait for native Rust widget extensions.
42///
43/// Extensions handle custom node types that the built-in renderer doesn't
44/// know about. The trait scales from trivial render-only widgets (implement
45/// `type_names`, `config_key`, `render`) to full custom iced widgets with
46/// autonomous state (implement all methods).
47///
48/// # Lifecycle
49///
50/// Methods are called in this order:
51///
52/// 1. **Registration** -- `type_names()` and `config_key()` are queried once
53///    at startup to build the dispatch index. `config_key()` must be unique
54///    and must not contain `':'` (reserved as the cache namespace separator).
55///
56/// 2. **`init(config)`** -- called when a Settings message arrives from the
57///    host. Receives the value from `extension_config[config_key]`, or
58///    `Value::Null` if absent. Called before any `prepare()`.
59///
60/// 3. **`prepare(node, caches, theme)`** -- called in the mutable phase
61///    (during `update()`) after every tree change (Snapshot or Patch), for
62///    each node whose type matches this extension. Use this to create or
63///    update per-node state in `ExtensionCaches`. Guaranteed to run before
64///    `render()` for the same tree state.
65///
66/// 4. **`render(node, env)`** -- called in the immutable phase (`view()`)
67///    to produce an iced `Element`. Receives read-only access to caches
68///    via `WidgetEnv`. May be called multiple times per frame. Must not
69///    block or perform I/O.
70///
71/// 5. **`handle_event(node_id, family, data, caches)`** -- called when a
72///    widget event is emitted for a node owned by this extension. Return
73///    `EventResult::PassThrough` to forward the event to the host,
74///    `Consumed(events)` to suppress it, or `Observed(events)` to forward
75///    the original AND emit additional events.
76///
77/// 6. **`handle_command(node_id, op, payload, caches)`** -- called when the
78///    host sends an `ExtensionCommand` targeting a node owned by this
79///    extension. Return any events to emit back to the host.
80///
81/// 7. **`cleanup(node_id, caches)`** -- called when a node is removed from
82///    the tree (detected during `prepare_all()`). Use this to release
83///    per-node resources from `ExtensionCaches`. Not called on process
84///    exit or panic.
85///
86/// # Panic isolation
87///
88/// All mutable methods (`init`, `prepare`, `handle_event`,
89/// `handle_command`, `cleanup`) are wrapped in `catch_unwind`. A panic
90/// poisons the extension -- subsequent calls are skipped and a red
91/// placeholder is rendered. Three consecutive `render()` panics also
92/// trigger poisoning. Poison state is cleared on the next Snapshot.
93///
94/// # Cache access
95///
96/// `prepare()`, `handle_event()`, `handle_command()`, and `cleanup()`
97/// receive `&mut ExtensionCaches` for read-write access. `render()`
98/// receives read-only access via `WidgetEnv.caches`. This split matches
99/// iced's `update()`/`view()` separation -- mutation happens in `update`,
100/// reads in `view`.
101///
102/// # Prop helpers
103///
104/// The prelude re-exports typed prop extraction functions from
105/// [`crate::prop_helpers`] for reading values from `TreeNode.props`:
106///
107/// - `prop_str(node, "key") -> Option<String>`
108/// - `prop_f32(node, "key") -> Option<f32>`
109/// - `prop_f64(node, "key") -> Option<f64>`
110/// - `prop_i32(node, "key") -> Option<i32>`
111/// - `prop_i64(node, "key") -> Option<i64>`
112/// - `prop_u32(node, "key") -> Option<u32>`
113/// - `prop_u64(node, "key") -> Option<u64>`
114/// - `prop_usize(node, "key") -> Option<usize>`
115/// - `prop_bool(node, "key") -> Option<bool>`
116/// - `prop_bool_default(node, "key", default) -> bool`
117/// - `prop_length(node, "key", default) -> Length`
118/// - `prop_color(node, "key") -> Option<Color>` (parses `#RRGGBB` / `#RRGGBBAA`)
119/// - `prop_str_array(node, "key") -> Option<Vec<String>>`
120/// - `prop_f32_array(node, "key") -> Option<Vec<f32>>`
121/// - `prop_f64_array(node, "key") -> Option<Vec<f64>>`
122/// - `prop_range_f32(node) -> RangeInclusive<f32>` (reads `"range"` prop)
123/// - `prop_range_f64(node) -> RangeInclusive<f64>` (reads `"range"` prop)
124/// - `prop_object(node, "key") -> Option<&Map<String, Value>>`
125/// - `prop_value(node, "key") -> Option<&Value>` (raw JSON access)
126/// - `prop_horizontal_alignment(node, "key") -> alignment::Horizontal`
127/// - `prop_vertical_alignment(node, "key") -> alignment::Vertical`
128/// - `prop_content_fit(node) -> Option<ContentFit>`
129/// - `value_to_length(val) -> Option<Length>` (lower-level conversion)
130///
131/// # Examples
132///
133/// A minimal render-only extension that displays a greeting:
134///
135/// ```rust,ignore
136/// use toddy_core::prelude::*;
137///
138/// struct GreetingExtension;
139///
140/// impl WidgetExtension for GreetingExtension {
141///     fn type_names(&self) -> &[&str] {
142///         &["greeting"]
143///     }
144///
145///     fn config_key(&self) -> &str {
146///         "greeting"
147///     }
148///
149///     fn render<'a>(&self, node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
150///         use toddy_core::iced::widget::text;
151///         let name = node.props.get("name")
152///             .and_then(|v| v.as_str())
153///             .unwrap_or("world");
154///         text(format!("Hello, {name}!")).into()
155///     }
156/// }
157/// ```
158pub trait WidgetExtension: Send + Sync + 'static {
159    /// Node type names this extension handles (e.g. ["sparkline", "heatmap"]).
160    fn type_names(&self) -> &[&str];
161
162    /// Key used to route configuration from the Settings wire message's
163    /// `extension_config` object. Must be unique across all extensions.
164    fn config_key(&self) -> &str;
165
166    /// Receive configuration from the host. Called on startup and renderer
167    /// restart. Receives `Value::Null` if no config provided.
168    fn init(&mut self, _config: &Value) {}
169
170    /// Initialize or synchronize state for a node. Called in the mutable
171    /// phase before view(), every time the tree changes.
172    fn prepare(&mut self, _node: &TreeNode, _caches: &mut ExtensionCaches, _theme: &Theme) {}
173
174    /// Build an iced Element for a node. Called in the immutable phase (view).
175    fn render<'a>(&self, node: &'a TreeNode, env: &WidgetEnv<'a>) -> Element<'a, Message>;
176
177    /// Handle an event emitted by this extension's widgets. Called before
178    /// the event reaches the wire.
179    fn handle_event(
180        &mut self,
181        _node_id: &str,
182        _family: &str,
183        _data: &Value,
184        _caches: &mut ExtensionCaches,
185    ) -> EventResult {
186        EventResult::PassThrough
187    }
188
189    /// Handle a command sent from the host directly to this extension.
190    ///
191    /// The host sends `ExtensionCommand` messages with an `op` string and a
192    /// JSON `payload`. By convention, `op` names use `snake_case` and are
193    /// scoped to the extension (e.g. `"reset_zoom"`, `"set_data"`). The
194    /// extension decides what ops it supports; unrecognized ops should be
195    /// logged and ignored (return an empty vec).
196    ///
197    /// Return a vec of `OutgoingEvent`s to emit back to the host. Errors
198    /// should be reported as events with family `"extension_error"` and
199    /// relevant details in the data payload, rather than panicking.
200    fn handle_command(
201        &mut self,
202        _node_id: &str,
203        _op: &str,
204        _payload: &Value,
205        _caches: &mut ExtensionCaches,
206    ) -> Vec<OutgoingEvent> {
207        vec![]
208    }
209
210    /// Clean up when a node is removed from the tree.
211    fn cleanup(&mut self, _node_id: &str, _caches: &mut ExtensionCaches) {}
212
213    /// Create a fresh instance for a new session. Required for
214    /// multiplexed mode (`--max-sessions > 1`). Each session gets its
215    /// own extension instances so mutable state is fully isolated.
216    ///
217    /// The default implementation panics. Extensions that support
218    /// multiplexed sessions must override this.
219    fn new_instance(&self) -> Box<dyn WidgetExtension> {
220        unimplemented!(
221            "extension `{}` does not support multiplexed sessions; \
222             implement new_instance() to enable --max-sessions > 1",
223            self.config_key()
224        );
225    }
226}
227
228// ---------------------------------------------------------------------------
229// EventResult
230// ---------------------------------------------------------------------------
231
232/// Result of extension event handling.
233///
234/// Returned from [`WidgetExtension::handle_event`] to control whether the
235/// original event reaches the host and whether additional events are emitted.
236#[derive(Debug)]
237#[must_use = "an EventResult should not be silently discarded"]
238pub enum EventResult {
239    /// Don't handle -- forward the original event to the host as-is.
240    PassThrough,
241    /// The extension consumed the event. The original event is suppressed and
242    /// will NOT be forwarded to the host. The contained events (if any) are
243    /// emitted instead. Note: `Consumed(vec![])` suppresses the original
244    /// event without emitting any replacement -- use this intentionally, as
245    /// the host will never see the event.
246    Consumed(Vec<OutgoingEvent>),
247    /// The extension observed the event. The original event IS forwarded to
248    /// the host, and the contained additional events are also emitted.
249    Observed(Vec<OutgoingEvent>),
250}
251
252// ---------------------------------------------------------------------------
253// ExtensionCaches
254// ---------------------------------------------------------------------------
255
256/// Type-erased cache storage for extensions.
257///
258/// Keys are namespaced by extension `config_key()` to prevent collisions
259/// between extensions that happen to use the same cache key string. All
260/// public methods accept a `namespace` parameter (the extension's
261/// `config_key()`) which is prefixed onto the raw key internally.
262pub struct ExtensionCaches {
263    inner: HashMap<String, Box<dyn Any + Send + Sync>>,
264}
265
266impl ExtensionCaches {
267    pub fn new() -> Self {
268        Self {
269            inner: HashMap::new(),
270        }
271    }
272
273    /// Build the internal namespaced key: `"config_key:raw_key"`.
274    fn namespaced_key(namespace: &str, key: &str) -> String {
275        format!("{namespace}:{key}")
276    }
277
278    pub fn get<T: 'static>(&self, namespace: &str, key: &str) -> Option<&T> {
279        self.inner
280            .get(&Self::namespaced_key(namespace, key))?
281            .downcast_ref()
282    }
283
284    pub fn get_mut<T: 'static>(&mut self, namespace: &str, key: &str) -> Option<&mut T> {
285        self.inner
286            .get_mut(&Self::namespaced_key(namespace, key))?
287            .downcast_mut()
288    }
289
290    pub fn get_or_insert<T: Send + Sync + 'static>(
291        &mut self,
292        namespace: &str,
293        key: &str,
294        default: impl FnOnce() -> T,
295    ) -> &mut T {
296        let ns_key = Self::namespaced_key(namespace, key);
297
298        // Check for type mismatch on an existing entry *before* consuming
299        // the default closure, so we can replace the stale value with a
300        // fresh default of the correct type.
301        let needs_replace = self
302            .inner
303            .get(&ns_key)
304            .is_some_and(|v| v.downcast_ref::<T>().is_none());
305
306        if needs_replace {
307            log::warn!(
308                "extension cache type mismatch for key `{ns_key}`: \
309                 replacing existing entry with new default"
310            );
311            self.inner.remove(&ns_key);
312        }
313
314        self.inner
315            .entry(ns_key)
316            .or_insert_with(|| Box::new(default()))
317            .downcast_mut()
318            .expect("downcast must succeed: entry was just inserted with correct type")
319    }
320
321    pub fn insert<T: Send + Sync + 'static>(&mut self, namespace: &str, key: &str, value: T) {
322        self.inner
323            .insert(Self::namespaced_key(namespace, key), Box::new(value));
324    }
325
326    pub fn remove(&mut self, namespace: &str, key: &str) -> bool {
327        self.inner
328            .remove(&Self::namespaced_key(namespace, key))
329            .is_some()
330    }
331
332    pub fn contains(&self, namespace: &str, key: &str) -> bool {
333        self.inner
334            .contains_key(&Self::namespaced_key(namespace, key))
335    }
336
337    /// Remove all entries for a given namespace prefix.
338    pub fn remove_namespace(&mut self, namespace: &str) {
339        let prefix = format!("{namespace}:");
340        self.inner.retain(|k, _| !k.starts_with(&prefix));
341    }
342
343    pub fn clear(&mut self) {
344        self.inner.clear();
345    }
346}
347
348impl Default for ExtensionCaches {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354// ---------------------------------------------------------------------------
355// WidgetEnv and RenderCtx
356// ---------------------------------------------------------------------------
357
358/// Context provided to extension `render()` methods.
359///
360/// All fields are immutable references -- mutation happens in `prepare()`,
361/// reads happen here. This mirrors iced's `update()`/`view()` split.
362///
363/// # Available data
364///
365/// - `caches` -- extension caches (read-only). Use
366///   `caches.get::<T>(config_key, node_id)` to read per-node state
367///   populated in `prepare()`.
368/// - `ctx` -- the shared [`RenderCtx`] carrying images, theme,
369///   defaults, and child rendering. Convenience methods below
370///   delegate to it: `images()`, `theme()`, `default_text_size()`,
371///   `default_font()`, `render_child()`.
372pub struct WidgetEnv<'a> {
373    pub caches: &'a ExtensionCaches,
374    pub ctx: RenderCtx<'a>,
375}
376
377impl<'a> WidgetEnv<'a> {
378    pub fn images(&self) -> &'a ImageRegistry {
379        self.ctx.images
380    }
381    pub fn theme(&self) -> &'a Theme {
382        self.ctx.theme
383    }
384    pub fn default_text_size(&self) -> Option<f32> {
385        self.ctx.default_text_size
386    }
387    pub fn default_font(&self) -> Option<iced::Font> {
388        self.ctx.default_font
389    }
390    pub fn render_child(&self, node: &'a TreeNode) -> Element<'a, Message> {
391        self.ctx.render_child(node)
392    }
393}
394
395/// Renders child nodes through the main dispatch. Copy-able (all shared refs).
396#[derive(Clone, Copy)]
397pub struct RenderCtx<'a> {
398    pub caches: &'a WidgetCaches,
399    pub images: &'a ImageRegistry,
400    pub theme: &'a Theme,
401    pub extensions: &'a ExtensionDispatcher,
402    pub default_text_size: Option<f32>,
403    pub default_font: Option<iced::Font>,
404}
405
406impl<'a> RenderCtx<'a> {
407    /// Render a child node through the main dispatch.
408    pub fn render_child(&self, node: &'a TreeNode) -> Element<'a, Message> {
409        crate::widgets::render(node, *self)
410    }
411
412    /// Create a new RenderCtx with a different theme, preserving all other fields.
413    pub fn with_theme(&self, theme: &'a Theme) -> Self {
414        RenderCtx { theme, ..*self }
415    }
416
417    /// Render all children of a node through the main dispatch.
418    pub fn render_children(&self, node: &'a TreeNode) -> Vec<Element<'a, Message>> {
419        node.children.iter().map(|c| self.render_child(c)).collect()
420    }
421}
422
423// ---------------------------------------------------------------------------
424// ExtensionDispatcher
425// ---------------------------------------------------------------------------
426
427/// Number of consecutive render panics before an extension is poisoned.
428const RENDER_PANIC_THRESHOLD: u32 = 3;
429
430/// Owns registered extensions and routes messages to them.
431///
432/// Maintains a type-name index for O(1) dispatch, a node-to-extension
433/// map for event/command routing, and per-extension poison state for
434/// panic isolation. Created via
435/// [`ToddyAppBuilder::build_dispatcher`](crate::app::ToddyAppBuilder::build_dispatcher).
436pub struct ExtensionDispatcher {
437    extensions: Vec<Box<dyn WidgetExtension>>,
438    type_name_index: HashMap<String, usize>,
439    node_extension_map: HashMap<String, usize>,
440    poisoned: Vec<bool>,
441    /// Per-extension consecutive render panic counter. Stored as AtomicU32
442    /// so `record_render_panic` can be called with `&self` (the dispatcher
443    /// is borrowed immutably during view/render).
444    render_panic_counts: Vec<AtomicU32>,
445}
446
447impl ExtensionDispatcher {
448    pub fn new(extensions: Vec<Box<dyn WidgetExtension>>) -> Self {
449        let n = extensions.len();
450
451        // Validate extension metadata before building the index.
452        for ext in &extensions {
453            if ext.config_key().is_empty() {
454                panic!(
455                    "extension registered with empty config_key() \
456                     (type_names: {:?})",
457                    ext.type_names()
458                );
459            }
460            if ext.config_key().contains(':') {
461                panic!(
462                    "extension config_key `{}` contains ':' (reserved as \
463                     cache namespace separator); type_names: {:?}",
464                    ext.config_key(),
465                    ext.type_names()
466                );
467            }
468            if ext.type_names().is_empty() {
469                log::warn!(
470                    "extension `{}` registered with empty type_names(); \
471                     it will never match any node type",
472                    ext.config_key()
473                );
474            }
475        }
476
477        // Check for duplicate config_key values.
478        let mut seen_config_keys: HashMap<&str, usize> = HashMap::new();
479        for (idx, ext) in extensions.iter().enumerate() {
480            let key = ext.config_key();
481            if let Some(prev_idx) = seen_config_keys.insert(key, idx) {
482                panic!(
483                    "duplicate extension config_key `{key}`: \
484                     extension at index {prev_idx} (type_names: {:?}) and \
485                     extension at index {idx} (type_names: {:?}) both use it",
486                    extensions[prev_idx].type_names(),
487                    ext.type_names(),
488                );
489            }
490        }
491
492        let mut type_name_index = HashMap::new();
493        for (idx, ext) in extensions.iter().enumerate() {
494            for &name in ext.type_names() {
495                if let Some(prev_idx) = type_name_index.insert(name.to_string(), idx) {
496                    panic!(
497                        "duplicate extension type name `{name}`: \
498                         extension `{}` (index {prev_idx}) and \
499                         extension `{}` (index {idx}) both claim it",
500                        extensions[prev_idx].config_key(),
501                        ext.config_key(),
502                    );
503                }
504            }
505        }
506
507        let render_panic_counts = (0..n).map(|_| AtomicU32::new(0)).collect();
508
509        Self {
510            extensions,
511            type_name_index,
512            node_extension_map: HashMap::new(),
513            poisoned: vec![false; n],
514            render_panic_counts,
515        }
516    }
517
518    /// Create a new dispatcher for a multiplexed session.
519    ///
520    /// Calls [`WidgetExtension::new_instance()`] on each registered
521    /// extension to produce independent instances with isolated mutable
522    /// state. The type-name index is rebuilt from the new instances.
523    ///
524    /// Panics if any extension has not implemented `new_instance()`.
525    pub fn clone_for_session(&self) -> Self {
526        let extensions: Vec<Box<dyn WidgetExtension>> = self
527            .extensions
528            .iter()
529            .map(|ext| ext.new_instance())
530            .collect();
531        Self::new(extensions)
532    }
533
534    /// Check if a node type is handled by an extension.
535    pub fn handles_type(&self, type_name: &str) -> bool {
536        self.type_name_index.contains_key(type_name)
537    }
538
539    /// Maximum tree recursion depth for walk_prepare.
540    const MAX_WALK_DEPTH: usize = crate::widgets::MAX_TREE_DEPTH;
541
542    /// Called after Core::apply() on tree changes.
543    pub fn prepare_all(&mut self, root: &TreeNode, caches: &mut ExtensionCaches, theme: &Theme) {
544        let mut new_map = HashMap::new();
545        self.walk_prepare(root, caches, theme, &mut new_map, 0);
546
547        // Prune stale nodes
548        for (old_id, ext_idx) in &self.node_extension_map {
549            if !new_map.contains_key(old_id) {
550                let ns = self.extensions[*ext_idx].config_key().to_string();
551                if self.poisoned[*ext_idx] {
552                    caches.remove(&ns, old_id);
553                    log::warn!(
554                        "skipping cleanup for poisoned extension `{ns}`; \
555                         cache entry removed for node `{old_id}`",
556                    );
557                } else if catch_unwind_enabled() {
558                    let result = catch_unwind(AssertUnwindSafe(|| {
559                        self.extensions[*ext_idx].cleanup(old_id, caches);
560                    }));
561                    if let Err(panic) = result {
562                        let msg = panic_message(&panic);
563                        log::error!("extension `{ns}` panicked in cleanup: {msg}",);
564                        self.poisoned[*ext_idx] = true;
565                        caches.remove(&ns, old_id);
566                    }
567                } else {
568                    self.extensions[*ext_idx].cleanup(old_id, caches);
569                }
570            }
571        }
572
573        self.node_extension_map = new_map;
574
575        // Check render panic counters -- poison extensions that exceeded
576        // the threshold. Also reset counters for non-poisoned extensions
577        // (a successful prepare cycle implies the tree was rebuilt, so
578        // we give extensions a fresh chance).
579        for idx in 0..self.extensions.len() {
580            let count = self.render_panic_counts[idx].load(Ordering::Relaxed);
581            if count >= RENDER_PANIC_THRESHOLD && !self.poisoned[idx] {
582                log::error!(
583                    "extension `{}` hit {} consecutive render panics, poisoning",
584                    self.extensions[idx].config_key(),
585                    count,
586                );
587                self.poisoned[idx] = true;
588            }
589            if !self.poisoned[idx] {
590                self.render_panic_counts[idx].store(0, Ordering::Relaxed);
591            }
592        }
593    }
594
595    fn walk_prepare(
596        &mut self,
597        node: &TreeNode,
598        caches: &mut ExtensionCaches,
599        theme: &Theme,
600        map: &mut HashMap<String, usize>,
601        depth: usize,
602    ) {
603        if depth > Self::MAX_WALK_DEPTH {
604            log::warn!(
605                "[id={}] walk_prepare depth exceeds {}, skipping subtree",
606                node.id,
607                Self::MAX_WALK_DEPTH
608            );
609            return;
610        }
611        if let Some(&idx) = self.type_name_index.get(node.type_name.as_str()) {
612            if !self.poisoned[idx] {
613                if catch_unwind_enabled() {
614                    let result = catch_unwind(AssertUnwindSafe(|| {
615                        self.extensions[idx].prepare(node, caches, theme);
616                    }));
617                    if let Err(panic) = result {
618                        let msg = panic_message(&panic);
619                        log::error!(
620                            "extension `{}` panicked in prepare: {msg}",
621                            self.extensions[idx].config_key()
622                        );
623                        self.poisoned[idx] = true;
624                    }
625                } else {
626                    self.extensions[idx].prepare(node, caches, theme);
627                }
628            }
629            map.insert(node.id.clone(), idx);
630        }
631        for child in &node.children {
632            self.walk_prepare(child, caches, theme, map, depth + 1);
633        }
634    }
635
636    /// Handle a Message::Event.
637    pub fn handle_event(
638        &mut self,
639        id: &str,
640        family: &str,
641        data: &Value,
642        caches: &mut ExtensionCaches,
643    ) -> EventResult {
644        let ext_idx = match self.node_extension_map.get(id) {
645            Some(&idx) => idx,
646            None => return EventResult::PassThrough,
647        };
648        if self.poisoned[ext_idx] {
649            log::error!(
650                "extension `{}` is poisoned, dropping event `{family}` for node `{id}`",
651                self.extensions[ext_idx].config_key()
652            );
653            return EventResult::PassThrough;
654        }
655        if catch_unwind_enabled() {
656            match catch_unwind(AssertUnwindSafe(|| {
657                self.extensions[ext_idx].handle_event(id, family, data, caches)
658            })) {
659                Ok(result) => result,
660                Err(panic) => {
661                    let msg = panic_message(&panic);
662                    log::error!(
663                        "extension `{}` panicked in handle_event: {msg}",
664                        self.extensions[ext_idx].config_key()
665                    );
666                    self.poisoned[ext_idx] = true;
667                    EventResult::PassThrough
668                }
669            }
670        } else {
671            self.extensions[ext_idx].handle_event(id, family, data, caches)
672        }
673    }
674
675    /// Handle an ExtensionCommand.
676    pub fn handle_command(
677        &mut self,
678        node_id: &str,
679        op: &str,
680        payload: &Value,
681        caches: &mut ExtensionCaches,
682    ) -> Vec<OutgoingEvent> {
683        let ext_idx = match self.node_extension_map.get(node_id) {
684            Some(&idx) => idx,
685            None => {
686                log::warn!("extension command for unknown node `{node_id}`, ignoring");
687                return vec![OutgoingEvent::generic(
688                    "extension_error".to_string(),
689                    node_id.to_string(),
690                    Some(serde_json::json!({
691                        "error": format!("no extension handles node `{node_id}`"),
692                        "op": op,
693                    })),
694                )];
695            }
696        };
697        if self.poisoned[ext_idx] {
698            return vec![OutgoingEvent::generic(
699                "extension_error".to_string(),
700                node_id.to_string(),
701                Some(serde_json::json!({
702                    "error": "extension is disabled due to previous panics",
703                    "op": op,
704                })),
705            )];
706        }
707        if catch_unwind_enabled() {
708            match catch_unwind(AssertUnwindSafe(|| {
709                self.extensions[ext_idx].handle_command(node_id, op, payload, caches)
710            })) {
711                Ok(events) => events,
712                Err(panic) => {
713                    let msg = panic_message(&panic);
714                    log::error!(
715                        "extension `{}` panicked in handle_command: {msg}",
716                        self.extensions[ext_idx].config_key()
717                    );
718                    self.poisoned[ext_idx] = true;
719                    // Report the panic back to the host so it can handle it.
720                    let error_data = serde_json::json!({
721                        "error": msg,
722                        "op": op,
723                    });
724                    vec![OutgoingEvent::generic(
725                        "extension_error",
726                        node_id.to_string(),
727                        Some(error_data),
728                    )]
729                }
730            }
731        } else {
732            self.extensions[ext_idx].handle_command(node_id, op, payload, caches)
733        }
734    }
735
736    /// Route configuration to extensions. `config` is the value of the
737    /// `extension_config` key from Settings -- a JSON object keyed by
738    /// each extension's `config_key()`.
739    pub fn init_all(&mut self, config: &Value) {
740        for (idx, ext) in self.extensions.iter_mut().enumerate() {
741            if self.poisoned[idx] {
742                continue;
743            }
744            let key = ext.config_key().to_string();
745            let slice = config.get(&key).unwrap_or(&Value::Null);
746            if catch_unwind_enabled() {
747                let result = catch_unwind(AssertUnwindSafe(|| {
748                    ext.init(slice);
749                }));
750                if let Err(panic) = result {
751                    let msg = panic_message(&panic);
752                    log::error!("extension `{key}` panicked in init: {msg}");
753                    self.poisoned[idx] = true;
754                }
755            } else {
756                ext.init(slice);
757            }
758        }
759    }
760
761    /// Render an extension node. Returns None if no extension handles this type.
762    ///
763    /// The caller must construct the `WidgetEnv` and pass it in. This avoids
764    /// a borrow-checker issue where a locally-constructed env would be dropped
765    /// before the returned Element (which borrows from the env).
766    ///
767    /// Note: catch_unwind happens in the caller (`widgets::render`) because
768    /// the returned Element borrows from env and can't be wrapped in a
769    /// closure. When a render panic is caught, the caller should call
770    /// `record_render_panic` to track consecutive failures.
771    pub fn render<'a>(
772        &'a self,
773        node: &'a TreeNode,
774        env: &WidgetEnv<'a>,
775    ) -> Option<Element<'a, Message>> {
776        let &idx = self.type_name_index.get(node.type_name.as_str())?;
777        if self.poisoned[idx] {
778            return Some(render_poisoned_placeholder(node));
779        }
780        let element = self.extensions[idx].render(node, env);
781        // Successful render -- reset consecutive panic counter.
782        self.render_panic_counts[idx].store(0, Ordering::Relaxed);
783        Some(element)
784    }
785
786    /// Record a render panic for the extension that handles `type_name`.
787    /// Called by the catch_unwind wrapper in `widgets::render` (which has
788    /// only `&self`). Uses AtomicU32 so no `&mut self` is required.
789    /// Returns `true` if the extension has reached the poison threshold.
790    pub fn record_render_panic(&self, type_name: &str) -> bool {
791        if let Some(&idx) = self.type_name_index.get(type_name) {
792            let prev = self.render_panic_counts[idx].fetch_add(1, Ordering::Relaxed);
793            prev + 1 >= RENDER_PANIC_THRESHOLD
794        } else {
795            false
796        }
797    }
798
799    /// Reset all poisoned flags and render panic counters. Called on Snapshot.
800    pub fn clear_poisoned(&mut self) {
801        self.poisoned.fill(false);
802        for counter in &self.render_panic_counts {
803            counter.store(0, Ordering::Relaxed);
804        }
805    }
806
807    /// Call cleanup() for every node currently tracked by the dispatcher.
808    ///
809    /// Used before a full state reset (e.g. Reset message) so extensions
810    /// get a chance to release per-node resources before their cache
811    /// entries are wiped.
812    pub fn cleanup_all(&mut self, caches: &mut ExtensionCaches) {
813        for (node_id, &ext_idx) in &self.node_extension_map {
814            if self.poisoned[ext_idx] {
815                continue;
816            }
817            if catch_unwind_enabled() {
818                let result = catch_unwind(AssertUnwindSafe(|| {
819                    self.extensions[ext_idx].cleanup(node_id, caches);
820                }));
821                if let Err(panic) = result {
822                    let msg = panic_message(&panic);
823                    log::error!(
824                        "extension `{}` panicked in cleanup: {msg}",
825                        self.extensions[ext_idx].config_key()
826                    );
827                    self.poisoned[ext_idx] = true;
828                }
829            } else {
830                self.extensions[ext_idx].cleanup(node_id, caches);
831            }
832        }
833    }
834
835    /// Full reset: call cleanup for all tracked nodes, clear the node map,
836    /// clear extension caches, and reset poisoned state.
837    ///
838    /// Extensions themselves (the registered trait objects) are preserved --
839    /// only per-node runtime state is wiped.
840    pub fn reset(&mut self, caches: &mut ExtensionCaches) {
841        self.cleanup_all(caches);
842        self.node_extension_map.clear();
843        caches.clear();
844        self.clear_poisoned();
845    }
846
847    /// Check if any extensions are registered.
848    pub fn is_empty(&self) -> bool {
849        self.extensions.is_empty()
850    }
851
852    /// Check if a specific extension (by index) is poisoned.
853    #[cfg(test)]
854    pub fn is_poisoned(&self, idx: usize) -> bool {
855        self.poisoned.get(idx).copied().unwrap_or(false)
856    }
857
858    /// Number of registered extensions.
859    pub fn len(&self) -> usize {
860        self.extensions.len()
861    }
862}
863
864impl Default for ExtensionDispatcher {
865    fn default() -> Self {
866        Self::new(vec![])
867    }
868}
869
870// ---------------------------------------------------------------------------
871// GenerationCounter
872// ---------------------------------------------------------------------------
873
874/// A monotonically increasing counter for tracking data changes.
875///
876/// Store in `ExtensionCaches` alongside your data. Call `bump()` when data
877/// changes (in `handle_command` or `prepare`). In your `canvas::Program`
878/// implementation, compare the generation against a saved value in your
879/// `Program::State` to decide whether to clear and redraw the cache.
880///
881/// # Example
882///
883/// ```ignore
884/// struct MyState {
885///     generation: u64,
886///     cache: canvas::Cache,
887/// }
888///
889/// impl canvas::Program<Message> for MyProgram {
890///     type State = MyState;
891///
892///     fn draw(&self, state: &MyState, ...) -> Vec<Geometry> {
893///         if state.generation != self.current_generation {
894///             state.cache.clear();
895///             // update state.generation after draw
896///         }
897///         vec![state.cache.draw(renderer, bounds.size(), |frame| { ... })]
898///     }
899/// }
900/// ```
901#[derive(Debug, Clone)]
902pub struct GenerationCounter {
903    value: u64,
904}
905
906impl GenerationCounter {
907    /// Create a new counter starting at zero.
908    pub fn new() -> Self {
909        Self { value: 0 }
910    }
911
912    /// Return the current generation value.
913    pub fn get(&self) -> u64 {
914        self.value
915    }
916
917    /// Increment the generation. Wraps on overflow (u64 -- effectively never).
918    pub fn bump(&mut self) {
919        self.value = self.value.wrapping_add(1);
920    }
921}
922
923impl Default for GenerationCounter {
924    fn default() -> Self {
925        Self::new()
926    }
927}
928
929// ---------------------------------------------------------------------------
930// Private helpers
931// ---------------------------------------------------------------------------
932
933fn render_poisoned_placeholder<'a>(node: &TreeNode) -> Element<'a, Message> {
934    use iced::Color;
935    use iced::widget::text;
936    text(format!(
937        "Extension error: type `{}`, node `{}`",
938        node.type_name, node.id
939    ))
940    .color(Color::from_rgb(1.0, 0.0, 0.0))
941    .into()
942}
943
944fn panic_message(panic: &Box<dyn Any + Send>) -> String {
945    if let Some(s) = panic.downcast_ref::<&str>() {
946        s.to_string()
947    } else if let Some(s) = panic.downcast_ref::<String>() {
948        s.clone()
949    } else {
950        "unknown panic".to_string()
951    }
952}
953
954// ---------------------------------------------------------------------------
955// Tests
956// ---------------------------------------------------------------------------
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961
962    // -- Test extension implementations --------------------------------------
963
964    /// Minimal test extension that renders a text widget.
965    struct TestExtension {
966        type_names: Vec<&'static str>,
967        config_key: &'static str,
968        init_called: bool,
969    }
970
971    impl TestExtension {
972        fn new(type_names: Vec<&'static str>, config_key: &'static str) -> Self {
973            Self {
974                type_names,
975                config_key,
976                init_called: false,
977            }
978        }
979    }
980
981    impl WidgetExtension for TestExtension {
982        fn type_names(&self) -> &[&str] {
983            &self.type_names
984        }
985
986        fn config_key(&self) -> &str {
987            self.config_key
988        }
989
990        fn init(&mut self, _config: &Value) {
991            self.init_called = true;
992        }
993
994        fn render<'a>(&self, node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
995            use iced::widget::text;
996            text(format!("test:{}", node.id)).into()
997        }
998    }
999
1000    /// Extension with empty type_names (valid but useless -- should warn).
1001    struct EmptyTypesExtension;
1002
1003    impl WidgetExtension for EmptyTypesExtension {
1004        fn type_names(&self) -> &[&str] {
1005            &[]
1006        }
1007        fn config_key(&self) -> &str {
1008            "empty_types"
1009        }
1010        fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
1011            use iced::widget::text;
1012            text("empty").into()
1013        }
1014    }
1015
1016    fn make_node(id: &str, type_name: &str) -> TreeNode {
1017        TreeNode {
1018            id: id.to_string(),
1019            type_name: type_name.to_string(),
1020            props: serde_json::json!({}),
1021            children: vec![],
1022        }
1023    }
1024
1025    // -- Registration and type_name_index ------------------------------------
1026
1027    #[test]
1028    fn registration_builds_type_name_index() {
1029        let ext = TestExtension::new(vec!["sparkline", "heatmap"], "charts");
1030        let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1031
1032        assert!(dispatcher.handles_type("sparkline"));
1033        assert!(dispatcher.handles_type("heatmap"));
1034        assert!(!dispatcher.handles_type("unknown"));
1035    }
1036
1037    #[test]
1038    fn registration_with_multiple_extensions() {
1039        let ext_a = TestExtension::new(vec!["sparkline"], "charts");
1040        let ext_b = TestExtension::new(vec!["gauge"], "instruments");
1041        let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
1042
1043        assert!(dispatcher.handles_type("sparkline"));
1044        assert!(dispatcher.handles_type("gauge"));
1045        assert_eq!(dispatcher.len(), 2);
1046    }
1047
1048    #[test]
1049    fn empty_dispatcher_handles_nothing() {
1050        let dispatcher = ExtensionDispatcher::default();
1051        assert!(dispatcher.is_empty());
1052        assert!(!dispatcher.handles_type("anything"));
1053    }
1054
1055    // -- Duplicate type name detection ---------------------------------------
1056
1057    #[test]
1058    #[should_panic(expected = "duplicate extension type name `sparkline`")]
1059    fn duplicate_type_name_panics() {
1060        let ext_a = TestExtension::new(vec!["sparkline"], "charts_a");
1061        let ext_b = TestExtension::new(vec!["sparkline"], "charts_b");
1062        ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
1063    }
1064
1065    #[test]
1066    #[should_panic(expected = "both claim it")]
1067    fn duplicate_type_name_error_identifies_conflicting_extensions() {
1068        let ext_a = TestExtension::new(vec!["widget_x"], "ext_alpha");
1069        let ext_b = TestExtension::new(vec!["widget_x"], "ext_beta");
1070        ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
1071    }
1072
1073    // -- Empty config_key validation -----------------------------------------
1074
1075    #[test]
1076    #[should_panic(expected = "empty config_key()")]
1077    fn empty_config_key_panics() {
1078        let ext = TestExtension::new(vec!["widget"], "");
1079        ExtensionDispatcher::new(vec![Box::new(ext)]);
1080    }
1081
1082    // -- Colon in config_key validation -----------------------------------------
1083
1084    #[test]
1085    #[should_panic(expected = "contains ':'")]
1086    fn config_key_with_colon_panics() {
1087        let ext = TestExtension::new(vec!["widget"], "bad:key");
1088        ExtensionDispatcher::new(vec![Box::new(ext)]);
1089    }
1090
1091    // -- Duplicate config_key validation ---------------------------------------
1092
1093    #[test]
1094    #[should_panic(expected = "duplicate extension config_key `charts`")]
1095    fn duplicate_config_key_panics() {
1096        let ext_a = TestExtension::new(vec!["sparkline"], "charts");
1097        let ext_b = TestExtension::new(vec!["heatmap"], "charts");
1098        ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
1099    }
1100
1101    // -- Empty type_names validation (warn, don't panic) ---------------------
1102
1103    #[test]
1104    fn empty_type_names_does_not_panic() {
1105        // Should log a warning but not panic.
1106        let ext = EmptyTypesExtension;
1107        let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1108        assert_eq!(dispatcher.len(), 1);
1109        assert!(!dispatcher.handles_type("anything"));
1110    }
1111
1112    // -- ExtensionCaches: get/insert/get_or_insert ---------------------------
1113
1114    #[test]
1115    fn cache_insert_and_get() {
1116        let mut caches = ExtensionCaches::new();
1117        caches.insert("charts", "node1", 42u32);
1118
1119        assert_eq!(caches.get::<u32>("charts", "node1"), Some(&42));
1120        assert_eq!(caches.get::<u32>("charts", "node2"), None);
1121    }
1122
1123    #[test]
1124    fn cache_get_mut() {
1125        let mut caches = ExtensionCaches::new();
1126        caches.insert("ns", "key", vec![1, 2, 3]);
1127
1128        if let Some(v) = caches.get_mut::<Vec<i32>>("ns", "key") {
1129            v.push(4);
1130        }
1131        assert_eq!(caches.get::<Vec<i32>>("ns", "key"), Some(&vec![1, 2, 3, 4]));
1132    }
1133
1134    #[test]
1135    fn cache_get_or_insert_creates_default() {
1136        let mut caches = ExtensionCaches::new();
1137        let val = caches.get_or_insert::<String>("ns", "key", || "hello".to_string());
1138        assert_eq!(val, "hello");
1139
1140        // Second call returns existing value, doesn't overwrite.
1141        let val = caches.get_or_insert::<String>("ns", "key", || "world".to_string());
1142        assert_eq!(val, "hello");
1143    }
1144
1145    #[test]
1146    fn cache_get_or_insert_type_mismatch_replaces_with_default() {
1147        let mut caches = ExtensionCaches::new();
1148        caches.insert("ns", "key", 42u32);
1149        // Previously this panicked. Now it logs a warning, replaces the
1150        // stale entry, and returns a fresh default of the requested type.
1151        let val = caches.get_or_insert::<String>("ns", "key", || "replaced".to_string());
1152        assert_eq!(val, "replaced");
1153    }
1154
1155    #[test]
1156    fn cache_wrong_type_returns_none() {
1157        let mut caches = ExtensionCaches::new();
1158        caches.insert("ns", "key", 42u32);
1159
1160        // Asking for a different type returns None (not a panic for get).
1161        assert_eq!(caches.get::<String>("ns", "key"), None);
1162    }
1163
1164    #[test]
1165    fn cache_remove_and_contains() {
1166        let mut caches = ExtensionCaches::new();
1167        caches.insert("ns", "key", 1u8);
1168
1169        assert!(caches.contains("ns", "key"));
1170        assert!(caches.remove("ns", "key"));
1171        assert!(!caches.contains("ns", "key"));
1172        assert!(!caches.remove("ns", "key"));
1173    }
1174
1175    #[test]
1176    fn cache_clear_removes_everything() {
1177        let mut caches = ExtensionCaches::new();
1178        caches.insert("a", "k1", 1u32);
1179        caches.insert("b", "k2", 2u32);
1180
1181        caches.clear();
1182        assert!(!caches.contains("a", "k1"));
1183        assert!(!caches.contains("b", "k2"));
1184    }
1185
1186    // -- Cache namespace isolation -------------------------------------------
1187
1188    #[test]
1189    fn cache_namespace_isolation() {
1190        let mut caches = ExtensionCaches::new();
1191
1192        // Two extensions use the same raw key "data" -- they shouldn't collide.
1193        caches.insert("charts", "data", vec![1.0f64, 2.0, 3.0]);
1194        caches.insert("gauges", "data", 42u32);
1195
1196        assert_eq!(
1197            caches.get::<Vec<f64>>("charts", "data"),
1198            Some(&vec![1.0, 2.0, 3.0])
1199        );
1200        assert_eq!(caches.get::<u32>("gauges", "data"), Some(&42));
1201    }
1202
1203    #[test]
1204    fn cache_remove_namespace() {
1205        let mut caches = ExtensionCaches::new();
1206        caches.insert("charts", "a", 1u32);
1207        caches.insert("charts", "b", 2u32);
1208        caches.insert("gauges", "a", 3u32);
1209
1210        caches.remove_namespace("charts");
1211
1212        assert!(!caches.contains("charts", "a"));
1213        assert!(!caches.contains("charts", "b"));
1214        assert!(caches.contains("gauges", "a"));
1215    }
1216
1217    // -- Poison flag management ----------------------------------------------
1218
1219    #[test]
1220    fn poison_flag_set_and_clear() {
1221        let ext = TestExtension::new(vec!["sparkline"], "charts");
1222        let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1223
1224        assert!(!dispatcher.is_poisoned(0));
1225
1226        // Simulate poisoning via render panic counter.
1227        for _ in 0..RENDER_PANIC_THRESHOLD {
1228            dispatcher.record_render_panic("sparkline");
1229        }
1230
1231        // Poisoning happens on next prepare_all call.
1232        let root = make_node("root", "column");
1233        let mut caches = ExtensionCaches::new();
1234        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1235
1236        assert!(dispatcher.is_poisoned(0));
1237
1238        // clear_poisoned resets everything.
1239        dispatcher.clear_poisoned();
1240        assert!(!dispatcher.is_poisoned(0));
1241    }
1242
1243    // -- Render panic tracking -----------------------------------------------
1244
1245    #[test]
1246    fn record_render_panic_increments_counter() {
1247        let ext = TestExtension::new(vec!["sparkline"], "charts");
1248        let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1249
1250        // Below threshold -- returns false.
1251        assert!(!dispatcher.record_render_panic("sparkline"));
1252        assert!(!dispatcher.record_render_panic("sparkline"));
1253
1254        // At threshold -- returns true.
1255        assert!(dispatcher.record_render_panic("sparkline"));
1256    }
1257
1258    #[test]
1259    fn record_render_panic_unknown_type_returns_false() {
1260        let dispatcher = ExtensionDispatcher::default();
1261        assert!(!dispatcher.record_render_panic("nonexistent"));
1262    }
1263
1264    // -- EventResult variants ------------------------------------------------
1265
1266    #[test]
1267    fn event_result_pass_through() {
1268        let result = EventResult::PassThrough;
1269        assert!(matches!(result, EventResult::PassThrough));
1270    }
1271
1272    #[test]
1273    fn event_result_consumed_with_events() {
1274        let events = vec![OutgoingEvent::generic("test", "n1".to_string(), None)];
1275        let result = EventResult::Consumed(events);
1276        match result {
1277            EventResult::Consumed(e) => assert_eq!(e.len(), 1),
1278            _ => panic!("expected Consumed"),
1279        }
1280    }
1281
1282    #[test]
1283    fn event_result_observed_with_events() {
1284        let events = vec![OutgoingEvent::generic("test", "n1".to_string(), None)];
1285        let result = EventResult::Observed(events);
1286        match result {
1287            EventResult::Observed(e) => assert_eq!(e.len(), 1),
1288            _ => panic!("expected Observed"),
1289        }
1290    }
1291
1292    // -- GenerationCounter ---------------------------------------------------
1293
1294    #[test]
1295    fn generation_counter_starts_at_zero() {
1296        let counter = GenerationCounter::new();
1297        assert_eq!(counter.get(), 0);
1298    }
1299
1300    #[test]
1301    fn generation_counter_bumps() {
1302        let mut counter = GenerationCounter::new();
1303        counter.bump();
1304        assert_eq!(counter.get(), 1);
1305        counter.bump();
1306        assert_eq!(counter.get(), 2);
1307    }
1308
1309    #[test]
1310    fn generation_counter_default() {
1311        let counter = GenerationCounter::default();
1312        assert_eq!(counter.get(), 0);
1313    }
1314
1315    // -- init_all ------------------------------------------------------------
1316
1317    #[test]
1318    fn init_all_routes_config_by_key() {
1319        let ext = TestExtension::new(vec!["sparkline"], "charts");
1320        let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1321
1322        let config = serde_json::json!({"charts": {"color": "red"}});
1323        dispatcher.init_all(&config);
1324
1325        // Can't easily inspect init_called through the trait object, but
1326        // at least verify no panic occurred.
1327        assert!(!dispatcher.is_poisoned(0));
1328    }
1329
1330    // -- panic_message helper ------------------------------------------------
1331
1332    #[test]
1333    fn panic_message_extracts_str() {
1334        let p: Box<dyn Any + Send> = Box::new("boom");
1335        assert_eq!(panic_message(&p), "boom");
1336    }
1337
1338    #[test]
1339    fn panic_message_extracts_string() {
1340        let p: Box<dyn Any + Send> = Box::new("kaboom".to_string());
1341        assert_eq!(panic_message(&p), "kaboom");
1342    }
1343
1344    #[test]
1345    fn panic_message_unknown_type() {
1346        let p: Box<dyn Any + Send> = Box::new(42u32);
1347        assert_eq!(panic_message(&p), "unknown panic");
1348    }
1349
1350    // -- handle_command panic emits error event ------------------------------
1351
1352    /// Extension that panics on handle_command.
1353    struct PanickingCommandExtension;
1354
1355    impl WidgetExtension for PanickingCommandExtension {
1356        fn type_names(&self) -> &[&str] {
1357            &["panicker"]
1358        }
1359        fn config_key(&self) -> &str {
1360            "panicker"
1361        }
1362        fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
1363            use iced::widget::text;
1364            text("panicker").into()
1365        }
1366        fn handle_command(
1367            &mut self,
1368            _node_id: &str,
1369            _op: &str,
1370            _payload: &Value,
1371            _caches: &mut ExtensionCaches,
1372        ) -> Vec<OutgoingEvent> {
1373            panic!("command went boom");
1374        }
1375    }
1376
1377    #[test]
1378    fn handle_command_panic_emits_error_event() {
1379        let ext = PanickingCommandExtension;
1380        let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1381        let mut caches = ExtensionCaches::new();
1382
1383        // Register the node in the extension map via prepare_all.
1384        let mut root = make_node("root", "column");
1385        root.children.push(make_node("p1", "panicker"));
1386        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1387
1388        let events = dispatcher.handle_command("p1", "do_thing", &Value::Null, &mut caches);
1389
1390        assert_eq!(events.len(), 1);
1391        let event = &events[0];
1392        assert_eq!(event.family, "extension_error");
1393        assert_eq!(event.id, "p1");
1394        let data = event.data.as_ref().expect("should have data");
1395        assert_eq!(
1396            data.get("error").and_then(|v| v.as_str()),
1397            Some("command went boom")
1398        );
1399        assert_eq!(data.get("op").and_then(|v| v.as_str()), Some("do_thing"));
1400
1401        // Extension should also be poisoned.
1402        assert!(dispatcher.is_poisoned(0));
1403    }
1404
1405    #[test]
1406    fn handle_command_poisoned_returns_error_event() {
1407        let ext = PanickingCommandExtension;
1408        let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1409        let mut caches = ExtensionCaches::new();
1410
1411        // Register the node.
1412        let mut root = make_node("root", "column");
1413        root.children.push(make_node("p1", "panicker"));
1414        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1415
1416        // Poison via render panic threshold.
1417        for _ in 0..RENDER_PANIC_THRESHOLD {
1418            dispatcher.record_render_panic("panicker");
1419        }
1420        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1421        assert!(dispatcher.is_poisoned(0));
1422
1423        // Command on a poisoned extension should return an error event.
1424        let events = dispatcher.handle_command("p1", "do_thing", &Value::Null, &mut caches);
1425        assert_eq!(events.len(), 1);
1426        let event = &events[0];
1427        assert_eq!(event.family, "extension_error");
1428        assert_eq!(event.id, "p1");
1429        let data = event.data.as_ref().expect("should have data");
1430        assert_eq!(
1431            data.get("error").and_then(|v| v.as_str()),
1432            Some("extension is disabled due to previous panics")
1433        );
1434        assert_eq!(data.get("op").and_then(|v| v.as_str()), Some("do_thing"));
1435    }
1436
1437    // -- cleanup_all ----------------------------------------------------------
1438
1439    /// Extension that tracks cleanup calls.
1440    struct CleanupTracker {
1441        cleaned_ids: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1442    }
1443
1444    impl CleanupTracker {
1445        fn new(tracker: std::sync::Arc<std::sync::Mutex<Vec<String>>>) -> Self {
1446            Self {
1447                cleaned_ids: tracker,
1448            }
1449        }
1450    }
1451
1452    impl WidgetExtension for CleanupTracker {
1453        fn type_names(&self) -> &[&str] {
1454            &["tracked"]
1455        }
1456        fn config_key(&self) -> &str {
1457            "tracker"
1458        }
1459        fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
1460            use iced::widget::text;
1461            text("tracked").into()
1462        }
1463        fn cleanup(&mut self, node_id: &str, _caches: &mut ExtensionCaches) {
1464            self.cleaned_ids.lock().unwrap().push(node_id.to_string());
1465        }
1466    }
1467
1468    #[test]
1469    fn cleanup_all_calls_cleanup_for_tracked_nodes() {
1470        let tracker = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1471        let ext = CleanupTracker::new(tracker.clone());
1472        let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1473        let mut caches = ExtensionCaches::new();
1474
1475        // Register two nodes via prepare_all.
1476        let mut root = make_node("root", "column");
1477        root.children.push(make_node("t1", "tracked"));
1478        root.children.push(make_node("t2", "tracked"));
1479        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1480
1481        // cleanup_all should fire cleanup for both tracked nodes.
1482        dispatcher.cleanup_all(&mut caches);
1483        let cleaned = tracker.lock().unwrap();
1484        assert!(cleaned.contains(&"t1".to_string()));
1485        assert!(cleaned.contains(&"t2".to_string()));
1486        assert_eq!(cleaned.len(), 2);
1487    }
1488
1489    #[test]
1490    fn cleanup_all_skips_poisoned_extensions() {
1491        let tracker = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1492        let ext = CleanupTracker::new(tracker.clone());
1493        let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1494        let mut caches = ExtensionCaches::new();
1495
1496        let mut root = make_node("root", "column");
1497        root.children.push(make_node("t1", "tracked"));
1498        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1499
1500        // Poison the extension via render panics.
1501        for _ in 0..RENDER_PANIC_THRESHOLD {
1502            dispatcher.record_render_panic("tracked");
1503        }
1504        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1505        assert!(dispatcher.is_poisoned(0));
1506
1507        // cleanup_all should skip poisoned extensions.
1508        dispatcher.cleanup_all(&mut caches);
1509        assert!(tracker.lock().unwrap().is_empty());
1510    }
1511
1512    // -- reset ----------------------------------------------------------------
1513
1514    #[test]
1515    fn reset_clears_node_map_and_caches() {
1516        let ext = TestExtension::new(vec!["sparkline"], "charts");
1517        let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1518        let mut caches = ExtensionCaches::new();
1519
1520        // Register a node and insert cache data.
1521        let mut root = make_node("root", "column");
1522        root.children.push(make_node("s1", "sparkline"));
1523        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1524        caches.insert("charts", "s1", 42u32);
1525        assert!(caches.contains("charts", "s1"));
1526
1527        // reset() should clean up everything.
1528        dispatcher.reset(&mut caches);
1529
1530        assert!(!caches.contains("charts", "s1"));
1531        assert!(!dispatcher.is_poisoned(0));
1532        // After reset, the dispatcher should not track any nodes.
1533        // Verify by checking that handle_event returns PassThrough.
1534        let result = dispatcher.handle_event("s1", "click", &Value::Null, &mut caches);
1535        assert!(matches!(result, EventResult::PassThrough));
1536    }
1537
1538    #[test]
1539    fn reset_clears_poisoned_state() {
1540        let ext = TestExtension::new(vec!["sparkline"], "charts");
1541        let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1542        let mut caches = ExtensionCaches::new();
1543
1544        // Poison the extension.
1545        for _ in 0..RENDER_PANIC_THRESHOLD {
1546            dispatcher.record_render_panic("sparkline");
1547        }
1548        let root = make_node("root", "column");
1549        dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1550        assert!(dispatcher.is_poisoned(0));
1551
1552        // reset() should clear poisoned state.
1553        dispatcher.reset(&mut caches);
1554        assert!(!dispatcher.is_poisoned(0));
1555    }
1556
1557    // -- Full poison lifecycle (render panics -> poisoned -> clear) -----------
1558
1559    /// Extension that panics on render.
1560    struct PanickingRenderExtension;
1561
1562    impl WidgetExtension for PanickingRenderExtension {
1563        fn type_names(&self) -> &[&str] {
1564            &["panicky_render"]
1565        }
1566        fn config_key(&self) -> &str {
1567            "panicky_render"
1568        }
1569        fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
1570            panic!("render goes boom");
1571        }
1572    }
1573
1574    #[test]
1575    fn poison_lifecycle_render_panics_then_clear() {
1576        let ext: Box<dyn WidgetExtension> = Box::new(PanickingRenderExtension);
1577        let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
1578        let mut caches = ExtensionCaches::new();
1579        let images = crate::image_registry::ImageRegistry::new();
1580        let theme = Theme::Dark;
1581
1582        // Register the node.
1583        let mut root = make_node("root", "column");
1584        root.children.push(make_node("pr1", "panicky_render"));
1585        dispatcher.prepare_all(&root, &mut caches, &theme);
1586
1587        // 1) Extension should not be poisoned yet.
1588        assert!(!dispatcher.is_poisoned(0));
1589
1590        // 2) Record RENDER_PANIC_THRESHOLD render panics.
1591        //    In real usage, catch_unwind in widgets::render calls
1592        //    record_render_panic. We simulate the same sequence.
1593        for i in 0..RENDER_PANIC_THRESHOLD {
1594            let at_threshold = dispatcher.record_render_panic("panicky_render");
1595            if i < RENDER_PANIC_THRESHOLD - 1 {
1596                assert!(!at_threshold, "should not be at threshold yet (i={i})");
1597            } else {
1598                assert!(at_threshold, "should be at threshold now");
1599            }
1600        }
1601
1602        // 3) prepare_all triggers the poisoning check.
1603        dispatcher.prepare_all(&root, &mut caches, &theme);
1604        assert!(
1605            dispatcher.is_poisoned(0),
1606            "extension should be poisoned after threshold + prepare_all"
1607        );
1608
1609        // 4) Verify the poisoned extension renders a placeholder via the
1610        //    dispatcher (returns Some with red error text, not a panic).
1611        let node = make_node("pr1", "panicky_render");
1612        {
1613            let widget_caches = crate::widgets::WidgetCaches::new();
1614            let ctx = RenderCtx {
1615                caches: &widget_caches,
1616                images: &images,
1617                theme: &theme,
1618                extensions: &dispatcher,
1619                default_text_size: None,
1620                default_font: None,
1621            };
1622            let env = WidgetEnv {
1623                caches: &caches,
1624                ctx,
1625            };
1626            let result = dispatcher.render(&node, &env);
1627            assert!(
1628                result.is_some(),
1629                "poisoned extension should still return Some (placeholder)"
1630            );
1631        } // borrows released here
1632
1633        // 5) clear_poisoned simulates what happens on Snapshot.
1634        dispatcher.clear_poisoned();
1635        assert!(
1636            !dispatcher.is_poisoned(0),
1637            "poison should be cleared after clear_poisoned"
1638        );
1639
1640        // 6) After clearing, the extension can render again (will panic
1641        //    again in this test, but the point is it's no longer skipped).
1642        //    We verify by checking that render() actually calls the
1643        //    extension (which panics) rather than returning the placeholder.
1644        //    We use catch_unwind to contain the panic.
1645        let widget_caches2 = crate::widgets::WidgetCaches::new();
1646        let ctx2 = RenderCtx {
1647            caches: &widget_caches2,
1648            images: &images,
1649            theme: &theme,
1650            extensions: &dispatcher,
1651            default_text_size: None,
1652            default_font: None,
1653        };
1654        let env2 = WidgetEnv {
1655            caches: &caches,
1656            ctx: ctx2,
1657        };
1658        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1659            dispatcher.render(&node, &env2)
1660        }));
1661        assert!(
1662            result.is_err(),
1663            "after clearing poison, render should call the extension again (which panics)"
1664        );
1665    }
1666}