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