Skip to main content

rosace_widgets/template/
inflate.rs

1//! The widget registry + interpreter (D103 / D102 Tier 1 — rollout step 3).
2//!
3//! The "inflater, not a renderer" (see `.steering/HOT_RELOAD.md`): [`inflate`]
4//! walks a [`Template`] and reconstructs a `Box<dyn Widget>` tree by calling the
5//! SAME widget constructors the release builder would — so the result is
6//! byte-for-byte the tree hand-written builder code produces, and the engine's
7//! normal `layout()`/`paint()` run on it unchanged. Nothing here paints; this
8//! only changes how the tree is *constructed* (from data instead of compiled
9//! calls).
10//!
11//! The one genuinely new runtime piece is the **widget registry**: a map from a
12//! widget's string name to a build closure that knows how to construct it from
13//! resolved props + children. Built-ins seed it; third-party widgets register
14//! the same way via [`register_widget`] (the D115 icon-registry / D124
15//! material-registry extensibility bar).
16//!
17//! # Holes
18//! Dynamic `{expr}` slots are supplied positionally as `&[Box<dyn Any>]` — the
19//! compiled values the running binary produces each frame. A build closure
20//! downcasts a hole to the type its prop needs. Value holes (numbers, strings)
21//! work today; **handler/closure holes** (`on_press`) need a typed
22//! handler-registry and are a named deferral (they tie into the SDUI
23//! name-binding note in D125).
24//!
25//! Trace: per the widget-layer convention (widgets don't emit `RosaceTrace`
26//! themselves — that's the engine's job), inflate instrumentation attaches when
27//! this is wired into the frame loop / hot-swap, not in this pure function.
28
29use std::any::Any;
30use std::collections::HashMap;
31use std::sync::{Arc, OnceLock, RwLock};
32
33use super::{StaticValue, PropValue, Template, TemplateNode};
34use crate::tree::{Button, Column, Row, Text, Widget};
35
36/// A nullary event handler (e.g. `Button::on_press`). Handlers travel through a
37/// hole wrapped as this type — concrete (so it round-trips through `Box<dyn
38/// Any>`), and callable. Arg-taking handlers (`Fn(T)`) are a future extension.
39pub type Handler = Arc<dyn Fn() + Send + Sync>;
40
41/// A resolved prop value handed to a build closure: either a template literal,
42/// or the compiled value at a hole slot (type-erased).
43pub enum PropInput<'a> {
44    Static(&'a StaticValue),
45    Hole(&'a dyn Any),
46}
47
48/// Register a widget with the interpreter WITHOUT hand-writing the build
49/// closure — the ergonomic front door to hot-reload extensibility.
50///
51/// You give the widget's registry name, its zero-arg constructor, whether it
52/// takes children, and a `"prop" => setter: Type` table. Each entry maps a
53/// template prop to a builder method + the type to extract (via [`FromProp`]).
54/// This is the boilerplate the future `#[derive(Widget)]` would generate; it is
55/// also the single place a widget's prop schema is declared (the same data a
56/// tooling/IDE schema would read).
57///
58/// ```ignore
59/// inflatable!("Column", Column::new(), children, {
60///     "spacing" => spacing: f32,
61/// });
62/// inflatable!("Text", Text::new(""), leaf, {});   // leaf: no children
63/// ```
64#[macro_export]
65macro_rules! inflatable {
66    // Container form: props + `.child(..)` children. (Setter-style widgets;
67    // positional constructor args aren't handled by this form — `_args`.)
68    ($name:literal, $ctor:expr, children, { $($prop:literal => $setter:ident : $ty:ty),* $(,)? }) => {
69        $crate::template::register_widget($name, |_args: &[$crate::template::PropInput], props, children| {
70            let mut w = $ctor;
71            for (k, v) in props {
72                // `v` legitimately goes unused when the prop table is empty.
73                let _ = &v;
74                match k.as_str() {
75                    $( $prop => w = w.$setter(<$ty as $crate::template::FromProp>::from_prop(v, $name, $prop)?), )*
76                    _ => return ::core::result::Result::Err(
77                        $crate::template::InflateError::UnknownProp { widget: $name.into(), prop: k.clone() }
78                    ),
79                }
80            }
81            for kid in children { w = w.child(kid); }
82            let _ = &mut w; // `mut` may go unused for a propless, childless widget.
83            ::core::result::Result::Ok(::std::boxed::Box::new(w) as ::std::boxed::Box<dyn $crate::tree::Widget>)
84        });
85    };
86    // Leaf form: props only, no children (given children → escalate).
87    ($name:literal, $ctor:expr, leaf, { $($prop:literal => $setter:ident : $ty:ty),* $(,)? }) => {
88        $crate::template::register_widget($name, |_args: &[$crate::template::PropInput], props, children: ::std::vec::Vec<::std::boxed::Box<dyn $crate::tree::Widget>>| {
89            if !children.is_empty() {
90                return ::core::result::Result::Err(
91                    $crate::template::InflateError::UnexpectedChildren { widget: $name.into() }
92                );
93            }
94            let mut w = $ctor;
95            for (k, v) in props {
96                let _ = &v;
97                match k.as_str() {
98                    $( $prop => w = w.$setter(<$ty as $crate::template::FromProp>::from_prop(v, $name, $prop)?), )*
99                    _ => return ::core::result::Result::Err(
100                        $crate::template::InflateError::UnknownProp { widget: $name.into(), prop: k.clone() }
101                    ),
102                }
103            }
104            let _ = &mut w;
105            ::core::result::Result::Ok(::std::boxed::Box::new(w) as ::std::boxed::Box<dyn $crate::tree::Widget>)
106        });
107    };
108}
109
110/// Why an [`inflate`] failed. All are "escalate, don't paint garbage" cases.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum InflateError {
113    /// No registered widget for this name.
114    UnknownWidget(String),
115    /// A registered widget got a prop it doesn't understand.
116    UnknownProp { widget: String, prop: String },
117    /// A prop's value was the wrong type for the setter (e.g. a closure where
118    /// an `f32` was expected) — the slot-signature mismatch guard in miniature.
119    PropType { widget: String, prop: String, expected: &'static str },
120    /// A leaf widget was given children it can't hold.
121    UnexpectedChildren { widget: String },
122    /// A `Hole(i)` referenced a slot past the end of the supplied hole array.
123    HoleOutOfRange { index: usize, len: usize },
124}
125
126impl std::fmt::Display for InflateError {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            InflateError::UnknownWidget(w) => write!(f, "unknown widget `{w}`"),
130            InflateError::UnknownProp { widget, prop } => write!(f, "`{widget}` has no prop `{prop}`"),
131            InflateError::PropType { widget, prop, expected } => {
132                write!(f, "`{widget}.{prop}` expected {expected}")
133            }
134            InflateError::UnexpectedChildren { widget } => write!(f, "`{widget}` cannot have children"),
135            InflateError::HoleOutOfRange { index, len } => {
136                write!(f, "hole #{index} out of range (only {len} supplied)")
137            }
138        }
139    }
140}
141impl std::error::Error for InflateError {}
142
143/// A widget build closure: construct the widget from its resolved props and
144/// already-inflated children. Higher-ranked over the props' lifetime so a
145/// single boxed closure works for any call.
146pub type BuildFn = Box<
147    dyn for<'a> Fn(&'a [PropInput<'a>], &'a [(String, PropInput<'a>)], Vec<Box<dyn Widget>>) -> Result<Box<dyn Widget>, InflateError>
148        + Send
149        + Sync,
150>;
151
152fn registry() -> &'static RwLock<HashMap<String, BuildFn>> {
153    static REG: OnceLock<RwLock<HashMap<String, BuildFn>>> = OnceLock::new();
154    REG.get_or_init(|| RwLock::new(builtin_widgets()))
155}
156
157/// Register (or replace) a widget build closure by name. Third-party widgets
158/// call this — same extensibility path as built-ins, no edit to rosace-* crates.
159pub fn register_widget<F>(name: impl Into<String>, build: F)
160where
161    F: for<'a> Fn(&'a [PropInput<'a>], &'a [(String, PropInput<'a>)], Vec<Box<dyn Widget>>) -> Result<Box<dyn Widget>, InflateError>
162        + Send
163        + Sync
164        + 'static,
165{
166    registry().write().unwrap_or_else(|e| e.into_inner()).insert(name.into(), Box::new(build));
167}
168
169/// Whether a widget name is registered (diagnostics / tests).
170pub fn is_registered(name: &str) -> bool {
171    registry().read().unwrap_or_else(|e| e.into_inner()).contains_key(name)
172}
173
174/// Inflate a template into a live widget tree, binding hole slots by index.
175pub fn inflate(template: &Template, holes: &[Box<dyn Any>]) -> Result<Box<dyn Widget>, InflateError> {
176    inflate_node(&template.root, holes)
177}
178
179/// Resolve one descriptor value to a live input: a static passes through, a
180/// hole binds to the compiled value at its index (out-of-range → escalate).
181fn resolve<'a>(value: &'a PropValue, holes: &'a [Box<dyn Any>]) -> Result<PropInput<'a>, InflateError> {
182    match value {
183        PropValue::Static(s) => Ok(PropInput::Static(s)),
184        PropValue::Hole(i) => holes
185            .get(*i)
186            .map(|h| PropInput::Hole(h.as_ref()))
187            .ok_or(InflateError::HoleOutOfRange { index: *i, len: holes.len() }),
188    }
189}
190
191fn inflate_node(node: &TemplateNode, holes: &[Box<dyn Any>]) -> Result<Box<dyn Widget>, InflateError> {
192    // Positional constructor args, then named props — both resolved the same way.
193    let mut args: Vec<PropInput<'_>> = Vec::with_capacity(node.args.len());
194    for value in &node.args {
195        args.push(resolve(value, holes)?);
196    }
197    let mut props: Vec<(String, PropInput<'_>)> = Vec::with_capacity(node.props.len());
198    for (key, value) in &node.props {
199        props.push((key.clone(), resolve(value, holes)?));
200    }
201
202    // Children first (depth-first), so the build closure receives live widgets.
203    let mut children: Vec<Box<dyn Widget>> = Vec::with_capacity(node.children.len());
204    for child in &node.children {
205        children.push(inflate_node(child, holes)?);
206    }
207
208    let reg = registry().read().unwrap_or_else(|e| e.into_inner());
209    let build = reg
210        .get(&node.widget)
211        .ok_or_else(|| InflateError::UnknownWidget(node.widget.clone()))?;
212    build(&args, &props, children)
213}
214
215// ── typed prop extraction ───────────────────────────────────────────────────
216
217/// Convert a resolved prop ([`PropInput`]) into a setter's argument type.
218///
219/// This is the typed edge between the untyped template/hole world and a
220/// widget's strongly-typed builder. A build closure calls
221/// `f32::from_prop(v, ..)` to get the value for `.spacing(f32)`. Implement it
222/// for your own prop types so `inflatable!`-registered widgets can accept them
223/// (mirrors the extensibility of the widget registry itself).
224pub trait FromProp: Sized {
225    /// The name shown in a [`InflateError::PropType`] when extraction fails.
226    const TYPE_NAME: &'static str;
227    fn from_prop(pi: &PropInput, widget: &str, prop: &str) -> Result<Self, InflateError>;
228}
229
230/// Shared error constructor for a type mismatch at a slot.
231fn prop_type_err<T: FromProp>(widget: &str, prop: &str) -> InflateError {
232    InflateError::PropType { widget: widget.to_string(), prop: prop.to_string(), expected: T::TYPE_NAME }
233}
234
235macro_rules! impl_from_prop_number {
236    ($($t:ty),+) => {$(
237        impl FromProp for $t {
238            const TYPE_NAME: &'static str = stringify!($t);
239            fn from_prop(pi: &PropInput, widget: &str, prop: &str) -> Result<Self, InflateError> {
240                match pi {
241                    PropInput::Static(StaticValue::Float(f)) => Ok(*f as $t),
242                    PropInput::Static(StaticValue::Int(i)) => Ok(*i as $t),
243                    PropInput::Static(StaticValue::Bool(b)) => Ok(*b as i64 as $t),
244                    PropInput::Hole(any) => any
245                        .downcast_ref::<$t>()
246                        .copied()
247                        .or_else(|| any.downcast_ref::<f32>().map(|v| *v as $t))
248                        .or_else(|| any.downcast_ref::<f64>().map(|v| *v as $t))
249                        .or_else(|| any.downcast_ref::<i64>().map(|v| *v as $t))
250                        .ok_or_else(|| prop_type_err::<$t>(widget, prop)),
251                    _ => Err(prop_type_err::<$t>(widget, prop)),
252                }
253            }
254        }
255    )+};
256}
257impl_from_prop_number!(f32, f64, i64);
258
259impl FromProp for bool {
260    const TYPE_NAME: &'static str = "bool";
261    fn from_prop(pi: &PropInput, widget: &str, prop: &str) -> Result<Self, InflateError> {
262        match pi {
263            PropInput::Static(StaticValue::Bool(b)) => Ok(*b),
264            PropInput::Hole(any) => any.downcast_ref::<bool>().copied().ok_or_else(|| prop_type_err::<bool>(widget, prop)),
265            _ => Err(prop_type_err::<bool>(widget, prop)),
266        }
267    }
268}
269
270impl FromProp for String {
271    const TYPE_NAME: &'static str = "string";
272    fn from_prop(pi: &PropInput, widget: &str, prop: &str) -> Result<Self, InflateError> {
273        match pi {
274            PropInput::Static(StaticValue::Str(s)) => Ok(s.clone()),
275            PropInput::Hole(any) => any
276                .downcast_ref::<String>()
277                .cloned()
278                .or_else(|| any.downcast_ref::<&str>().map(|s| s.to_string()))
279                .ok_or_else(|| prop_type_err::<String>(widget, prop)),
280            _ => Err(prop_type_err::<String>(widget, prop)),
281        }
282    }
283}
284
285/// A handler is always a hole (a closure can't be a literal). The compiled
286/// binary wraps it as [`Handler`] and puts it in the hole array; here we
287/// downcast it back.
288impl FromProp for Handler {
289    const TYPE_NAME: &'static str = "handler";
290    fn from_prop(pi: &PropInput, widget: &str, prop: &str) -> Result<Self, InflateError> {
291        match pi {
292            PropInput::Hole(any) => any
293                .downcast_ref::<Handler>()
294                .cloned()
295                .ok_or_else(|| prop_type_err::<Handler>(widget, prop)),
296            _ => Err(prop_type_err::<Handler>(widget, prop)),
297        }
298    }
299}
300
301// ── built-in widgets ────────────────────────────────────────────────────────
302
303fn builtin_widgets() -> HashMap<String, BuildFn> {
304    let mut m: HashMap<String, BuildFn> = HashMap::new();
305    m.insert("Column".into(), Box::new(build_column));
306    m.insert("Row".into(), Box::new(build_row));
307    m.insert("Text".into(), Box::new(build_text));
308    m.insert("Button".into(), Box::new(build_button));
309    m
310}
311
312// Button's label is a positional arg (`Button("Save")`); `on_press` is a
313// handler hole (a nullary closure the compiled binary wrapped as `Handler`).
314fn build_button(args: &[PropInput], props: &[(String, PropInput)], children: Vec<Box<dyn Widget>>) -> Result<Box<dyn Widget>, InflateError> {
315    if !children.is_empty() {
316        return Err(InflateError::UnexpectedChildren { widget: "Button".into() });
317    }
318    let label = match args.first() {
319        Some(a) => String::from_prop(a, "Button", "label")?,
320        None => String::new(),
321    };
322    let mut button = Button::new(label);
323    for (k, v) in props {
324        match k.as_str() {
325            "on_press" => {
326                let handler = Handler::from_prop(v, "Button", "on_press")?;
327                button = button.on_press(move || (*handler)());
328            }
329            _ => return Err(InflateError::UnknownProp { widget: "Button".into(), prop: k.clone() }),
330        }
331    }
332    Ok(Box::new(button))
333}
334
335// Column/Row take no positional args (`Column::new()`); `_args` is ignored (a
336// stray `Column(x)` would already fail the release builder's `Column::new(x)`).
337fn build_column(_args: &[PropInput], props: &[(String, PropInput)], children: Vec<Box<dyn Widget>>) -> Result<Box<dyn Widget>, InflateError> {
338    let mut col = Column::new();
339    for (k, v) in props {
340        match k.as_str() {
341            "spacing" => col = col.spacing(f32::from_prop(v, "Column", "spacing")?),
342            _ => return Err(InflateError::UnknownProp { widget: "Column".into(), prop: k.clone() }),
343        }
344    }
345    for kid in children {
346        col = col.child(kid);
347    }
348    Ok(Box::new(col))
349}
350
351fn build_row(_args: &[PropInput], props: &[(String, PropInput)], children: Vec<Box<dyn Widget>>) -> Result<Box<dyn Widget>, InflateError> {
352    let mut row = Row::new();
353    for (k, v) in props {
354        match k.as_str() {
355            "spacing" => row = row.spacing(f32::from_prop(v, "Row", "spacing")?),
356            _ => return Err(InflateError::UnknownProp { widget: "Row".into(), prop: k.clone() }),
357        }
358    }
359    for kid in children {
360        row = row.child(kid);
361    }
362    Ok(Box::new(row))
363}
364
365// Text's content is a POSITIONAL constructor arg: `Text("Hi")` → `Text::new("Hi")`.
366fn build_text(args: &[PropInput], props: &[(String, PropInput)], children: Vec<Box<dyn Widget>>) -> Result<Box<dyn Widget>, InflateError> {
367    if !children.is_empty() {
368        return Err(InflateError::UnexpectedChildren { widget: "Text".into() });
369    }
370    let content = match args.first() {
371        Some(a) => String::from_prop(a, "Text", "text")?,
372        None => String::new(),
373    };
374    // Text has no named-prop setters wired yet; reject unknowns rather than
375    // silently drop them.
376    if let Some((k, _)) = props.first() {
377        return Err(InflateError::UnknownProp { widget: "Text".into(), prop: k.clone() });
378    }
379    Ok(Box::new(Text::new(content)))
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::template::{Template, TemplateKey, TemplateNode};
386    use crate::tree::{LayoutCtx, Widget};
387    use rosace_layout::Constraints;
388
389    fn tmpl(root: TemplateNode) -> Template {
390        Template::new(TemplateKey::new("src/inflate_test.rs", 1, 1), root)
391    }
392
393    /// Lay a widget out under a shared headless context → its measured size.
394    fn measure(w: &dyn Widget) -> rosace_core::types::Size {
395        let font = rosace_render::FontCache::embedded();
396        let theme = rosace_theme::built_in::dark_theme();
397        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
398        w.layout(&ctx)
399    }
400
401    #[test]
402    fn button_inflates_with_a_handler_hole_and_matches_the_builder() {
403        // A nullary handler wrapped as `Handler`, supplied via a hole.
404        let handler: Handler = Arc::new(|| {});
405        let t = tmpl(
406            TemplateNode::new("Button")
407                .with_arg_static(StaticValue::Str("Save".into()))
408                .with_hole("on_press", 0),
409        );
410        let holes: Vec<Box<dyn Any>> = vec![Box::new(handler)];
411        let inflated = inflate(&t, &holes).expect("button with a handler hole inflates");
412        assert_eq!(measure(&*inflated), measure(&Button::new("Save").on_press(|| {})));
413    }
414
415    #[test]
416    fn a_non_handler_value_in_a_handler_slot_escalates() {
417        // A number where on_press expects a Handler → PropType, never garbage.
418        let t = tmpl(
419            TemplateNode::new("Button")
420                .with_arg_static(StaticValue::Str("x".into()))
421                .with_hole("on_press", 0),
422        );
423        let holes: Vec<Box<dyn Any>> = vec![Box::new(42i64)];
424        assert!(matches!(inflate(&t, &holes).err(), Some(InflateError::PropType { .. })));
425    }
426
427    #[test]
428    fn positional_arg_constructs_a_text_like_the_builder() {
429        // Text("Hi") — the content is a positional constructor arg, static.
430        let t = tmpl(TemplateNode::new("Text").with_arg_static(StaticValue::Str("Hi".into())));
431        let inflated = inflate(&t, &[]).expect("inflate Text(\"Hi\")");
432        assert_eq!(measure(&*inflated), measure(&Text::new("Hi")));
433    }
434
435    #[test]
436    fn positional_arg_binds_a_hole() {
437        // Text(title) — content comes from a runtime hole.
438        let t = tmpl(TemplateNode::new("Text").with_arg_hole(0));
439        let holes: Vec<Box<dyn Any>> = vec![Box::new(String::from("live"))];
440        let inflated = inflate(&t, &holes).expect("inflate Text(hole)");
441        assert_eq!(measure(&*inflated), measure(&Text::new("live")));
442    }
443
444    #[test]
445    fn unknown_widget_escalates() {
446        let t = tmpl(TemplateNode::new("NoSuchWidget"));
447        assert_eq!(inflate(&t, &[]).err(), Some(InflateError::UnknownWidget("NoSuchWidget".into())));
448    }
449
450    #[test]
451    fn inflates_children_and_matches_the_builder_for_a_multi_child_tree() {
452        // Column does its own layout over private children (it doesn't expose
453        // them via children()), so prove nesting through observable layout:
454        // two Text children must (a) match the equivalent builder tree and
455        // (b) make the column taller than an empty one.
456        let t = tmpl(
457            TemplateNode::new("Column")
458                .with_static("spacing", StaticValue::Float(8.0))
459                .with_child(TemplateNode::new("Text").with_arg_static(StaticValue::Str("A".into())))
460                .with_child(TemplateNode::new("Text").with_arg_static(StaticValue::Str("B".into()))),
461        );
462        let inflated = inflate(&t, &[]).expect("inflate");
463        let built = Column::new().spacing(8.0).child(Text::new("A")).child(Text::new("B"));
464        assert_eq!(measure(&*inflated), measure(&built), "two-child inflate must match builder");
465
466        let empty = inflate(&tmpl(TemplateNode::new("Column")), &[]).expect("inflate empty");
467        assert!(measure(&*inflated).height > measure(&*empty).height, "children should add height");
468    }
469
470    #[test]
471    fn inflated_static_tree_lays_out_identically_to_the_builder() {
472        let t = tmpl(
473            TemplateNode::new("Column")
474                .with_static("spacing", StaticValue::Float(8.0))
475                .with_child(TemplateNode::new("Text").with_arg_static(StaticValue::Str("Hi".into()))),
476        );
477        let inflated = inflate(&t, &[]).expect("inflate");
478        let built = Column::new().spacing(8.0).child(Text::new("Hi"));
479        assert_eq!(measure(&*inflated), measure(&built), "inflater must match builder output");
480    }
481
482    #[test]
483    fn binds_value_holes_by_index_matching_the_builder() {
484        // spacing and text both come from holes, bound by position.
485        let t = tmpl(
486            TemplateNode::new("Column")
487                .with_hole("spacing", 0)
488                .with_child(TemplateNode::new("Text").with_arg_hole(1)),
489        );
490        let holes: Vec<Box<dyn Any>> = vec![Box::new(8.0f32), Box::new(String::from("Hi"))];
491        let inflated = inflate(&t, &holes).expect("inflate");
492        let built = Column::new().spacing(8.0).child(Text::new("Hi"));
493        assert_eq!(measure(&*inflated), measure(&built), "hole binding must match builder output");
494    }
495
496    #[test]
497    fn hole_out_of_range_escalates() {
498        let t = tmpl(TemplateNode::new("Column").with_hole("spacing", 5));
499        assert_eq!(inflate(&t, &[]).err(), Some(InflateError::HoleOutOfRange { index: 5, len: 0 }));
500    }
501
502    #[test]
503    fn wrong_hole_type_escalates() {
504        // spacing hole holds a String, not an f32 → PropType, never a bad widget.
505        let t = tmpl(TemplateNode::new("Column").with_hole("spacing", 0));
506        let holes: Vec<Box<dyn Any>> = vec![Box::new(String::from("not a number"))];
507        assert_eq!(
508            inflate(&t, &holes).err(),
509            Some(InflateError::PropType { widget: "Column".into(), prop: "spacing".into(), expected: "f32" })
510        );
511    }
512
513    #[test]
514    fn unknown_prop_escalates() {
515        let t = tmpl(TemplateNode::new("Column").with_static("bogus", StaticValue::Int(1)));
516        assert_eq!(
517            inflate(&t, &[]).err(),
518            Some(InflateError::UnknownProp { widget: "Column".into(), prop: "bogus".into() })
519        );
520    }
521
522    #[test]
523    fn third_party_widget_registers_and_inflates() {
524        // Mirrors the D115 extensibility bar: a widget rosace never heard of.
525        register_widget("MyBadge", |_args, _props, _children| Ok(Box::new(Text::new("badge")) as Box<dyn Widget>));
526        assert!(is_registered("MyBadge"));
527        let t = tmpl(TemplateNode::new("MyBadge"));
528        let w = inflate(&t, &[]).expect("custom widget inflates");
529        // It behaves like the Text it wraps.
530        assert_eq!(measure(&*w), measure(&Text::new("badge")));
531    }
532
533    // ── inflatable! macro (ergonomic registration) ─────────────────────────
534
535    #[test]
536    fn inflatable_macro_registers_a_container_with_props_and_children() {
537        // No hand-written closure — the macro generates it from a prop table.
538        crate::inflatable!("MacroCol", Column::new(), children, {
539            "spacing" => spacing: f32,
540        });
541        assert!(is_registered("MacroCol"));
542
543        let t = tmpl(
544            TemplateNode::new("MacroCol")
545                .with_static("spacing", StaticValue::Float(8.0))
546                .with_child(TemplateNode::new("Text").with_arg_static(StaticValue::Str("A".into())))
547                .with_child(TemplateNode::new("Text").with_arg_static(StaticValue::Str("B".into()))),
548        );
549        let inflated = inflate(&t, &[]).expect("macro-registered widget inflates");
550        let built = Column::new().spacing(8.0).child(Text::new("A")).child(Text::new("B"));
551        assert_eq!(measure(&*inflated), measure(&built), "macro closure must match builder");
552    }
553
554    #[test]
555    fn inflatable_macro_binds_a_hole_and_reports_unknown_props() {
556        crate::inflatable!("MacroCol2", Column::new(), children, {
557            "spacing" => spacing: f32,
558        });
559        // Hole binding through the generated closure.
560        let t = tmpl(TemplateNode::new("MacroCol2").with_hole("spacing", 0));
561        let holes: Vec<Box<dyn Any>> = vec![Box::new(6.0f32)];
562        let inflated = inflate(&t, &holes).expect("hole binds");
563        assert_eq!(measure(&*inflated), measure(&Column::new().spacing(6.0)));
564        // Unknown prop still escalates.
565        let bad = tmpl(TemplateNode::new("MacroCol2").with_static("nope", StaticValue::Int(1)));
566        assert_eq!(
567            inflate(&bad, &[]).err(),
568            Some(InflateError::UnknownProp { widget: "MacroCol2".into(), prop: "nope".into() })
569        );
570    }
571
572    #[test]
573    fn inflatable_macro_leaf_rejects_children() {
574        crate::inflatable!("MacroLeaf", Text::new("leaf"), leaf, {});
575        assert!(is_registered("MacroLeaf"));
576        // Inflates as a leaf.
577        assert_eq!(
578            measure(&*inflate(&tmpl(TemplateNode::new("MacroLeaf")), &[]).unwrap()),
579            measure(&Text::new("leaf"))
580        );
581        // Given children → escalate, don't silently drop them.
582        let with_kids = tmpl(TemplateNode::new("MacroLeaf").with_child(TemplateNode::new("Text")));
583        assert_eq!(
584            inflate(&with_kids, &[]).err(),
585            Some(InflateError::UnexpectedChildren { widget: "MacroLeaf".into() })
586        );
587    }
588}