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