Skip to main content

yog_cli/
yui_component.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::yui_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="StatBar" 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 crate::yui::{self, Element, RAW_CALL, RAW_VALUE, UI_USE, err};
35
36/// Index of the components without a code-behind, included by `ui_components!()`.
37pub const INDEX_FILE: &str = ".yog-build/generated/components.g.rs";
38
39/// The property types and their Rust field types.
40const KINDS: &[(&str, &str)] = &[
41    ("text", "String"),
42    ("number", "f32"),
43    ("int", "i64"),
44    ("bool", "bool"),
45    ("color", "u32"),
46    ("choice", "String"),
47    ("image", "String"),
48];
49
50#[derive(Debug, Clone)]
51struct Prop {
52    name: String,
53    /// `text`, `number`, ..., or `event`.
54    kind: String,
55    default: String,
56    doc: String,
57    category: String,
58    options: Vec<String>,
59}
60
61/// The `<Component>` root's attributes (besides `d:*`, design-time only).
62const ROOT_ATTRS: &[&str] = &[
63    "name",
64    "display_name",
65    "description",
66    "icon",
67    "category",
68    "design_w",
69    "design_h",
70    "toolbox",
71];
72/// A `<Property>`'s / `<Event>`'s attributes.
73const PROP_ATTRS: &[&str] = &["name", "type", "default", "options", "doc", "category"];
74
75fn is_ident(s: &str) -> bool {
76    s.chars()
77        .next()
78        .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
79        && s.chars()
80            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
81}
82
83/// The component's type name: `name=`, else the file's PascalCase stem.
84fn type_name(root: &Element, include_name: &str) -> Result<String, String> {
85    let name = match root.attr("name").map(str::trim).filter(|s| !s.is_empty()) {
86        Some(n) => n.to_string(),
87        None => yui::pascal(include_name.rsplit('/').next().unwrap_or(include_name)),
88    };
89    let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
90        && name.chars().all(|c| c.is_ascii_alphanumeric());
91    if !ok {
92        return err(
93            root,
94            format_args!("name=\"{name}\": a component's name is PascalCase (StatBar)"),
95        );
96    }
97    Ok(name)
98}
99
100fn declarations(root: &Element) -> Result<(Vec<Prop>, Option<&Element>), String> {
101    let mut props: Vec<Prop> = Vec::new();
102    let mut body = None;
103    for child in &root.children {
104        match child.name.as_str() {
105            "Property" | "Event" => {
106                for (key, _) in &child.attrs {
107                    let allowed = if child.name == "Event" {
108                        &["name", "doc", "category"][..]
109                    } else {
110                        PROP_ATTRS
111                    };
112                    if !allowed.contains(&key.as_str()) {
113                        return err(
114                            child,
115                            format_args!("has no attribute `{key}` ({})", allowed.join(", ")),
116                        );
117                    }
118                }
119                let name = child
120                    .attr("name")
121                    .map(str::trim)
122                    .unwrap_or_default()
123                    .to_string();
124                if !is_ident(&name) {
125                    return err(
126                        child,
127                        format_args!("name=\"{name}\": expected a snake_case name"),
128                    );
129                }
130                if yui::is_common_attr(&name) {
131                    return err(
132                        child,
133                        format_args!(
134                            "`{name}` is an attribute every element has — pick another name"
135                        ),
136                    );
137                }
138                if props.iter().any(|p| p.name == name) {
139                    return err(child, format_args!("`{name}` is declared twice"));
140                }
141                let kind = if child.name == "Event" {
142                    "event".to_string()
143                } else {
144                    let kind = child.attr("type").unwrap_or("text").trim().to_string();
145                    if !KINDS.iter().any(|(k, _)| *k == kind) {
146                        let known: Vec<&str> = KINDS.iter().map(|(k, _)| *k).collect();
147                        return err(
148                            child,
149                            format_args!("type=\"{kind}\": expected one of {}", known.join(", ")),
150                        );
151                    }
152                    kind
153                };
154                let options: Vec<String> = child
155                    .attr("options")
156                    .unwrap_or_default()
157                    .split(',')
158                    .map(|s| s.trim().to_string())
159                    .filter(|s| !s.is_empty())
160                    .collect();
161                if kind == "choice" && options.is_empty() {
162                    return err(child, "a choice needs options=\"a, b, c\"");
163                }
164                let mut default = child
165                    .attr("default")
166                    .map(str::to_string)
167                    .unwrap_or_else(|| match kind.as_str() {
168                        "number" | "int" => "0".into(),
169                        "bool" => "false".into(),
170                        "color" => "#FFFFFFFF".into(),
171                        "choice" => options[0].clone(),
172                        _ => String::new(),
173                    });
174                match kind.as_str() {
175                    "number" if default.trim().parse::<f32>().is_err() => {
176                        return err(
177                            child,
178                            format_args!("default=\"{default}\": expected a number"),
179                        );
180                    }
181                    "int" if default.trim().parse::<i64>().is_err() => {
182                        return err(
183                            child,
184                            format_args!("default=\"{default}\": expected a whole number"),
185                        );
186                    }
187                    "bool" if !matches!(default.trim(), "true" | "false") => {
188                        return err(
189                            child,
190                            format_args!("default=\"{default}\": expected true or false"),
191                        );
192                    }
193                    "color" => {
194                        // A literal or a `.yres` color, stored as its value.
195                        let c = yui::color_attr(child, "default", &default)?;
196                        default = format!("#{c:08X}");
197                    }
198                    "choice" if !options.contains(&default) => {
199                        return err(
200                            child,
201                            format_args!("default=\"{default}\" isn't one of the options"),
202                        );
203                    }
204                    _ => {}
205                }
206                props.push(Prop {
207                    name,
208                    kind,
209                    default,
210                    doc: child.attr("doc").unwrap_or_default().to_string(),
211                    category: child.attr("category").unwrap_or_default().to_string(),
212                    options,
213                });
214            }
215            _ if body.is_some() => {
216                return err(
217                    child,
218                    "a component has one body element (wrap several in a Column, Row or Canvas)",
219                );
220            }
221            _ => body = Some(child),
222        }
223    }
224    Ok((props, body))
225}
226
227/// The body with every `{x:Bind ...}` attribute turned into a builder call
228/// (or, on a nested component, a Rust-valued property).
229fn bind_tree(el: &Element, counter: &mut usize) -> Result<Element, String> {
230    let kind = el.name.as_str();
231    let builtin = yui::is_builtin(kind);
232    if kind == "Component" || kind == "Screen" {
233        return err(el, "can't be inside a component");
234    }
235    let mut out = Element {
236        name: el.name.clone(),
237        attrs: Vec::new(),
238        children: Vec::new(),
239        line: el.line,
240    };
241    for (key, value) in &el.attrs {
242        if key.starts_with("d:") {
243            continue; // design-time only (the designer's sample values)
244        }
245        let Some(rust) = crate::yui_vm::compile_bind(value) else {
246            out.attrs.push((key.clone(), value.clone()));
247            continue;
248        };
249        let e = rust.map_err(|e| format!("line {}: <{kind}> {key}=\"{value}\": {e}", el.line))?;
250        let conv = "::yog_api::ui::component::bind";
251        if !builtin && !yui::is_common_attr(key) {
252            // A nested component's property: passed as text.
253            out.attrs
254                .push((key.clone(), format!("{RAW_VALUE}{conv}::text(&({e}))")));
255            continue;
256        }
257        let call = match key.as_str() {
258            "text" if matches!(kind, "Label" | "Button" | "TextBox") => {
259                format!("with_text({conv}::text(&({e})))")
260            }
261            "placeholder" if kind == "TextBox" => format!("placeholder({conv}::text(&({e})))"),
262            "source" if kind == "Surface" => {
263                out.attrs.push(("source".into(), "yog:none".into()));
264                format!("with_source({conv}::text(&({e})))")
265            }
266            "source" if kind == "Image" => {
267                // A valid stand-in for the static checks; replaced at build.
268                out.attrs.push(("source".into(), "item:air".into()));
269                format!("with_source({conv}::text(&({e})))")
270            }
271            "item" if matches!(kind, "ItemSlot" | "InvSlot") => {
272                format!("with_source({conv}::text(&({e})))")
273            }
274            "count" if kind == "InvSlot" => format!("with_count({conv}::count(&({e})))"),
275            "bg" | "color" | "focus_color" => format!("{key}({conv}::color(&({e})))"),
276            "tint" if kind == "Image" => format!("tint({conv}::color(&({e})))"),
277            "enabled" | "visible" | "shadow" => format!("{key}({conv}::flag({e}))"),
278            "w" | "h" => format!("{key}({conv}::units(&({e})))"),
279            "gap" | "flex" | "min_w" | "min_h" | "font_scale" => {
280                format!("{key}({conv}::number(&({e})))")
281            }
282            "on_click" | "on_change" | "on_submit" => format!("{key}({conv}::text(&({e})))"),
283            _ => {
284                return err(
285                    el,
286                    format_args!("{key}=\"{value}\": `{key}` can't be bound"),
287                );
288            }
289        };
290        out.attrs.push((format!("{RAW_CALL}{counter}"), call));
291        *counter += 1;
292    }
293    for child in &el.children {
294        out.children.push(bind_tree(child, counter)?);
295    }
296    Ok(out)
297}
298
299fn rust_str(s: &str) -> String {
300    format!("{s:?}")
301}
302
303/// The `.g.rs` source of a `<Component>` `.yui` (`name`: its path under
304/// `src/` without the extension, as for screens).
305pub fn generate(
306    root: &Element,
307    name: &str,
308    mod_id: Option<&str>,
309) -> Result<(String, Vec<String>), String> {
310    let ty = type_name(root, name)?;
311    for (key, _) in &root.attrs {
312        if !ROOT_ATTRS.contains(&key.as_str()) && !key.starts_with("d:") {
313            return err(
314                root,
315                format_args!("has no attribute `{key}` ({})", ROOT_ATTRS.join(", ")),
316            );
317        }
318    }
319    let size = |key: &str| -> Result<f32, String> {
320        match root.attr(key).map(str::trim) {
321            None | Some("") => Ok(0.0),
322            Some(v) => match v.parse::<f32>() {
323                Ok(n) if n >= 0.0 => Ok(n),
324                _ => err(
325                    root,
326                    format_args!("{key}=\"{v}\": expected a size in GUI pixels"),
327                ),
328            },
329        }
330    };
331    let design = (size("design_w")?, size("design_h")?);
332    let toolbox = match root.attr("toolbox").map(str::trim) {
333        None | Some("true") => true,
334        Some("false") => false,
335        Some(v) => {
336            return err(
337                root,
338                format_args!("toolbox=\"{v}\": expected true or false"),
339            );
340        }
341    };
342    let icon = root.attr("icon").unwrap_or_default().trim();
343    if !icon.is_empty() && !icon.starts_with('@') && !icon.contains(':') {
344        return err(
345            root,
346            format_args!(
347                "icon=\"{icon}\": expected an image source (item:apple, sprite:button, mod:gui/logo) or @Icons.name"
348            ),
349        );
350    }
351    let (props, body) = declarations(root)?;
352    let Some(body) = body else {
353        return err(
354            root,
355            "has no body — add one element (a Column, Row, Canvas, ...) after the declarations",
356        );
357    };
358    let mut counter = 0;
359    let bound = bind_tree(body, &mut counter)?;
360    let (tree, _ids) = yui::static_widget(&bound, false, 1, mod_id)?;
361    let doc = root.attr("description").unwrap_or_default();
362
363    let mut fields = String::new();
364    let mut init = String::new();
365    let mut decls = String::new();
366    for p in &props {
367        let rust_ty = KINDS
368            .iter()
369            .find(|(k, _)| *k == p.kind)
370            .map_or("String", |(_, t)| t);
371        if !p.doc.is_empty() {
372            let _ = writeln!(fields, "    /// {}", p.doc.replace('\n', " "));
373        }
374        let _ = writeln!(fields, "    pub {}: {rust_ty},", p.name);
375        let read = match p.kind.as_str() {
376            "text" => format!("p.text({})", rust_str(&p.name)),
377            "number" => format!("p.number({})", rust_str(&p.name)),
378            "int" => format!("p.int({})", rust_str(&p.name)),
379            "bool" => format!("p.bool({})", rust_str(&p.name)),
380            "color" => format!("p.color({})", rust_str(&p.name)),
381            _ => format!("p.word({}).to_string()", rust_str(&p.name)),
382        };
383        let _ = writeln!(init, "            {}: {read},", p.name);
384        let decl = match p.kind.as_str() {
385            "bool" => format!("Prop::bool({}, {})", rust_str(&p.name), p.default.trim()),
386            "event" => format!("Prop::event({})", rust_str(&p.name)),
387            "choice" => format!(
388                "Prop::choice({}, &[{}], {})",
389                rust_str(&p.name),
390                p.options
391                    .iter()
392                    .map(|o| rust_str(o))
393                    .collect::<Vec<_>>()
394                    .join(", "),
395                rust_str(&p.default)
396            ),
397            k => format!("Prop::{k}({}, {})", rust_str(&p.name), rust_str(&p.default)),
398        };
399        let decl = if p.doc.is_empty() {
400            decl
401        } else {
402            format!("{decl}.doc({})", rust_str(&p.doc))
403        };
404        let decl = if p.category.is_empty() {
405            decl
406        } else {
407            format!("{decl}.category({})", rust_str(&p.category))
408        };
409        let _ = writeln!(decls, "        {decl},");
410    }
411
412    let code = format!(
413        r#"// <auto-generated>
414//     Generated by yog from src/{name}.yui — do not edit: changes are lost
415//     on the next `yog build` / `yog check` / `yog ui gen`. Edit the .yui
416//     file (Yog-IDLE's designer) instead.
417// </auto-generated>
418
419/// {doc_line}
420#[derive(Debug, Clone, PartialEq)]
421pub struct {ty} {{
422{fields}}}
423
424impl {ty} {{
425    /// The property values of an instance (defaults for what it doesn't set).
426    #[allow(unused_variables)]
427    pub fn from_props(p: &::yog_api::ui::component::Props) -> Self {{
428        Self {{
429{init}        }}
430    }}
431
432    /// The component's widget tree for these values.
433    #[allow(unused_variables, clippy::needless_borrow, clippy::needless_borrows_for_generic_args)]
434    pub fn view(&self) -> ::yog_api::ui::Widget {{
435        #[allow(unused_imports)]
436        {UI_USE}
437        let vm = self;
438        {tree}
439    }}
440}}
441
442impl ::yog_api::ui::component::Component for {ty} {{
443    const NAME: &'static str = {name_lit};
444    const DOC: &'static str = {doc_lit};
445    const DISPLAY_NAME: &'static str = {display_lit};
446    const ICON: &'static str = {icon_lit};
447    const CATEGORY: &'static str = {category_lit};
448    const DESIGN_SIZE: (f32, f32) = ({design_w:?}, {design_h:?});
449    const TOOLBOX: bool = {toolbox};
450    const PROPS: &'static [::yog_api::ui::component::Prop] = {{
451        #[allow(unused_imports)]
452        use ::yog_api::ui::component::Prop;
453        &[
454{decls}        ]
455    }};
456    fn build(props: &::yog_api::ui::component::Props) -> ::yog_api::ui::Widget {{
457        Self::from_props(props).view()
458    }}
459}}
460
461::yog_api::ui_component!({ty});
462"#,
463        doc_line = if doc.is_empty() {
464            format!("The `{ty}` component.")
465        } else {
466            doc.replace('\n', " ")
467        },
468        name_lit = rust_str(&ty),
469        doc_lit = rust_str(doc),
470        display_lit = rust_str(root.attr("display_name").unwrap_or_default()),
471        icon_lit = rust_str(icon),
472        category_lit = rust_str(root.attr("category").unwrap_or_default()),
473        design_w = design.0,
474        design_h = design.1,
475    );
476    Ok((code, Vec::new()))
477}
478
479/// Whether `src` is a component `.yui` (its root is `<Component>`).
480fn is_component_file(path: &Path) -> bool {
481    std::fs::read_to_string(path)
482        .ok()
483        .and_then(|s| yui::parse_xml(&s).ok())
484        .is_some_and(|r| r.name == "Component")
485}
486
487/// Writes [`INDEX_FILE`]: a module per component `.yui` under `src/` that
488/// has no code-behind `.rs` next to it (those `ui_include!` themselves).
489/// Warns when there are such components but `src/lib.rs` doesn't call
490/// `yog_api::ui_components!()`.
491pub fn write_index(root: &Path) -> Result<PathBuf, String> {
492    let mut out = String::from(
493        "// <auto-generated> by yog: component .yui files without a code-behind,\n\
494         // compiled in by `yog_api::ui_components!()` — do not edit. </auto-generated>\n",
495    );
496    let mut count = 0;
497    for file in yui::find_yui_files(&root.join("src")) {
498        if file.with_extension("rs").is_file() || !is_component_file(&file) {
499            continue;
500        }
501        let name = yui::include_name(root, &file);
502        let module = name.replace(['/', '-'], "_");
503        let _ = writeln!(
504            out,
505            "#[allow(unused_imports)]\npub mod {module} {{\n    use super::*;\n    include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{name}.g.rs\"));\n}}"
506        );
507        count += 1;
508    }
509    if count > 0 {
510        let lib = std::fs::read_to_string(root.join("src/lib.rs")).unwrap_or_default();
511        if !lib.contains("ui_components!") {
512            eprintln!(
513                "    warning: {count} component(s) without a code-behind aren't compiled in — add `yog_api::ui_components!();` to src/lib.rs"
514            );
515        }
516    }
517    let path = root.join(INDEX_FILE);
518    crate::write_if_changed(&path, &out)?;
519    Ok(path)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    fn codegen(src: &str) -> Result<String, String> {
527        generate(&yui::parse_xml(src)?, "ui/stat_bar", None).map(|(c, _)| c)
528    }
529
530    #[test]
531    fn generates_a_component() {
532        let code = codegen(r##"<Component description="A bar." icon="@Icons.bar" category="Bars" design_w="120" design_h="10" d:bg="#FF000000">
533            <Property name="label" default="HP" doc="Left text" category="Text"/>
534            <Property name="value" type="number" default="0.5"/>
535            <Property name="fill" type="color" default="#FF55FF55"/>
536            <Property name="size" type="choice" options="small, big"/>
537            <Property name="wide" type="bool" default="true"/>
538            <Event name="on_select"/>
539            <Row w="100%" gap="4" on_click="{x:Bind on_select}">
540                <Label text="{x:Bind label}" d:text="Sample" visible="{x:Bind wide}"/>
541                <Spacer w="{x:Bind bar_w()}" h="6" bg="{x:Bind fill}"/>
542                <Pill text="{x:Bind label}" tone="red"/>
543            </Row>
544        </Component>"##)
545        .unwrap();
546        for want in [
547            "pub struct StatBar {",
548            "    /// Left text\n    pub label: String,",
549            "pub value: f32,",
550            "pub fill: u32,",
551            "label: p.text(\"label\"),",
552            "size: p.word(\"size\").to_string(),",
553            "Prop::text(\"label\", \"HP\").doc(\"Left text\").category(\"Text\")",
554            "const DOC: &'static str = \"A bar.\";",
555            "const ICON: &'static str = \"@Icons.bar\";",
556            "const CATEGORY: &'static str = \"Bars\";",
557            "const DESIGN_SIZE: (f32, f32) = (120.0, 10.0);",
558            "const TOOLBOX: bool = true;",
559            "Prop::choice(\"size\", &[\"small\", \"big\"], \"small\")",
560            "Prop::bool(\"wide\", true)",
561            "Prop::event(\"on_select\")",
562            "const NAME: &'static str = \"StatBar\";",
563            ".with_text(::yog_api::ui::component::bind::text(&(vm.label)))",
564            ".visible(::yog_api::ui::component::bind::flag(vm.wide))",
565            ".w(::yog_api::ui::component::bind::units(&(vm.bar_w())))",
566            ".bg(::yog_api::ui::component::bind::color(&(vm.fill)))",
567            ".on_click(::yog_api::ui::component::bind::text(&(vm.on_select)))",
568            "component::build(\"Pill\", &[(\"text\", &*::yog_api::ui::component::bind::text(&(vm.label))), (\"tone\", \"red\")])",
569            "::yog_api::ui_component!(StatBar);",
570        ] {
571            assert!(code.contains(want), "missing {want:?} in\n{code}");
572        }
573    }
574
575    #[test]
576    fn color_properties_take_yres_colors() {
577        let mut refs = crate::yres::Refs::default();
578        refs.colors.insert("Colors.fill".into(), 0xFF01_0203);
579        crate::yui::set_resource_refs(&refs);
580        let code = codegen(
581            r#"<Component><Property name="fill" type="color" default="@Colors.fill"/><Row bg="@Colors.fill"/></Component>"#,
582        );
583        let screen = crate::yui::generate_for(
584            r#"<Screen id="m:s" width="10" height="10"><Example fill="@Colors.fill"/></Screen>"#,
585            "s",
586            None,
587        );
588        crate::yui::set_resource_refs(&crate::yres::Refs::default());
589        let code = code.unwrap();
590        assert!(
591            code.contains(r##"Prop::color("fill", "#FF010203")"##),
592            "{code}"
593        );
594        assert!(code.contains(".bg(0xFF010203)"), "{code}");
595        let (screen, _) = screen.unwrap();
596        assert!(screen.contains(r##"("fill", "#FF010203")"##), "{screen}");
597    }
598
599    #[test]
600    fn rejects_bad_components() {
601        for (src, msg) in [
602            (r#"<Component name="bad"><Row/></Component>"#, "PascalCase"),
603            (
604                r#"<Component><Property name="w"/><Row/></Component>"#,
605                "every element has",
606            ),
607            (
608                r#"<Component><Property name="halign"/><Row/></Component>"#,
609                "every element has",
610            ),
611            (
612                r#"<Component><Property name="margin"/><Row/></Component>"#,
613                "every element has",
614            ),
615            (
616                r#"<Component><Property name="a" type="float"/><Row/></Component>"#,
617                "expected one of",
618            ),
619            (
620                r#"<Component><Property name="a" type="number" default="x"/><Row/></Component>"#,
621                "expected a number",
622            ),
623            (r#"<Component><Row/><Row/></Component>"#, "one body element"),
624            (
625                r#"<Component doc="x"><Row/></Component>"#,
626                "no attribute `doc`",
627            ),
628            (
629                r#"<Component design_w="wide"><Row/></Component>"#,
630                "expected a size",
631            ),
632            (
633                r#"<Component toolbox="no"><Row/></Component>"#,
634                "expected true or false",
635            ),
636            (
637                r#"<Component icon="apple"><Row/></Component>"#,
638                "image source",
639            ),
640            (
641                r#"<Component><Event name="on_x" type="text"/><Row/></Component>"#,
642                "no attribute `type`",
643            ),
644            (
645                r#"<Component><Property name="a"/></Component>"#,
646                "has no body",
647            ),
648            (
649                r#"<Component><Row padding="{x:Bind a}"/></Component>"#,
650                "can't be bound",
651            ),
652            (
653                r#"<Component><Row bg="{x:Bind cx.close()}"/></Component>"#,
654                "cx",
655            ),
656        ] {
657            let e = codegen(src).unwrap_err();
658            assert!(e.contains(msg), "{src}: {e}");
659        }
660    }
661
662    #[test]
663    fn index_lists_components_without_code_behind() {
664        let dir = std::env::temp_dir().join(format!("yog-components-{}", std::process::id()));
665        let _ = std::fs::remove_dir_all(&dir);
666        std::fs::create_dir_all(dir.join("src/ui")).unwrap();
667        std::fs::write(dir.join("yog.toml"), "").unwrap();
668        std::fs::write(
669            dir.join("src/ui/pill.yui"),
670            "<Component><Label/></Component>",
671        )
672        .unwrap();
673        std::fs::write(dir.join("src/bar.yui"), "<Component><Label/></Component>").unwrap();
674        std::fs::write(dir.join("src/bar.rs"), "").unwrap();
675        std::fs::write(dir.join("src/main.yui"), r#"<Screen id="a:b"/>"#).unwrap();
676        let index = std::fs::read_to_string(write_index(&dir).unwrap()).unwrap();
677        assert!(
678            index.contains("pub mod ui_pill {") && index.contains("/generated/ui/pill.g.rs"),
679            "{index}"
680        );
681        assert!(!index.contains("bar") && !index.contains("main"), "{index}");
682        let _ = std::fs::remove_dir_all(&dir);
683    }
684}