Skip to main content

yog_cli/
yui.rs

1//! `.yui` screen files — the declarative form of a `yog-ui` widget tree that
2//! Yog-IDLE's screen designer edits — and their code generation.
3//!
4//! A `.yui` file is a small XAML-like XML document: one element per widget,
5//! attributes named after the `yog-ui` builder methods they become:
6//!
7//! ```xml
8//! <Screen id="mymod:main_menu" width="176" height="166" anchor="center" bg="#C0101010" padding="8">
9//!   <Label id="title" x="8" y="6" text="Main Menu" color="#FFFFFF" no_wrap="true"/>
10//!   <Button x="38" y="40" w="100" h="20" text="Play" bg="#FF555555" on_click="play"/>
11//!   <Column x="8" y="70" w="160" gap="2">
12//!     <Label text="Flowing child"/>
13//!   </Column>
14//! </Screen>
15//! ```
16//!
17//! `yog build` (and `yog check`, `yog ui gen`) turns `src/**/foo.yui` into
18//! `.yog-build/generated/**/foo.g.rs` — WPF's `MainWindow.g.cs`: generated
19//! into the build directory on every build, never committed, pulled into the
20//! screen's hand-written code-behind module with `yog_api::ui_include!("foo")`.
21//! See `docs/yui.md` in Yog-IDLE for the full format.
22
23use std::collections::HashSet;
24use std::fmt::Write as _;
25use std::path::{Path, PathBuf};
26
27use yog_gfx::image::ImageSource;
28
29// ── XML subset (yog-book's) ───────────────────────────────────────────────────
30
31pub use yog_book::ui::{Element, parse as parse_xml};
32
33// ── Screen model ──────────────────────────────────────────────────────────────
34
35/// The import generated widget-building code starts with.
36pub(crate) const UI_USE: &str =
37    "use ::yog_api::ui::{widget, Align, Dock, FlexDir, FocusStyle, Units};";
38
39/// Whether `<kind>` is a built-in element.
40pub(crate) fn is_builtin(kind: &str) -> bool {
41    CONTAINERS.contains(&kind) || LEAVES.contains(&kind)
42}
43
44/// Whether `<kind>` is a mod's own component (not a built-in element).
45pub(crate) fn is_custom_component(kind: &str) -> bool {
46    !is_builtin(kind) && is_component_name(kind)
47}
48
49/// `line N: <Kind> msg`.
50pub(crate) fn err<T>(el: &Element, msg: impl std::fmt::Display) -> Result<T, String> {
51    Err(format!("line {}: <{}> {msg}", el.line, el.name))
52}
53
54/// Whether `key` is an attribute every element takes (not a component's
55/// own property).
56pub(crate) fn is_common_attr(key: &str) -> bool {
57    COMMON_ATTRS.contains(&key) || key == "visible"
58}
59
60const CONTAINERS: &[&str] = &["Screen", "Canvas", "Column", "Row", "ScrollView", "Grid"];
61const LEAVES: &[&str] = &[
62    "Label", "Button", "TextBox", "ItemSlot", "InvSlot", "Slot", "PlayerInventory", "Image",
63    "Spacer", "Surface", "Machine",
64];
65const COMMON_ATTRS: &[&str] = &[
66    "id",
67    "x",
68    "y",
69    "w",
70    "h",
71    "min_w",
72    "min_h",
73    "flex",
74    "dock",
75    "gap",
76    "padding",
77    "bg",
78    "color",
79    "align",
80    "halign",
81    "valign",
82    "margin",
83    "font_scale",
84    "font",
85    "shadow",
86    "no_wrap",
87    "ellipsis",
88    "focus_style",
89    "focus_color",
90    "enabled",
91    "on_click",
92    "tooltip",
93];
94
95fn kind_attrs(kind: &str) -> &'static [&'static str] {
96    match kind {
97        "Screen" => &["width", "height", "anchor", "modal", "pause", "live", "theme"],
98        "Label" | "Button" => &["text"],
99        "TextBox" => &[
100            "text",
101            "placeholder",
102            "max_length",
103            "on_change",
104            "on_submit",
105        ],
106        "ItemSlot" => &["item"],
107        "InvSlot" => &["item", "count"],
108        "Slot" => &["index", "accepts"],
109        "Image" => &["source", "stretch", "uv", "tint"],
110        "Surface" => &["source"],
111        "Machine" => &["kind", "result", "places"],
112        _ => &[],
113    }
114}
115
116pub(crate) fn number(el: &Element, key: &str, value: &str) -> Result<f32, String> {
117    value
118        .trim()
119        .parse::<f32>()
120        .or_else(|_| err(el, format_args!("{key}=\"{value}\": expected a number")))
121}
122
123pub(crate) fn boolean(el: &Element, key: &str, value: &str) -> Result<bool, String> {
124    match value.trim() {
125        "true" => Ok(true),
126        "false" => Ok(false),
127        _ => err(
128            el,
129            format_args!("{key}=\"{value}\": expected true or false"),
130        ),
131    }
132}
133
134/// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB` → `0xAARRGGBB`.
135pub fn color(value: &str) -> Option<u32> {
136    let v = value.trim();
137    let hex = v
138        .strip_prefix('#')
139        .or_else(|| v.strip_prefix("0x"))
140        .or_else(|| v.strip_prefix("0X"))?
141        .replace('_', "");
142    let n = u32::from_str_radix(&hex, 16).ok()?;
143    match hex.len() {
144        6 => Some(0xFF00_0000 | n),
145        8 => Some(n),
146        _ => None,
147    }
148}
149
150/// A color attribute: a literal (see [`color`]) or `@<Set>.<name>`, a color
151/// of `resources/<Set>.yres` (see [`set_resource_refs`]).
152pub(crate) fn color_attr(el: &Element, key: &str, value: &str) -> Result<u32, String> {
153    if let Some(reference) = value.trim().strip_prefix('@') {
154        return resource_color(reference).ok_or_else(|| {
155            format!(
156                "line {}: <{}> {key}=\"{value}\": no color `{reference}` in resources/*.yres",
157                el.line, el.name
158            )
159        });
160    }
161    color(value).ok_or_else(|| {
162        format!(
163            "line {}: <{}> {key}=\"{value}\": expected #RRGGBB, #AARRGGBB or @<Set>.<name>",
164            el.line, el.name
165        )
166    })
167}
168
169/// Whether `@<Set>.<name>` names a text of the mod's `.yres` resources.
170fn is_resource_text(reference: &str) -> bool {
171    RESOURCE_REFS.with(|r| r.borrow().texts.contains_key(reference.trim()))
172}
173
174/// The color `@<Set>.<name>` names in the mod's `.yres` resources.
175fn resource_color(reference: &str) -> Option<u32> {
176    RESOURCE_REFS.with(|r| r.borrow().colors.get(reference.trim()).copied())
177}
178
179fn f(v: f32) -> String {
180    format!("{v:?}")
181}
182
183fn size(el: &Element, key: &str, value: &str) -> Result<Option<String>, String> {
184    let v = value.trim();
185    if v.is_empty() || v == "auto" {
186        return Ok(None);
187    }
188    if let Some(p) = v.strip_suffix('%') {
189        return Ok(Some(format!("Units::Percent({})", f(number(el, key, p)?))));
190    }
191    Ok(Some(format!("Units::Px({})", f(number(el, key, v)?))))
192}
193
194/// CSS-style shorthand: 1 value = all sides, 2 = vertical horizontal, 4 = top right bottom left.
195fn padding(el: &Element, key: &str, value: &str) -> Result<[f32; 4], String> {
196    let parts: Vec<f32> = value
197        .split([' ', ','])
198        .filter(|s| !s.is_empty())
199        .map(|s| number(el, key, s))
200        .collect::<Result<_, _>>()?;
201    match parts.as_slice() {
202        [a] => Ok([*a; 4]),
203        [v, h] => Ok([*v, *h, *v, *h]),
204        [t, r, b, l] => Ok([*t, *r, *b, *l]),
205        _ => Err(format!(
206            "line {}: <{}> {key}=\"{value}\": expected 1, 2 or 4 numbers",
207            el.line, el.name
208        )),
209    }
210}
211
212/// `MinHeight` → `min_height`.
213pub(crate) fn snake(name: &str) -> String {
214    let mut out = String::new();
215    for (i, c) in name.chars().enumerate() {
216        if c.is_ascii_uppercase() {
217            if i > 0 {
218                out.push('_');
219            }
220            out.push(c.to_ascii_lowercase());
221        } else {
222            out.push(c);
223        }
224    }
225    out
226}
227
228/// The `.yui` spelling of a WPF/WinUI attribute name, for error hints.
229fn wpf_hint(key: &str) -> Option<&'static str> {
230    Some(match key {
231        "HorizontalAlignment" => "halign",
232        "VerticalAlignment" => "valign",
233        "Margin" => "margin",
234        "Padding" => "padding",
235        "Width" => "w",
236        "Height" => "h",
237        "MinWidth" => "min_w",
238        "MinHeight" => "min_h",
239        "Background" => "bg",
240        "Foreground" => "color",
241        "Text" | "Content" => "text",
242        "RowDefinitions" => "rows",
243        "ColumnDefinitions" => "columns",
244        "RowSpacing" => "row_spacing",
245        "ColumnSpacing" => "column_spacing",
246        "Visibility" => "visible",
247        "IsEnabled" => "enabled",
248        _ => return None,
249    })
250}
251
252/// A `<Screen>`'s `width`, `height` (default 176 × 166, the vanilla
253/// container size) and `anchor` (default `center`).
254pub(crate) fn screen_frame(root: &Element) -> Result<(f32, f32, &'static str), String> {
255    let size = |key: &str, default: f32| {
256        root.attr(key)
257            .map(|v| number(root, key, v))
258            .transpose()
259            .map(|v| v.unwrap_or(default))
260    };
261    let anchor = pick(
262        root,
263        "anchor",
264        root.attr("anchor").unwrap_or("center"),
265        &[
266            "center",
267            "top_left",
268            "top",
269            "top_right",
270            "left",
271            "right",
272            "bottom_left",
273            "bottom",
274            "bottom_right",
275            "fill",
276        ],
277    )?;
278    Ok((size("width", 176.0)?, size("height", 166.0)?, anchor))
279}
280
281fn pick<'v>(el: &Element, key: &str, value: &str, options: &[&'v str]) -> Result<&'v str, String> {
282    options
283        .iter()
284        .find(|o| **o == value.trim())
285        .copied()
286        .ok_or_else(|| {
287            format!(
288                "line {}: <{}> {key}=\"{value}\": expected one of {}",
289                el.line,
290                el.name,
291                options.join(", ")
292            )
293        })
294}
295
296/// `stat_bar` / `my-mod` → `StatBar` / `MyMod`.
297pub fn pascal(value: &str) -> String {
298    value
299        .split(['_', '-'])
300        .map(|w| {
301            let mut c = w.chars();
302            c.next()
303                .map(|f| f.to_uppercase().collect::<String>() + c.as_str())
304                .unwrap_or_default()
305        })
306        .collect()
307}
308
309/// `<StatBar …/>`: a custom component's name (not a built-in element).
310fn is_component_name(kind: &str) -> bool {
311    kind.chars().next().is_some_and(|c| c.is_ascii_uppercase())
312        && kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
313}
314
315/// A custom component built by name from its own (non-common) attributes;
316/// `@key` values are translated like `text`.
317fn component_expr(el: &Element) -> Result<String, String> {
318    let props: Vec<String> = el
319        .attrs
320        .iter()
321        .filter(|(k, _)| !COMMON_ATTRS.contains(&k.as_str()))
322        .map(|(k, v)| {
323            if let Some(rust) = v.strip_prefix(RAW_VALUE) {
324                return Ok(format!("({k:?}, &*{rust})"));
325            }
326            match v.strip_prefix('@') {
327                // A `.yres` color (for a color property): its value.
328                Some(rest) if !is_resource_text(rest) && resource_color(rest).is_some() => {
329                    let c = resource_color(rest).unwrap_or_default();
330                    Ok(format!("({k:?}, \"#{c:08X}\")"))
331                }
332                Some(rest) if !rest.trim().is_empty() => Ok(format!("({k:?}, &*{})", text_attr(el, k, v)?)),
333                _ => Ok(format!("({k:?}, {v:?})")),
334            }
335        })
336        .collect::<Result<_, String>>()?;
337    Ok(format!(
338        "::yog_api::ui::component::build({:?}, &[{}])",
339        el.name,
340        props.join(", ")
341    ))
342}
343
344/// The Rust expression for a text attribute, its `@` forms as everywhere
345/// in Yog Data (`yog_data::typed`): `"@Strings.title"` is a text of
346/// `resources/Strings.yres` (see [`set_resource_refs`]), `"@lang:gui.done"`
347/// any translation key, `"@item:minecraft:stick"` (`@block:`, `@entity:`)
348/// that content's name — each looked up when the tree is built
349/// (`::yog_api::tr`, so it follows the game's language); `"@@..."` is a
350/// literal text starting with `@`; anything else is the literal text.
351pub fn text_expr(value: &str) -> Result<String, String> {
352    if value.trim() == "@" || !value.starts_with('@') {
353        return Ok(format!("{value:?}"));
354    }
355    let tr = |key: &str| format!("::yog_api::tr({key:?})");
356    match yog_data::typed::classify(value.trim()) {
357        Ok(yog_data::Value::Str(s)) => Ok(format!("{s:?}")),
358        Ok(yog_data::Value::Lang(key)) => Ok(tr(&key)),
359        Ok(yog_data::Value::Res(r)) => {
360            let name = format!("{}.{}", r.set, r.key);
361            RESOURCE_REFS
362                .with(|refs| refs.borrow().texts.get(&name).map(|(k, _)| k.clone()))
363                .map(|k| tr(&k))
364                .ok_or_else(|| format!("no text `{name}` in resources/*.yres"))
365        }
366        Ok(yog_data::Value::Ref(r)) if matches!(r.kind.as_str(), "item" | "block" | "entity") => {
367            let (ns, path) = r.id.split('[').next().unwrap_or(&r.id).split_once(':').unwrap_or(("minecraft", &r.id));
368            Ok(tr(&format!("{}.{ns}.{}", r.kind, path.replace('/', "."))))
369        }
370        Ok(other) => Err(format!("{} isn't a text", other.kind_name())),
371        Err(_) if !value.contains(':') => Err(format!(
372            "write a translation key as `@lang:{}`, a .yres text as `@Set.name`, a literal `@` as `@@`",
373            value[1..].trim()
374        )),
375        Err(e) => Err(e),
376    }
377}
378
379/// [`text_expr`] for attribute `key` of `el`, a problem saying where.
380pub(crate) fn text_attr(el: &Element, key: &str, value: &str) -> Result<String, String> {
381    text_expr(value).map_err(|e| format!("line {}: <{}> {key}=\"{value}\": {e}", el.line, el.name))
382}
383
384thread_local! {
385    /// The mod's `.yres` resources, for `@<Set>.<name>` in attributes.
386    static RESOURCE_REFS: std::cell::RefCell<crate::yres::Refs> = Default::default();
387}
388
389/// Makes the mod's compiled `.yres` resources visible to the generator:
390/// `text="@Strings.title"` and `source="@Icons.logo"` resolve through them.
391pub fn set_resource_refs(refs: &crate::yres::Refs) {
392    RESOURCE_REFS.with(|r| *r.borrow_mut() = refs.clone());
393}
394
395/// A Rust constant name for an event/widget id: `mymod:btn-play` → `MYMOD_BTN_PLAY`.
396pub fn const_name(value: &str) -> String {
397    let mut out = String::new();
398    let mut prev_lower = false;
399    for c in value.chars() {
400        if c.is_ascii_alphanumeric() {
401            if c.is_ascii_uppercase() && prev_lower {
402                out.push('_');
403            }
404            prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
405            out.push(c.to_ascii_uppercase());
406        } else {
407            if !out.ends_with('_') {
408                out.push('_');
409            }
410            prev_lower = false;
411        }
412    }
413    let out = out.trim_matches('_').to_string();
414    if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
415        format!("_{out}")
416    } else {
417        out
418    }
419}
420
421/// Names in first-seen order, each once.
422#[derive(Debug, Default)]
423pub(crate) struct Names {
424    list: Vec<String>,
425    seen: HashSet<String>,
426}
427
428impl Names {
429    pub(crate) fn push(&mut self, name: &str) {
430        if !self.seen.contains(name) {
431            self.seen.insert(name.to_string());
432            self.list.push(name.to_string());
433        }
434    }
435
436    pub(crate) fn as_slice(&self) -> &[String] {
437        &self.list
438    }
439
440    pub(crate) fn into_vec(self) -> Vec<String> {
441        self.list
442    }
443}
444
445/// `pub const NAME: &str = "value";` lines for `values`, the names from
446/// [`const_name`] (a `_` appended to repeats).
447pub(crate) fn str_consts(values: &[String]) -> String {
448    let mut out = String::new();
449    let mut used: HashSet<String> = HashSet::new();
450    for v in values {
451        let mut name = const_name(v);
452        while used.contains(&name) {
453            name.push('_');
454        }
455        let _ = writeln!(out, "    pub const {name}: &str = {v:?};");
456        used.insert(name);
457    }
458    out
459}
460
461struct Gen {
462    events: Names,
463    ids: Names,
464    /// The mod's id (from `yog.toml`), for `mod:` image sources.
465    mod_id: Option<String>,
466}
467
468impl Gen {
469    fn new(mod_id: Option<&str>) -> Self {
470        Gen {
471            events: Names::default(),
472            ids: Names::default(),
473            mod_id: mod_id.map(str::to_string),
474        }
475    }
476
477    /// `widget::surface(...)` for a `<Surface>`: the id its drawing is
478    /// registered under (`ns:path`; `mod:path` = the mod's namespace).
479    fn surface(&mut self, el: &Element) -> Result<String, String> {
480        let line = el.line;
481        let source = el
482            .attr("source")
483            .ok_or_else(|| format!("line {line}: <Surface> needs a source (the id its code registers, e.g. source=\"mod:grid\")"))?
484            .trim();
485        let id = match source.strip_prefix("mod:") {
486            Some(path) => match &self.mod_id {
487                Some(m) => format!("{m}:{path}"),
488                None => {
489                    return Err(format!(
490                        "line {line}: <Surface> source=\"{source}\": mod: needs the mod id (no yog.toml found)"
491                    ));
492                }
493            },
494            None => source.to_string(),
495        };
496        if !id.contains(':') || !crate::content::valid_id(&id) {
497            return Err(format!(
498                "line {line}: <Surface> source=\"{source}\": expected <namespace>:<name> or mod:<name> \
499                 (lower case a-z, 0-9, `_`, `-`, `.`, `/`)"
500            ));
501        }
502        Ok(format!("widget::surface({id:?})"))
503    }
504
505    /// `widget::image(...)` for an `<Image>`: its `source` checked and
506    /// `mod:` resolved, plus `stretch`/`uv`/`tint`.
507    fn image(&mut self, el: &Element) -> Result<String, String> {
508        let line = el.line;
509        let source = el
510            .attr("source")
511            .ok_or_else(|| {
512                format!("line {line}: <Image> needs a source (e.g. source=\"item:apple\")")
513            })?
514            .trim();
515        let source = image_source(el, "source", source, self.mod_id.as_deref())?;
516        let mut expr = format!("widget::image({source:?})");
517        if let Some(v) = el.attr("stretch") {
518            let s = match v.trim().to_ascii_lowercase().replace('_', "").as_str() {
519                "none" => "None",
520                "fill" => "Fill",
521                "uniform" => "Uniform",
522                "uniformtofill" => "UniformToFill",
523                _ => {
524                    return Err(format!(
525                        "line {line}: <Image> stretch=\"{v}\": expected None, Fill, Uniform or UniformToFill"
526                    ));
527                }
528            };
529            let _ = write!(expr, ".stretch(::yog_api::ui::Stretch::{s})");
530        }
531        if let Some(v) = el.attr("uv") {
532            let nums: Vec<f32> = v
533                .split([',', ' '])
534                .filter(|p| !p.trim().is_empty())
535                .map(|p| number(el, "uv", p))
536                .collect::<Result<_, _>>()?;
537            let [u, vv, w, h] = nums[..] else {
538                return Err(format!(
539                    "line {line}: <Image> uv=\"{v}\": expected \"u v width height\""
540                ));
541            };
542            let _ = write!(expr, ".region([{}, {}, {}, {}])", f(u), f(vv), f(w), f(h));
543        }
544        if let Some(v) = el.attr("tint") {
545            let _ = write!(expr, ".tint(0x{:08X})", color_attr(el, "tint", v)?);
546        }
547        Ok(expr)
548    }
549
550    /// The builder expression for `el`. `in_canvas`: the parent positions
551    /// every child absolutely (`Screen`/`Canvas`) — a missing x/y means 0.
552    fn widget(&mut self, el: &Element, in_canvas: bool, depth: usize) -> Result<String, String> {
553        let kind = el.name.as_str();
554        let is_container = CONTAINERS.contains(&kind);
555        // Any other PascalCase element is one of the mod's own components
556        // (`yog_api::ui_component!`), looked up by name when the tree is built.
557        let is_component = !is_container && !LEAVES.contains(&kind) && is_component_name(kind);
558        if !is_container && !LEAVES.contains(&kind) && !is_component {
559            return Err(format!(
560                "line {}: unknown element <{kind}> (expected one of {}, {}, or a custom component's PascalCase name)",
561                el.line,
562                CONTAINERS[1..].join(", "),
563                LEAVES.join(", ")
564            ));
565        }
566        if kind == "Screen" && depth > 0 {
567            return Err(format!(
568                "line {}: <Screen> must be the root element",
569                el.line
570            ));
571        }
572        if !is_container && !el.children.is_empty() {
573            return Err(format!("line {}: <{kind}> can't have children", el.line));
574        }
575        for (key, _) in &el.attrs {
576            if !is_component
577                && !key.starts_with(RAW_CALL)
578                && !COMMON_ATTRS.contains(&key.as_str())
579                && !kind_attrs(kind).contains(&key.as_str())
580            {
581                let hint = wpf_hint(key)
582                    .map(|h| format!(" — .yui writes it `{h}`"))
583                    .unwrap_or_default();
584                return Err(format!(
585                    "line {}: <{kind}> has no attribute `{key}`{hint}",
586                    el.line
587                ));
588            }
589        }
590        let text = || text_attr(el, "text", el.attr("text").unwrap_or_default());
591        let mut expr = match kind {
592            "Screen" | "Canvas" | "Column" => "widget::panel(FlexDir::Column)".to_string(),
593            "Row" => "widget::panel(FlexDir::Row)".to_string(),
594            "ScrollView" => "widget::scroll_view()".to_string(),
595            "Grid" => "widget::grid()".to_string(),
596            "Label" => format!("widget::label({})", text()?),
597            "Button" => format!("widget::button({})", text()?),
598            "TextBox" => format!("widget::text_box({})", text()?),
599            "ItemSlot" => format!(
600                "widget::item_slot({:?})",
601                el.attr("item").unwrap_or_default()
602            ),
603            // What a <RecipeArea> leaves (`crate::recipe_area`): which slots make what.
604            "Machine" => {
605                let places: Vec<String> = el
606                    .attr("places")
607                    .unwrap_or_default()
608                    .split(',')
609                    .filter(|p| !p.is_empty())
610                    .map(|p| {
611                        let (slot, rest) = p.split_once('/').unwrap_or((p, "0=0"));
612                        let (i, inv) = rest.split_once('=').unwrap_or(("0", "0"));
613                        format!("({slot:?}, {i}, {inv})")
614                    })
615                    .collect();
616                format!(
617                    "widget::machine({:?}, &[{}], {})",
618                    el.attr("kind").unwrap_or_default(),
619                    places.join(", "),
620                    el.attr("result").unwrap_or("0")
621                )
622            }
623            "InvSlot" => {
624                let count = match el.attr("count") {
625                    Some(c) => c.trim().parse::<u32>().map_err(|_| {
626                        format!(
627                            "line {}: <InvSlot> count=\"{c}\": expected a whole number",
628                            el.line
629                        )
630                    })?,
631                    None => 1,
632                };
633                format!(
634                    "widget::inv_slot({:?}, {count})",
635                    el.attr("item").unwrap_or_default()
636                )
637            }
638            "Slot" => {
639                let index = el.attr("index").unwrap_or_default();
640                let index = index.trim().parse::<usize>().map_err(|_| {
641                    format!(
642                        "line {}: <Slot> index=\"{index}\": expected the container slot's number (0, 1, ...)",
643                        el.line
644                    )
645                })?;
646                let accepts: Vec<&str> = el
647                    .attr("accepts")
648                    .unwrap_or_default()
649                    .split(|c: char| c.is_whitespace() || c == ',')
650                    .filter(|a| !a.is_empty())
651                    .collect();
652                for a in &accepts {
653                    let id = a.strip_prefix('#').unwrap_or(a);
654                    let ok = id.split_once(':').is_some_and(|(ns, path)| {
655                        !ns.is_empty() && !path.is_empty() && valid_resource_id(ns) && valid_resource_id(path)
656                    });
657                    if !ok {
658                        return Err(format!(
659                            "line {}: <Slot> accepts=\"{a}\": expected an item id (minecraft:redstone) or an item tag (#c:dusts), with its namespace",
660                            el.line
661                        ));
662                    }
663                }
664                if accepts.is_empty() {
665                    format!("widget::slot({index})")
666                } else {
667                    format!("widget::slot_accepting({index}, &{accepts:?})")
668                }
669            }
670            "PlayerInventory" => "widget::player_inventory()".to_string(),
671            "Image" => self.image(el)?,
672            "Surface" => self.surface(el)?,
673            _ if is_component => component_expr(el)?,
674            _ => "widget::spacer()".to_string(),
675        };
676        let indent = "    ".repeat(depth + 2);
677        let mut call = |s: String| {
678            let _ = write!(expr, "\n{indent}.{s}");
679        };
680        if kind == "Screen" {
681            if let Some(v) = el.attr("width") {
682                call(format!("w(Units::Px({}))", f(number(el, "width", v)?)));
683            }
684            if let Some(v) = el.attr("height") {
685                call(format!("h(Units::Px({}))", f(number(el, "height", v)?)));
686            }
687        }
688        let x = el.attr("x").map(|v| number(el, "x", v)).transpose()?;
689        let y = el.attr("y").map(|v| number(el, "y", v)).transpose()?;
690        if in_canvas || x.is_some() || y.is_some() {
691            call(format!(
692                "at({}, {})",
693                f(x.unwrap_or(0.0)),
694                f(y.unwrap_or(0.0))
695            ));
696        }
697        for (key, value) in &el.attrs {
698            let v = value.as_str();
699            if key.starts_with(RAW_CALL) {
700                // Builder calls added by the view-model generator.
701                call(value.clone());
702                continue;
703            }
704            if is_component && !COMMON_ATTRS.contains(&key.as_str()) {
705                // A component's own property (passed to `build` above); an
706                // `on_*` one names an event like `on_click` does.
707                if key.starts_with("on_") && !v.trim().is_empty() {
708                    self.events.push(value);
709                }
710                continue;
711            }
712            match key.as_str() {
713                "id" => {
714                    self.ids.push(value);
715                    call(format!("id({v:?})"));
716                }
717                "on_click" | "on_change" | "on_submit" => {
718                    self.events.push(value);
719                    call(format!("{key}({v:?})"));
720                }
721                "placeholder" => call(format!("placeholder({})", text_attr(el, key, v)?)),
722                "tooltip" => call(format!("tooltip({})", text_attr(el, key, v)?)),
723                "font" => call(format!("font({:?})", v.trim())),
724                "max_length" => {
725                    let n = v.trim().parse::<usize>().map_err(|_| {
726                        format!(
727                            "line {}: <{kind}> max_length=\"{v}\": expected a whole number",
728                            el.line
729                        )
730                    })?;
731                    call(format!("max_length({n})"));
732                }
733                "w" | "h" => {
734                    if let Some(s) = size(el, key, v)? {
735                        call(format!("{key}({s})"));
736                    }
737                }
738                "min_w" | "min_h" | "flex" | "gap" | "font_scale" => {
739                    call(format!("{key}({})", f(number(el, key, v)?)));
740                }
741                "dock" => {
742                    let d = pick(
743                        el,
744                        key,
745                        v,
746                        &["none", "fill", "left", "right", "top", "bottom"],
747                    )?;
748                    call(format!("dock(Dock::{})", pascal(d)));
749                }
750                "align" => {
751                    let a = pick(el, key, v, &["start", "center", "end"])?;
752                    call(format!("align(Align::{})", pascal(a)));
753                }
754                "focus_style" => {
755                    let s = pick(el, key, v, &["outline", "fill", "none"])?;
756                    call(format!("focus_style(FocusStyle::{})", pascal(s)));
757                }
758                "padding" | "margin" => {
759                    let [t, r, b, l] = padding(el, key, v)?;
760                    call(format!("{key}({}, {}, {}, {})", f(t), f(r), f(b), f(l)));
761                }
762                "halign" => {
763                    let a = pick(el, key, v, &["left", "center", "right", "stretch"])?;
764                    call(format!("halign(::yog_api::ui::HAlign::{})", pascal(a)));
765                }
766                "valign" => {
767                    let a = pick(el, key, v, &["top", "center", "bottom", "stretch"])?;
768                    call(format!("valign(::yog_api::ui::VAlign::{})", pascal(a)));
769                }
770                "bg" if is_image_value(v) => {
771                    let src = image_source(el, key, v.trim(), self.mod_id.as_deref())?;
772                    call(format!("bg_image({src:?})"));
773                }
774                "bg" | "color" | "focus_color" => {
775                    call(format!("{key}(0x{:08X})", color_attr(el, key, v)?));
776                }
777                "shadow" | "enabled" => call(format!("{key}({})", boolean(el, key, v)?)),
778                "no_wrap" if boolean(el, key, v)? => call("no_wrap()".to_string()),
779                "ellipsis" if boolean(el, key, v)? => call("ellipsis()".to_string()),
780                _ => {} // kind-specific, handled above (or Screen metadata)
781            }
782        }
783        if !el.children.is_empty() {
784            let canvas = matches!(kind, "Screen" | "Canvas");
785            for child in &el.children {
786                let c = self.widget(child, canvas, depth + 1)?;
787                let _ = write!(expr, "\n{indent}.child({c})");
788            }
789        }
790        Ok(expr)
791    }
792}
793
794/// Lower case a-z, 0-9, `_`, `-`, `.`, `/` — a resource id's namespace or path.
795fn valid_resource_id(s: &str) -> bool {
796    s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-./".contains(c))
797}
798
799/// The theme `<Screen theme="...">` names, if any.
800pub(crate) fn screen_theme(root: &Element) -> Result<Option<yog_ui::theme::Theme>, String> {
801    let Some(id) = root.attr("theme").map(str::trim).filter(|t| !t.is_empty()) else {
802        return Ok(None);
803    };
804    yog_ui::theme::Theme::builtin(id).map(Some).ok_or_else(|| {
805        format!(
806            "line {}: <Screen> theme=\"{id}\": no such theme (built in: {})",
807            root.line,
808            yog_ui::theme::Theme::BUILTIN.join(", ")
809        )
810    })
811}
812
813/// Gives every element of `el`'s subtree its theme defaults (what it
814/// doesn't set itself) — for screens without styles to merge first.
815pub(crate) fn apply_theme(el: &mut Element, theme: &yog_ui::theme::Theme) {
816    theme.apply(&el.name, &mut el.attrs);
817    for c in &mut el.children {
818        apply_theme(c, theme);
819    }
820}
821
822/// Whether a `bg` value is an image (`sprite:panel`, `mymod:gui/frame`,
823/// an `@Set.name` image) rather than a color (`#RRGGBB`, `@Colors.name`).
824pub(crate) fn is_image_value(v: &str) -> bool {
825    let v = v.trim();
826    match v.strip_prefix('@') {
827        Some(name) => RESOURCE_REFS.with(|r| r.borrow().images.contains_key(name)),
828        None => !v.starts_with('#') && v.contains(':'),
829    }
830}
831
832/// An image source attribute (`<Image source>`, `bg="sprite:..."`): checks
833/// it and resolves `@Set.name` and `mod:` → the source the host draws.
834pub(crate) fn image_source(el: &Element, attr: &str, source: &str, mod_id: Option<&str>) -> Result<String, String> {
835    let line = el.line;
836    let kind = el.name.as_str();
837    let mod_id = mod_id.map(str::to_owned);
838    let resolved;
839    let source = match source.strip_prefix('@') {
840        Some(name) => {
841            resolved = RESOURCE_REFS
842                .with(|r| r.borrow().images.get(name).cloned())
843                .ok_or_else(|| format!("line {line}: <{kind}> {attr}=\"{source}\": no image `{name}` in resources/*.yres"))?;
844            resolved.as_str()
845        }
846        None => source,
847    };
848    let parsed = ImageSource::parse(source).ok_or_else(|| {
849        format!(
850            "line {line}: <{kind}> {attr}=\"{source}\": expected item:<id>, block:<id>, \
851             sprite:<name>, mod:<path> or <namespace>:<path>"
852        )
853    })?;
854    let source = match parsed {
855        ImageSource::ModPath(path) => match &mod_id {
856            Some(id) => yog_gfx::image::texture_id(id, &path),
857            None => {
858                return Err(format!(
859                    "line {line}: <{kind}> {attr}=\"{source}\": mod: needs the mod id (no yog.toml found)"
860                ));
861            }
862        },
863        ImageSource::Sprite(name)
864            if !name.contains(':') && yog_gfx::image::sprite(&name).is_none() =>
865        {
866            let names: Vec<&str> = yog_gfx::image::SPRITES.iter().map(|s| s.name).collect();
867            return Err(format!(
868                "line {line}: <{kind}> {attr}=\"{source}\": unknown sprite (known: {})",
869                names.join(", ")
870            ));
871        }
872        ImageSource::Texture(_) => {
873            if let Some(alias) = yog_gfx::image::suggest_alias(source) {
874                return Err(format!(
875                    "line {line}: <{kind}> {attr}=\"{source}\" is a raw resource path, which \
876                     changes between Minecraft versions — write {attr}=\"{alias}\""
877                ));
878            }
879            source.to_string()
880        }
881        _ => source.to_string(),
882    };
883    Ok(source)
884}
885
886/// Prefix of a custom component property's value that is a Rust
887/// expression (a `String`) rather than text — set by the component
888/// generator for bound properties; never in a `.yui`.
889pub(crate) const RAW_VALUE: &str = "\u{1}rs:";
890
891/// Attribute-name prefix of a raw builder call (`__rs0="action(...)"`)
892/// that the view-model generator attaches to elements; never in a `.yui`.
893pub(crate) const RAW_CALL: &str = "__rs";
894
895/// The builder expression of `el` and its subtree, and the ids found —
896/// the static half of a view-model screen.
897pub(crate) fn static_widget(
898    el: &Element,
899    in_canvas: bool,
900    depth: usize,
901    mod_id: Option<&str>,
902) -> Result<(String, Vec<String>), String> {
903    let mut codegen = Gen::new(mod_id);
904    let expr = codegen.widget(el, in_canvas, depth)?;
905    Ok((expr, codegen.ids.into_vec()))
906}
907
908/// Generates the `.g.rs` source for a `.yui` document. `name` is the screen's
909/// path under `src/` without the extension (`mod_list`, `screens/options`):
910/// what the code-behind passes to `ui_include!`.
911#[cfg_attr(not(test), allow(dead_code))] // the tests' entry point
912pub fn generate(src: &str, name: &str) -> Result<String, String> {
913    generate_for(src, name, None).map(|(code, _)| code)
914}
915
916/// [`generate`] for the mod `mod_id` (resolves `mod:` image sources), also
917/// returning `line N: ...` warnings.
918pub fn generate_for(
919    src: &str,
920    name: &str,
921    mod_id: Option<&str>,
922) -> Result<(String, Vec<String>), String> {
923    generate_with(src, name, mod_id, &[])
924}
925
926/// [`generate_for`] with the mod's recipe layouts, for `<RecipeArea>`s.
927pub fn generate_with(
928    src: &str,
929    name: &str,
930    mod_id: Option<&str>,
931    layouts: &[yog_book::layout::RecipeLayout],
932) -> Result<(String, Vec<String>), String> {
933    let mut root = parse_xml(src)?;
934    // A book's kind of page or a recipe layout isn't code: the build reads it as data.
935    if crate::book_page::is_book_ui(&root) {
936        return Ok((String::new(), Vec::new()));
937    }
938    crate::recipe_area::expand(&mut root, layouts, mod_id.unwrap_or("minecraft"))?;
939    crate::yui_grid::normalize(&mut root)?;
940    if root.name == "Component" {
941        return crate::yui_component::generate(&root, name, mod_id);
942    }
943    if root.attr("x:ViewModel").is_some() {
944        return crate::yui_vm::generate(&root, name, mod_id);
945    }
946    if let Some(theme) = screen_theme(&root)? {
947        apply_theme(&mut root, &theme);
948    }
949    if root.name != "Screen" {
950        return Err(format!(
951            "line {}: the root element must be <Screen>, not <{}>",
952            root.line, root.name
953        ));
954    }
955    let id = root
956        .attr("id")
957        .filter(|s| !s.trim().is_empty())
958        .ok_or_else(|| {
959            format!(
960                "line {}: <Screen> needs an id (the UI id, e.g. \"mymod:main\")",
961                root.line
962            )
963        })?
964        .to_string();
965    let (width, height, anchor) = screen_frame(&root)?;
966    let modal = root
967        .attr("modal")
968        .map(|v| boolean(&root, "modal", v))
969        .transpose()?
970        .unwrap_or(true);
971    let pause = root
972        .attr("pause")
973        .map(|v| boolean(&root, "pause", v))
974        .transpose()?
975        .unwrap_or(false);
976
977    let mut codegen = Gen::new(mod_id);
978    // The screen's own id is the UI id, not a widget id.
979    let mut content_root = root.clone();
980    content_root.attrs.retain(|(k, _)| k != "id");
981    let body = codegen.widget(&content_root, false, 0)?;
982
983    let (ax, ay) = match anchor {
984        "top_left" => ("0.0", "0.0"),
985        "top" => ("((screen_w - WIDTH) / 2.0).trunc()", "0.0"),
986        "top_right" => ("screen_w - WIDTH", "0.0"),
987        "left" => ("0.0", "((screen_h - HEIGHT) / 2.0).trunc()"),
988        "right" => ("screen_w - WIDTH", "((screen_h - HEIGHT) / 2.0).trunc()"),
989        "bottom_left" => ("0.0", "screen_h - HEIGHT"),
990        "bottom" => ("((screen_w - WIDTH) / 2.0).trunc()", "screen_h - HEIGHT"),
991        "bottom_right" => ("screen_w - WIDTH", "screen_h - HEIGHT"),
992        // `trunc`: the game's whole-pixel `(width - backgroundWidth) / 2`.
993        _ => (
994            "((screen_w - WIDTH) / 2.0).trunc()",
995            "((screen_h - HEIGHT) / 2.0).trunc()",
996        ),
997    };
998    // As the game places its screens: a bigger one than the window keeps
999    // its size and is cut at the window's edges (evenly, centred), and a
1000    // container's picture stays over its real slots.
1001    let place = if anchor == "fill" {
1002        "content.w(Units::Percent(100.0)).h(Units::Percent(100.0)).at(0.0, 0.0)".to_string()
1003    } else {
1004        format!("content.at({ax}, {ay})")
1005    };
1006
1007    let mut out = String::new();
1008    let _ = write!(
1009        out,
1010        r#"// <auto-generated>
1011//     Generated by yog from src/{name}.yui — do not edit: changes are lost
1012//     on the next `yog build` / `yog check` / `yog ui gen`. Edit the .yui
1013//     file (Yog-IDLE's screen designer) instead.
1014// </auto-generated>
1015//
1016// Pulled into the screen's code-behind module with:
1017//
1018//     yog_api::ui_include!("{name}");
1019
1020/// The UI id this screen registers under (`register_ui`, `on_ui_render`, `open_ui`).
1021#[allow(dead_code)]
1022pub const ID: &str = {id:?};
1023/// Design-time size in GUI pixels.
1024#[allow(dead_code)]
1025pub const WIDTH: f32 = {width};
1026#[allow(dead_code)]
1027pub const HEIGHT: f32 = {height};
1028
1029/// `on_click`/`on_change`/`on_submit` events raised by this screen's widgets.
1030#[allow(dead_code)]
1031pub mod events {{
1032{events}}}
1033
1034/// Ids of this screen's named widgets (for `Widget::find_mut`, `UiRoot::find_rect`).
1035#[allow(dead_code)]
1036pub mod ids {{
1037{ids}}}
1038
1039/// The screen's widget tree, positioned at (0, 0).
1040#[allow(dead_code)]
1041pub fn build() -> ::yog_api::ui::Widget {{
1042    #[allow(unused_imports)]
1043    {UI_USE}
1044    {body}
1045}}
1046
1047/// The screen laid out for a `screen_w` × `screen_h` GUI, anchored {anchor}
1048/// — `customize` may change the tree first (dynamic text, enabled state, ...).
1049#[allow(dead_code)]
1050pub fn root_with(
1051    screen_w: f32,
1052    screen_h: f32,
1053    customize: impl FnOnce(&mut ::yog_api::ui::Widget),
1054) -> ::yog_api::ui::UiRoot {{
1055    use ::yog_api::ui::{{widget, FlexDir, Units}};
1056    let mut content = build();
1057    customize(&mut content);
1058    let screen = widget::panel(FlexDir::Column)
1059        .w(Units::Px(screen_w))
1060        .h(Units::Px(screen_h))
1061        .child({place});
1062    let mut ui = ::yog_api::ui::UiRoot::new(ID, screen);
1063    ui.layout(screen_w, screen_h);
1064    ui
1065}}
1066
1067/// [`root_with`] without changes.
1068#[allow(dead_code)]
1069pub fn root(screen_w: f32, screen_h: f32) -> ::yog_api::ui::UiRoot {{
1070    root_with(screen_w, screen_h, |_| {{}})
1071}}
1072
1073static __YUI_LAST: ::std::sync::Mutex<Option<::yog_api::ui::UiRoot>> = ::std::sync::Mutex::new(None);
1074
1075/// Draws the screen — call from `on_ui_render(ID, ...)`. Remembers the
1076/// layout for [`click`].
1077#[allow(dead_code)]
1078pub fn render(gfx: &::yog_api::GfxContext) {{
1079    render_with(gfx, |_| {{}})
1080}}
1081
1082/// [`render`], changing the tree first (see [`root_with`]).
1083#[allow(dead_code)]
1084pub fn render_with(gfx: &::yog_api::GfxContext, customize: impl FnOnce(&mut ::yog_api::ui::Widget)) {{
1085    let (w, h) = gfx.screen_size();
1086    let ui = root_with(w as f32, h as f32, customize);
1087    ui.render(gfx);
1088    if let Ok(mut last) = __YUI_LAST.lock() {{
1089        *last = Some(ui);
1090    }}
1091}}
1092
1093/// The `on_click` event of the widget under a `"click:X:Y"` UI event (what
1094/// `register_ui`'s handler receives) — `None` for other events or a miss.
1095#[allow(dead_code)]
1096pub fn click(event: &str) -> Option<String> {{
1097    let mut parts = event.strip_prefix("click:")?.split(':');
1098    let x: f32 = parts.next()?.parse().ok()?;
1099    let y: f32 = parts.next()?.parse().ok()?;
1100    let last = __YUI_LAST.lock().ok()?;
1101    last.as_ref()?.hit_test(x, y)?.on_click.clone()
1102}}
1103
1104/// Runs `f` on the layout drawn by the last [`render`] — for reading
1105/// rects and scroll state (`UiRoot::find_node`) when handling UI events.
1106/// `None` before the first render.
1107#[allow(dead_code)]
1108pub fn with_last<R>(f: impl FnOnce(&::yog_api::ui::UiRoot) -> R) -> Option<R> {{
1109    let last = __YUI_LAST.lock().ok()?;
1110    last.as_ref().map(f)
1111}}
1112
1113/// Feeds a UI event to the text box `id` (one of [`ids`]) as laid out by the
1114/// last [`render`] — call from `register_ui`'s handler; pass the same state
1115/// to `TextBoxState::apply` in [`render_with`]'s customize. `Ignored` before
1116/// the first render or for an unknown id.
1117#[allow(dead_code)]
1118pub fn text_box(
1119    id: &str,
1120    state: &mut ::yog_api::ui::TextBoxState,
1121    event: &str,
1122) -> ::yog_api::ui::TextBoxEvent {{
1123    with_last(|ui| ui.find_node(id).map(|node| state.handle(event, node)))
1124        .flatten()
1125        .unwrap_or(::yog_api::ui::TextBoxEvent::Ignored)
1126}}
1127
1128/// Opens the screen (modal: {modal}, pauses the game: {pause}).
1129#[allow(dead_code)]
1130pub fn open() {{
1131    ::yog_api::open_ui(ID, {modal}, {pause});
1132}}
1133
1134/// Closes the screen if it's open (what Esc does) — for a Done/Cancel
1135/// button. Takes effect after the current event handler returns.
1136#[allow(dead_code)]
1137pub fn close() {{
1138    ::yog_api::close_ui(ID);
1139}}
1140"#,
1141        width = f(width),
1142        height = f(height),
1143        events = str_consts(codegen.events.as_slice()),
1144        ids = str_consts(codegen.ids.as_slice()),
1145    );
1146    // Plain screens have nothing to warn about (yet); view-model screens do.
1147    Ok((out, Vec::new()))
1148}
1149
1150/// Where generated screen code goes, under the project root.
1151pub const GENERATED_DIR: &str = ".yog-build/generated";
1152
1153fn absolute(path: &Path) -> PathBuf {
1154    if path.is_absolute() {
1155        path.to_path_buf()
1156    } else {
1157        std::env::current_dir().unwrap_or_default().join(path)
1158    }
1159}
1160
1161/// The project root (the folder with `yog.toml`) a `.yui` file belongs to.
1162pub fn project_root(yui: &Path) -> Option<PathBuf> {
1163    absolute(yui)
1164        .ancestors()
1165        .skip(1)
1166        .find(|d| d.join(crate::manifest::MANIFEST).is_file())
1167        .map(Path::to_path_buf)
1168}
1169
1170/// `foo.yui`'s name for `ui_include!`: its path under `src/` without the
1171/// extension, `/`-separated (`mod_list`, `screens/options`).
1172pub fn include_name(root: &Path, yui: &Path) -> String {
1173    let abs = absolute(yui);
1174    let rel = abs
1175        .strip_prefix(root.join("src"))
1176        .or_else(|_| abs.strip_prefix(root))
1177        .unwrap_or(&abs)
1178        .with_extension("");
1179    rel.components()
1180        .map(|c| c.as_os_str().to_string_lossy().into_owned())
1181        .collect::<Vec<_>>()
1182        .join("/")
1183}
1184
1185/// Where `src/<name>.yui`'s generated code goes:
1186/// `.yog-build/generated/<name>.g.rs`.
1187pub fn generated_path(root: &Path, name: &str) -> PathBuf {
1188    root.join(GENERATED_DIR).join(format!("{name}.g.rs"))
1189}
1190
1191/// Generates `.yog-build/generated/<name>.g.rs` for one `.yui` file of the
1192/// project at `root` — only rewritten when the output changed, so cargo
1193/// doesn't rebuild for nothing.
1194pub fn generate_file(root: &Path, yui: &Path) -> Result<PathBuf, String> {
1195    let src = std::fs::read_to_string(yui).map_err(|e| format!("{}: {e}", yui.display()))?;
1196    let name = include_name(root, yui);
1197    let mod_id = std::fs::read_to_string(root.join(crate::manifest::MANIFEST))
1198        .ok()
1199        .and_then(|t| crate::manifest::parse_yog_toml(&t).ok())
1200        .map(|t| t.id);
1201    // The mod's recipe layouts, for the screen's <RecipeArea>s.
1202    let layouts: Vec<yog_book::layout::RecipeLayout> = if src.contains("<RecipeArea") {
1203        crate::book_page::find_layouts(root, mod_id.as_deref().unwrap_or("minecraft"), &mut Vec::new()).into_iter().map(|(_, l)| l).collect()
1204    } else {
1205        Vec::new()
1206    };
1207    let (code, warnings) = generate_with(&src, &name, mod_id.as_deref(), &layouts)
1208        .map_err(|e| format!("{}: {e}", yui.display()))?;
1209    for w in warnings {
1210        eprintln!("    warning: {}: {w}", yui.display());
1211    }
1212    let out = generated_path(root, &name);
1213    crate::write_if_changed(&out, &code)?;
1214    Ok(out)
1215}
1216
1217/// Every `*.<ext>` file under `dir`, recursively.
1218fn find_files(dir: &Path, ext: &str) -> Vec<PathBuf> {
1219    let mut found = Vec::new();
1220    let Ok(entries) = std::fs::read_dir(dir) else {
1221        return found;
1222    };
1223    for entry in entries.flatten() {
1224        let path = entry.path();
1225        if path.is_dir() {
1226            found.extend(find_files(&path, ext));
1227        } else if path.extension().is_some_and(|e| e == ext) {
1228            found.push(path);
1229        }
1230    }
1231    found.sort();
1232    found
1233}
1234
1235/// Every `.yui` file under `dir`, recursively.
1236/// Whether `path`'s root is a `<Component book_page="…">`.
1237pub fn is_book_page_file(path: &Path) -> bool {
1238    std::fs::read_to_string(path)
1239        .ok()
1240        .and_then(|s| parse_xml(&s).ok())
1241        .is_some_and(|r| crate::book_page::is_book_ui(&r))
1242}
1243
1244pub fn find_yui_files(dir: &Path) -> Vec<PathBuf> {
1245    find_files(dir, "yui")
1246}
1247
1248/// `yog build`'s step: regenerates every `src/**/*.yui`'s `.g.rs` and drops
1249/// generated files whose `.yui` is gone.
1250pub fn generate_all(root: &Path) -> Result<usize, String> {
1251    let files = find_yui_files(&root.join("src"));
1252    let mut wanted = Vec::new();
1253    for file in &files {
1254        // A kind of book page is the book's (`crate::book_page`), not a widget.
1255        if is_book_page_file(file) {
1256            continue;
1257        }
1258        wanted.push(generate_file(root, file)?);
1259    }
1260    // Components without a code-behind, for `ui_components!()`.
1261    wanted.push(crate::yui_component::write_index(root)?);
1262    // `.yres` accessors live there too (`crate::yres` cleans those up).
1263    let res_index = root.join(crate::yres::GENERATED_FILE);
1264    let res_dir = root.join(crate::yres::GENERATED_DIR);
1265    // So do `content/` blocks and items (`crate::content`).
1266    let content_file = root.join(crate::content::GENERATED_FILE);
1267    for stale in find_files(&root.join(GENERATED_DIR), "rs") {
1268        if stale.to_string_lossy().ends_with(".g.rs")
1269            && !wanted.contains(&stale)
1270            && stale != res_index
1271            && stale != content_file
1272            && !stale.starts_with(&res_dir)
1273        {
1274            let _ = std::fs::remove_file(&stale);
1275        }
1276    }
1277    Ok(files.len())
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283
1284    #[test]
1285    fn book_pages_and_recipe_layouts_make_no_code() {
1286        for src in [
1287            r#"<Component name="123Layout" recipe_layout="123" width="10" height="10"><RecipeSlot slot="a"/></Component>"#,
1288            r#"<Component book_page="note"><BookText text="x"/></Component>"#,
1289        ] {
1290            assert_eq!(generate_for(src, "x", Some("m")).unwrap(), (String::new(), Vec::new()));
1291        }
1292    }
1293
1294    fn screen(body: &str) -> String {
1295        format!(r#"<Screen id="mymod:s">{body}</Screen>"#)
1296    }
1297
1298    #[test]
1299    fn custom_components() {
1300        let (code, _) = generate_for(
1301            &screen(r##"<StatBar x="4" w="80" label="@@hp" value="0.5" on_select="pick" bg="#FF000000"/>"##),
1302            "s",
1303            None,
1304        )
1305        .unwrap();
1306        assert!(
1307            code.contains(r#"::yog_api::ui::component::build("StatBar", &[("label", &*"@hp"), ("value", "0.5"), ("on_select", "pick")])"#),
1308            "{code}"
1309        );
1310        assert!(
1311            code.contains(".bg(0xFF000000)") && code.contains(".w(Units::Px(80.0))"),
1312            "{code}"
1313        );
1314        assert!(!code.contains(".value("), "{code}");
1315        assert!(generate_for(&screen(r#"<statBar/>"#), "s", None).is_err());
1316        assert!(generate_for(&screen(r#"<StatBar><Label/></StatBar>"#), "s", None).is_err());
1317    }
1318
1319    #[test]
1320    fn surfaces() {
1321        let (code, _) = generate_for(
1322            &screen(r#"<Surface w="100" h="50" source="mod:grid"/>"#),
1323            "s",
1324            Some("example"),
1325        )
1326        .unwrap();
1327        assert!(
1328            code.contains(r#"widget::surface("example:grid")"#),
1329            "{code}"
1330        );
1331        let (code, _) =
1332            generate_for(&screen(r#"<Surface source="other:pad"/>"#), "s", None).unwrap();
1333        assert!(code.contains(r#"widget::surface("other:pad")"#), "{code}");
1334        assert!(generate_for(&screen(r#"<Surface/>"#), "s", None).is_err());
1335        assert!(generate_for(&screen(r#"<Surface source="pad"/>"#), "s", None).is_err());
1336        assert!(generate_for(&screen(r#"<Surface source="mod:pad"/>"#), "s", None).is_err());
1337        assert!(generate_for(&screen(r#"<Surface source="a:b" text="x"/>"#), "s", None).is_err());
1338    }
1339
1340    #[test]
1341    fn image_sources() {
1342        let (code, warnings) = generate_for(
1343            &screen(r##"<Image w="156" h="29" source="item:apple" stretch="UniformToFill" tint="#80FFFFFF"/>"##),
1344            "s",
1345            None,
1346        )
1347        .unwrap();
1348        assert!(code.contains(r#"widget::image("item:apple").stretch(::yog_api::ui::Stretch::UniformToFill).tint(0x80FFFFFF)"#), "{code}");
1349        assert!(
1350            code.contains(".w(Units::Px(156.0))") || code.contains("w(156"),
1351            "{code}"
1352        );
1353        assert!(warnings.is_empty(), "{warnings:?}");
1354
1355        let (code, _) = generate_for(
1356            &screen(r#"<Image source="mod:gui/logo" uv="0 0 32 16"/>"#),
1357            "s",
1358            Some("mymod"),
1359        )
1360        .unwrap();
1361        assert!(
1362            code.contains(
1363                r#"widget::image("mymod:textures/gui/logo.png").region([0.0, 0.0, 32.0, 16.0])"#
1364            ),
1365            "{code}"
1366        );
1367        assert!(generate_for(&screen(r#"<Image source="mod:gui/logo"/>"#), "s", None).is_err());
1368        assert!(generate_for(&screen(r#"<Image source="sprite:nope"/>"#), "s", None).is_err());
1369        assert!(generate_for(&screen(r#"<Image/>"#), "s", None).is_err());
1370        assert!(
1371            generate_for(
1372                &screen(r#"<Image source="item:apple" stretch="big"/>"#),
1373                "s",
1374                None
1375            )
1376            .is_err()
1377        );
1378    }
1379
1380    #[test]
1381    fn raw_texture_paths_are_errors_naming_the_alias() {
1382        let err = generate_for(
1383            &screen(r#"<Image source="minecraft:textures/item/apple.png"/>"#),
1384            "s",
1385            None,
1386        )
1387        .unwrap_err();
1388        assert!(err.contains(r#"source="item:apple""#), "{err}");
1389        assert!(
1390            generate_for(&screen(r#"<Image texture="item:apple"/>"#), "s", None)
1391                .unwrap_err()
1392                .contains("no attribute `texture`")
1393        );
1394    }
1395
1396    #[test]
1397    fn image_backgrounds() {
1398        let code = generate_for(&screen(r##"<Column bg="sprite:panel"><Label bg="#FF000000"/></Column>"##), "s", None)
1399            .unwrap()
1400            .0;
1401        assert!(code.contains(r#"bg_image("sprite:panel")"#), "{code}");
1402        assert!(code.contains("bg(0xFF000000)"), "{code}");
1403        let err = generate_for(&screen(r#"<Column bg="sprite:nope"/>"#), "s", None).unwrap_err();
1404        assert!(err.contains("<Column> bg=\"sprite:nope\": unknown sprite"), "{err}");
1405    }
1406
1407    #[test]
1408    fn inventory_slots() {
1409        let code = generate_for(
1410            &screen(r#"<Column><Slot index="2"/><PlayerInventory/></Column>"#),
1411            "s",
1412            None,
1413        )
1414        .unwrap()
1415        .0;
1416        assert!(code.contains("widget::slot(2)"), "{code}");
1417        assert!(code.contains("widget::player_inventory()"), "{code}");
1418        let code = generate_for(&screen(r#"<Slot index="1" accepts="minecraft:redstone #c:dusts"/>"#), "s", None).unwrap().0;
1419        assert!(code.contains(r##"widget::slot_accepting(1, &["minecraft:redstone", "#c:dusts"])"##), "{code}");
1420        let err = generate_for(&screen(r#"<Slot index="1" accepts="redstone"/>"#), "s", None).unwrap_err();
1421        assert!(err.contains("with its namespace"), "{err}");
1422        let err = generate_for(&screen(r#"<Slot index="first"/>"#), "s", None).unwrap_err();
1423        assert!(err.contains("expected the container slot's number"), "{err}");
1424    }
1425
1426    const SAMPLE: &str = r##"<?xml version="1.0" encoding="utf-8"?>
1427<!-- a comment -->
1428<Screen id="mymod:main" width="176" height="166" bg="#C0101010" padding="8">
1429  <Label id="title" x="8" y="6" text="Main &amp; Menu" color="#FFFFFF" no_wrap="true"/>
1430  <Button x="38" y="40" w="100" h="20" text="Play" bg="#FF555555" on_click="mymod:play"/>
1431  <Row x="8" y="70" w="50%" gap="2" align="center">
1432    <InvSlot item="minecraft:diamond" count="3"/>
1433    <Spacer flex="1"/>
1434  </Row>
1435  <ScrollView id="list" x="0" y="100" w="80" h="40">
1436    <Label text="A long line" ellipsis="true"/>
1437  </ScrollView>
1438</Screen>
1439"##;
1440
1441    #[test]
1442    fn parses_the_subset() {
1443        let root = parse_xml(SAMPLE).unwrap();
1444        assert_eq!(root.name, "Screen");
1445        assert_eq!(root.children.len(), 4);
1446        assert_eq!(root.children[0].attr("text"), Some("Main & Menu"));
1447        assert_eq!(root.children[2].children[0].attr("count"), Some("3"));
1448        assert_eq!(root.children[1].line, 5);
1449    }
1450
1451    #[test]
1452    fn generates_builder_code() {
1453        let code = generate(SAMPLE, "main").unwrap();
1454        assert!(code.contains(r#"pub const ID: &str = "mymod:main";"#));
1455        assert!(code.contains(r#"widget::label("Main & Menu")"#));
1456        assert!(code.contains(".at(8.0, 6.0)"));
1457        assert!(code.contains(".color(0xFFFFFFFF)"));
1458        assert!(code.contains(".bg(0xC0101010)"));
1459        assert!(code.contains(".no_wrap()"));
1460        assert!(code.contains(".w(Units::Percent(50.0))"));
1461        assert!(code.contains("widget::inv_slot(\"minecraft:diamond\", 3)"));
1462        assert!(code.contains(r#"pub const MYMOD_PLAY: &str = "mymod:play";"#));
1463        assert!(code.contains(r#"pub const TITLE: &str = "title";"#));
1464        assert!(code.contains(".padding(8.0, 8.0, 8.0, 8.0)"));
1465        assert!(code.contains("widget::scroll_view()"));
1466        assert!(code.contains(".ellipsis()"));
1467        assert!(code.contains("pub fn with_last<R>"));
1468        assert!(code.starts_with("// <auto-generated>"));
1469        assert!(code.contains(r#"yog_api::ui_include!("main");"#));
1470        assert!(code.contains("pub fn close()"));
1471        // Children of a Row aren't positioned unless they say so.
1472        assert!(!code.contains("widget::spacer()\n                .at("));
1473    }
1474
1475    #[test]
1476    fn reports_errors_with_lines() {
1477        let bad = "<Screen id=\"a:b\">\n  <Label txt=\"x\"/>\n</Screen>";
1478        let err = generate(bad, "x.yui").unwrap_err();
1479        assert!(err.contains("line 2") && err.contains("txt"), "{err}");
1480        assert!(generate("<Screen id=\"a:b\"><Label/></Scren>", "x.yui").is_err());
1481        assert!(generate("<Column/>", "x.yui").is_err());
1482        assert!(generate("<Screen/>", "x.yui").unwrap_err().contains("id"));
1483        let bad_color = "<Screen id=\"a:b\" bg=\"red\"/>";
1484        assert!(
1485            generate(bad_color, "x.yui")
1486                .unwrap_err()
1487                .contains("#RRGGBB")
1488        );
1489    }
1490
1491    /// What the IDE's Format Document writes (Visual Studio's XAML layout):
1492    /// one attribute per line, comments among the children, ` />`.
1493    #[test]
1494    fn parses_formatted_markup() {
1495        let src = r##"<?xml version="1.0" encoding="utf-8"?>
1496<Screen id="mymod:main"
1497        width="176"
1498        height="166"
1499        bg="#C0101010">
1500    <!-- the title -->
1501    <Label id="title"
1502           x="8"
1503           y="6"
1504           text="Main"
1505           no_wrap="true" />
1506
1507    <Row x="8" y="70" w="160">
1508        <!--
1509          a multi-line comment
1510        -->
1511        <Spacer flex="1" />
1512    </Row>
1513</Screen>
1514"##;
1515        let root = parse_xml(src).unwrap();
1516        assert_eq!(root.attr("height"), Some("166"));
1517        assert_eq!(root.children.len(), 2);
1518        assert_eq!(root.children[0].attr("no_wrap"), Some("true"));
1519        assert_eq!(root.children[0].line, 7);
1520        assert_eq!(root.children[1].children.len(), 1);
1521        let code = generate(src, "main.yui").unwrap();
1522        assert!(code.contains(".at(8.0, 6.0)"));
1523    }
1524
1525    #[test]
1526    fn text_box_and_font() {
1527        let src = r#"<Screen id="a:b">
1528  <TextBox id="search" w="100%" placeholder="Search..." max_length="64" on_change="search" on_submit="go" font="minecraft:uniform" shadow="false"/>
1529</Screen>"#;
1530        let code = generate(src, "x").unwrap();
1531        assert!(code.contains(r#"widget::text_box("")"#), "{code}");
1532        assert!(code.contains(r#".placeholder("Search...")"#));
1533        assert!(code.contains(".max_length(64)"));
1534        assert!(code.contains(r#".on_change("search")"#));
1535        assert!(code.contains(r#".on_submit("go")"#));
1536        assert!(code.contains(r#".font("minecraft:uniform")"#));
1537        assert!(code.contains(".shadow(false)"));
1538        assert!(code.contains(r#"pub const GO: &str = "go";"#));
1539        assert!(code.contains("pub fn text_box("));
1540        let bad = r#"<Screen id="a:b"><TextBox max_length="x"/></Screen>"#;
1541        assert!(generate(bad, "x").unwrap_err().contains("whole number"));
1542    }
1543
1544    #[test]
1545    fn generated_paths() {
1546        let dir = std::env::temp_dir().join(format!("yog-yui-test-{}", std::process::id()));
1547        let _ = std::fs::remove_dir_all(&dir);
1548        std::fs::create_dir_all(dir.join("src/screens")).unwrap();
1549        std::fs::write(dir.join("yog.toml"), "").unwrap();
1550        let yui = dir.join("src/screens/opts.yui");
1551        std::fs::write(&yui, r#"<Screen id="a:b"/>"#).unwrap();
1552        assert_eq!(project_root(&yui).as_deref(), Some(dir.as_path()));
1553        assert_eq!(include_name(&dir, &yui), "screens/opts");
1554        assert_eq!(generate_all(&dir).unwrap(), 1);
1555        let generated = dir.join(".yog-build/generated/screens/opts.g.rs");
1556        assert!(generated.is_file());
1557        std::fs::remove_file(&yui).unwrap();
1558        generate_all(&dir).unwrap();
1559        assert!(!generated.exists());
1560        let _ = std::fs::remove_dir_all(&dir);
1561    }
1562
1563    #[test]
1564    fn const_names() {
1565        assert_eq!(const_name("mymod:btn-play"), "MYMOD_BTN_PLAY");
1566        assert_eq!(const_name("openMenu"), "OPEN_MENU");
1567        assert_eq!(const_name("1st"), "_1ST");
1568    }
1569
1570    #[test]
1571    fn text_starting_with_at_is_a_translation_key() {
1572        assert_eq!(text_expr("@lang:gui.done").unwrap(), "::yog_api::tr(\"gui.done\")");
1573        assert_eq!(text_expr("@item:minecraft:stick").unwrap(), "::yog_api::tr(\"item.minecraft.stick\")");
1574        assert_eq!(text_expr("@@home").unwrap(), "\"@home\"");
1575        assert_eq!(text_expr("plain").unwrap(), "\"plain\"");
1576        assert_eq!(text_expr("@").unwrap(), "\"@\"");
1577        // The old bare key, a missing text, something that isn't a text.
1578        assert!(text_expr("@gui.done").unwrap_err().contains("`@lang:gui.done`"));
1579        assert!(text_expr("@Strings.nope").unwrap_err().contains("no text `Strings.nope`"));
1580        assert!(text_expr("@image:sprite:x").unwrap_err().contains("isn't a text"));
1581        let mut refs = crate::yres::Refs::default();
1582        refs.texts.insert(
1583            "Strings.title".into(),
1584            ("mymod.title".into(), "Title".into()),
1585        );
1586        set_resource_refs(&refs);
1587        assert_eq!(
1588            text_expr("@Strings.title").unwrap(),
1589            "::yog_api::tr(\"mymod.title\")"
1590        );
1591        set_resource_refs(&crate::yres::Refs::default());
1592        let code = generate(
1593            r#"<Screen id="m:s" width="10" height="10"><Label text="@lang:m.title"/><Button text="@@x"/></Screen>"#,
1594            "s.yui",
1595        )
1596        .unwrap();
1597        assert!(
1598            code.contains("widget::label(::yog_api::tr(\"m.title\"))"),
1599            "{code}"
1600        );
1601        assert!(code.contains("widget::button(\"@x\")"), "{code}");
1602    }
1603
1604    #[test]
1605    fn color_attributes_resolve_yres_colors() {
1606        let mut refs = crate::yres::Refs::default();
1607        refs.colors.insert("Colors.text".into(), 0xFF12_3456);
1608        set_resource_refs(&refs);
1609        let code = generate(
1610            r#"<Screen id="m:s" width="10" height="10"><Label text="x" color="@Colors.text"/></Screen>"#,
1611            "s.yui",
1612        );
1613        let missing = generate(
1614            r#"<Screen id="m:s" width="10" height="10"><Label text="x" color="@Colors.nope"/></Screen>"#,
1615            "s.yui",
1616        );
1617        set_resource_refs(&crate::yres::Refs::default());
1618        let code = code.unwrap();
1619        assert!(code.contains("0xFF123456"), "{code}");
1620        assert!(missing.unwrap_err().contains("no color `Colors.nope`"));
1621    }
1622}