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 yog_sync::Mutex;
37
38use yog_gfx::Color;
39use yog_ids::{yui_attr as attr, yui_value as value};
40
41use crate::FlexDir;
42use crate::widget::{self, Widget};
43use yog_ids::prop_kind;
44
45/// A property's type: how `.yui` writes it and how the designer edits it.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PropKind {
48    /// Free text (`@key` is a translation key, as for built-in `text`).
49    Text,
50    /// A number (`1.5`).
51    Number,
52    /// A whole number (`3`).
53    Int,
54    /// `true` / `false`.
55    Bool,
56    /// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB`.
57    Color,
58    /// One of the listed words.
59    Choice(&'static [&'static str]),
60    /// An action name (like `on_click`): the component hands it to a
61    /// widget's `on_click`, and the screen's code-behind gets the event.
62    Event,
63    /// A Yog image source (`item:apple`, `mymod:gui/logo`, ...).
64    Image,
65}
66
67impl PropKind {
68    /// The designer's name for the kind.
69    pub fn as_str(self) -> &'static str {
70        match self {
71            PropKind::Text => prop_kind::TEXT,
72            PropKind::Number => prop_kind::NUMBER,
73            PropKind::Int => prop_kind::INT,
74            PropKind::Bool => prop_kind::BOOL,
75            PropKind::Color => prop_kind::COLOR,
76            PropKind::Choice(_) => prop_kind::CHOICE,
77            PropKind::Event => prop_kind::EVENT,
78            PropKind::Image => prop_kind::IMAGE,
79        }
80    }
81}
82
83/// One property of a component.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Prop {
86    pub name: &'static str,
87    pub kind: PropKind,
88    /// The value when the element doesn't set it, as `.yui` would write it.
89    pub default: &'static str,
90    /// Shown in the designer's property grid.
91    pub doc: &'static str,
92    /// The property grid's category for it (empty: the component's own).
93    pub category: &'static str,
94}
95
96impl Prop {
97    pub const fn new(name: &'static str, kind: PropKind, default: &'static str) -> Self {
98        Self {
99            name,
100            kind,
101            default,
102            doc: "",
103            category: "",
104        }
105    }
106    pub const fn text(name: &'static str, default: &'static str) -> Self {
107        Self::new(name, PropKind::Text, default)
108    }
109    pub const fn number(name: &'static str, default: &'static str) -> Self {
110        Self::new(name, PropKind::Number, default)
111    }
112    pub const fn int(name: &'static str, default: &'static str) -> Self {
113        Self::new(name, PropKind::Int, default)
114    }
115    pub const fn bool(name: &'static str, default: bool) -> Self {
116        Self::new(
117            name,
118            PropKind::Bool,
119            if default { value::TRUE } else { value::FALSE },
120        )
121    }
122    pub const fn color(name: &'static str, default: &'static str) -> Self {
123        Self::new(name, PropKind::Color, default)
124    }
125    pub const fn choice(
126        name: &'static str,
127        options: &'static [&'static str],
128        default: &'static str,
129    ) -> Self {
130        Self::new(name, PropKind::Choice(options), default)
131    }
132    pub const fn event(name: &'static str) -> Self {
133        Self::new(name, PropKind::Event, "")
134    }
135    pub const fn image(name: &'static str, default: &'static str) -> Self {
136        Self::new(name, PropKind::Image, default)
137    }
138    /// Adds the property grid's description.
139    pub const fn doc(self, doc: &'static str) -> Self {
140        Self { doc, ..self }
141    }
142    /// Puts it in a property grid category of its own.
143    pub const fn category(self, category: &'static str) -> Self {
144        Self { category, ..self }
145    }
146}
147
148/// A custom component — implemented by the code `yog build` generates
149/// from a `<Component>` `.yui` (see the [module docs](self)).
150pub trait Component {
151    /// The `.yui` element name (`<StatBar …/>`), PascalCase.
152    const NAME: &'static str;
153    /// Its description: the designer's toolbox tooltip and property grid
154    /// header. Texts here and below may be `@Set.key` resource references.
155    const DOC: &'static str = "";
156    /// The designer's toolbox label (empty: `NAME`).
157    const DISPLAY_NAME: &'static str = "";
158    /// The toolbox icon, a Yog image source (`item:…`, `sprite:…`, `mod:…`)
159    /// or `@Icons.x`; empty: a generic glyph.
160    const ICON: &'static str = "";
161    /// The toolbox category (empty: "<mod> Components").
162    const CATEGORY: &'static str = "";
163    /// Its size on the designer's canvas and when dropped (0: unset).
164    const DESIGN_SIZE: (f32, f32) = (0.0, 0.0);
165    /// Whether the toolbox offers it (false: internal to the mod's own markup).
166    const TOOLBOX: bool = true;
167    const PROPS: &'static [Prop] = &[];
168    /// The widget tree for these properties. Common attributes (position,
169    /// size, `bg`, `on_click`, ...) are applied to the result by the caller.
170    fn build(props: &Props) -> Widget;
171}
172
173/// A registered component (see [`register`]).
174#[derive(Debug, Clone, Copy)]
175pub struct ComponentSpec {
176    pub name: &'static str,
177    pub doc: &'static str,
178    pub display_name: &'static str,
179    pub icon: &'static str,
180    pub category: &'static str,
181    pub design_size: (f32, f32),
182    pub toolbox: bool,
183    pub props: &'static [Prop],
184    pub build: fn(&Props) -> Widget,
185}
186
187impl ComponentSpec {
188    pub fn of<C: Component>() -> Self {
189        Self {
190            name: C::NAME,
191            doc: C::DOC,
192            display_name: C::DISPLAY_NAME,
193            icon: C::ICON,
194            category: C::CATEGORY,
195            design_size: C::DESIGN_SIZE,
196            toolbox: C::TOOLBOX,
197            props: C::PROPS,
198            build: C::build,
199        }
200    }
201}
202
203static REGISTRY: Mutex<Vec<ComponentSpec>> = Mutex::new(Vec::new());
204
205/// Adds `C` to this library's components (a later registration of the same
206/// name replaces the earlier one). `yog_api::ui_component!` calls this when
207/// the mod's library loads.
208pub fn register<C: Component>() {
209    register_spec(ComponentSpec::of::<C>());
210}
211
212pub fn register_spec(spec: ComponentSpec) {
213    let mut all = REGISTRY.lock();
214    all.retain(|s| s.name != spec.name);
215    all.push(spec);
216}
217
218/// Every registered component, in registration order.
219pub fn components() -> Vec<ComponentSpec> {
220    REGISTRY.lock().clone()
221}
222
223/// The registered component `name`.
224pub fn find(name: &str) -> Option<ComponentSpec> {
225    REGISTRY.lock().iter().find(|s| s.name == name).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/// [`error_box`]'s look: a dark red box, light red words, the room around
239/// them, and its least size.
240const ERROR_BG: Color = Color(0xC0_400000);
241const ERROR_TEXT: Color = Color(0xFF_FF8080);
242const ERROR_PAD: f32 = 2.0;
243const ERROR_MIN: [f32; 2] = [16.0, 12.0];
244
245/// What [`build`] shows for a component it can't build.
246pub fn error_box(message: &str) -> Widget {
247    widget::panel(FlexDir::Column)
248        .bg(ERROR_BG)
249        .padding(ERROR_PAD, ERROR_PAD, ERROR_PAD, ERROR_PAD)
250        .child(widget::label(message).color(ERROR_TEXT))
251        .min_w(ERROR_MIN[0])
252        .min_h(ERROR_MIN[1])
253}
254
255/// A color value that is missing or malformed: opaque white.
256const FALLBACK_COLOR: Color = Color::WHITE;
257
258/// A component's property values: what the element set, else the defaults.
259#[derive(Debug, Clone, Default)]
260pub struct Props {
261    values: HashMap<String, String>,
262}
263
264impl Props {
265    /// `props`' defaults overridden by `attrs`.
266    pub fn new(props: &[Prop], attrs: &[(&str, &str)]) -> Self {
267        let mut values: HashMap<String, String> = props
268            .iter()
269            .map(|p| (p.name.to_string(), p.default.to_string()))
270            .collect();
271        for (k, v) in attrs {
272            values.insert((*k).to_string(), (*v).to_string());
273        }
274        Self { values }
275    }
276
277    /// The raw value (`None`: neither set nor declared).
278    pub fn get(&self, name: &str) -> Option<&str> {
279        self.values.get(name).map(String::as_str)
280    }
281
282    /// A text value (`@@x` is the literal `@x`).
283    pub fn text(&self, name: &str) -> String {
284        let v = self.get(name).unwrap_or_default();
285        match v.strip_prefix("@@") {
286            Some(rest) => format!("@{rest}"),
287            None => v.to_string(),
288        }
289    }
290
291    /// A number (0 if missing or not a number).
292    pub fn number(&self, name: &str) -> f32 {
293        self.get(name)
294            .and_then(|v| v.trim().parse().ok())
295            .unwrap_or(0.0)
296    }
297
298    /// A whole number (0 if missing or not one).
299    pub fn int(&self, name: &str) -> i64 {
300        self.get(name)
301            .and_then(|v| v.trim().parse().ok())
302            .unwrap_or(0)
303    }
304
305    /// `true` only for `true`.
306    pub fn bool(&self, name: &str) -> bool {
307        self.get(name).is_some_and(|v| v.trim() == value::TRUE)
308    }
309
310    /// A color (opaque white if missing or malformed).
311    pub fn color(&self, name: &str) -> Color {
312        self.get(name)
313            .and_then(parse_color)
314            .unwrap_or(FALLBACK_COLOR)
315    }
316
317    /// A choice / event / image value, trimmed.
318    pub fn word(&self, name: &str) -> &str {
319        self.get(name).map(str::trim).unwrap_or_default()
320    }
321}
322
323/// Conversions of `{x:Bind ...}` values to what a bound attribute takes
324/// (generated component code calls these).
325pub mod bind {
326    use std::fmt::Display;
327
328    use yog_gfx::Color;
329
330    use crate::Units;
331
332    /// `text`, `placeholder`, image sources, nested component properties.
333    pub fn text<T: Display + ?Sized>(v: &T) -> String {
334        v.to_string()
335    }
336
337    /// `bg`, `color`, `focus_color`, `tint`: a color, `0xAARRGGBB`, or a `.yui` color string.
338    pub fn color<T: BindColor>(v: T) -> Color {
339        v.to_color()
340    }
341
342    /// `enabled`, `visible`, `shadow`.
343    pub fn flag(v: impl Into<bool>) -> bool {
344        v.into()
345    }
346
347    /// `count` of an `InvSlot`.
348    pub fn count<T: BindNumber>(v: T) -> u32 {
349        v.to_f32().max(0.0) as u32
350    }
351
352    /// `gap`, `flex`, `min_w`, `min_h`, `font_scale`.
353    pub fn number<T: BindNumber>(v: T) -> f32 {
354        v.to_f32()
355    }
356
357    /// `w`, `h`: a number of pixels, [`Units`], or a `.yui` size string (`"50%"`, `"auto"`).
358    pub fn units<T: BindUnits>(v: T) -> Units {
359        v.to_units()
360    }
361
362    pub trait BindColor {
363        fn to_color(self) -> Color;
364    }
365    impl BindColor for Color {
366        fn to_color(self) -> Color {
367            self
368        }
369    }
370    impl BindColor for &Color {
371        fn to_color(self) -> Color {
372            *self
373        }
374    }
375    impl BindColor for u32 {
376        fn to_color(self) -> Color {
377            Color(self)
378        }
379    }
380    impl BindColor for &u32 {
381        fn to_color(self) -> Color {
382            Color(*self)
383        }
384    }
385    impl BindColor for &str {
386        fn to_color(self) -> Color {
387            super::parse_color(self).unwrap_or(super::FALLBACK_COLOR)
388        }
389    }
390    impl BindColor for &&str {
391        fn to_color(self) -> Color {
392            (*self).to_color()
393        }
394    }
395    impl BindColor for &String {
396        fn to_color(self) -> Color {
397            self.as_str().to_color()
398        }
399    }
400    impl BindColor for String {
401        fn to_color(self) -> Color {
402            self.as_str().to_color()
403        }
404    }
405
406    pub trait BindNumber {
407        fn to_f32(self) -> f32;
408    }
409    macro_rules! numbers {
410        ($($t:ty),*) => {$(
411            impl BindNumber for $t {
412                fn to_f32(self) -> f32 {
413                    self as f32
414                }
415            }
416            impl BindNumber for &$t {
417                fn to_f32(self) -> f32 {
418                    *self as f32
419                }
420            }
421        )*};
422    }
423    numbers!(f32, f64, i32, i64, u32, u64, usize);
424
425    pub trait BindUnits {
426        fn to_units(self) -> Units;
427    }
428    impl<T: BindNumber> BindUnits for T {
429        fn to_units(self) -> Units {
430            Units::Px(self.to_f32())
431        }
432    }
433    impl BindUnits for Units {
434        fn to_units(self) -> Units {
435            self
436        }
437    }
438    impl BindUnits for &str {
439        fn to_units(self) -> Units {
440            let v = self.trim();
441            if v.is_empty() || v == yog_ids::yui_value::AUTO {
442                Units::Auto
443            } else if let Some(p) = v.strip_suffix('%') {
444                Units::Percent(p.trim().parse().unwrap_or(0.0))
445            } else {
446                Units::Px(v.parse().unwrap_or(0.0))
447            }
448        }
449    }
450    impl BindUnits for &Units {
451        fn to_units(self) -> Units {
452            *self
453        }
454    }
455    impl BindUnits for &&str {
456        fn to_units(self) -> Units {
457            (*self).to_units()
458        }
459    }
460    impl BindUnits for &String {
461        fn to_units(self) -> Units {
462            self.as_str().to_units()
463        }
464    }
465}
466
467/// The attributes every `.yui` element takes (`yog build` applies them to
468/// a component's widget with builder calls — they never reach its
469/// [`Props`]).
470pub const COMMON_ATTRS: &[&str] = &[
471    attr::ID,
472    attr::X,
473    attr::Y,
474    attr::W,
475    attr::H,
476    attr::MIN_W,
477    attr::MIN_H,
478    attr::FLEX,
479    attr::DOCK,
480    attr::GAP,
481    attr::PADDING,
482    attr::BG,
483    attr::COLOR,
484    attr::ALIGN,
485    attr::FONT_SCALE,
486    attr::FONT,
487    attr::SHADOW,
488    attr::NO_WRAP,
489    attr::ELLIPSIS,
490    attr::FOCUS_STYLE,
491    attr::FOCUS_COLOR,
492    attr::ENABLED,
493    attr::ON_CLICK,
494    attr::VISIBLE,
495];
496
497/// One common style attribute applied to `w` as generated code would
498/// (layout attributes — position, size, dock, flex — are left to the
499/// caller; malformed values are ignored).
500pub fn apply_style(w: Widget, key: &str, value: &str) -> Widget {
501    match crate::attr::Attr::parse(key, value) {
502        Ok(Some(attr)) if attr.is_style() => attr.apply(w),
503        _ => match key {
504            attr::FONT => w.font(value.trim()),
505            _ => w,
506        },
507    }
508}
509
510/// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB` (as `.yui` colors), or a decimal
511/// `0xAARRGGBB` value (what a bound `u32` property arrives as).
512pub fn parse_color(value: &str) -> Option<Color> {
513    Color::parse(value)
514}
515
516pub mod designer;
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    struct Badge;
523    impl Component for Badge {
524        const NAME: &'static str = "TestBadge";
525        const ICON: &'static str = "item:apple";
526        const DESIGN_SIZE: (f32, f32) = (40.0, 12.0);
527        const PROPS: &'static [Prop] = &[
528            Prop::text("label", "new"),
529            Prop::color("fill", "#FF3060C0").doc("Background"),
530            Prop::choice("size", &["small", "large"], "small"),
531        ];
532        fn build(p: &Props) -> Widget {
533            widget::label(p.text("label"))
534                .bg(p.color("fill"))
535                .padding(1.0, 2.0, 1.0, 2.0)
536        }
537    }
538
539    #[test]
540    fn props_fall_back_to_defaults() {
541        let p = Props::new(Badge::PROPS, &[("label", "@@x"), ("n", " 2.5 ")]);
542        assert_eq!(p.text("label"), "@x");
543        assert_eq!(p.color("fill"), Color(0xFF3060C0));
544        assert_eq!(p.word("size"), "small");
545        assert_eq!(p.number("n"), 2.5);
546        assert_eq!(p.int("missing"), 0);
547        assert!(!p.bool("missing"));
548    }
549
550    #[test]
551    fn builds_registered_components() {
552        register::<Badge>();
553        let w = build("TestBadge", &[("label", "hi")]);
554        assert!(matches!(&w.kind, crate::widget::WidgetKind::Label(t) if t == "hi"));
555        assert_eq!(w.style.bg, Color(0xFF3060C0));
556        let missing = build("Nope", &[]);
557        assert!(!missing.children.is_empty());
558    }
559
560    #[test]
561    fn describes_its_components() {
562        register::<Badge>();
563        let mut out = Vec::new();
564        unsafe extern "C" fn sink(ud: *mut std::ffi::c_void, b: yog_abi::YogBytes) {
565            // SAFETY: `ud` is the `Vec` below; `b` valid for the call.
566            unsafe { (*(ud as *mut Vec<u8>)).extend_from_slice(b.as_slice()) };
567        }
568        let req = yog_data::to_bytes(&designer::Request::Describe);
569        let ok = unsafe {
570            designer::call(
571                yog_abi::YogBytes::new(&req),
572                std::ptr::null(),
573                &mut out as *mut Vec<u8> as *mut _,
574                sink,
575            )
576        };
577        let reply: designer::Reply = yog_data::read_bytes(&out).unwrap();
578        assert!(ok, "{reply:?}");
579        let Ok(designer::Answer::Described { components }) = reply else {
580            panic!("{reply:?}");
581        };
582        let badge = components.iter().find(|c| c.name == "TestBadge").unwrap();
583        assert_eq!(
584            (badge.icon.as_str(), badge.design_w, badge.design_h),
585            ("item:apple", 40.0, 12.0)
586        );
587        assert!(badge.toolbox && badge.category.is_empty());
588        assert!(badge.props.iter().any(|p| p.options == ["small", "large"]));
589    }
590}