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