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 crate::FlexDir;
39use crate::widget::{self, Widget};
40
41/// A property's type: how `.yui` writes it and how the designer edits it.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum PropKind {
44    /// Free text (`@key` is a translation key, as for built-in `text`).
45    Text,
46    /// A number (`1.5`).
47    Number,
48    /// A whole number (`3`).
49    Int,
50    /// `true` / `false`.
51    Bool,
52    /// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB`.
53    Color,
54    /// One of the listed words.
55    Choice(&'static [&'static str]),
56    /// An action name (like `on_click`): the component hands it to a
57    /// widget's `on_click`, and the screen's code-behind gets the event.
58    Event,
59    /// A Yog image source (`item:apple`, `mymod:gui/logo`, ...).
60    Image,
61}
62
63impl PropKind {
64    /// The designer's name for the kind.
65    pub fn as_str(self) -> &'static str {
66        match self {
67            PropKind::Text => "text",
68            PropKind::Number => "number",
69            PropKind::Int => "int",
70            PropKind::Bool => "bool",
71            PropKind::Color => "color",
72            PropKind::Choice(_) => "choice",
73            PropKind::Event => "event",
74            PropKind::Image => "image",
75        }
76    }
77}
78
79/// One property of a component.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct Prop {
82    pub name: &'static str,
83    pub kind: PropKind,
84    /// The value when the element doesn't set it, as `.yui` would write it.
85    pub default: &'static str,
86    /// Shown in the designer's property grid.
87    pub doc: &'static str,
88    /// The property grid's category for it (empty: the component's own).
89    pub category: &'static str,
90}
91
92impl Prop {
93    pub const fn new(name: &'static str, kind: PropKind, default: &'static str) -> Self {
94        Self {
95            name,
96            kind,
97            default,
98            doc: "",
99            category: "",
100        }
101    }
102    pub const fn text(name: &'static str, default: &'static str) -> Self {
103        Self::new(name, PropKind::Text, default)
104    }
105    pub const fn number(name: &'static str, default: &'static str) -> Self {
106        Self::new(name, PropKind::Number, default)
107    }
108    pub const fn int(name: &'static str, default: &'static str) -> Self {
109        Self::new(name, PropKind::Int, default)
110    }
111    pub const fn bool(name: &'static str, default: bool) -> Self {
112        Self::new(name, PropKind::Bool, if default { "true" } else { "false" })
113    }
114    pub const fn color(name: &'static str, default: &'static str) -> Self {
115        Self::new(name, PropKind::Color, default)
116    }
117    pub const fn choice(
118        name: &'static str,
119        options: &'static [&'static str],
120        default: &'static str,
121    ) -> Self {
122        Self::new(name, PropKind::Choice(options), default)
123    }
124    pub const fn event(name: &'static str) -> Self {
125        Self::new(name, PropKind::Event, "")
126    }
127    pub const fn image(name: &'static str, default: &'static str) -> Self {
128        Self::new(name, PropKind::Image, default)
129    }
130    /// Adds the property grid's description.
131    pub const fn doc(self, doc: &'static str) -> Self {
132        Self { doc, ..self }
133    }
134    /// Puts it in a property grid category of its own.
135    pub const fn category(self, category: &'static str) -> Self {
136        Self { category, ..self }
137    }
138}
139
140/// A custom component — implemented by the code `yog build` generates
141/// from a `<Component>` `.yui` (see the [module docs](self)).
142pub trait Component {
143    /// The `.yui` element name (`<StatBar …/>`), PascalCase.
144    const NAME: &'static str;
145    /// Its description: the designer's toolbox tooltip and property grid
146    /// header. Texts here and below may be `@Set.key` resource references.
147    const DOC: &'static str = "";
148    /// The designer's toolbox label (empty: `NAME`).
149    const DISPLAY_NAME: &'static str = "";
150    /// The toolbox icon, a Yog image source (`item:…`, `sprite:…`, `mod:…`)
151    /// or `@Icons.x`; empty: a generic glyph.
152    const ICON: &'static str = "";
153    /// The toolbox category (empty: "<mod> Components").
154    const CATEGORY: &'static str = "";
155    /// Its size on the designer's canvas and when dropped (0: unset).
156    const DESIGN_SIZE: (f32, f32) = (0.0, 0.0);
157    /// Whether the toolbox offers it (false: internal to the mod's own markup).
158    const TOOLBOX: bool = true;
159    const PROPS: &'static [Prop] = &[];
160    /// The widget tree for these properties. Common attributes (position,
161    /// size, `bg`, `on_click`, ...) are applied to the result by the caller.
162    fn build(props: &Props) -> Widget;
163}
164
165/// A registered component (see [`register`]).
166#[derive(Debug, Clone, Copy)]
167pub struct ComponentSpec {
168    pub name: &'static str,
169    pub doc: &'static str,
170    pub display_name: &'static str,
171    pub icon: &'static str,
172    pub category: &'static str,
173    pub design_size: (f32, f32),
174    pub toolbox: bool,
175    pub props: &'static [Prop],
176    pub build: fn(&Props) -> Widget,
177}
178
179impl ComponentSpec {
180    pub fn of<C: Component>() -> Self {
181        Self {
182            name: C::NAME,
183            doc: C::DOC,
184            display_name: C::DISPLAY_NAME,
185            icon: C::ICON,
186            category: C::CATEGORY,
187            design_size: C::DESIGN_SIZE,
188            toolbox: C::TOOLBOX,
189            props: C::PROPS,
190            build: C::build,
191        }
192    }
193}
194
195static REGISTRY: Mutex<Vec<ComponentSpec>> = Mutex::new(Vec::new());
196
197/// Adds `C` to this library's components (a later registration of the same
198/// name replaces the earlier one). `yog_api::ui_component!` calls this when
199/// the mod's library loads.
200pub fn register<C: Component>() {
201    register_spec(ComponentSpec::of::<C>());
202}
203
204pub fn register_spec(spec: ComponentSpec) {
205    let mut all = REGISTRY.lock().unwrap_or_else(PoisonError::into_inner);
206    all.retain(|s| s.name != spec.name);
207    all.push(spec);
208}
209
210/// Every registered component, in registration order.
211pub fn components() -> Vec<ComponentSpec> {
212    REGISTRY
213        .lock()
214        .unwrap_or_else(PoisonError::into_inner)
215        .clone()
216}
217
218/// The registered component `name`.
219pub fn find(name: &str) -> Option<ComponentSpec> {
220    REGISTRY
221        .lock()
222        .unwrap_or_else(PoisonError::into_inner)
223        .iter()
224        .find(|s| s.name == name)
225        .copied()
226}
227
228/// The component `name` built from `.yui` attribute values (what generated
229/// screen code calls). An unknown name gives a red error box rather than a
230/// panic, so a screen with a typo still opens.
231pub fn build(name: &str, attrs: &[(&str, &str)]) -> Widget {
232    match find(name) {
233        Some(spec) => (spec.build)(&Props::new(spec.props, attrs)),
234        None => error_box(&format!("unknown component <{name}>")),
235    }
236}
237
238/// What [`build`] shows for a component it can't build.
239pub fn error_box(message: &str) -> Widget {
240    widget::panel(FlexDir::Column)
241        .bg(0xC0_400000)
242        .padding(2.0, 2.0, 2.0, 2.0)
243        .child(widget::label(message).color(0xFF_FF8080))
244        .min_w(16.0)
245        .min_h(12.0)
246}
247
248/// A color value that is missing or malformed: opaque white.
249const FALLBACK_COLOR: u32 = 0xFF_FFFFFF;
250
251/// A component's property values: what the element set, else the defaults.
252#[derive(Debug, Clone, Default)]
253pub struct Props {
254    values: HashMap<String, String>,
255}
256
257impl Props {
258    /// `props`' defaults overridden by `attrs`.
259    pub fn new(props: &[Prop], attrs: &[(&str, &str)]) -> Self {
260        let mut values: HashMap<String, String> = props
261            .iter()
262            .map(|p| (p.name.to_string(), p.default.to_string()))
263            .collect();
264        for (k, v) in attrs {
265            values.insert((*k).to_string(), (*v).to_string());
266        }
267        Self { values }
268    }
269
270    /// The raw value (`None`: neither set nor declared).
271    pub fn get(&self, name: &str) -> Option<&str> {
272        self.values.get(name).map(String::as_str)
273    }
274
275    /// A text value (`@@x` is the literal `@x`).
276    pub fn text(&self, name: &str) -> String {
277        let v = self.get(name).unwrap_or_default();
278        match v.strip_prefix("@@") {
279            Some(rest) => format!("@{rest}"),
280            None => v.to_string(),
281        }
282    }
283
284    /// A number (0 if missing or not a number).
285    pub fn number(&self, name: &str) -> f32 {
286        self.get(name)
287            .and_then(|v| v.trim().parse().ok())
288            .unwrap_or(0.0)
289    }
290
291    /// A whole number (0 if missing or not one).
292    pub fn int(&self, name: &str) -> i64 {
293        self.get(name)
294            .and_then(|v| v.trim().parse().ok())
295            .unwrap_or(0)
296    }
297
298    /// `true` only for `true`.
299    pub fn bool(&self, name: &str) -> bool {
300        self.get(name).is_some_and(|v| v.trim() == "true")
301    }
302
303    /// A color `0xAARRGGBB` (opaque white if missing or malformed).
304    pub fn color(&self, name: &str) -> u32 {
305        self.get(name)
306            .and_then(parse_color)
307            .unwrap_or(FALLBACK_COLOR)
308    }
309
310    /// A choice / event / image value, trimmed.
311    pub fn word(&self, name: &str) -> &str {
312        self.get(name).map(str::trim).unwrap_or_default()
313    }
314}
315
316/// Conversions of `{x:Bind ...}` values to what a bound attribute takes
317/// (generated component code calls these).
318pub mod bind {
319    use std::fmt::Display;
320
321    use crate::Units;
322
323    /// `text`, `placeholder`, image sources, nested component properties.
324    pub fn text<T: Display + ?Sized>(v: &T) -> String {
325        v.to_string()
326    }
327
328    /// `bg`, `color`, `focus_color`, `tint`: `0xAARRGGBB`, or a `.yui` color string.
329    pub fn color<T: BindColor>(v: T) -> u32 {
330        v.to_argb()
331    }
332
333    /// `enabled`, `visible`, `shadow`.
334    pub fn flag(v: impl Into<bool>) -> bool {
335        v.into()
336    }
337
338    /// `count` of an `InvSlot`.
339    pub fn count<T: BindNumber>(v: T) -> u32 {
340        v.to_f32().max(0.0) as u32
341    }
342
343    /// `gap`, `flex`, `min_w`, `min_h`, `font_scale`.
344    pub fn number<T: BindNumber>(v: T) -> f32 {
345        v.to_f32()
346    }
347
348    /// `w`, `h`: a number of pixels, [`Units`], or a `.yui` size string (`"50%"`, `"auto"`).
349    pub fn units<T: BindUnits>(v: T) -> Units {
350        v.to_units()
351    }
352
353    pub trait BindColor {
354        fn to_argb(self) -> u32;
355    }
356    impl BindColor for u32 {
357        fn to_argb(self) -> u32 {
358            self
359        }
360    }
361    impl BindColor for &u32 {
362        fn to_argb(self) -> u32 {
363            *self
364        }
365    }
366    impl BindColor for &str {
367        fn to_argb(self) -> u32 {
368            super::parse_color(self).unwrap_or(super::FALLBACK_COLOR)
369        }
370    }
371    impl BindColor for &&str {
372        fn to_argb(self) -> u32 {
373            (*self).to_argb()
374        }
375    }
376    impl BindColor for &String {
377        fn to_argb(self) -> u32 {
378            self.as_str().to_argb()
379        }
380    }
381    impl BindColor for String {
382        fn to_argb(self) -> u32 {
383            self.as_str().to_argb()
384        }
385    }
386
387    pub trait BindNumber {
388        fn to_f32(self) -> f32;
389    }
390    macro_rules! numbers {
391        ($($t:ty),*) => {$(
392            impl BindNumber for $t {
393                fn to_f32(self) -> f32 {
394                    self as f32
395                }
396            }
397            impl BindNumber for &$t {
398                fn to_f32(self) -> f32 {
399                    *self as f32
400                }
401            }
402        )*};
403    }
404    numbers!(f32, f64, i32, i64, u32, u64, usize);
405
406    pub trait BindUnits {
407        fn to_units(self) -> Units;
408    }
409    impl<T: BindNumber> BindUnits for T {
410        fn to_units(self) -> Units {
411            Units::Px(self.to_f32())
412        }
413    }
414    impl BindUnits for Units {
415        fn to_units(self) -> Units {
416            self
417        }
418    }
419    impl BindUnits for &str {
420        fn to_units(self) -> Units {
421            let v = self.trim();
422            if v.is_empty() || v == "auto" {
423                Units::Auto
424            } else if let Some(p) = v.strip_suffix('%') {
425                Units::Percent(p.trim().parse().unwrap_or(0.0))
426            } else {
427                Units::Px(v.parse().unwrap_or(0.0))
428            }
429        }
430    }
431    impl BindUnits for &Units {
432        fn to_units(self) -> Units {
433            *self
434        }
435    }
436    impl BindUnits for &&str {
437        fn to_units(self) -> Units {
438            (*self).to_units()
439        }
440    }
441    impl BindUnits for &String {
442        fn to_units(self) -> Units {
443            self.as_str().to_units()
444        }
445    }
446}
447
448/// The attributes every `.yui` element takes (`yog build` applies them to
449/// a component's widget with builder calls — they never reach its
450/// [`Props`]).
451pub const COMMON_ATTRS: &[&str] = &[
452    "id",
453    "x",
454    "y",
455    "w",
456    "h",
457    "min_w",
458    "min_h",
459    "flex",
460    "dock",
461    "gap",
462    "padding",
463    "bg",
464    "color",
465    "align",
466    "font_scale",
467    "font",
468    "shadow",
469    "no_wrap",
470    "ellipsis",
471    "focus_style",
472    "focus_color",
473    "enabled",
474    "on_click",
475    "visible",
476];
477
478/// One common style attribute applied to `w` as generated code would
479/// (layout attributes — position, size, dock, flex — are left to the
480/// caller; malformed values are ignored).
481pub fn apply_style(w: Widget, key: &str, value: &str) -> Widget {
482    let v = value.trim();
483    let num = || v.parse::<f32>().ok();
484    // `set` applied when the value parsed, else `w` as it is.
485    fn with<T>(w: Widget, value: Option<T>, set: fn(Widget, T) -> Widget) -> Widget {
486        match value {
487            Some(v) => set(w, v),
488            None => w,
489        }
490    }
491    match key {
492        "bg" => with(w, parse_color(v), Widget::bg),
493        "color" => with(w, parse_color(v), Widget::color),
494        "focus_color" => with(w, parse_color(v), Widget::focus_color),
495        "min_w" => with(w, num(), Widget::min_w),
496        "min_h" => with(w, num(), Widget::min_h),
497        "gap" => with(w, num(), Widget::gap),
498        "font_scale" => with(w, num(), Widget::font_scale),
499        "font" => w.font(v),
500        "shadow" => w.shadow(v == "true"),
501        "enabled" => w.enabled(v != "false"),
502        "no_wrap" if v == "true" => w.no_wrap(),
503        "ellipsis" if v == "true" => w.ellipsis(),
504        "align" => w.align(match v {
505            "center" => crate::Align::Center,
506            "end" => crate::Align::End,
507            _ => crate::Align::Start,
508        }),
509        "padding" => {
510            let parts: Option<Vec<f32>> = v
511                .split([' ', ','])
512                .filter(|s| !s.is_empty())
513                .map(|s| s.parse().ok())
514                .collect();
515            match parts.as_deref() {
516                Some([a]) => w.padding(*a, *a, *a, *a),
517                Some([vv, hh]) => w.padding(*vv, *hh, *vv, *hh),
518                Some([t, r, b, l]) => w.padding(*t, *r, *b, *l),
519                _ => w,
520            }
521        }
522        _ => w,
523    }
524}
525
526/// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB` (as `.yui` colors), or a decimal
527/// `0xAARRGGBB` value (what a bound `u32` property arrives as).
528pub fn parse_color(value: &str) -> Option<u32> {
529    let v = value.trim();
530    if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
531        return v.parse().ok();
532    }
533    let hex = v
534        .strip_prefix('#')
535        .or_else(|| v.strip_prefix("0x"))
536        .or_else(|| v.strip_prefix("0X"))?
537        .replace('_', "");
538    let n = u32::from_str_radix(&hex, 16).ok()?;
539    match hex.len() {
540        6 => Some(0xFF00_0000 | n),
541        8 => Some(n),
542        _ => None,
543    }
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"), 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, 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}