Skip to main content

yog_cli/ui/component/
mod.rs

1//! Component `.yui` files: a `<Component>` root declaring properties and
2//! events, with one body element bound to them through `{x:Bind ...}` (the
3//! same bindings as view-model screens, [`crate::ui::vm::compile_bind`]).
4//! Generates a struct with one typed field per property and its
5//! `yog_api::ui::component::Component` implementation, registered with
6//! `ui_component!`. See Yog-IDLE's `docs/yui.md`, "Components".
7//!
8//! ```xml
9//! <Component name=yog_ids::yui::STAT_BAR description="A labelled bar." icon="item:experience_bottle"
10//!            design_w="120" design_h="10">
11//!     <Property name="label" type="text" default="HP" doc="Shown on the left" category="Text"/>
12//!     <Property name="value" type="number" default="0.5"/>
13//!     <Event name="on_select"/>
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//! Bindings are evaluated once per instance, when the screen's tree is
22//! built. Methods they call (`bar_w()`) live in the optional code-behind
23//! `stat_bar.rs` (`yog_api::ui_include!("stat_bar"); impl StatBar { ... }`);
24//! components without one are compiled in by `yog_api::ui_components!()`.
25//!
26//! The root's designer metadata — `display_name`, `description`, `icon`,
27//! `category`, `design_w`/`design_h`, `toolbox` — goes into the
28//! implementation's constants as written (`@Set.key` references included),
29//! for Yog-IDLE's designer host.
30
31use std::fmt::Write as _;
32use std::path::{Path, PathBuf};
33
34use yog_ids::prop_kind as pk;
35use yog_ids::yui_attr as at;
36use yog_ids::yui_component as ca;
37
38use crate::ui::yui::{self, Element, RAW_CALL, RAW_VALUE, UI_USE, err};
39use yog_data::Diagnostic;
40
41/// Index of the components without a code-behind, included by `ui_components!()`.
42pub const INDEX_FILE: &str = yog_ids::concatcp!(
43    yog_ids::project::GENERATED,
44    yog_ids::sep::PATH,
45    "components.g.rs"
46);
47
48/// A property type: its word, its field's Rust type, and whether
49/// `Props` reads it by a method of its name (else as a word).
50struct KindSpec {
51    word: &'static str,
52    rust: &'static str,
53    typed: bool,
54}
55
56/// The property types.
57const KINDS: [KindSpec; 7] = [
58    KindSpec {
59        word: pk::TEXT,
60        rust: "String",
61        typed: true,
62    },
63    KindSpec {
64        word: pk::NUMBER,
65        rust: "f32",
66        typed: true,
67    },
68    KindSpec {
69        word: pk::INT,
70        rust: "i64",
71        typed: true,
72    },
73    KindSpec {
74        word: pk::BOOL,
75        rust: "bool",
76        typed: true,
77    },
78    KindSpec {
79        word: pk::COLOR,
80        rust: "u32",
81        typed: true,
82    },
83    KindSpec {
84        word: pk::CHOICE,
85        rust: "String",
86        typed: false,
87    },
88    KindSpec {
89        word: pk::IMAGE,
90        rust: "String",
91        typed: false,
92    },
93];
94
95/// A color property's value when it gives none: opaque white.
96const WHITE: &str = "#FFFFFFFF";
97/// What separates a choice's options.
98const OPTION_SEP: char = ',';
99
100#[derive(Debug, Clone)]
101struct Prop {
102    name: String,
103    /// `text`, `number`, ..., or `event`.
104    kind: String,
105    default: String,
106    doc: String,
107    category: String,
108    options: Vec<String>,
109}
110
111/// The `<Component>` root's attributes (besides `d:*`, design-time only).
112const ROOT_ATTRS: &[&str] = &[
113    ca::NAME,
114    ca::DISPLAY_NAME,
115    ca::DESCRIPTION,
116    ca::ICON,
117    ca::CATEGORY,
118    ca::DESIGN_W,
119    ca::DESIGN_H,
120    ca::TOOLBOX,
121];
122/// A `<Property>`'s attributes, and an `<Event>`'s.
123const PROP_ATTRS: &[&str] = &[
124    ca::NAME,
125    ca::TYPE,
126    ca::DEFAULT,
127    ca::OPTIONS,
128    ca::DOC,
129    ca::CATEGORY,
130];
131const EVENT_ATTRS: &[&str] = &[ca::NAME, ca::DOC, ca::CATEGORY];
132
133/// A property's name: snake_case.
134fn is_ident(s: &str) -> bool {
135    s.chars()
136        .next()
137        .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
138        && s.chars()
139            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
140}
141
142/// The component's type name: `name=`, else the file's PascalCase stem.
143fn type_name(root: &Element, include_name: &str) -> Result<String, Diagnostic> {
144    let name = match root.attr(ca::NAME).map(str::trim).filter(|s| !s.is_empty()) {
145        Some(n) => n.to_string(),
146        None => yog_codegen::pascal(
147            include_name
148                .rsplit(yog_ids::sep::PATH)
149                .next()
150                .unwrap_or(include_name),
151        ),
152    };
153    let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
154        && name.chars().all(|c| c.is_ascii_alphanumeric());
155    if !ok {
156        return err(
157            root,
158            format_args!("name=\"{name}\": a component's name is PascalCase (StatBar)"),
159        );
160    }
161    Ok(name)
162}
163
164/// A property's value when the `.yui` gives none, by its type.
165pub(crate) fn default_of(kind: &str, options: &[String]) -> String {
166    match kind {
167        pk::NUMBER | pk::INT => "0".into(),
168        pk::BOOL => yog_ids::yui_value::FALSE.into(),
169        pk::COLOR => WHITE.into(),
170        pk::CHOICE => options.first().cloned().unwrap_or_default(),
171        _ => String::new(),
172    }
173}
174
175/// A choice's options as written: `a, b, c`.
176pub(crate) fn options_of(written: Option<&str>) -> Vec<String> {
177    written
178        .unwrap_or_default()
179        .split(OPTION_SEP)
180        .map(|s| s.trim().to_string())
181        .filter(|s| !s.is_empty())
182        .collect()
183}
184
185/// The declarations, in order, and the one body element after them.
186fn declarations(root: &Element) -> Result<(Vec<Prop>, Option<&Element>), Diagnostic> {
187    let mut props: Vec<Prop> = Vec::new();
188    let mut body = None;
189    for child in &root.children {
190        if matches!(
191            child.name.as_str(),
192            yog_ids::yui::PROPERTY | yog_ids::yui::EVENT
193        ) {
194            let p = declaration(child)?;
195            if props.iter().any(|q| q.name == p.name) {
196                return err(child, format_args!("`{}` is declared twice", p.name));
197            }
198            props.push(p);
199        } else if body.is_some() {
200            return err(
201                child,
202                "a component has one body element (wrap several in a Column, Row or Canvas)",
203            );
204        } else {
205            body = Some(child);
206        }
207    }
208    Ok((props, body))
209}
210
211/// One `<Property>` or `<Event>`.
212fn declaration(child: &Element) -> Result<Prop, Diagnostic> {
213    let event = child.name == yog_ids::yui::EVENT;
214    let allowed = if event { EVENT_ATTRS } else { PROP_ATTRS };
215    if let Some((key, _)) = child
216        .attrs
217        .iter()
218        .find(|(k, _)| !allowed.contains(&k.as_str()))
219    {
220        return err(
221            child,
222            format_args!("has no attribute `{key}` ({})", allowed.join(", ")),
223        );
224    }
225    let name = child
226        .attr(ca::NAME)
227        .map(str::trim)
228        .unwrap_or_default()
229        .to_string();
230    if !is_ident(&name) {
231        return err(
232            child,
233            format_args!("name=\"{name}\": expected a snake_case name"),
234        );
235    }
236    if yui::is_common_attr(&name) {
237        return err(
238            child,
239            format_args!("`{name}` is an attribute every element has — pick another name"),
240        );
241    }
242    let kind = if event {
243        pk::EVENT.to_string()
244    } else {
245        declared_kind(child)?
246    };
247    let options = options_of(child.attr(ca::OPTIONS));
248    if kind == pk::CHOICE && options.is_empty() {
249        return err(child, "a choice needs options=\"a, b, c\"");
250    }
251    let default = checked_default(child, &kind, &options)?;
252    let text = |key: &str| child.attr(key).unwrap_or_default().to_string();
253    Ok(Prop {
254        name,
255        kind,
256        default,
257        doc: text(ca::DOC),
258        category: text(ca::CATEGORY),
259        options,
260    })
261}
262
263/// A `<Property>`'s type: one of [`KINDS`] (text when it gives none).
264fn declared_kind(child: &Element) -> Result<String, Diagnostic> {
265    let kind = child.attr(ca::TYPE).unwrap_or(pk::TEXT).trim().to_string();
266    if !KINDS.iter().any(|k| k.word == kind) {
267        let known: Vec<&str> = KINDS.iter().map(|k| k.word).collect();
268        return err(
269            child,
270            format_args!("type=\"{kind}\": expected one of {}", known.join(", ")),
271        );
272    }
273    Ok(kind)
274}
275
276/// A property's default — as written, or its type's — checked against its
277/// type; a color's as its value.
278fn checked_default(child: &Element, kind: &str, options: &[String]) -> Result<String, Diagnostic> {
279    let default = child
280        .attr(ca::DEFAULT)
281        .map_or_else(|| default_of(kind, options), str::to_string);
282    let v = default.trim();
283    let wrong = match kind {
284        pk::NUMBER => v.parse::<f32>().is_err().then_some("expected a number"),
285        pk::INT => v
286            .parse::<i64>()
287            .is_err()
288            .then_some("expected a whole number"),
289        pk::BOOL => (!matches!(v, yog_ids::yui_value::TRUE | yog_ids::yui_value::FALSE))
290            .then_some("expected true or false"),
291        // A literal or a `.yres` color, stored as its value.
292        pk::COLOR => {
293            return yui::color_attr(child, ca::DEFAULT, &default).map(|c| format!("#{c:08X}"));
294        }
295        pk::CHOICE => (!options.contains(&default)).then_some("isn't one of the options"),
296        _ => None,
297    };
298    match wrong {
299        Some(why) if kind == pk::CHOICE => err(child, format_args!("default=\"{default}\" {why}")),
300        Some(why) => err(child, format_args!("default=\"{default}\": {why}")),
301        None => Ok(default),
302    }
303}
304
305/// What `<Image>` and `<Surface>` show until a bound source is set — valid
306/// for the static checks.
307const IMAGE_STAND_IN: &str = "item:air";
308const SURFACE_STAND_IN: &str = "yog:none";
309/// The binding conversions of the API.
310const CONV: &str = "::yog_api::ui::component::bind";
311
312/// The body with every `{x:Bind ...}` attribute turned into a builder call
313/// (or, on a nested component, a Rust-valued property).
314fn bind_tree(el: &Element, counter: &mut usize) -> Result<Element, Diagnostic> {
315    let kind = el.name.as_str();
316    if kind == yog_ids::yui::COMPONENT || kind == yog_ids::yui::SCREEN {
317        return err(el, "can't be inside a component");
318    }
319    let mut out = Element {
320        name: el.name.clone(),
321        attrs: Vec::new(),
322        children: Vec::new(),
323        line: el.line,
324    };
325    // Design-time attributes (the designer's sample values) are left out.
326    for (key, value) in el
327        .attrs
328        .iter()
329        .filter(|(k, _)| !k.starts_with(yog_ids::yui_markup::DESIGN))
330    {
331        let Some(rust) = crate::ui::vm::compile_bind(value) else {
332            out.attrs.push((key.clone(), value.clone()));
333            continue;
334        };
335        let e = rust.map_err(|e| {
336            Diagnostic::from(format!("<{kind}> {key}=\"{value}\": {e}")).at_line(el.line)
337        })?;
338        if !yui::is_builtin(kind) && !yui::is_common_attr(key) {
339            // A nested component's property: passed as text.
340            out.attrs
341                .push((key.clone(), format!("{RAW_VALUE}{CONV}::text(&({e}))")));
342            continue;
343        }
344        if let Some(stand_in) = stand_in(kind, key) {
345            out.attrs.push((at::SOURCE.into(), stand_in.into()));
346        }
347        let call = bound_call(kind, key, &e).map_or_else(
348            || {
349                err(
350                    el,
351                    format_args!("{key}=\"{value}\": `{key}` can't be bound"),
352                )
353            },
354            Ok,
355        )?;
356        out.attrs.push((format!("{RAW_CALL}{counter}"), call));
357        *counter += 1;
358    }
359    for child in &el.children {
360        out.children.push(bind_tree(child, counter)?);
361    }
362    Ok(out)
363}
364
365/// The static source an element with a bound source shows until it's set.
366fn stand_in(kind: &str, key: &str) -> Option<&'static str> {
367    match (kind, key) {
368        (yog_ids::yui::SURFACE, at::SOURCE) => Some(SURFACE_STAND_IN),
369        (yog_ids::yui::IMAGE, at::SOURCE) => Some(IMAGE_STAND_IN),
370        _ => None,
371    }
372}
373
374/// The builder call setting attribute `key` of a `kind` element to the
375/// value of Rust expression `e`; none when it can't be bound.
376fn bound_call(kind: &str, key: &str, e: &str) -> Option<String> {
377    use yog_ids::yui as y;
378    let conv = |f: &str| format!("{CONV}::{f}(&({e}))");
379    Some(match key {
380        at::TEXT if matches!(kind, y::LABEL | y::BUTTON | y::TEXT_BOX) => {
381            format!("with_text({})", conv("text"))
382        }
383        at::PLACEHOLDER if kind == y::TEXT_BOX => format!("{key}({})", conv("text")),
384        at::SOURCE if matches!(kind, y::SURFACE | y::IMAGE) => {
385            format!("with_source({})", conv("text"))
386        }
387        at::ITEM if matches!(kind, y::ITEM_SLOT | y::INV_SLOT) => {
388            format!("with_source({})", conv("text"))
389        }
390        at::COUNT if kind == y::INV_SLOT => format!("with_count({})", conv("count")),
391        at::BG | at::COLOR | at::FOCUS_COLOR => format!("{key}({})", conv("color")),
392        at::TINT if kind == y::IMAGE => format!("{key}({})", conv("color")),
393        at::ENABLED | at::VISIBLE | at::SHADOW => format!("{key}({CONV}::flag({e}))"),
394        at::W | at::H => format!("{key}({})", conv("units")),
395        at::GAP | at::FLEX | at::MIN_W | at::MIN_H | at::FONT_SCALE => {
396            format!("{key}({})", conv("number"))
397        }
398        at::ON_CLICK | at::ON_CHANGE | at::ON_SUBMIT => format!("{key}({})", conv("text")),
399        _ => return None,
400    })
401}
402
403/// The root's designer metadata, as the implementation's constants say it.
404struct Meta<'a> {
405    doc: &'a str,
406    display_name: &'a str,
407    icon: &'a str,
408    category: &'a str,
409    design: (f32, f32),
410    toolbox: bool,
411}
412
413/// What an image source starts with or has, beside a `.yres` image (`@Icons.name`).
414const RES_MARK: char = '@';
415
416impl<'a> Meta<'a> {
417    fn read(root: &'a Element) -> Result<Meta<'a>, Diagnostic> {
418        if let Some((key, _)) = root.attrs.iter().find(|(k, _)| {
419            !ROOT_ATTRS.contains(&k.as_str()) && !k.starts_with(yog_ids::yui_markup::DESIGN)
420        }) {
421            return err(
422                root,
423                format_args!("has no attribute `{key}` ({})", ROOT_ATTRS.join(", ")),
424            );
425        }
426        let toolbox = match root.attr(ca::TOOLBOX).map(str::trim) {
427            None | Some(yog_ids::yui_value::TRUE) => true,
428            Some(yog_ids::yui_value::FALSE) => false,
429            Some(v) => {
430                return err(
431                    root,
432                    format_args!("toolbox=\"{v}\": expected true or false"),
433                );
434            }
435        };
436        let icon = root.attr(ca::ICON).unwrap_or_default().trim();
437        if !icon.is_empty()
438            && !icon.starts_with(RES_MARK)
439            && !icon.contains(yog_ids::sep::NAMESPACE)
440        {
441            return err(
442                root,
443                format_args!(
444                    "icon=\"{icon}\": expected an image source (item:apple, sprite:button, mod:gui/logo) or @Icons.name"
445                ),
446            );
447        }
448        let text = |key: &str| root.attr(key).unwrap_or_default();
449        Ok(Meta {
450            doc: text(ca::DESCRIPTION),
451            display_name: text(ca::DISPLAY_NAME),
452            icon,
453            category: text(ca::CATEGORY),
454            design: (
455                design_size(root, ca::DESIGN_W)?,
456                design_size(root, ca::DESIGN_H)?,
457            ),
458            toolbox,
459        })
460    }
461}
462
463/// A design size: GUI pixels, none for 0.
464fn design_size(root: &Element, key: &str) -> Result<f32, Diagnostic> {
465    match root.attr(key).map(str::trim) {
466        None | Some("") => Ok(0.0),
467        Some(v) => match v.parse::<f32>() {
468            Ok(n) if n >= 0.0 => Ok(n),
469            _ => err(
470                root,
471                format_args!("{key}=\"{v}\": expected a size in GUI pixels"),
472            ),
473        },
474    }
475}
476
477/// A property's field, its line of `from_props`, and its declaration.
478fn prop_code(p: &Prop) -> (String, String, String) {
479    let lit = |s: &str| format!("{s:?}");
480    let spec = KINDS.iter().find(|k| k.word == p.kind);
481    let mut field = String::new();
482    if !p.doc.is_empty() {
483        let _ = writeln!(field, "    /// {}", p.doc.replace('\n', " "));
484    }
485    let _ = writeln!(
486        field,
487        "    pub {}: {},",
488        p.name,
489        spec.map_or("String", |k| k.rust)
490    );
491    let read = match spec {
492        Some(k) if k.typed => format!("p.{}({})", k.word, lit(&p.name)),
493        _ => format!("p.word({}).to_string()", lit(&p.name)),
494    };
495    let init = format!("            {}: {read},\n", p.name);
496    let mut decl = match p.kind.as_str() {
497        pk::BOOL => format!("Prop::bool({}, {})", lit(&p.name), p.default.trim()),
498        pk::EVENT => format!("Prop::event({})", lit(&p.name)),
499        pk::CHOICE => format!(
500            "Prop::choice({}, &[{}], {})",
501            lit(&p.name),
502            p.options
503                .iter()
504                .map(|o| lit(o))
505                .collect::<Vec<_>>()
506                .join(", "),
507            lit(&p.default)
508        ),
509        k => format!("Prop::{k}({}, {})", lit(&p.name), lit(&p.default)),
510    };
511    if !p.doc.is_empty() {
512        let _ = write!(decl, ".doc({})", lit(&p.doc));
513    }
514    if !p.category.is_empty() {
515        let _ = write!(decl, ".category({})", lit(&p.category));
516    }
517    (field, init, format!("        {decl},\n"))
518}
519
520/// The `.g.rs` source of a `<Component>` `.yui` (`name`: its path under
521/// `src/` without the extension, as for screens).
522pub(crate) fn generate(
523    root: &Element,
524    name: &str,
525    mod_id: Option<&str>,
526) -> Result<(String, Vec<Diagnostic>), Diagnostic> {
527    let ty = type_name(root, name)?;
528    let meta = Meta::read(root)?;
529    let (props, body) = declarations(root)?;
530    let Some(body) = body else {
531        return err(
532            root,
533            "has no body — add one element (a Column, Row, Canvas, ...) after the declarations",
534        );
535    };
536    let bound = bind_tree(body, &mut 0)?;
537    let (tree, _ids) = yui::static_widget(&bound, false, 1, mod_id)?;
538    Ok((component_code(name, &ty, &meta, &props, &tree), Vec::new()))
539}
540
541/// The component's struct, its view, and its `Component` implementation.
542fn component_code(name: &str, ty: &str, meta: &Meta, props: &[Prop], tree: &str) -> String {
543    let (mut fields, mut init, mut decls) = (String::new(), String::new(), String::new());
544    for (f, i, d) in props.iter().map(prop_code) {
545        fields.push_str(&f);
546        init.push_str(&i);
547        decls.push_str(&d);
548    }
549    let lit = |s: &str| format!("{s:?}");
550    let doc_line = if meta.doc.is_empty() {
551        format!("The `{ty}` component.")
552    } else {
553        meta.doc.replace('\n', " ")
554    };
555    let values = [
556        ("HEADER", crate::ui::generated_header(name)),
557        ("NAME", name.to_string()),
558        ("DOC_LINE", doc_line),
559        ("TY", ty.to_string()),
560        ("FIELDS", fields),
561        ("INIT", init),
562        ("UI_USE", UI_USE.to_string()),
563        ("TREE", tree.to_string()),
564        ("NAME_LIT", lit(ty)),
565        ("DOC_LIT", lit(meta.doc)),
566        ("DISPLAY_LIT", lit(meta.display_name)),
567        ("ICON_LIT", lit(meta.icon)),
568        ("CATEGORY_LIT", lit(meta.category)),
569        ("DESIGN_W", format!("{:?}", meta.design.0)),
570        ("DESIGN_H", format!("{:?}", meta.design.1)),
571        ("TOOLBOX", meta.toolbox.to_string()),
572        ("DECLS", decls),
573    ];
574    crate::project::rust_code::fill(TEMPLATE, &values)
575}
576
577/// The generated code of a component, its `$NAME$`s to fill.
578const TEMPLATE: &str = include_str!("component.g.rs.in");
579/// Whether `src` is a component `.yui` (its root is `<Component>`).
580fn is_component_file(path: &Path) -> bool {
581    std::fs::read_to_string(path)
582        .ok()
583        .and_then(|s| yui::parse_xml(&s).ok())
584        .is_some_and(|r| r.name == yog_ids::yui::COMPONENT && !crate::ui::book_page::is_book_ui(&r))
585}
586
587/// What a mod's library says to compile in its components without a code-behind.
588const COMPONENTS_MACRO: &str = "ui_components!";
589
590/// Writes [`INDEX_FILE`]: a module per component `.yui` under `src/` that
591/// has no code-behind `.rs` next to it (those `ui_include!` themselves).
592/// With a warning when there are such components but `src/lib.rs` doesn't
593/// call `yog_api::ui_components!()`.
594pub fn write_index(root: &Path) -> Result<(PathBuf, Option<String>), Diagnostic> {
595    // The component .yui files without a code-behind, compiled in by
596    // `yog_api::ui_components!()`.
597    let mut out = crate::ui::generated_header("**/*");
598    let src = root.join(yog_ids::project::SRC);
599    let mut count = 0;
600    for file in yui::find_yui_files(&src) {
601        if file.with_extension("rs").is_file() || !is_component_file(&file) {
602            continue;
603        }
604        let name = yui::include_name(root, &file);
605        let module = yog_ids::rust_name(&name.replace(yog_ids::sep::PATH, yog_ids::sep::WORD));
606        let _ = writeln!(
607            out,
608            // The module's `use super::*` is for a component that names the crate's items; most don't.
609            "#[allow(unused_imports)]\npub mod {module} {{\n    use super::*;\n    include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{name}.g.rs\"));\n}}"
610        );
611        count += 1;
612    }
613    let lib = std::fs::read_to_string(src.join("lib.rs")).unwrap_or_default();
614    let warning = (count > 0 && !lib.contains(COMPONENTS_MACRO))
615        .then(|| format!("{count} component(s) without a code-behind aren't compiled in — add `yog_api::ui_components!();` to src/lib.rs"));
616    let path = root.join(INDEX_FILE);
617    crate::write_if_changed(&path, &out)?;
618    Ok((path, warning))
619}
620
621#[cfg(test)]
622mod tests;