Skip to main content

toddy_core/widgets/
render.rs

1//! Main render dispatch: maps a [`TreeNode`] to an iced [`Element`].
2//!
3//! This is the immutable side of the ensure_caches/render split. All
4//! mutable cache state must be pre-populated by [`super::ensure_caches`]
5//! before calling [`render`]. Recursion depth is bounded by a
6//! thread-local counter.
7
8use std::cell::Cell;
9
10use iced::widget::{Space, container, text};
11use iced::{Color, Element};
12
13use super::caches::MAX_TREE_DEPTH;
14use super::helpers::*;
15use super::{canvas, display, input, interactive, layout, table, validate};
16use crate::extensions::RenderCtx;
17use crate::message::Message;
18use crate::protocol::TreeNode;
19
20// ---------------------------------------------------------------------------
21// Main render dispatch
22// ---------------------------------------------------------------------------
23
24/// Map a TreeNode to an iced Element. Unknown types render as an empty container.
25///
26/// This is the immutable side of the ensure_caches/render split. All mutable
27/// cache state (text_editor Content, markdown Items, combo_box State, canvas
28/// Cache, etc.) must be pre-populated by [`super::ensure_caches`] before calling
29/// this function. `render` works exclusively with shared (`&`) references
30/// to caches, so it can run inside iced's `view()` which only has `&self`.
31pub fn render<'a>(node: &'a TreeNode, ctx: RenderCtx<'a>) -> Element<'a, Message> {
32    // Track recursion depth via thread-local counter. Each call increments
33    // on entry; the DepthGuard decrements on drop (including early returns).
34    thread_local! {
35        static RENDER_DEPTH: Cell<usize> = const { Cell::new(0) };
36    }
37    struct DepthGuard;
38    impl Drop for DepthGuard {
39        fn drop(&mut self) {
40            RENDER_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
41        }
42    }
43
44    let depth = RENDER_DEPTH.with(|d| {
45        let new = d.get() + 1;
46        d.set(new);
47        new
48    });
49    let _guard = DepthGuard;
50
51    if depth > MAX_TREE_DEPTH {
52        log::warn!(
53            "[id={}] render depth exceeds {MAX_TREE_DEPTH}, returning placeholder",
54            node.id
55        );
56        return text("Max depth exceeded")
57            .color(Color::from_rgb(1.0, 0.0, 0.0))
58            .into();
59    }
60
61    if validate::is_validate_props_enabled() {
62        validate::validate_props(node);
63    }
64
65    let element = match node.type_name.as_str() {
66        // Layout widgets
67        "column" => layout::render_column(node, ctx),
68        "row" => layout::render_row(node, ctx),
69        "container" => layout::render_container(node, ctx),
70        "stack" => layout::render_stack(node, ctx),
71        "grid" => layout::render_grid(node, ctx),
72        "pin" => layout::render_pin(node, ctx),
73        "keyed_column" => layout::render_keyed_column(node, ctx),
74        "float" => layout::render_float(node, ctx),
75        "responsive" => layout::render_responsive(node, ctx),
76        "scrollable" => layout::render_scrollable(node, ctx),
77        "pane_grid" => layout::render_pane_grid(node, ctx),
78        // Display widgets
79        "text" => display::render_text(node, ctx),
80        "rich_text" | "rich" => display::render_rich_text(node, ctx),
81        "space" => display::render_space(node, ctx),
82        "rule" => display::render_rule(node, ctx),
83        "progress_bar" => display::render_progress_bar(node, ctx),
84        "image" => display::render_image(node, ctx),
85        "svg" => display::render_svg(node, ctx),
86        "markdown" => display::render_markdown(node, ctx),
87        "qr_code" => display::render_qr_code(node, ctx),
88        // Input widgets
89        "text_input" => input::render_text_input(node, ctx),
90        "text_editor" => input::render_text_editor(node, ctx),
91        "checkbox" => input::render_checkbox(node, ctx),
92        "toggler" => input::render_toggler(node, ctx),
93        "radio" => input::render_radio(node, ctx),
94        "slider" => input::render_slider(node, ctx),
95        "vertical_slider" => input::render_vertical_slider(node, ctx),
96        "pick_list" => input::render_pick_list(node, ctx),
97        "combo_box" => input::render_combo_box(node, ctx),
98        // Interactive widgets
99        "button" => interactive::render_button(node, ctx),
100        "mouse_area" => interactive::render_mouse_area(node, ctx),
101        "sensor" => interactive::render_sensor(node, ctx),
102        "tooltip" => interactive::render_tooltip(node, ctx),
103        "themer" => interactive::render_themer(node, ctx),
104        "window" => interactive::render_window(node, ctx),
105        "overlay" => interactive::render_overlay(node, ctx),
106        // Canvas
107        "canvas" => canvas::render_canvas(node, ctx),
108        // Table
109        "table" => table::render_table(node, ctx),
110        // Extension dispatch
111        unknown => {
112            if ctx.extensions.handles_type(unknown) {
113                let env = crate::extensions::WidgetEnv {
114                    caches: &ctx.caches.extension,
115                    ctx,
116                };
117                // catch_unwind at the render boundary: extension panics produce
118                // a red placeholder instead of crashing the renderer.
119                // We track consecutive render panics via an atomic counter
120                // on the dispatcher; after N consecutive panics, the
121                // extension is poisoned on the next prepare_all cycle.
122                if crate::extensions::catch_unwind_enabled() {
123                    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
124                        ctx.extensions.render(node, &env)
125                    })) {
126                        Ok(Some(element)) => element,
127                        Ok(None) => container(Space::new()).into(),
128                        Err(_) => {
129                            let at_threshold = ctx.extensions.record_render_panic(unknown);
130                            if at_threshold {
131                                log::error!(
132                                    "[id={}] extension for type `{unknown}` hit render panic \
133                                     threshold, will be poisoned on next prepare cycle",
134                                    node.id
135                                );
136                            } else {
137                                log::error!("extension panicked in render for node `{}`", node.id);
138                            }
139                            iced::widget::text(format!(
140                                "Extension error: type `{unknown}`, node `{}`",
141                                node.id
142                            ))
143                            .color(iced::Color::from_rgb(1.0, 0.0, 0.0))
144                            .into()
145                        }
146                    }
147                } else {
148                    match ctx.extensions.render(node, &env) {
149                        Some(element) => element,
150                        None => container(Space::new()).into(),
151                    }
152                }
153            } else {
154                log::warn!(
155                    "[id={}] unknown node type `{unknown}`, rendering as empty container",
156                    node.id
157                );
158                container(Space::new()).into()
159            }
160        }
161    };
162
163    // Explicit a11y overrides take precedence.
164    let overrides = crate::widgets::a11y::A11yOverrides::from_props(&node.props).or_else(|| {
165        // Auto-infer accessibility overrides from widget-specific props
166        // when the host hasn't set an explicit a11y block.
167        let props = node.props.as_object();
168        match node.type_name.as_str() {
169            // Image and SVG use iced's native .alt()/.description() methods
170            // directly, so no A11yOverride wrapping needed for those.
171            "text_input" | "text_editor" | "combo_box" => prop_str(props, "placeholder")
172                .map(crate::widgets::a11y::A11yOverrides::with_description),
173            _ => None,
174        }
175    });
176
177    if let Some(overrides) = overrides {
178        return crate::widgets::a11y::A11yOverride::wrap(element, overrides).into();
179    }
180
181    element
182}
183
184// ---------------------------------------------------------------------------
185// Tests
186// ---------------------------------------------------------------------------
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::extensions::ExtensionDispatcher;
192    use crate::image_registry::ImageRegistry;
193    use crate::protocol::TreeNode;
194    use crate::widgets::WidgetCaches;
195
196    // -- Image registry handle lookup --
197
198    #[test]
199    fn image_registry_handle_lookup() {
200        let mut registry = ImageRegistry::new();
201        // Minimal valid 1x1 RGB PNG.
202        let png_bytes: Vec<u8> = vec![
203            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // signature
204            0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
205            0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
206            0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8-bit RGB
207            0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT
208            0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2,
209            0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, // IEND
210            0x44, 0xAE, 0x42, 0x60, 0x82,
211        ];
212        registry
213            .create_from_bytes("test_sprite", png_bytes)
214            .expect("test sprite should be valid");
215        assert!(
216            registry.get("test_sprite").is_some(),
217            "registered handle should be retrievable"
218        );
219        assert!(
220            registry.get("nonexistent").is_none(),
221            "unregistered name should return None"
222        );
223    }
224
225    // -----------------------------------------------------------------------
226    // Render smoke tests -- verify render() doesn't panic for common types
227    // -----------------------------------------------------------------------
228
229    fn smoke_node(id: &str, type_name: &str, props: serde_json::Value) -> TreeNode {
230        TreeNode {
231            id: id.to_string(),
232            type_name: type_name.to_string(),
233            props,
234            children: vec![],
235        }
236    }
237
238    fn smoke_node_with_children(
239        id: &str,
240        type_name: &str,
241        props: serde_json::Value,
242        children: Vec<TreeNode>,
243    ) -> TreeNode {
244        TreeNode {
245            id: id.to_string(),
246            type_name: type_name.to_string(),
247            props,
248            children,
249        }
250    }
251
252    fn smoke_text_child() -> TreeNode {
253        smoke_node("child", "text", serde_json::json!({"content": "hi"}))
254    }
255
256    fn smoke_ctx<'a>(
257        caches: &'a WidgetCaches,
258        images: &'a ImageRegistry,
259        theme: &'a iced::Theme,
260        dispatcher: &'a ExtensionDispatcher,
261    ) -> RenderCtx<'a> {
262        RenderCtx {
263            caches,
264            images,
265            theme,
266            extensions: dispatcher,
267            default_text_size: None,
268            default_font: None,
269        }
270    }
271
272    #[test]
273    fn render_smoke_text() {
274        let node = smoke_node("t", "text", serde_json::json!({"content": "hello"}));
275        let caches = WidgetCaches::new();
276        let images = ImageRegistry::new();
277        let theme = iced::Theme::Dark;
278        let dispatcher = ExtensionDispatcher::default();
279        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
280        let _elem = render(&node, ctx);
281    }
282
283    #[test]
284    fn render_smoke_column_empty() {
285        let node = smoke_node("c", "column", serde_json::json!({}));
286        let caches = WidgetCaches::new();
287        let images = ImageRegistry::new();
288        let theme = iced::Theme::Dark;
289        let dispatcher = ExtensionDispatcher::default();
290        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
291        let _elem = render(&node, ctx);
292    }
293
294    #[test]
295    fn render_smoke_row_empty() {
296        let node = smoke_node("r", "row", serde_json::json!({}));
297        let caches = WidgetCaches::new();
298        let images = ImageRegistry::new();
299        let theme = iced::Theme::Dark;
300        let dispatcher = ExtensionDispatcher::default();
301        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
302        let _elem = render(&node, ctx);
303    }
304
305    #[test]
306    fn render_smoke_container_with_child() {
307        let node = smoke_node_with_children(
308            "ct",
309            "container",
310            serde_json::json!({}),
311            vec![smoke_text_child()],
312        );
313        let caches = WidgetCaches::new();
314        let images = ImageRegistry::new();
315        let theme = iced::Theme::Dark;
316        let dispatcher = ExtensionDispatcher::default();
317        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
318        let _elem = render(&node, ctx);
319    }
320
321    #[test]
322    fn render_smoke_button_with_child() {
323        let node = smoke_node_with_children(
324            "btn",
325            "button",
326            serde_json::json!({}),
327            vec![smoke_text_child()],
328        );
329        let caches = WidgetCaches::new();
330        let images = ImageRegistry::new();
331        let theme = iced::Theme::Dark;
332        let dispatcher = ExtensionDispatcher::default();
333        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
334        let _elem = render(&node, ctx);
335    }
336
337    #[test]
338    fn render_smoke_checkbox() {
339        let node = smoke_node(
340            "cb",
341            "checkbox",
342            serde_json::json!({"label": "Accept", "checked": true}),
343        );
344        let caches = WidgetCaches::new();
345        let images = ImageRegistry::new();
346        let theme = iced::Theme::Dark;
347        let dispatcher = ExtensionDispatcher::default();
348        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
349        let _elem = render(&node, ctx);
350    }
351
352    #[test]
353    fn render_smoke_space() {
354        let node = smoke_node("sp", "space", serde_json::json!({}));
355        let caches = WidgetCaches::new();
356        let images = ImageRegistry::new();
357        let theme = iced::Theme::Dark;
358        let dispatcher = ExtensionDispatcher::default();
359        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
360        let _elem = render(&node, ctx);
361    }
362
363    #[test]
364    fn render_smoke_rule() {
365        let node = smoke_node("rl", "rule", serde_json::json!({"direction": "horizontal"}));
366        let caches = WidgetCaches::new();
367        let images = ImageRegistry::new();
368        let theme = iced::Theme::Dark;
369        let dispatcher = ExtensionDispatcher::default();
370        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
371        let _elem = render(&node, ctx);
372    }
373
374    #[test]
375    fn render_smoke_progress_bar() {
376        let node = smoke_node(
377            "pb",
378            "progress_bar",
379            serde_json::json!({"value": 50.0, "min": 0.0, "max": 100.0}),
380        );
381        let caches = WidgetCaches::new();
382        let images = ImageRegistry::new();
383        let theme = iced::Theme::Dark;
384        let dispatcher = ExtensionDispatcher::default();
385        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
386        let _elem = render(&node, ctx);
387    }
388
389    #[test]
390    fn render_smoke_slider() {
391        let node = smoke_node(
392            "sl",
393            "slider",
394            serde_json::json!({"min": 0.0, "max": 100.0, "value": 50.0}),
395        );
396        let caches = WidgetCaches::new();
397        let images = ImageRegistry::new();
398        let theme = iced::Theme::Dark;
399        let dispatcher = ExtensionDispatcher::default();
400        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
401        let _elem = render(&node, ctx);
402    }
403
404    #[test]
405    fn render_smoke_text_input() {
406        let node = smoke_node(
407            "ti",
408            "text_input",
409            serde_json::json!({"placeholder": "Type here", "value": ""}),
410        );
411        let caches = WidgetCaches::new();
412        let images = ImageRegistry::new();
413        let theme = iced::Theme::Dark;
414        let dispatcher = ExtensionDispatcher::default();
415        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
416        let _elem = render(&node, ctx);
417    }
418
419    #[test]
420    fn render_smoke_toggler() {
421        let node = smoke_node("tg", "toggler", serde_json::json!({"is_toggled": false}));
422        let caches = WidgetCaches::new();
423        let images = ImageRegistry::new();
424        let theme = iced::Theme::Dark;
425        let dispatcher = ExtensionDispatcher::default();
426        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
427        let _elem = render(&node, ctx);
428    }
429
430    #[test]
431    fn render_smoke_stack_empty() {
432        let node = smoke_node("st", "stack", serde_json::json!({}));
433        let caches = WidgetCaches::new();
434        let images = ImageRegistry::new();
435        let theme = iced::Theme::Dark;
436        let dispatcher = ExtensionDispatcher::default();
437        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
438        let _elem = render(&node, ctx);
439    }
440
441    // -----------------------------------------------------------------------
442    // Error path tests -- unknown type and missing props
443    // -----------------------------------------------------------------------
444
445    #[test]
446    fn render_unknown_type_returns_element_without_panic() {
447        let node = smoke_node("unk", "definitely_not_a_widget", serde_json::json!({}));
448        let caches = WidgetCaches::new();
449        let images = ImageRegistry::new();
450        let theme = iced::Theme::Dark;
451        let dispatcher = ExtensionDispatcher::default();
452        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
453        // Should produce the empty container fallback, not panic.
454        let _elem = render(&node, ctx);
455    }
456
457    #[test]
458    fn render_text_input_missing_props_does_not_panic() {
459        let node = smoke_node("ti_empty", "text_input", serde_json::json!({}));
460        let caches = WidgetCaches::new();
461        let images = ImageRegistry::new();
462        let theme = iced::Theme::Dark;
463        let dispatcher = ExtensionDispatcher::default();
464        let ctx = smoke_ctx(&caches, &images, &theme, &dispatcher);
465        let _elem = render(&node, ctx);
466    }
467
468    // -----------------------------------------------------------------------
469    // A11y auto-inference tests
470    // -----------------------------------------------------------------------
471
472    /// Helper: extract auto-inferred overrides the same way render() does,
473    /// without actually rendering (avoids needing image handles etc.).
474    fn infer_a11y_overrides(node: &TreeNode) -> Option<crate::widgets::a11y::A11yOverrides> {
475        crate::widgets::a11y::A11yOverrides::from_props(&node.props).or_else(|| {
476            let props = node.props.as_object();
477            match node.type_name.as_str() {
478                // Image and SVG use iced's native .alt()/.description() methods
479                // directly, so no A11yOverride wrapping needed for those.
480                "text_input" | "text_editor" | "combo_box" => prop_str(props, "placeholder")
481                    .map(crate::widgets::a11y::A11yOverrides::with_description),
482                _ => None,
483            }
484        })
485    }
486
487    #[test]
488    fn a11y_image_alt_uses_native_iced_method_not_override() {
489        // Image/SVG alt text is handled by iced's native .alt() method,
490        // not by A11yOverride wrapping. No override should be created.
491        let node = smoke_node(
492            "img1",
493            "image",
494            serde_json::json!({"source": "logo.png", "alt": "Company logo"}),
495        );
496        assert!(
497            infer_a11y_overrides(&node).is_none(),
498            "image with alt should NOT get A11yOverride (uses native .alt())"
499        );
500    }
501
502    #[test]
503    fn a11y_svg_alt_uses_native_iced_method_not_override() {
504        let node = smoke_node(
505            "svg1",
506            "svg",
507            serde_json::json!({"source": "icon.svg", "alt": "Settings icon"}),
508        );
509        assert!(
510            infer_a11y_overrides(&node).is_none(),
511            "svg with alt should NOT get A11yOverride (uses native .alt())"
512        );
513    }
514
515    #[test]
516    fn a11y_auto_infer_text_input_placeholder_as_description() {
517        let node = smoke_node(
518            "ti1",
519            "text_input",
520            serde_json::json!({"placeholder": "Search...", "value": ""}),
521        );
522        let overrides =
523            infer_a11y_overrides(&node).expect("should infer overrides from placeholder");
524        assert_eq!(overrides.description.as_deref(), Some("Search..."));
525        assert!(overrides.label.is_none());
526    }
527
528    #[test]
529    fn a11y_explicit_overrides_take_precedence_over_alt() {
530        let node = smoke_node(
531            "img2",
532            "image",
533            serde_json::json!({
534                "source": "logo.png",
535                "alt": "Auto alt",
536                "a11y": {"label": "Explicit label"}
537            }),
538        );
539        let overrides = infer_a11y_overrides(&node).expect("should have explicit overrides");
540        // Explicit label wins; no double-wrapping.
541        assert_eq!(overrides.label.as_deref(), Some("Explicit label"));
542    }
543
544    #[test]
545    fn a11y_no_wrapping_without_alt_or_a11y() {
546        let node = smoke_node("txt1", "text", serde_json::json!({"content": "hello"}));
547        assert!(
548            infer_a11y_overrides(&node).is_none(),
549            "plain text node should not get a11y wrapping"
550        );
551    }
552
553    #[test]
554    fn a11y_no_wrapping_image_without_alt() {
555        let node = smoke_node(
556            "img3",
557            "image",
558            serde_json::json!({"source": "decorative.png"}),
559        );
560        assert!(
561            infer_a11y_overrides(&node).is_none(),
562            "image without alt should not get a11y wrapping"
563        );
564    }
565
566    #[test]
567    fn a11y_no_wrapping_text_input_without_placeholder() {
568        let node = smoke_node(
569            "ti2",
570            "text_input",
571            serde_json::json!({"value": "typed text"}),
572        );
573        assert!(
574            infer_a11y_overrides(&node).is_none(),
575            "text_input without placeholder should not get a11y wrapping"
576        );
577    }
578}