1use 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
20pub fn render<'a>(node: &'a TreeNode, ctx: RenderCtx<'a>) -> Element<'a, Message> {
32 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 "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 "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 "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 "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" => canvas::render_canvas(node, ctx),
108 "table" => table::render_table(node, ctx),
110 unknown => {
112 if ctx.extensions.handles_type(unknown) {
113 let env = crate::extensions::WidgetEnv {
114 caches: &ctx.caches.extension,
115 ctx,
116 };
117 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 let overrides = crate::widgets::a11y::A11yOverrides::from_props(&node.props).or_else(|| {
165 let props = node.props.as_object();
168 match node.type_name.as_str() {
169 "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#[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 #[test]
199 fn image_registry_handle_lookup() {
200 let mut registry = ImageRegistry::new();
201 let png_bytes: Vec<u8> = vec![
203 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 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, 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 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 #[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 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 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 "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 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 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}