1use std::collections::HashSet;
24use std::fmt::Write as _;
25use std::path::{Path, PathBuf};
26
27use yog_gfx::image::ImageSource;
28
29#[derive(Debug, Clone)]
33pub struct Element {
34 pub name: String,
35 pub attrs: Vec<(String, String)>,
36 pub children: Vec<Element>,
37 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 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 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 `&`".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
245pub 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
258pub(crate) const UI_USE: &str =
262 "use ::yog_api::ui::{widget, Align, Dock, FlexDir, FocusStyle, Units};";
263
264pub(crate) fn is_builtin(kind: &str) -> bool {
266 CONTAINERS.contains(&kind) || LEAVES.contains(&kind)
267}
268
269pub(crate) fn is_custom_component(kind: &str) -> bool {
271 !is_builtin(kind) && is_component_name(kind)
272}
273
274pub(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
279pub(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
358pub 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
374pub(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
393fn is_resource_text(reference: &str) -> bool {
395 RESOURCE_REFS.with(|r| r.borrow().texts.contains_key(reference.trim()))
396}
397
398fn 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
418fn 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
436pub(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
452fn 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
476pub(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
520pub 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
533fn 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
539fn component_expr(el: &Element) -> 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 format!("({k:?}, &*{rust})");
549 }
550 match v.strip_prefix('@') {
551 Some(rest) if !is_resource_text(rest) && resource_color(rest).is_some() => {
553 let c = resource_color(rest).unwrap_or_default();
554 format!("({k:?}, \"#{c:08X}\")")
555 }
556 Some(rest) if !rest.trim().is_empty() => format!("({k:?}, &*{})", text_expr(v)),
557 _ => format!("({k:?}, {v:?})"),
558 }
559 })
560 .collect();
561 format!(
562 "::yog_api::ui::component::build({:?}, &[{}])",
563 el.name,
564 props.join(", ")
565 )
566}
567
568pub fn text_expr(value: &str) -> String {
576 match value.strip_prefix('@') {
577 Some(rest) if rest.starts_with('@') => format!("{rest:?}"),
578 Some(key) if !key.trim().is_empty() => {
579 let key = key.trim();
580 let resolved =
581 RESOURCE_REFS.with(|r| r.borrow().texts.get(key).map(|(k, _)| k.clone()));
582 format!("::yog_api::tr({:?})", resolved.as_deref().unwrap_or(key))
583 }
584 _ => format!("{value:?}"),
585 }
586}
587
588thread_local! {
589 static RESOURCE_REFS: std::cell::RefCell<crate::yres::Refs> = Default::default();
591}
592
593pub fn set_resource_refs(refs: &crate::yres::Refs) {
596 RESOURCE_REFS.with(|r| *r.borrow_mut() = refs.clone());
597}
598
599pub fn const_name(value: &str) -> String {
601 let mut out = String::new();
602 let mut prev_lower = false;
603 for c in value.chars() {
604 if c.is_ascii_alphanumeric() {
605 if c.is_ascii_uppercase() && prev_lower {
606 out.push('_');
607 }
608 prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
609 out.push(c.to_ascii_uppercase());
610 } else {
611 if !out.ends_with('_') {
612 out.push('_');
613 }
614 prev_lower = false;
615 }
616 }
617 let out = out.trim_matches('_').to_string();
618 if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
619 format!("_{out}")
620 } else {
621 out
622 }
623}
624
625#[derive(Debug, Default)]
627pub(crate) struct Names {
628 list: Vec<String>,
629 seen: HashSet<String>,
630}
631
632impl Names {
633 pub(crate) fn push(&mut self, name: &str) {
634 if !self.seen.contains(name) {
635 self.seen.insert(name.to_string());
636 self.list.push(name.to_string());
637 }
638 }
639
640 pub(crate) fn as_slice(&self) -> &[String] {
641 &self.list
642 }
643
644 pub(crate) fn into_vec(self) -> Vec<String> {
645 self.list
646 }
647}
648
649pub(crate) fn str_consts(values: &[String]) -> String {
652 let mut out = String::new();
653 let mut used: HashSet<String> = HashSet::new();
654 for v in values {
655 let mut name = const_name(v);
656 while used.contains(&name) {
657 name.push('_');
658 }
659 let _ = writeln!(out, " pub const {name}: &str = {v:?};");
660 used.insert(name);
661 }
662 out
663}
664
665struct Gen {
666 events: Names,
667 ids: Names,
668 mod_id: Option<String>,
670}
671
672impl Gen {
673 fn new(mod_id: Option<&str>) -> Self {
674 Gen {
675 events: Names::default(),
676 ids: Names::default(),
677 mod_id: mod_id.map(str::to_string),
678 }
679 }
680
681 fn surface(&mut self, el: &Element) -> Result<String, String> {
684 let line = el.line;
685 let source = el
686 .attr("source")
687 .ok_or_else(|| format!("line {line}: <Surface> needs a source (the id its code registers, e.g. source=\"mod:grid\")"))?
688 .trim();
689 let id = match source.strip_prefix("mod:") {
690 Some(path) => match &self.mod_id {
691 Some(m) => format!("{m}:{path}"),
692 None => {
693 return Err(format!(
694 "line {line}: <Surface> source=\"{source}\": mod: needs the mod id (no yog.toml found)"
695 ));
696 }
697 },
698 None => source.to_string(),
699 };
700 if !id.contains(':') || !crate::content::valid_id(&id) {
701 return Err(format!(
702 "line {line}: <Surface> source=\"{source}\": expected <namespace>:<name> or mod:<name> \
703 (lower case a-z, 0-9, `_`, `-`, `.`, `/`)"
704 ));
705 }
706 Ok(format!("widget::surface({id:?})"))
707 }
708
709 fn image(&mut self, el: &Element) -> Result<String, String> {
712 let line = el.line;
713 let source = el
714 .attr("source")
715 .ok_or_else(|| {
716 format!("line {line}: <Image> needs a source (e.g. source=\"item:apple\")")
717 })?
718 .trim();
719 let source = image_source(el, "source", source, self.mod_id.as_deref())?;
720 let mut expr = format!("widget::image({source:?})");
721 if let Some(v) = el.attr("stretch") {
722 let s = match v.trim().to_ascii_lowercase().replace('_', "").as_str() {
723 "none" => "None",
724 "fill" => "Fill",
725 "uniform" => "Uniform",
726 "uniformtofill" => "UniformToFill",
727 _ => {
728 return Err(format!(
729 "line {line}: <Image> stretch=\"{v}\": expected None, Fill, Uniform or UniformToFill"
730 ));
731 }
732 };
733 let _ = write!(expr, ".stretch(::yog_api::ui::Stretch::{s})");
734 }
735 if let Some(v) = el.attr("uv") {
736 let nums: Vec<f32> = v
737 .split([',', ' '])
738 .filter(|p| !p.trim().is_empty())
739 .map(|p| number(el, "uv", p))
740 .collect::<Result<_, _>>()?;
741 let [u, vv, w, h] = nums[..] else {
742 return Err(format!(
743 "line {line}: <Image> uv=\"{v}\": expected \"u v width height\""
744 ));
745 };
746 let _ = write!(expr, ".region([{}, {}, {}, {}])", f(u), f(vv), f(w), f(h));
747 }
748 if let Some(v) = el.attr("tint") {
749 let _ = write!(expr, ".tint(0x{:08X})", color_attr(el, "tint", v)?);
750 }
751 Ok(expr)
752 }
753
754 fn widget(&mut self, el: &Element, in_canvas: bool, depth: usize) -> Result<String, String> {
757 let kind = el.name.as_str();
758 let is_container = CONTAINERS.contains(&kind);
759 let is_component = !is_container && !LEAVES.contains(&kind) && is_component_name(kind);
762 if !is_container && !LEAVES.contains(&kind) && !is_component {
763 return Err(format!(
764 "line {}: unknown element <{kind}> (expected one of {}, {}, or a custom component's PascalCase name)",
765 el.line,
766 CONTAINERS[1..].join(", "),
767 LEAVES.join(", ")
768 ));
769 }
770 if kind == "Screen" && depth > 0 {
771 return Err(format!(
772 "line {}: <Screen> must be the root element",
773 el.line
774 ));
775 }
776 if !is_container && !el.children.is_empty() {
777 return Err(format!("line {}: <{kind}> can't have children", el.line));
778 }
779 for (key, _) in &el.attrs {
780 if !is_component
781 && !key.starts_with(RAW_CALL)
782 && !COMMON_ATTRS.contains(&key.as_str())
783 && !kind_attrs(kind).contains(&key.as_str())
784 {
785 let hint = wpf_hint(key)
786 .map(|h| format!(" — .yui writes it `{h}`"))
787 .unwrap_or_default();
788 return Err(format!(
789 "line {}: <{kind}> has no attribute `{key}`{hint}",
790 el.line
791 ));
792 }
793 }
794 let text = || text_expr(el.attr("text").unwrap_or_default());
795 let mut expr = match kind {
796 "Screen" | "Canvas" | "Column" => "widget::panel(FlexDir::Column)".to_string(),
797 "Row" => "widget::panel(FlexDir::Row)".to_string(),
798 "ScrollView" => "widget::scroll_view()".to_string(),
799 "Grid" => "widget::grid()".to_string(),
800 "Label" => format!("widget::label({})", text()),
801 "Button" => format!("widget::button({})", text()),
802 "TextBox" => format!("widget::text_box({})", text()),
803 "ItemSlot" => format!(
804 "widget::item_slot({:?})",
805 el.attr("item").unwrap_or_default()
806 ),
807 "InvSlot" => {
808 let count = match el.attr("count") {
809 Some(c) => c.trim().parse::<u32>().map_err(|_| {
810 format!(
811 "line {}: <InvSlot> count=\"{c}\": expected a whole number",
812 el.line
813 )
814 })?,
815 None => 1,
816 };
817 format!(
818 "widget::inv_slot({:?}, {count})",
819 el.attr("item").unwrap_or_default()
820 )
821 }
822 "Slot" => {
823 let index = el.attr("index").unwrap_or_default();
824 let index = index.trim().parse::<usize>().map_err(|_| {
825 format!(
826 "line {}: <Slot> index=\"{index}\": expected the container slot's number (0, 1, ...)",
827 el.line
828 )
829 })?;
830 let accepts: Vec<&str> = el
831 .attr("accepts")
832 .unwrap_or_default()
833 .split(|c: char| c.is_whitespace() || c == ',')
834 .filter(|a| !a.is_empty())
835 .collect();
836 for a in &accepts {
837 let id = a.strip_prefix('#').unwrap_or(a);
838 let ok = id.split_once(':').is_some_and(|(ns, path)| {
839 !ns.is_empty() && !path.is_empty() && valid_resource_id(ns) && valid_resource_id(path)
840 });
841 if !ok {
842 return Err(format!(
843 "line {}: <Slot> accepts=\"{a}\": expected an item id (minecraft:redstone) or an item tag (#c:dusts), with its namespace",
844 el.line
845 ));
846 }
847 }
848 if accepts.is_empty() {
849 format!("widget::slot({index})")
850 } else {
851 format!("widget::slot_accepting({index}, &{accepts:?})")
852 }
853 }
854 "PlayerInventory" => "widget::player_inventory()".to_string(),
855 "Image" => self.image(el)?,
856 "Surface" => self.surface(el)?,
857 _ if is_component => component_expr(el),
858 _ => "widget::spacer()".to_string(),
859 };
860 let indent = " ".repeat(depth + 2);
861 let mut call = |s: String| {
862 let _ = write!(expr, "\n{indent}.{s}");
863 };
864 if kind == "Screen" {
865 if let Some(v) = el.attr("width") {
866 call(format!("w(Units::Px({}))", f(number(el, "width", v)?)));
867 }
868 if let Some(v) = el.attr("height") {
869 call(format!("h(Units::Px({}))", f(number(el, "height", v)?)));
870 }
871 }
872 let x = el.attr("x").map(|v| number(el, "x", v)).transpose()?;
873 let y = el.attr("y").map(|v| number(el, "y", v)).transpose()?;
874 if in_canvas || x.is_some() || y.is_some() {
875 call(format!(
876 "at({}, {})",
877 f(x.unwrap_or(0.0)),
878 f(y.unwrap_or(0.0))
879 ));
880 }
881 for (key, value) in &el.attrs {
882 let v = value.as_str();
883 if key.starts_with(RAW_CALL) {
884 call(value.clone());
886 continue;
887 }
888 if is_component && !COMMON_ATTRS.contains(&key.as_str()) {
889 if key.starts_with("on_") && !v.trim().is_empty() {
892 self.events.push(value);
893 }
894 continue;
895 }
896 match key.as_str() {
897 "id" => {
898 self.ids.push(value);
899 call(format!("id({v:?})"));
900 }
901 "on_click" | "on_change" | "on_submit" => {
902 self.events.push(value);
903 call(format!("{key}({v:?})"));
904 }
905 "placeholder" => call(format!("placeholder({})", text_expr(v))),
906 "tooltip" => call(format!("tooltip({})", text_expr(v))),
907 "font" => call(format!("font({:?})", v.trim())),
908 "max_length" => {
909 let n = v.trim().parse::<usize>().map_err(|_| {
910 format!(
911 "line {}: <{kind}> max_length=\"{v}\": expected a whole number",
912 el.line
913 )
914 })?;
915 call(format!("max_length({n})"));
916 }
917 "w" | "h" => {
918 if let Some(s) = size(el, key, v)? {
919 call(format!("{key}({s})"));
920 }
921 }
922 "min_w" | "min_h" | "flex" | "gap" | "font_scale" => {
923 call(format!("{key}({})", f(number(el, key, v)?)));
924 }
925 "dock" => {
926 let d = pick(
927 el,
928 key,
929 v,
930 &["none", "fill", "left", "right", "top", "bottom"],
931 )?;
932 call(format!("dock(Dock::{})", pascal(d)));
933 }
934 "align" => {
935 let a = pick(el, key, v, &["start", "center", "end"])?;
936 call(format!("align(Align::{})", pascal(a)));
937 }
938 "focus_style" => {
939 let s = pick(el, key, v, &["outline", "fill", "none"])?;
940 call(format!("focus_style(FocusStyle::{})", pascal(s)));
941 }
942 "padding" | "margin" => {
943 let [t, r, b, l] = padding(el, key, v)?;
944 call(format!("{key}({}, {}, {}, {})", f(t), f(r), f(b), f(l)));
945 }
946 "halign" => {
947 let a = pick(el, key, v, &["left", "center", "right", "stretch"])?;
948 call(format!("halign(::yog_api::ui::HAlign::{})", pascal(a)));
949 }
950 "valign" => {
951 let a = pick(el, key, v, &["top", "center", "bottom", "stretch"])?;
952 call(format!("valign(::yog_api::ui::VAlign::{})", pascal(a)));
953 }
954 "bg" if is_image_value(v) => {
955 let src = image_source(el, key, v.trim(), self.mod_id.as_deref())?;
956 call(format!("bg_image({src:?})"));
957 }
958 "bg" | "color" | "focus_color" => {
959 call(format!("{key}(0x{:08X})", color_attr(el, key, v)?));
960 }
961 "shadow" | "enabled" => call(format!("{key}({})", boolean(el, key, v)?)),
962 "no_wrap" if boolean(el, key, v)? => call("no_wrap()".to_string()),
963 "ellipsis" if boolean(el, key, v)? => call("ellipsis()".to_string()),
964 _ => {} }
966 }
967 if !el.children.is_empty() {
968 let canvas = matches!(kind, "Screen" | "Canvas");
969 for child in &el.children {
970 let c = self.widget(child, canvas, depth + 1)?;
971 let _ = write!(expr, "\n{indent}.child({c})");
972 }
973 }
974 Ok(expr)
975 }
976}
977
978fn valid_resource_id(s: &str) -> bool {
980 s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-./".contains(c))
981}
982
983pub(crate) fn screen_theme(root: &Element) -> Result<Option<yog_ui::theme::Theme>, String> {
985 let Some(id) = root.attr("theme").map(str::trim).filter(|t| !t.is_empty()) else {
986 return Ok(None);
987 };
988 yog_ui::theme::Theme::builtin(id).map(Some).ok_or_else(|| {
989 format!(
990 "line {}: <Screen> theme=\"{id}\": no such theme (built in: {})",
991 root.line,
992 yog_ui::theme::Theme::BUILTIN.join(", ")
993 )
994 })
995}
996
997pub(crate) fn apply_theme(el: &mut Element, theme: &yog_ui::theme::Theme) {
1000 theme.apply(&el.name, &mut el.attrs);
1001 for c in &mut el.children {
1002 apply_theme(c, theme);
1003 }
1004}
1005
1006pub(crate) fn is_image_value(v: &str) -> bool {
1009 let v = v.trim();
1010 match v.strip_prefix('@') {
1011 Some(name) => RESOURCE_REFS.with(|r| r.borrow().images.contains_key(name)),
1012 None => !v.starts_with('#') && v.contains(':'),
1013 }
1014}
1015
1016pub(crate) fn image_source(el: &Element, attr: &str, source: &str, mod_id: Option<&str>) -> Result<String, String> {
1019 let line = el.line;
1020 let kind = el.name.as_str();
1021 let mod_id = mod_id.map(str::to_owned);
1022 let resolved;
1023 let source = match source.strip_prefix('@') {
1024 Some(name) => {
1025 resolved = RESOURCE_REFS
1026 .with(|r| r.borrow().images.get(name).cloned())
1027 .ok_or_else(|| format!("line {line}: <{kind}> {attr}=\"{source}\": no image `{name}` in resources/*.yres"))?;
1028 resolved.as_str()
1029 }
1030 None => source,
1031 };
1032 let parsed = ImageSource::parse(source).ok_or_else(|| {
1033 format!(
1034 "line {line}: <{kind}> {attr}=\"{source}\": expected item:<id>, block:<id>, \
1035 sprite:<name>, mod:<path> or <namespace>:<path>"
1036 )
1037 })?;
1038 let source = match parsed {
1039 ImageSource::ModPath(path) => match &mod_id {
1040 Some(id) => yog_gfx::image::texture_id(id, &path),
1041 None => {
1042 return Err(format!(
1043 "line {line}: <{kind}> {attr}=\"{source}\": mod: needs the mod id (no yog.toml found)"
1044 ));
1045 }
1046 },
1047 ImageSource::Sprite(name)
1048 if !name.contains(':') && yog_gfx::image::sprite(&name).is_none() =>
1049 {
1050 let names: Vec<&str> = yog_gfx::image::SPRITES.iter().map(|s| s.name).collect();
1051 return Err(format!(
1052 "line {line}: <{kind}> {attr}=\"{source}\": unknown sprite (known: {})",
1053 names.join(", ")
1054 ));
1055 }
1056 ImageSource::Texture(_) => {
1057 if let Some(alias) = yog_gfx::image::suggest_alias(source) {
1058 return Err(format!(
1059 "line {line}: <{kind}> {attr}=\"{source}\" is a raw resource path, which \
1060 changes between Minecraft versions — write {attr}=\"{alias}\""
1061 ));
1062 }
1063 source.to_string()
1064 }
1065 _ => source.to_string(),
1066 };
1067 Ok(source)
1068}
1069
1070pub(crate) const RAW_VALUE: &str = "\u{1}rs:";
1074
1075pub(crate) const RAW_CALL: &str = "__rs";
1078
1079pub(crate) fn static_widget(
1082 el: &Element,
1083 in_canvas: bool,
1084 depth: usize,
1085 mod_id: Option<&str>,
1086) -> Result<(String, Vec<String>), String> {
1087 let mut codegen = Gen::new(mod_id);
1088 let expr = codegen.widget(el, in_canvas, depth)?;
1089 Ok((expr, codegen.ids.into_vec()))
1090}
1091
1092#[cfg_attr(not(test), allow(dead_code))] pub fn generate(src: &str, name: &str) -> Result<String, String> {
1097 generate_for(src, name, None).map(|(code, _)| code)
1098}
1099
1100pub fn generate_for(
1103 src: &str,
1104 name: &str,
1105 mod_id: Option<&str>,
1106) -> Result<(String, Vec<String>), String> {
1107 let mut root = parse_xml(src)?;
1108 crate::yui_grid::normalize(&mut root)?;
1109 if root.name == "Component" {
1110 return crate::yui_component::generate(&root, name, mod_id);
1111 }
1112 if root.attr("x:ViewModel").is_some() {
1113 return crate::yui_vm::generate(&root, name, mod_id);
1114 }
1115 if let Some(theme) = screen_theme(&root)? {
1116 apply_theme(&mut root, &theme);
1117 }
1118 if root.name != "Screen" {
1119 return Err(format!(
1120 "line {}: the root element must be <Screen>, not <{}>",
1121 root.line, root.name
1122 ));
1123 }
1124 let id = root
1125 .attr("id")
1126 .filter(|s| !s.trim().is_empty())
1127 .ok_or_else(|| {
1128 format!(
1129 "line {}: <Screen> needs an id (the UI id, e.g. \"mymod:main\")",
1130 root.line
1131 )
1132 })?
1133 .to_string();
1134 let (width, height, anchor) = screen_frame(&root)?;
1135 let modal = root
1136 .attr("modal")
1137 .map(|v| boolean(&root, "modal", v))
1138 .transpose()?
1139 .unwrap_or(true);
1140 let pause = root
1141 .attr("pause")
1142 .map(|v| boolean(&root, "pause", v))
1143 .transpose()?
1144 .unwrap_or(false);
1145
1146 let mut codegen = Gen::new(mod_id);
1147 let mut content_root = root.clone();
1149 content_root.attrs.retain(|(k, _)| k != "id");
1150 let body = codegen.widget(&content_root, false, 0)?;
1151
1152 let (ax, ay) = match anchor {
1153 "top_left" => ("0.0", "0.0"),
1154 "top" => ("((screen_w - WIDTH) / 2.0).floor()", "0.0"),
1155 "top_right" => ("screen_w - WIDTH", "0.0"),
1156 "left" => ("0.0", "((screen_h - HEIGHT) / 2.0).floor()"),
1157 "right" => ("screen_w - WIDTH", "((screen_h - HEIGHT) / 2.0).floor()"),
1158 "bottom_left" => ("0.0", "screen_h - HEIGHT"),
1159 "bottom" => ("((screen_w - WIDTH) / 2.0).floor()", "screen_h - HEIGHT"),
1160 "bottom_right" => ("screen_w - WIDTH", "screen_h - HEIGHT"),
1161 _ => (
1162 "((screen_w - WIDTH) / 2.0).floor()",
1163 "((screen_h - HEIGHT) / 2.0).floor()",
1164 ),
1165 };
1166 let place = if anchor == "fill" {
1167 "content.w(Units::Percent(100.0)).h(Units::Percent(100.0)).at(0.0, 0.0)".to_string()
1168 } else {
1169 format!("content.at(({ax}).max(0.0), ({ay}).max(0.0))")
1170 };
1171
1172 let mut out = String::new();
1173 let _ = write!(
1174 out,
1175 r#"// <auto-generated>
1176// Generated by yog from src/{name}.yui — do not edit: changes are lost
1177// on the next `yog build` / `yog check` / `yog ui gen`. Edit the .yui
1178// file (Yog-IDLE's screen designer) instead.
1179// </auto-generated>
1180//
1181// Pulled into the screen's code-behind module with:
1182//
1183// yog_api::ui_include!("{name}");
1184
1185/// The UI id this screen registers under (`register_ui`, `on_ui_render`, `open_ui`).
1186#[allow(dead_code)]
1187pub const ID: &str = {id:?};
1188/// Design-time size in GUI pixels.
1189#[allow(dead_code)]
1190pub const WIDTH: f32 = {width};
1191#[allow(dead_code)]
1192pub const HEIGHT: f32 = {height};
1193
1194/// `on_click`/`on_change`/`on_submit` events raised by this screen's widgets.
1195#[allow(dead_code)]
1196pub mod events {{
1197{events}}}
1198
1199/// Ids of this screen's named widgets (for `Widget::find_mut`, `UiRoot::find_rect`).
1200#[allow(dead_code)]
1201pub mod ids {{
1202{ids}}}
1203
1204/// The screen's widget tree, positioned at (0, 0).
1205#[allow(dead_code)]
1206pub fn build() -> ::yog_api::ui::Widget {{
1207 #[allow(unused_imports)]
1208 {UI_USE}
1209 {body}
1210}}
1211
1212/// The screen laid out for a `screen_w` × `screen_h` GUI, anchored {anchor}
1213/// — `customize` may change the tree first (dynamic text, enabled state, ...).
1214#[allow(dead_code)]
1215pub fn root_with(
1216 screen_w: f32,
1217 screen_h: f32,
1218 customize: impl FnOnce(&mut ::yog_api::ui::Widget),
1219) -> ::yog_api::ui::UiRoot {{
1220 use ::yog_api::ui::{{widget, FlexDir, Units}};
1221 let mut content = build();
1222 customize(&mut content);
1223 let screen = widget::panel(FlexDir::Column)
1224 .w(Units::Px(screen_w))
1225 .h(Units::Px(screen_h))
1226 .child({place});
1227 let mut ui = ::yog_api::ui::UiRoot::new(ID, screen);
1228 ui.layout(screen_w, screen_h);
1229 ui
1230}}
1231
1232/// [`root_with`] without changes.
1233#[allow(dead_code)]
1234pub fn root(screen_w: f32, screen_h: f32) -> ::yog_api::ui::UiRoot {{
1235 root_with(screen_w, screen_h, |_| {{}})
1236}}
1237
1238static __YUI_LAST: ::std::sync::Mutex<Option<::yog_api::ui::UiRoot>> = ::std::sync::Mutex::new(None);
1239
1240/// Draws the screen — call from `on_ui_render(ID, ...)`. Remembers the
1241/// layout for [`click`].
1242#[allow(dead_code)]
1243pub fn render(gfx: &::yog_api::GfxContext) {{
1244 render_with(gfx, |_| {{}})
1245}}
1246
1247/// [`render`], changing the tree first (see [`root_with`]).
1248#[allow(dead_code)]
1249pub fn render_with(gfx: &::yog_api::GfxContext, customize: impl FnOnce(&mut ::yog_api::ui::Widget)) {{
1250 let (w, h) = gfx.screen_size();
1251 let ui = root_with(w as f32, h as f32, customize);
1252 ui.render(gfx);
1253 if let Ok(mut last) = __YUI_LAST.lock() {{
1254 *last = Some(ui);
1255 }}
1256}}
1257
1258/// The `on_click` event of the widget under a `"click:X:Y"` UI event (what
1259/// `register_ui`'s handler receives) — `None` for other events or a miss.
1260#[allow(dead_code)]
1261pub fn click(event: &str) -> Option<String> {{
1262 let mut parts = event.strip_prefix("click:")?.split(':');
1263 let x: f32 = parts.next()?.parse().ok()?;
1264 let y: f32 = parts.next()?.parse().ok()?;
1265 let last = __YUI_LAST.lock().ok()?;
1266 last.as_ref()?.hit_test(x, y)?.on_click.clone()
1267}}
1268
1269/// Runs `f` on the layout drawn by the last [`render`] — for reading
1270/// rects and scroll state (`UiRoot::find_node`) when handling UI events.
1271/// `None` before the first render.
1272#[allow(dead_code)]
1273pub fn with_last<R>(f: impl FnOnce(&::yog_api::ui::UiRoot) -> R) -> Option<R> {{
1274 let last = __YUI_LAST.lock().ok()?;
1275 last.as_ref().map(f)
1276}}
1277
1278/// Feeds a UI event to the text box `id` (one of [`ids`]) as laid out by the
1279/// last [`render`] — call from `register_ui`'s handler; pass the same state
1280/// to `TextBoxState::apply` in [`render_with`]'s customize. `Ignored` before
1281/// the first render or for an unknown id.
1282#[allow(dead_code)]
1283pub fn text_box(
1284 id: &str,
1285 state: &mut ::yog_api::ui::TextBoxState,
1286 event: &str,
1287) -> ::yog_api::ui::TextBoxEvent {{
1288 with_last(|ui| ui.find_node(id).map(|node| state.handle(event, node)))
1289 .flatten()
1290 .unwrap_or(::yog_api::ui::TextBoxEvent::Ignored)
1291}}
1292
1293/// Opens the screen (modal: {modal}, pauses the game: {pause}).
1294#[allow(dead_code)]
1295pub fn open() {{
1296 ::yog_api::open_ui(ID, {modal}, {pause});
1297}}
1298
1299/// Closes the screen if it's open (what Esc does) — for a Done/Cancel
1300/// button. Takes effect after the current event handler returns.
1301#[allow(dead_code)]
1302pub fn close() {{
1303 ::yog_api::close_ui(ID);
1304}}
1305"#,
1306 width = f(width),
1307 height = f(height),
1308 events = str_consts(codegen.events.as_slice()),
1309 ids = str_consts(codegen.ids.as_slice()),
1310 );
1311 Ok((out, Vec::new()))
1313}
1314
1315pub const GENERATED_DIR: &str = ".yog-build/generated";
1317
1318fn absolute(path: &Path) -> PathBuf {
1319 if path.is_absolute() {
1320 path.to_path_buf()
1321 } else {
1322 std::env::current_dir().unwrap_or_default().join(path)
1323 }
1324}
1325
1326pub fn project_root(yui: &Path) -> Option<PathBuf> {
1328 absolute(yui)
1329 .ancestors()
1330 .skip(1)
1331 .find(|d| d.join(crate::manifest::MANIFEST).is_file())
1332 .map(Path::to_path_buf)
1333}
1334
1335pub fn include_name(root: &Path, yui: &Path) -> String {
1338 let abs = absolute(yui);
1339 let rel = abs
1340 .strip_prefix(root.join("src"))
1341 .or_else(|_| abs.strip_prefix(root))
1342 .unwrap_or(&abs)
1343 .with_extension("");
1344 rel.components()
1345 .map(|c| c.as_os_str().to_string_lossy().into_owned())
1346 .collect::<Vec<_>>()
1347 .join("/")
1348}
1349
1350pub fn generated_path(root: &Path, name: &str) -> PathBuf {
1353 root.join(GENERATED_DIR).join(format!("{name}.g.rs"))
1354}
1355
1356pub fn generate_file(root: &Path, yui: &Path) -> Result<PathBuf, String> {
1360 let src = std::fs::read_to_string(yui).map_err(|e| format!("{}: {e}", yui.display()))?;
1361 let name = include_name(root, yui);
1362 let mod_id = std::fs::read_to_string(root.join(crate::manifest::MANIFEST))
1363 .ok()
1364 .and_then(|t| crate::manifest::parse_yog_toml(&t).ok())
1365 .map(|t| t.id);
1366 let (code, warnings) = generate_for(&src, &name, mod_id.as_deref())
1367 .map_err(|e| format!("{}: {e}", yui.display()))?;
1368 for w in warnings {
1369 eprintln!(" warning: {}: {w}", yui.display());
1370 }
1371 let out = generated_path(root, &name);
1372 crate::write_if_changed(&out, &code)?;
1373 Ok(out)
1374}
1375
1376fn find_files(dir: &Path, ext: &str) -> Vec<PathBuf> {
1378 let mut found = Vec::new();
1379 let Ok(entries) = std::fs::read_dir(dir) else {
1380 return found;
1381 };
1382 for entry in entries.flatten() {
1383 let path = entry.path();
1384 if path.is_dir() {
1385 found.extend(find_files(&path, ext));
1386 } else if path.extension().is_some_and(|e| e == ext) {
1387 found.push(path);
1388 }
1389 }
1390 found.sort();
1391 found
1392}
1393
1394pub fn find_yui_files(dir: &Path) -> Vec<PathBuf> {
1396 find_files(dir, "yui")
1397}
1398
1399pub fn generate_all(root: &Path) -> Result<usize, String> {
1402 let files = find_yui_files(&root.join("src"));
1403 let mut wanted = Vec::new();
1404 for file in &files {
1405 wanted.push(generate_file(root, file)?);
1406 }
1407 wanted.push(crate::yui_component::write_index(root)?);
1409 let res_index = root.join(crate::yres::GENERATED_FILE);
1411 let res_dir = root.join(crate::yres::GENERATED_DIR);
1412 let content_file = root.join(crate::content::GENERATED_FILE);
1414 for stale in find_files(&root.join(GENERATED_DIR), "rs") {
1415 if stale.to_string_lossy().ends_with(".g.rs")
1416 && !wanted.contains(&stale)
1417 && stale != res_index
1418 && stale != content_file
1419 && !stale.starts_with(&res_dir)
1420 {
1421 let _ = std::fs::remove_file(&stale);
1422 }
1423 }
1424 Ok(files.len())
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429 use super::*;
1430
1431 fn screen(body: &str) -> String {
1432 format!(r#"<Screen id="mymod:s">{body}</Screen>"#)
1433 }
1434
1435 #[test]
1436 fn custom_components() {
1437 let (code, _) = generate_for(
1438 &screen(r##"<StatBar x="4" w="80" label="@@hp" value="0.5" on_select="pick" bg="#FF000000"/>"##),
1439 "s",
1440 None,
1441 )
1442 .unwrap();
1443 assert!(
1444 code.contains(r#"::yog_api::ui::component::build("StatBar", &[("label", &*"@hp"), ("value", "0.5"), ("on_select", "pick")])"#),
1445 "{code}"
1446 );
1447 assert!(
1448 code.contains(".bg(0xFF000000)") && code.contains(".w(Units::Px(80.0))"),
1449 "{code}"
1450 );
1451 assert!(!code.contains(".value("), "{code}");
1452 assert!(generate_for(&screen(r#"<statBar/>"#), "s", None).is_err());
1453 assert!(generate_for(&screen(r#"<StatBar><Label/></StatBar>"#), "s", None).is_err());
1454 }
1455
1456 #[test]
1457 fn surfaces() {
1458 let (code, _) = generate_for(
1459 &screen(r#"<Surface w="100" h="50" source="mod:grid"/>"#),
1460 "s",
1461 Some("example"),
1462 )
1463 .unwrap();
1464 assert!(
1465 code.contains(r#"widget::surface("example:grid")"#),
1466 "{code}"
1467 );
1468 let (code, _) =
1469 generate_for(&screen(r#"<Surface source="other:pad"/>"#), "s", None).unwrap();
1470 assert!(code.contains(r#"widget::surface("other:pad")"#), "{code}");
1471 assert!(generate_for(&screen(r#"<Surface/>"#), "s", None).is_err());
1472 assert!(generate_for(&screen(r#"<Surface source="pad"/>"#), "s", None).is_err());
1473 assert!(generate_for(&screen(r#"<Surface source="mod:pad"/>"#), "s", None).is_err());
1474 assert!(generate_for(&screen(r#"<Surface source="a:b" text="x"/>"#), "s", None).is_err());
1475 }
1476
1477 #[test]
1478 fn image_sources() {
1479 let (code, warnings) = generate_for(
1480 &screen(r##"<Image w="156" h="29" source="item:apple" stretch="UniformToFill" tint="#80FFFFFF"/>"##),
1481 "s",
1482 None,
1483 )
1484 .unwrap();
1485 assert!(code.contains(r#"widget::image("item:apple").stretch(::yog_api::ui::Stretch::UniformToFill).tint(0x80FFFFFF)"#), "{code}");
1486 assert!(
1487 code.contains(".w(Units::Px(156.0))") || code.contains("w(156"),
1488 "{code}"
1489 );
1490 assert!(warnings.is_empty(), "{warnings:?}");
1491
1492 let (code, _) = generate_for(
1493 &screen(r#"<Image source="mod:gui/logo" uv="0 0 32 16"/>"#),
1494 "s",
1495 Some("mymod"),
1496 )
1497 .unwrap();
1498 assert!(
1499 code.contains(
1500 r#"widget::image("mymod:textures/gui/logo.png").region([0.0, 0.0, 32.0, 16.0])"#
1501 ),
1502 "{code}"
1503 );
1504 assert!(generate_for(&screen(r#"<Image source="mod:gui/logo"/>"#), "s", None).is_err());
1505 assert!(generate_for(&screen(r#"<Image source="sprite:nope"/>"#), "s", None).is_err());
1506 assert!(generate_for(&screen(r#"<Image/>"#), "s", None).is_err());
1507 assert!(
1508 generate_for(
1509 &screen(r#"<Image source="item:apple" stretch="big"/>"#),
1510 "s",
1511 None
1512 )
1513 .is_err()
1514 );
1515 }
1516
1517 #[test]
1518 fn raw_texture_paths_are_errors_naming_the_alias() {
1519 let err = generate_for(
1520 &screen(r#"<Image source="minecraft:textures/item/apple.png"/>"#),
1521 "s",
1522 None,
1523 )
1524 .unwrap_err();
1525 assert!(err.contains(r#"source="item:apple""#), "{err}");
1526 assert!(
1527 generate_for(&screen(r#"<Image texture="item:apple"/>"#), "s", None)
1528 .unwrap_err()
1529 .contains("no attribute `texture`")
1530 );
1531 }
1532
1533 #[test]
1534 fn image_backgrounds() {
1535 let code = generate_for(&screen(r##"<Column bg="sprite:panel"><Label bg="#FF000000"/></Column>"##), "s", None)
1536 .unwrap()
1537 .0;
1538 assert!(code.contains(r#"bg_image("sprite:panel")"#), "{code}");
1539 assert!(code.contains("bg(0xFF000000)"), "{code}");
1540 let err = generate_for(&screen(r#"<Column bg="sprite:nope"/>"#), "s", None).unwrap_err();
1541 assert!(err.contains("<Column> bg=\"sprite:nope\": unknown sprite"), "{err}");
1542 }
1543
1544 #[test]
1545 fn inventory_slots() {
1546 let code = generate_for(
1547 &screen(r#"<Column><Slot index="2"/><PlayerInventory/></Column>"#),
1548 "s",
1549 None,
1550 )
1551 .unwrap()
1552 .0;
1553 assert!(code.contains("widget::slot(2)"), "{code}");
1554 assert!(code.contains("widget::player_inventory()"), "{code}");
1555 let code = generate_for(&screen(r#"<Slot index="1" accepts="minecraft:redstone #c:dusts"/>"#), "s", None).unwrap().0;
1556 assert!(code.contains(r##"widget::slot_accepting(1, &["minecraft:redstone", "#c:dusts"])"##), "{code}");
1557 let err = generate_for(&screen(r#"<Slot index="1" accepts="redstone"/>"#), "s", None).unwrap_err();
1558 assert!(err.contains("with its namespace"), "{err}");
1559 let err = generate_for(&screen(r#"<Slot index="first"/>"#), "s", None).unwrap_err();
1560 assert!(err.contains("expected the container slot's number"), "{err}");
1561 }
1562
1563 const SAMPLE: &str = r##"<?xml version="1.0" encoding="utf-8"?>
1564<!-- a comment -->
1565<Screen id="mymod:main" width="176" height="166" bg="#C0101010" padding="8">
1566 <Label id="title" x="8" y="6" text="Main & Menu" color="#FFFFFF" no_wrap="true"/>
1567 <Button x="38" y="40" w="100" h="20" text="Play" bg="#FF555555" on_click="mymod:play"/>
1568 <Row x="8" y="70" w="50%" gap="2" align="center">
1569 <InvSlot item="minecraft:diamond" count="3"/>
1570 <Spacer flex="1"/>
1571 </Row>
1572 <ScrollView id="list" x="0" y="100" w="80" h="40">
1573 <Label text="A long line" ellipsis="true"/>
1574 </ScrollView>
1575</Screen>
1576"##;
1577
1578 #[test]
1579 fn parses_the_subset() {
1580 let root = parse_xml(SAMPLE).unwrap();
1581 assert_eq!(root.name, "Screen");
1582 assert_eq!(root.children.len(), 4);
1583 assert_eq!(root.children[0].attr("text"), Some("Main & Menu"));
1584 assert_eq!(root.children[2].children[0].attr("count"), Some("3"));
1585 assert_eq!(root.children[1].line, 5);
1586 }
1587
1588 #[test]
1589 fn generates_builder_code() {
1590 let code = generate(SAMPLE, "main").unwrap();
1591 assert!(code.contains(r#"pub const ID: &str = "mymod:main";"#));
1592 assert!(code.contains(r#"widget::label("Main & Menu")"#));
1593 assert!(code.contains(".at(8.0, 6.0)"));
1594 assert!(code.contains(".color(0xFFFFFFFF)"));
1595 assert!(code.contains(".bg(0xC0101010)"));
1596 assert!(code.contains(".no_wrap()"));
1597 assert!(code.contains(".w(Units::Percent(50.0))"));
1598 assert!(code.contains("widget::inv_slot(\"minecraft:diamond\", 3)"));
1599 assert!(code.contains(r#"pub const MYMOD_PLAY: &str = "mymod:play";"#));
1600 assert!(code.contains(r#"pub const TITLE: &str = "title";"#));
1601 assert!(code.contains(".padding(8.0, 8.0, 8.0, 8.0)"));
1602 assert!(code.contains("widget::scroll_view()"));
1603 assert!(code.contains(".ellipsis()"));
1604 assert!(code.contains("pub fn with_last<R>"));
1605 assert!(code.starts_with("// <auto-generated>"));
1606 assert!(code.contains(r#"yog_api::ui_include!("main");"#));
1607 assert!(code.contains("pub fn close()"));
1608 assert!(!code.contains("widget::spacer()\n .at("));
1610 }
1611
1612 #[test]
1613 fn reports_errors_with_lines() {
1614 let bad = "<Screen id=\"a:b\">\n <Label txt=\"x\"/>\n</Screen>";
1615 let err = generate(bad, "x.yui").unwrap_err();
1616 assert!(err.contains("line 2") && err.contains("txt"), "{err}");
1617 assert!(generate("<Screen id=\"a:b\"><Label/></Scren>", "x.yui").is_err());
1618 assert!(generate("<Column/>", "x.yui").is_err());
1619 assert!(generate("<Screen/>", "x.yui").unwrap_err().contains("id"));
1620 let bad_color = "<Screen id=\"a:b\" bg=\"red\"/>";
1621 assert!(
1622 generate(bad_color, "x.yui")
1623 .unwrap_err()
1624 .contains("#RRGGBB")
1625 );
1626 }
1627
1628 #[test]
1631 fn parses_formatted_markup() {
1632 let src = r##"<?xml version="1.0" encoding="utf-8"?>
1633<Screen id="mymod:main"
1634 width="176"
1635 height="166"
1636 bg="#C0101010">
1637 <!-- the title -->
1638 <Label id="title"
1639 x="8"
1640 y="6"
1641 text="Main"
1642 no_wrap="true" />
1643
1644 <Row x="8" y="70" w="160">
1645 <!--
1646 a multi-line comment
1647 -->
1648 <Spacer flex="1" />
1649 </Row>
1650</Screen>
1651"##;
1652 let root = parse_xml(src).unwrap();
1653 assert_eq!(root.attr("height"), Some("166"));
1654 assert_eq!(root.children.len(), 2);
1655 assert_eq!(root.children[0].attr("no_wrap"), Some("true"));
1656 assert_eq!(root.children[0].line, 7);
1657 assert_eq!(root.children[1].children.len(), 1);
1658 let code = generate(src, "main.yui").unwrap();
1659 assert!(code.contains(".at(8.0, 6.0)"));
1660 }
1661
1662 #[test]
1663 fn text_box_and_font() {
1664 let src = r#"<Screen id="a:b">
1665 <TextBox id="search" w="100%" placeholder="Search..." max_length="64" on_change="search" on_submit="go" font="minecraft:uniform" shadow="false"/>
1666</Screen>"#;
1667 let code = generate(src, "x").unwrap();
1668 assert!(code.contains(r#"widget::text_box("")"#), "{code}");
1669 assert!(code.contains(r#".placeholder("Search...")"#));
1670 assert!(code.contains(".max_length(64)"));
1671 assert!(code.contains(r#".on_change("search")"#));
1672 assert!(code.contains(r#".on_submit("go")"#));
1673 assert!(code.contains(r#".font("minecraft:uniform")"#));
1674 assert!(code.contains(".shadow(false)"));
1675 assert!(code.contains(r#"pub const GO: &str = "go";"#));
1676 assert!(code.contains("pub fn text_box("));
1677 let bad = r#"<Screen id="a:b"><TextBox max_length="x"/></Screen>"#;
1678 assert!(generate(bad, "x").unwrap_err().contains("whole number"));
1679 }
1680
1681 #[test]
1682 fn generated_paths() {
1683 let dir = std::env::temp_dir().join(format!("yog-yui-test-{}", std::process::id()));
1684 let _ = std::fs::remove_dir_all(&dir);
1685 std::fs::create_dir_all(dir.join("src/screens")).unwrap();
1686 std::fs::write(dir.join("yog.toml"), "").unwrap();
1687 let yui = dir.join("src/screens/opts.yui");
1688 std::fs::write(&yui, r#"<Screen id="a:b"/>"#).unwrap();
1689 assert_eq!(project_root(&yui).as_deref(), Some(dir.as_path()));
1690 assert_eq!(include_name(&dir, &yui), "screens/opts");
1691 assert_eq!(generate_all(&dir).unwrap(), 1);
1692 let generated = dir.join(".yog-build/generated/screens/opts.g.rs");
1693 assert!(generated.is_file());
1694 std::fs::remove_file(&yui).unwrap();
1695 generate_all(&dir).unwrap();
1696 assert!(!generated.exists());
1697 let _ = std::fs::remove_dir_all(&dir);
1698 }
1699
1700 #[test]
1701 fn const_names() {
1702 assert_eq!(const_name("mymod:btn-play"), "MYMOD_BTN_PLAY");
1703 assert_eq!(const_name("openMenu"), "OPEN_MENU");
1704 assert_eq!(const_name("1st"), "_1ST");
1705 }
1706
1707 #[test]
1708 fn text_starting_with_at_is_a_translation_key() {
1709 assert_eq!(text_expr("@mymod.title"), "::yog_api::tr(\"mymod.title\")");
1710 assert_eq!(text_expr("@@home"), "\"@home\"");
1711 assert_eq!(text_expr("plain"), "\"plain\"");
1712 assert_eq!(text_expr("@"), "\"@\"");
1713 let mut refs = crate::yres::Refs::default();
1714 refs.texts.insert(
1715 "Strings.title".into(),
1716 ("mymod.title".into(), "Title".into()),
1717 );
1718 set_resource_refs(&refs);
1719 assert_eq!(
1720 text_expr("@Strings.title"),
1721 "::yog_api::tr(\"mymod.title\")"
1722 );
1723 set_resource_refs(&crate::yres::Refs::default());
1724 let code = generate(
1725 r#"<Screen id="m:s" width="10" height="10"><Label text="@m.title"/><Button text="@@x"/></Screen>"#,
1726 "s.yui",
1727 )
1728 .unwrap();
1729 assert!(
1730 code.contains("widget::label(::yog_api::tr(\"m.title\"))"),
1731 "{code}"
1732 );
1733 assert!(code.contains("widget::button(\"@x\")"), "{code}");
1734 }
1735
1736 #[test]
1737 fn color_attributes_resolve_yres_colors() {
1738 let mut refs = crate::yres::Refs::default();
1739 refs.colors.insert("Colors.text".into(), 0xFF12_3456);
1740 set_resource_refs(&refs);
1741 let code = generate(
1742 r#"<Screen id="m:s" width="10" height="10"><Label text="x" color="@Colors.text"/></Screen>"#,
1743 "s.yui",
1744 );
1745 let missing = generate(
1746 r#"<Screen id="m:s" width="10" height="10"><Label text="x" color="@Colors.nope"/></Screen>"#,
1747 "s.yui",
1748 );
1749 set_resource_refs(&crate::yres::Refs::default());
1750 let code = code.unwrap();
1751 assert!(code.contains("0xFF123456"), "{code}");
1752 assert!(missing.unwrap_err().contains("no color `Colors.nope`"));
1753 }
1754}