Skip to main content

yog_ui/
component.rs

1//! Custom components — a mod's own reusable widgets, used from `.yui`
2//! markup like the built-in elements (the user controls of WPF).
3//!
4//! A component is a `.yui` file with a `<Component>` root: its properties
5//! and events declared in markup, its body bound to them with
6//! `{x:Bind ...}`, and an optional code-behind `.rs` for methods the
7//! bindings call (see Yog-IDLE's `docs/yui.md`, "Components"):
8//!
9//! ```xml
10//! <Component name="StatBar" doc="A labelled progress bar." width="120" height="10">
11//!     <Property name="label" type="text" default="HP"/>
12//!     <Property name="value" type="number" default="0.5" doc="Filled fraction, 0..1"/>
13//!     <Row w="100%" gap="4">
14//!         <Label text="{x:Bind label}"/>
15//!         <Spacer w="{x:Bind bar_w()}" h="6" bg="#FF55FF55"/>
16//!     </Row>
17//! </Component>
18//! ```
19//!
20//! `yog build` generates the [`Component`] implementation from it (a struct
21//! with one typed field per property, `NAME`, `DOC`, `PROPS`, `build`) and
22//! registers it (`yog_api::ui_component!`) — this module is that code's
23//! runtime: the registry, [`Props`] (the instance's attribute values, read
24//! back typed), [`bind`] (binding conversions) and [`designer`], Yog-IDLE's
25//! door into a mod built for the host machine.
26//!
27//! A screen uses a component like any element:
28//! `<StatBar x="8" y="8" w="120" label="Mana" value="0.3"/>` becomes
29//! `component::build("StatBar", &[("label", "Mana"), ("value", "0.3")])`
30//! followed by the builder calls for the common attributes (`x`, `y`, `w`,
31//! `h`, `bg`, `on_click`, ...).
32
33use std::collections::HashMap;
34use std::sync::{Mutex, PoisonError};
35
36use crate::widget::{self, Widget};
37use crate::FlexDir;
38
39/// A property's type: how `.yui` writes it and how the designer edits it.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PropKind {
42    /// Free text (`@key` is a translation key, as for built-in `text`).
43    Text,
44    /// A number (`1.5`).
45    Number,
46    /// A whole number (`3`).
47    Int,
48    /// `true` / `false`.
49    Bool,
50    /// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB`.
51    Color,
52    /// One of the listed words.
53    Choice(&'static [&'static str]),
54    /// An action name (like `on_click`): the component hands it to a
55    /// widget's `on_click`, and the screen's code-behind gets the event.
56    Event,
57    /// A Yog image source (`item:apple`, `mymod:gui/logo`, ...).
58    Image,
59}
60
61impl PropKind {
62    /// The designer's name for the kind.
63    pub fn as_str(self) -> &'static str {
64        match self {
65            PropKind::Text => "text",
66            PropKind::Number => "number",
67            PropKind::Int => "int",
68            PropKind::Bool => "bool",
69            PropKind::Color => "color",
70            PropKind::Choice(_) => "choice",
71            PropKind::Event => "event",
72            PropKind::Image => "image",
73        }
74    }
75}
76
77/// One property of a component.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct Prop {
80    pub name: &'static str,
81    pub kind: PropKind,
82    /// The value when the element doesn't set it, as `.yui` would write it.
83    pub default: &'static str,
84    /// Shown in the designer's property grid.
85    pub doc: &'static str,
86}
87
88impl Prop {
89    pub const fn new(name: &'static str, kind: PropKind, default: &'static str) -> Self {
90        Self { name, kind, default, doc: "" }
91    }
92    pub const fn text(name: &'static str, default: &'static str) -> Self {
93        Self::new(name, PropKind::Text, default)
94    }
95    pub const fn number(name: &'static str, default: &'static str) -> Self {
96        Self::new(name, PropKind::Number, default)
97    }
98    pub const fn int(name: &'static str, default: &'static str) -> Self {
99        Self::new(name, PropKind::Int, default)
100    }
101    pub const fn bool(name: &'static str, default: bool) -> Self {
102        Self::new(name, PropKind::Bool, if default { "true" } else { "false" })
103    }
104    pub const fn color(name: &'static str, default: &'static str) -> Self {
105        Self::new(name, PropKind::Color, default)
106    }
107    pub const fn choice(name: &'static str, options: &'static [&'static str], default: &'static str) -> Self {
108        Self::new(name, PropKind::Choice(options), default)
109    }
110    pub const fn event(name: &'static str) -> Self {
111        Self::new(name, PropKind::Event, "")
112    }
113    pub const fn image(name: &'static str, default: &'static str) -> Self {
114        Self::new(name, PropKind::Image, default)
115    }
116    /// Adds the property grid's description.
117    pub const fn doc(self, doc: &'static str) -> Self {
118        Self { doc, ..self }
119    }
120}
121
122/// A custom component — implemented by the code `yog build` generates
123/// from a `<Component>` `.yui` (see the [module docs](self)).
124pub trait Component {
125    /// The `.yui` element name (`<StatBar …/>`), PascalCase.
126    const NAME: &'static str;
127    /// One line for the designer's toolbox tooltip.
128    const DOC: &'static str = "";
129    const PROPS: &'static [Prop] = &[];
130    /// The widget tree for these properties. Common attributes (position,
131    /// size, `bg`, `on_click`, ...) are applied to the result by the caller.
132    fn build(props: &Props) -> Widget;
133}
134
135/// A registered component (see [`register`]).
136#[derive(Debug, Clone, Copy)]
137pub struct ComponentSpec {
138    pub name: &'static str,
139    pub doc: &'static str,
140    pub props: &'static [Prop],
141    pub build: fn(&Props) -> Widget,
142}
143
144impl ComponentSpec {
145    pub fn of<C: Component>() -> Self {
146        Self { name: C::NAME, doc: C::DOC, props: C::PROPS, build: C::build }
147    }
148}
149
150static REGISTRY: Mutex<Vec<ComponentSpec>> = Mutex::new(Vec::new());
151
152/// Adds `C` to this library's components (a later registration of the same
153/// name replaces the earlier one). `yog_api::ui_component!` calls this when
154/// the mod's library loads.
155pub fn register<C: Component>() {
156    register_spec(ComponentSpec::of::<C>());
157}
158
159pub fn register_spec(spec: ComponentSpec) {
160    let mut all = REGISTRY.lock().unwrap_or_else(PoisonError::into_inner);
161    all.retain(|s| s.name != spec.name);
162    all.push(spec);
163}
164
165/// Every registered component, in registration order.
166pub fn components() -> Vec<ComponentSpec> {
167    REGISTRY.lock().unwrap_or_else(PoisonError::into_inner).clone()
168}
169
170/// The registered component `name`.
171pub fn find(name: &str) -> Option<ComponentSpec> {
172    REGISTRY.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|s| s.name == name).copied()
173}
174
175/// The component `name` built from `.yui` attribute values (what generated
176/// screen code calls). An unknown name gives a red error box rather than a
177/// panic, so a screen with a typo still opens.
178pub fn build(name: &str, attrs: &[(&str, &str)]) -> Widget {
179    match find(name) {
180        Some(spec) => (spec.build)(&Props::new(spec.props, attrs)),
181        None => error_box(&format!("unknown component <{name}>")),
182    }
183}
184
185/// What [`build`] shows for a component it can't build.
186pub fn error_box(message: &str) -> Widget {
187    widget::panel(FlexDir::Column)
188        .bg(0xC0_400000)
189        .padding(2.0, 2.0, 2.0, 2.0)
190        .child(widget::label(message).color(0xFF_FF8080))
191        .min_w(16.0)
192        .min_h(12.0)
193}
194
195/// A component's property values: what the element set, else the defaults.
196#[derive(Debug, Clone, Default)]
197pub struct Props {
198    values: HashMap<String, String>,
199}
200
201impl Props {
202    /// `props`' defaults overridden by `attrs`.
203    pub fn new(props: &[Prop], attrs: &[(&str, &str)]) -> Self {
204        let mut values: HashMap<String, String> = props.iter().map(|p| (p.name.to_string(), p.default.to_string())).collect();
205        for (k, v) in attrs {
206            values.insert((*k).to_string(), (*v).to_string());
207        }
208        Self { values }
209    }
210
211    /// The raw value (`None`: neither set nor declared).
212    pub fn get(&self, name: &str) -> Option<&str> {
213        self.values.get(name).map(String::as_str)
214    }
215
216    /// A text value (`@@x` is the literal `@x`).
217    pub fn text(&self, name: &str) -> String {
218        let v = self.get(name).unwrap_or_default();
219        match v.strip_prefix("@@") {
220            Some(rest) => format!("@{rest}"),
221            None => v.to_string(),
222        }
223    }
224
225    /// A number (0 if missing or not a number).
226    pub fn number(&self, name: &str) -> f32 {
227        self.get(name).and_then(|v| v.trim().parse().ok()).unwrap_or(0.0)
228    }
229
230    /// A whole number (0 if missing or not one).
231    pub fn int(&self, name: &str) -> i64 {
232        self.get(name).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
233    }
234
235    /// `true` only for `true`.
236    pub fn bool(&self, name: &str) -> bool {
237        self.get(name).is_some_and(|v| v.trim() == "true")
238    }
239
240    /// A color `0xAARRGGBB` (opaque white if missing or malformed).
241    pub fn color(&self, name: &str) -> u32 {
242        self.get(name).and_then(parse_color).unwrap_or(0xFF_FFFFFF)
243    }
244
245    /// A choice / event / image value, trimmed.
246    pub fn word(&self, name: &str) -> &str {
247        self.get(name).map(str::trim).unwrap_or_default()
248    }
249}
250
251/// Conversions of `{x:Bind ...}` values to what a bound attribute takes
252/// (generated component code calls these).
253pub mod bind {
254    use std::fmt::Display;
255
256    use crate::Units;
257
258    /// `text`, `placeholder`, image sources, nested component properties.
259    pub fn text<T: Display + ?Sized>(v: &T) -> String {
260        v.to_string()
261    }
262
263    /// `bg`, `color`, `focus_color`, `tint`: `0xAARRGGBB`, or a `.yui` color string.
264    pub fn color<T: BindColor>(v: T) -> u32 {
265        v.to_argb()
266    }
267
268    /// `enabled`, `visible`, `shadow`.
269    pub fn flag(v: impl Into<bool>) -> bool {
270        v.into()
271    }
272
273    /// `count` of an `InvSlot`.
274    pub fn count<T: BindNumber>(v: T) -> u32 {
275        v.to_f32().max(0.0) as u32
276    }
277
278    /// `gap`, `flex`, `min_w`, `min_h`, `font_scale`.
279    pub fn number<T: BindNumber>(v: T) -> f32 {
280        v.to_f32()
281    }
282
283    /// `w`, `h`: a number of pixels, [`Units`], or a `.yui` size string (`"50%"`, `"auto"`).
284    pub fn units<T: BindUnits>(v: T) -> Units {
285        v.to_units()
286    }
287
288    pub trait BindColor {
289        fn to_argb(self) -> u32;
290    }
291    impl BindColor for u32 {
292        fn to_argb(self) -> u32 {
293            self
294        }
295    }
296    impl BindColor for &u32 {
297        fn to_argb(self) -> u32 {
298            *self
299        }
300    }
301    impl BindColor for &str {
302        fn to_argb(self) -> u32 {
303            super::parse_color(self).unwrap_or(0xFF_FFFFFF)
304        }
305    }
306    impl BindColor for &&str {
307        fn to_argb(self) -> u32 {
308            (*self).to_argb()
309        }
310    }
311    impl BindColor for &String {
312        fn to_argb(self) -> u32 {
313            self.as_str().to_argb()
314        }
315    }
316    impl BindColor for String {
317        fn to_argb(self) -> u32 {
318            self.as_str().to_argb()
319        }
320    }
321
322    pub trait BindNumber {
323        fn to_f32(self) -> f32;
324    }
325    macro_rules! numbers {
326        ($($t:ty),*) => {$(
327            impl BindNumber for $t {
328                fn to_f32(self) -> f32 {
329                    self as f32
330                }
331            }
332            impl BindNumber for &$t {
333                fn to_f32(self) -> f32 {
334                    *self as f32
335                }
336            }
337        )*};
338    }
339    numbers!(f32, f64, i32, i64, u32, u64, usize);
340
341    pub trait BindUnits {
342        fn to_units(self) -> Units;
343    }
344    impl<T: BindNumber> BindUnits for T {
345        fn to_units(self) -> Units {
346            Units::Px(self.to_f32())
347        }
348    }
349    impl BindUnits for Units {
350        fn to_units(self) -> Units {
351            self
352        }
353    }
354    impl BindUnits for &str {
355        fn to_units(self) -> Units {
356            let v = self.trim();
357            if v.is_empty() || v == "auto" {
358                Units::Auto
359            } else if let Some(p) = v.strip_suffix('%') {
360                Units::Percent(p.trim().parse().unwrap_or(0.0))
361            } else {
362                Units::Px(v.parse().unwrap_or(0.0))
363            }
364        }
365    }
366    impl BindUnits for &Units {
367        fn to_units(self) -> Units {
368            *self
369        }
370    }
371    impl BindUnits for &&str {
372        fn to_units(self) -> Units {
373            (*self).to_units()
374        }
375    }
376    impl BindUnits for &String {
377        fn to_units(self) -> Units {
378            self.as_str().to_units()
379        }
380    }
381}
382
383/// The attributes every `.yui` element takes (`yog build` applies them to
384/// a component's widget with builder calls — they never reach its
385/// [`Props`]).
386pub const COMMON_ATTRS: &[&str] = &[
387    "id", "x", "y", "w", "h", "min_w", "min_h", "flex", "dock", "gap", "padding", "bg", "color", "align", "font_scale",
388    "font", "shadow", "no_wrap", "ellipsis", "focus_style", "focus_color", "enabled", "on_click", "visible",
389];
390
391/// One common style attribute applied to `w` as generated code would
392/// (layout attributes — position, size, dock, flex — are left to the
393/// caller; malformed values are ignored).
394pub fn apply_style(w: Widget, key: &str, value: &str) -> Widget {
395    let v = value.trim();
396    let num = || v.parse::<f32>().ok();
397    match key {
398        "bg" => parse_color(v).map_or(w.clone(), |c| w.clone().bg(c)),
399        "color" => parse_color(v).map_or(w.clone(), |c| w.clone().color(c)),
400        "focus_color" => parse_color(v).map_or(w.clone(), |c| w.clone().focus_color(c)),
401        "min_w" => num().map_or(w.clone(), |n| w.clone().min_w(n)),
402        "min_h" => num().map_or(w.clone(), |n| w.clone().min_h(n)),
403        "gap" => num().map_or(w.clone(), |n| w.clone().gap(n)),
404        "font_scale" => num().map_or(w.clone(), |n| w.clone().font_scale(n)),
405        "font" => w.font(v),
406        "shadow" => w.shadow(v == "true"),
407        "enabled" => w.enabled(v != "false"),
408        "no_wrap" if v == "true" => w.no_wrap(),
409        "ellipsis" if v == "true" => w.ellipsis(),
410        "align" => w.align(match v {
411            "center" => crate::Align::Center,
412            "end" => crate::Align::End,
413            _ => crate::Align::Start,
414        }),
415        "padding" => {
416            let parts: Option<Vec<f32>> = v.split([' ', ',']).filter(|s| !s.is_empty()).map(|s| s.parse().ok()).collect();
417            match parts.as_deref() {
418                Some([a]) => w.padding(*a, *a, *a, *a),
419                Some([vv, hh]) => w.padding(*vv, *hh, *vv, *hh),
420                Some([t, r, b, l]) => w.padding(*t, *r, *b, *l),
421                _ => w,
422            }
423        }
424        _ => w,
425    }
426}
427
428/// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB` (as `.yui` colors).
429pub fn parse_color(value: &str) -> Option<u32> {
430    let v = value.trim();
431    let hex = v.strip_prefix('#').or_else(|| v.strip_prefix("0x")).or_else(|| v.strip_prefix("0X"))?.replace('_', "");
432    let n = u32::from_str_radix(&hex, 16).ok()?;
433    match hex.len() {
434        6 => Some(0xFF00_0000 | n),
435        8 => Some(n),
436        _ => None,
437    }
438}
439
440/// The screen designer's protocol (see the [module docs](self)): one C-ABI
441/// entry point, `yog_ui_designer`, taking a text request and answering
442/// through a write callback.
443///
444/// Requests (lines; values escaped with `\\` and `\n`):
445///
446/// - `describe` → JSON `{"abi":1,"components":[{"name","doc","props":[{"name","kind","default","doc","options"}]}]}`
447/// - `render` / `<name>` / `<w> <h> <avail_w> <avail_h>` / `key=value`… —
448///   builds the component, sizes it `w × h` (≤ 0: its natural size, within
449///   `avail_w × avail_h`), draws it at the origin through the `YogGfxApi`
450///   and answers `{"w":…,"h":…}`.
451///
452/// A failure (unknown component, panic in `build`) answers `false` and an
453/// error message.
454pub mod designer {
455    use std::ffi::c_void;
456    use std::fmt::Write as _;
457    use std::panic::{catch_unwind, AssertUnwindSafe};
458
459    use yog_abi::{YogGfxApi, YogStr};
460    use yog_gfx::GfxContext;
461
462    use super::{apply_style, components, find, PropKind, Props, COMMON_ATTRS};
463    use crate::widget::{self, Widget};
464    use crate::{FlexDir, UiRoot, Units};
465
466    /// Version of this protocol; the designer host refuses others.
467    pub const ABI: u32 = 1;
468
469    /// `write(user_data, text)`: the host's sink for the answer.
470    pub type WriteFn = unsafe extern "C" fn(user_data: *mut c_void, text: YogStr);
471
472    /// `export_mod!`'s `yog_ui_designer`.
473    ///
474    /// # Safety
475    /// `request` must be valid UTF-8 for the call; `gfx` a valid table (or
476    /// null for `describe`); `write` sound to call with `user_data`.
477    pub unsafe fn call(request: YogStr, gfx: *const YogGfxApi, user_data: *mut c_void, write: WriteFn) -> bool {
478        // SAFETY: the caller's contract.
479        let request = unsafe { request.as_str() };
480        let (ok, answer) = match catch_unwind(AssertUnwindSafe(|| answer(request, gfx))) {
481            Ok(Ok(a)) => (true, a),
482            Ok(Err(e)) => (false, e),
483            Err(panic) => (false, panic_message(&*panic)),
484        };
485        // SAFETY: the caller's contract.
486        unsafe { write(user_data, YogStr::from_str(&answer)) };
487        ok
488    }
489
490    fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
491        let msg = panic
492            .downcast_ref::<&str>()
493            .map(|s| s.to_string())
494            .or_else(|| panic.downcast_ref::<String>().cloned())
495            .unwrap_or_else(|| "unknown panic".into());
496        format!("the component panicked: {msg}")
497    }
498
499    fn answer(request: &str, gfx: *const YogGfxApi) -> Result<String, String> {
500        let mut lines = request.lines();
501        match lines.next().map(str::trim) {
502            Some("describe") => Ok(describe()),
503            Some("render") => {
504                if gfx.is_null() {
505                    return Err("render: no gfx table".into());
506                }
507                let name = lines.next().unwrap_or_default().trim().to_string();
508                let size: Vec<f32> = lines.next().unwrap_or_default().split_whitespace().filter_map(|n| n.parse().ok()).collect();
509                let [w, h, aw, ah] = size[..] else { return Err("render: expected `w h avail_w avail_h`".into()) };
510                let attrs: Vec<(String, String)> =
511                    lines.filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), unescape(v))).collect();
512                // SAFETY: non-null, valid for the call (the caller's contract).
513                let ctx = unsafe { GfxContext::from_raw(gfx) };
514                render(&ctx, &name, &attrs, [w, h, aw, ah])
515            }
516            other => Err(format!("unknown designer request {other:?}")),
517        }
518    }
519
520    fn render(ctx: &GfxContext, name: &str, attrs: &[(String, String)], [w, h, aw, ah]: [f32; 4]) -> Result<String, String> {
521        let spec = find(name).ok_or_else(|| format!("no component named {name}"))?;
522        type Attrs<'a> = Vec<(&'a str, &'a str)>;
523        let (common, own): (Attrs, Attrs) =
524            attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).partition(|(k, _)| COMMON_ATTRS.contains(k));
525        let mut root: Widget = (spec.build)(&Props::new(spec.props, &own));
526        for (k, v) in common {
527            root = apply_style(root, k, v);
528        }
529        if w > 0.0 {
530            root = root.w(Units::Px(w));
531        }
532        if h > 0.0 {
533            root = root.h(Units::Px(h));
534        }
535        let (aw, ah) = (aw.max(1.0), ah.max(1.0));
536        let screen = widget::panel(FlexDir::Column).w(Units::Px(aw)).h(Units::Px(ah)).child(root.at(0.0, 0.0));
537        // Measured like the game measures: through the host's text/image metrics.
538        crate::text::use_host_metrics(ctx);
539        crate::image::use_host_images(ctx);
540        let mut ui = UiRoot::new("yog:designer", screen);
541        ui.layout(aw, ah);
542        let rect = ui.layout_root.children.first().map(|n| n.rect).unwrap_or_default();
543        ui.render(ctx);
544        Ok(format!("{{\"w\":{},\"h\":{}}}", rect.w, rect.h))
545    }
546
547    fn describe() -> String {
548        let mut out = format!("{{\"abi\":{ABI},\"components\":[");
549        for (i, c) in components().iter().enumerate() {
550            if i > 0 {
551                out.push(',');
552            }
553            let _ = write!(out, "{{\"name\":{},\"doc\":{},\"props\":[", json(c.name), json(c.doc));
554            for (j, p) in c.props.iter().enumerate() {
555                if j > 0 {
556                    out.push(',');
557                }
558                let options = match p.kind {
559                    PropKind::Choice(o) => o.iter().map(|s| json(s)).collect::<Vec<_>>().join(","),
560                    _ => String::new(),
561                };
562                let _ = write!(
563                    out,
564                    "{{\"name\":{},\"kind\":{},\"default\":{},\"doc\":{},\"options\":[{options}]}}",
565                    json(p.name),
566                    json(p.kind.as_str()),
567                    json(p.default),
568                    json(p.doc)
569                );
570            }
571            out.push_str("]}");
572        }
573        out.push_str("]}");
574        out
575    }
576
577    /// A JSON string literal.
578    fn json(s: &str) -> String {
579        let mut out = String::with_capacity(s.len() + 2);
580        out.push('"');
581        for c in s.chars() {
582            match c {
583                '"' => out.push_str("\\\""),
584                '\\' => out.push_str("\\\\"),
585                '\n' => out.push_str("\\n"),
586                '\r' => out.push_str("\\r"),
587                '\t' => out.push_str("\\t"),
588                c if (c as u32) < 0x20 => {
589                    let _ = write!(out, "\\u{:04x}", c as u32);
590                }
591                c => out.push(c),
592            }
593        }
594        out.push('"');
595        out
596    }
597
598    /// A request value as written by [`escape`].
599    pub fn unescape(v: &str) -> String {
600        let mut out = String::with_capacity(v.len());
601        let mut chars = v.chars();
602        while let Some(c) = chars.next() {
603            if c != '\\' {
604                out.push(c);
605                continue;
606            }
607            match chars.next() {
608                Some('n') => out.push('\n'),
609                Some(other) => out.push(other),
610                None => out.push('\\'),
611            }
612        }
613        out
614    }
615
616    /// A request value: one line, `\\` and `\n` escaped.
617    pub fn escape(v: &str) -> String {
618        v.replace('\\', "\\\\").replace('\n', "\\n")
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    struct Badge;
627    impl Component for Badge {
628        const NAME: &'static str = "TestBadge";
629        const PROPS: &'static [Prop] = &[
630            Prop::text("label", "new"),
631            Prop::color("fill", "#FF3060C0").doc("Background"),
632            Prop::choice("size", &["small", "large"], "small"),
633        ];
634        fn build(p: &Props) -> Widget {
635            widget::label(p.text("label")).bg(p.color("fill")).padding(1.0, 2.0, 1.0, 2.0)
636        }
637    }
638
639    #[test]
640    fn props_fall_back_to_defaults() {
641        let p = Props::new(Badge::PROPS, &[("label", "@@x"), ("n", " 2.5 ")]);
642        assert_eq!(p.text("label"), "@x");
643        assert_eq!(p.color("fill"), 0xFF3060C0);
644        assert_eq!(p.word("size"), "small");
645        assert_eq!(p.number("n"), 2.5);
646        assert_eq!(p.int("missing"), 0);
647        assert!(!p.bool("missing"));
648    }
649
650    #[test]
651    fn builds_registered_components() {
652        register::<Badge>();
653        let w = build("TestBadge", &[("label", "hi")]);
654        assert!(matches!(&w.kind, crate::widget::WidgetKind::Label(t) if t == "hi"));
655        assert_eq!(w.style.bg, 0xFF3060C0);
656        let missing = build("Nope", &[]);
657        assert!(!missing.children.is_empty());
658    }
659
660    #[test]
661    fn describes_and_escapes() {
662        register::<Badge>();
663        let mut out = String::new();
664        unsafe extern "C" fn sink(ud: *mut std::ffi::c_void, s: yog_abi::YogStr) {
665            // SAFETY: `ud` is the `String` below; `s` valid for the call.
666            unsafe { (*(ud as *mut String)).push_str(s.as_str()) };
667        }
668        let req = "describe";
669        let ok = unsafe { designer::call(yog_abi::YogStr::from_str(req), std::ptr::null(), &mut out as *mut String as *mut _, sink) };
670        assert!(ok, "{out}");
671        assert!(out.contains("\"name\":\"TestBadge\""), "{out}");
672        assert!(out.contains("\"options\":[\"small\",\"large\"]"), "{out}");
673        assert_eq!(designer::unescape(&designer::escape("a\\b\nc")), "a\\b\nc");
674    }
675}