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