use std::cell::Cell;
use iced::widget::text;
use iced::{Color, Element, Theme};
use crate::PlushieRenderer;
use crate::message::Message;
use crate::protocol::TreeNode;
use crate::render_ctx::RenderCtx;
use crate::shared_state::MAX_TREE_DEPTH;
use crate::validate;
pub fn render<'a, R: PlushieRenderer>(
node: &'a TreeNode,
ctx: RenderCtx<'a, R>,
) -> Element<'a, Message, Theme, R> {
thread_local! {
static RENDER_DEPTH: Cell<usize> = const { Cell::new(0) };
}
struct DepthGuard;
impl Drop for DepthGuard {
fn drop(&mut self) {
RENDER_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
}
}
let depth = RENDER_DEPTH.with(|d| {
let new = d.get() + 1;
d.set(new);
new
});
let _guard = DepthGuard;
if depth > MAX_TREE_DEPTH {
log::warn!(
"[id={}] render depth exceeds {MAX_TREE_DEPTH}, returning placeholder",
node.id
);
return text("Max depth exceeded")
.color(Color::from_rgb(1.0, 0.0, 0.0))
.into();
}
if ctx.validate_props {
validate::validate_props(node);
}
let element = ctx.registry.render_node(node, &ctx);
let inferred = ctx.registry.infer_a11y_for_node(node);
let explicit = crate::a11y::A11yOverrides::from_props(&node.props);
let overrides = match (inferred, explicit) {
(Some(inf), Some(exp)) => Some(crate::a11y::A11yOverrides::merge(&inf, &exp)),
(Some(inf), None) => Some(inf),
(None, Some(exp)) => Some(exp),
(None, None) => None,
};
if let Some(overrides) = overrides {
return crate::a11y::A11yOverride::wrap(element, overrides).into();
}
element
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image_registry::ImageRegistry;
use crate::protocol::TreeNode;
use crate::registry::WidgetRegistry;
use crate::shared_state::SharedState;
use crate::widget::widget_set::iced_widget_set;
#[test]
fn image_registry_handle_lookup() {
let mut registry = ImageRegistry::new();
let png_bytes: Vec<u8> = vec![
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, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0xF0, 0x1F, 0x00, 0x05, 0x00, 0x01, 0xFF,
0x89, 0x99, 0x3D, 0x1D, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
registry
.create_from_bytes("test_sprite", png_bytes)
.expect("test sprite should be valid");
assert!(
registry.get("test_sprite").is_some(),
"registered handle should be retrievable"
);
assert!(
registry.get("nonexistent").is_none(),
"unregistered name should return None"
);
}
use crate::testing::{
node_with_props as smoke_node, node_with_props_and_children as smoke_node_with_children,
};
use plushie_core::types::Role;
fn smoke_text_child() -> TreeNode {
smoke_node("child", "text", serde_json::json!({"content": "hi"}))
}
fn smoke_registry() -> WidgetRegistry {
let mut registry = WidgetRegistry::new();
registry.register_set(&iced_widget_set());
registry
}
fn smoke_ctx<'a>(
caches: &'a SharedState,
images: &'a ImageRegistry,
theme: &'a iced::Theme,
registry: &'a WidgetRegistry,
) -> RenderCtx<'a> {
RenderCtx {
caches,
images,
theme,
theme_chrome: crate::theming::ThemeChrome::default(),
registry,
default_text_size: None,
default_font: None,
window_id: "",
scale_factor: 1.0,
validate_props: crate::validate::is_validate_props_enabled(),
}
}
fn render_prepared(mut node: TreeNode) {
let mut caches: SharedState = SharedState::new();
let images = ImageRegistry::new();
let theme = iced::Theme::Dark;
let mut registry = smoke_registry();
registry.prepare_walk(&mut node, &mut caches, &theme);
let ctx = smoke_ctx(&caches, &images, &theme, ®istry);
let _elem = render(&node, ctx);
}
#[test]
fn render_smoke_text() {
let node = smoke_node("t", "text", serde_json::json!({"content": "hello"}));
render_prepared(node);
}
#[test]
fn render_smoke_column_empty() {
let node = smoke_node("c", "column", serde_json::json!({}));
render_prepared(node);
}
#[test]
fn render_smoke_row_empty() {
let node = smoke_node("r", "row", serde_json::json!({}));
render_prepared(node);
}
#[test]
fn render_smoke_container_with_child() {
let node = smoke_node_with_children(
"ct",
"container",
serde_json::json!({}),
vec![smoke_text_child()],
);
render_prepared(node);
}
#[test]
fn render_smoke_button_with_child() {
let node = smoke_node_with_children(
"btn",
"button",
serde_json::json!({}),
vec![smoke_text_child()],
);
render_prepared(node);
}
#[test]
fn render_smoke_checkbox() {
let node = smoke_node(
"cb",
"checkbox",
serde_json::json!({"label": "Accept", "checked": true}),
);
render_prepared(node);
}
#[test]
fn render_smoke_space() {
let node = smoke_node("sp", "space", serde_json::json!({}));
render_prepared(node);
}
#[test]
fn render_smoke_rule() {
let node = smoke_node("rl", "rule", serde_json::json!({"direction": "horizontal"}));
render_prepared(node);
}
#[test]
fn render_smoke_progress_bar() {
let node = smoke_node(
"pb",
"progress_bar",
serde_json::json!({"value": 50.0, "min": 0.0, "max": 100.0}),
);
render_prepared(node);
}
#[test]
fn render_smoke_slider() {
let node = smoke_node(
"sl",
"slider",
serde_json::json!({"min": 0.0, "max": 100.0, "value": 50.0}),
);
render_prepared(node);
}
#[test]
fn render_smoke_text_input() {
let node = smoke_node(
"ti",
"text_input",
serde_json::json!({"placeholder": "Type here", "value": ""}),
);
render_prepared(node);
}
#[test]
fn render_smoke_toggler() {
let node = smoke_node("tg", "toggler", serde_json::json!({"is_toggled": false}));
render_prepared(node);
}
#[test]
fn render_smoke_stack_empty() {
let node = smoke_node("st", "stack", serde_json::json!({}));
render_prepared(node);
}
#[test]
fn render_unknown_type_returns_element_without_panic() {
let node = smoke_node("unk", "definitely_not_a_widget", serde_json::json!({}));
let caches: SharedState = SharedState::new();
let images = ImageRegistry::new();
let theme = iced::Theme::Dark;
let registry = smoke_registry();
let ctx = smoke_ctx(&caches, &images, &theme, ®istry);
let _elem = render(&node, ctx);
}
#[test]
fn render_text_input_missing_props_does_not_panic() {
let node = smoke_node("ti_empty", "text_input", serde_json::json!({}));
render_prepared(node);
}
fn infer_a11y_overrides(node: &TreeNode) -> Option<crate::a11y::A11yOverrides> {
let props = &node.props;
let registry = smoke_registry();
let inferred = registry.infer_a11y_for_node(node);
let explicit = crate::a11y::A11yOverrides::from_props(props);
match (inferred, explicit) {
(Some(inf), Some(exp)) => Some(crate::a11y::A11yOverrides::merge(&inf, &exp)),
(Some(inf), None) => Some(inf),
(None, Some(exp)) => Some(exp),
(None, None) => None,
}
}
#[test]
fn a11y_image_alt_uses_native_iced_method_not_override() {
let node = smoke_node(
"img1",
"image",
serde_json::json!({"source": "logo.png", "alt": "Company logo"}),
);
assert!(
infer_a11y_overrides(&node).is_none(),
"image with alt should NOT get A11yOverride (uses native .alt())"
);
}
#[test]
fn a11y_svg_alt_uses_native_iced_method_not_override() {
let node = smoke_node(
"svg1",
"svg",
serde_json::json!({"source": "icon.svg", "alt": "Settings icon"}),
);
assert!(
infer_a11y_overrides(&node).is_none(),
"svg with alt should NOT get A11yOverride (uses native .alt())"
);
}
#[test]
fn a11y_auto_infer_text_input_placeholder_as_description() {
let node = smoke_node(
"ti1",
"text_input",
serde_json::json!({"placeholder": "Search...", "value": ""}),
);
let overrides =
infer_a11y_overrides(&node).expect("should infer overrides from placeholder");
assert_eq!(overrides.description(), Some("Search..."));
assert!(overrides.label().is_none());
}
#[test]
fn a11y_explicit_overrides_take_precedence_over_alt() {
let node = smoke_node(
"img2",
"image",
serde_json::json!({
"source": "logo.png",
"alt": "Auto alt",
"a11y": {"label": "Explicit label"}
}),
);
let overrides = infer_a11y_overrides(&node).expect("should have explicit overrides");
assert_eq!(overrides.label(), Some("Explicit label"));
}
#[test]
fn a11y_text_uses_native_text_operation_without_override() {
let node = smoke_node("txt1", "text", serde_json::json!({"content": "hello"}));
assert!(
infer_a11y_overrides(&node).is_none(),
"text should not get a wrapper role"
);
}
#[test]
fn a11y_no_wrapping_image_without_alt() {
let node = smoke_node(
"img3",
"image",
serde_json::json!({"source": "decorative.png"}),
);
assert!(
infer_a11y_overrides(&node).is_none(),
"image without alt should not get a11y wrapping"
);
}
#[test]
fn a11y_auto_infer_combo_box_placeholder_as_description() {
let node = smoke_node(
"cb1",
"combo_box",
serde_json::json!({"placeholder": "Select an option...", "value": ""}),
);
let overrides =
infer_a11y_overrides(&node).expect("should infer overrides from placeholder");
assert_eq!(overrides.description(), Some("Select an option..."));
assert!(overrides.label().is_none());
}
#[test]
fn a11y_auto_infer_pick_list_placeholder_as_description() {
let node = smoke_node(
"pick1",
"pick_list",
serde_json::json!({"placeholder": "Choose one", "options": ["A", "B"]}),
);
let overrides =
infer_a11y_overrides(&node).expect("should infer overrides from placeholder");
assert_eq!(overrides.description(), Some("Choose one"));
}
#[test]
fn a11y_auto_infer_text_editor_placeholder_as_description() {
let node = smoke_node(
"te1",
"text_editor",
serde_json::json!({"placeholder": "Write something..."}),
);
let overrides =
infer_a11y_overrides(&node).expect("should infer overrides from placeholder");
assert_eq!(overrides.description(), Some("Write something..."));
assert!(overrides.label().is_none());
}
#[test]
fn a11y_combo_box_without_placeholder_declares_has_popup() {
let node = smoke_node("cb2", "combo_box", serde_json::json!({"value": "selected"}));
let overrides = infer_a11y_overrides(&node).expect("combo_box always declares has_popup");
assert!(overrides.label().is_none());
assert!(overrides.description().is_none());
assert_eq!(
overrides.core().has_popup,
Some(plushie_core::types::HasPopup::Listbox)
);
}
#[test]
fn a11y_text_input_without_placeholder_uses_native_role() {
let node = smoke_node(
"ti2",
"text_input",
serde_json::json!({"value": "typed text"}),
);
assert!(
infer_a11y_overrides(&node).is_none(),
"text_input should not get a wrapper role"
);
}
#[test]
fn a11y_explicit_label_merges_with_inferred_description() {
let node = smoke_node(
"search",
"text_input",
serde_json::json!({
"placeholder": "Search...",
"a11y": {"label": "Global search"}
}),
);
let overrides = infer_a11y_overrides(&node)
.expect("merged overrides should be present when either side sets fields");
assert_eq!(
overrides.label(),
Some("Global search"),
"explicit label should win"
);
assert_eq!(
overrides.description(),
Some("Search..."),
"inferred description should survive merge"
);
}
#[test]
fn a11y_does_not_infer_role_for_tooltip_wrapper() {
let node = smoke_node_with_children(
"help",
"tooltip",
serde_json::json!({"tip": "More details"}),
vec![smoke_text_child()],
);
assert!(
infer_a11y_overrides(&node).is_none(),
"tooltip has its own iced a11y relationship"
);
}
#[test]
fn a11y_does_not_infer_roles_for_native_or_pass_through_widgets() {
for type_name in [
"button",
"checkbox",
"column",
"combo_box",
"container",
"floating",
"float",
"grid",
"overlay",
"pane_grid",
"pick_list",
"pin",
"pointer_area",
"progress_bar",
"radio",
"responsive",
"row",
"rule",
"scrollable",
"sensor",
"slider",
"stack",
"themer",
"text_editor",
"text_input",
"toggler",
"vertical_slider",
"window",
"rich",
"rich_text",
"text",
] {
let node = smoke_node_with_children(
type_name,
type_name,
serde_json::json!({}),
vec![smoke_text_child()],
);
let overrides = infer_a11y_overrides(&node);
let inferred_role = overrides.as_ref().and_then(|o| o.role());
assert!(
inferred_role.is_none(),
"{type_name} should not get an inferred wrapper role; got {:?}",
inferred_role
);
}
}
#[test]
fn a11y_does_not_infer_roles_for_widgets_with_custom_a11y() {
for (type_name, props) in [
(
"canvas",
serde_json::json!({"role": "image", "alt": "Chart"}),
),
("table", serde_json::json!({"columns": []})),
] {
let node = smoke_node(type_name, type_name, props);
assert!(
infer_a11y_overrides(&node).is_none(),
"{type_name} owns its accessible nodes"
);
}
}
#[test]
fn a11y_explicit_role_is_preserved() {
let node = smoke_node(
"save",
"button",
serde_json::json!({
"a11y": {"role": "link"}
}),
);
let overrides = infer_a11y_overrides(&node).expect("explicit role should be preserved");
assert_eq!(overrides.core().role, Some(Role::Link));
}
#[test]
fn a11y_auto_infer_button_mnemonic() {
let node = smoke_node(
"save",
"button",
serde_json::json!({"label": "Save", "mnemonic": "S"}),
);
let overrides = infer_a11y_overrides(&node).expect("should infer mnemonic");
assert_eq!(overrides.core().mnemonic, Some('S'));
}
#[test]
fn a11y_auto_infer_access_key_alias() {
let node = smoke_node(
"remember",
"checkbox",
serde_json::json!({"label": "Remember me", "access_key": "R"}),
);
let overrides = infer_a11y_overrides(&node).expect("should infer access key");
assert_eq!(overrides.core().mnemonic, Some('R'));
}
#[test]
fn a11y_top_level_mnemonic_wins_over_access_key() {
let node = smoke_node(
"choice",
"radio",
serde_json::json!({"value": "yes", "mnemonic": "Y", "access_key": "N"}),
);
let overrides = infer_a11y_overrides(&node).expect("should infer mnemonic");
assert_eq!(overrides.core().mnemonic, Some('Y'));
}
#[test]
fn a11y_explicit_mnemonic_wins_over_top_level_prop() {
let node = smoke_node(
"save",
"button",
serde_json::json!({
"label": "Save",
"mnemonic": "S",
"a11y": {"mnemonic": "V"}
}),
);
let overrides = infer_a11y_overrides(&node).expect("should merge mnemonic overrides");
assert_eq!(overrides.core().mnemonic, Some('V'));
}
}