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