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