1use std::fmt::Write as _;
32use std::path::{Path, PathBuf};
33
34use crate::yui::{self, Element, RAW_CALL, RAW_VALUE, UI_USE, err};
35
36pub const INDEX_FILE: &str = ".yog-build/generated/components.g.rs";
38
39const KINDS: &[(&str, &str)] = &[
41 ("text", "String"),
42 ("number", "f32"),
43 ("int", "i64"),
44 ("bool", "bool"),
45 ("color", "u32"),
46 ("choice", "String"),
47 ("image", "String"),
48];
49
50#[derive(Debug, Clone)]
51struct Prop {
52 name: String,
53 kind: String,
55 default: String,
56 doc: String,
57 category: String,
58 options: Vec<String>,
59}
60
61const ROOT_ATTRS: &[&str] = &[
63 "name",
64 "display_name",
65 "description",
66 "icon",
67 "category",
68 "design_w",
69 "design_h",
70 "toolbox",
71];
72const PROP_ATTRS: &[&str] = &["name", "type", "default", "options", "doc", "category"];
74
75fn is_ident(s: &str) -> bool {
76 s.chars()
77 .next()
78 .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
79 && s.chars()
80 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
81}
82
83fn type_name(root: &Element, include_name: &str) -> Result<String, String> {
85 let name = match root.attr("name").map(str::trim).filter(|s| !s.is_empty()) {
86 Some(n) => n.to_string(),
87 None => yui::pascal(include_name.rsplit('/').next().unwrap_or(include_name)),
88 };
89 let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
90 && name.chars().all(|c| c.is_ascii_alphanumeric());
91 if !ok {
92 return err(
93 root,
94 format_args!("name=\"{name}\": a component's name is PascalCase (StatBar)"),
95 );
96 }
97 Ok(name)
98}
99
100fn declarations(root: &Element) -> Result<(Vec<Prop>, Option<&Element>), String> {
101 let mut props: Vec<Prop> = Vec::new();
102 let mut body = None;
103 for child in &root.children {
104 match child.name.as_str() {
105 "Property" | "Event" => {
106 for (key, _) in &child.attrs {
107 let allowed = if child.name == "Event" {
108 &["name", "doc", "category"][..]
109 } else {
110 PROP_ATTRS
111 };
112 if !allowed.contains(&key.as_str()) {
113 return err(
114 child,
115 format_args!("has no attribute `{key}` ({})", allowed.join(", ")),
116 );
117 }
118 }
119 let name = child
120 .attr("name")
121 .map(str::trim)
122 .unwrap_or_default()
123 .to_string();
124 if !is_ident(&name) {
125 return err(
126 child,
127 format_args!("name=\"{name}\": expected a snake_case name"),
128 );
129 }
130 if yui::is_common_attr(&name) {
131 return err(
132 child,
133 format_args!(
134 "`{name}` is an attribute every element has — pick another name"
135 ),
136 );
137 }
138 if props.iter().any(|p| p.name == name) {
139 return err(child, format_args!("`{name}` is declared twice"));
140 }
141 let kind = if child.name == "Event" {
142 "event".to_string()
143 } else {
144 let kind = child.attr("type").unwrap_or("text").trim().to_string();
145 if !KINDS.iter().any(|(k, _)| *k == kind) {
146 let known: Vec<&str> = KINDS.iter().map(|(k, _)| *k).collect();
147 return err(
148 child,
149 format_args!("type=\"{kind}\": expected one of {}", known.join(", ")),
150 );
151 }
152 kind
153 };
154 let options: Vec<String> = child
155 .attr("options")
156 .unwrap_or_default()
157 .split(',')
158 .map(|s| s.trim().to_string())
159 .filter(|s| !s.is_empty())
160 .collect();
161 if kind == "choice" && options.is_empty() {
162 return err(child, "a choice needs options=\"a, b, c\"");
163 }
164 let mut default = child
165 .attr("default")
166 .map(str::to_string)
167 .unwrap_or_else(|| match kind.as_str() {
168 "number" | "int" => "0".into(),
169 "bool" => "false".into(),
170 "color" => "#FFFFFFFF".into(),
171 "choice" => options[0].clone(),
172 _ => String::new(),
173 });
174 match kind.as_str() {
175 "number" if default.trim().parse::<f32>().is_err() => {
176 return err(
177 child,
178 format_args!("default=\"{default}\": expected a number"),
179 );
180 }
181 "int" if default.trim().parse::<i64>().is_err() => {
182 return err(
183 child,
184 format_args!("default=\"{default}\": expected a whole number"),
185 );
186 }
187 "bool" if !matches!(default.trim(), "true" | "false") => {
188 return err(
189 child,
190 format_args!("default=\"{default}\": expected true or false"),
191 );
192 }
193 "color" => {
194 let c = yui::color_attr(child, "default", &default)?;
196 default = format!("#{c:08X}");
197 }
198 "choice" if !options.contains(&default) => {
199 return err(
200 child,
201 format_args!("default=\"{default}\" isn't one of the options"),
202 );
203 }
204 _ => {}
205 }
206 props.push(Prop {
207 name,
208 kind,
209 default,
210 doc: child.attr("doc").unwrap_or_default().to_string(),
211 category: child.attr("category").unwrap_or_default().to_string(),
212 options,
213 });
214 }
215 _ if body.is_some() => {
216 return err(
217 child,
218 "a component has one body element (wrap several in a Column, Row or Canvas)",
219 );
220 }
221 _ => body = Some(child),
222 }
223 }
224 Ok((props, body))
225}
226
227fn bind_tree(el: &Element, counter: &mut usize) -> Result<Element, String> {
230 let kind = el.name.as_str();
231 let builtin = yui::is_builtin(kind);
232 if kind == "Component" || kind == "Screen" {
233 return err(el, "can't be inside a component");
234 }
235 let mut out = Element {
236 name: el.name.clone(),
237 attrs: Vec::new(),
238 children: Vec::new(),
239 line: el.line,
240 };
241 for (key, value) in &el.attrs {
242 if key.starts_with("d:") {
243 continue; }
245 let Some(rust) = crate::yui_vm::compile_bind(value) else {
246 out.attrs.push((key.clone(), value.clone()));
247 continue;
248 };
249 let e = rust.map_err(|e| format!("line {}: <{kind}> {key}=\"{value}\": {e}", el.line))?;
250 let conv = "::yog_api::ui::component::bind";
251 if !builtin && !yui::is_common_attr(key) {
252 out.attrs
254 .push((key.clone(), format!("{RAW_VALUE}{conv}::text(&({e}))")));
255 continue;
256 }
257 let call = match key.as_str() {
258 "text" if matches!(kind, "Label" | "Button" | "TextBox") => {
259 format!("with_text({conv}::text(&({e})))")
260 }
261 "placeholder" if kind == "TextBox" => format!("placeholder({conv}::text(&({e})))"),
262 "source" if kind == "Surface" => {
263 out.attrs.push(("source".into(), "yog:none".into()));
264 format!("with_source({conv}::text(&({e})))")
265 }
266 "source" if kind == "Image" => {
267 out.attrs.push(("source".into(), "item:air".into()));
269 format!("with_source({conv}::text(&({e})))")
270 }
271 "item" if matches!(kind, "ItemSlot" | "InvSlot") => {
272 format!("with_source({conv}::text(&({e})))")
273 }
274 "count" if kind == "InvSlot" => format!("with_count({conv}::count(&({e})))"),
275 "bg" | "color" | "focus_color" => format!("{key}({conv}::color(&({e})))"),
276 "tint" if kind == "Image" => format!("tint({conv}::color(&({e})))"),
277 "enabled" | "visible" | "shadow" => format!("{key}({conv}::flag({e}))"),
278 "w" | "h" => format!("{key}({conv}::units(&({e})))"),
279 "gap" | "flex" | "min_w" | "min_h" | "font_scale" => {
280 format!("{key}({conv}::number(&({e})))")
281 }
282 "on_click" | "on_change" | "on_submit" => format!("{key}({conv}::text(&({e})))"),
283 _ => {
284 return err(
285 el,
286 format_args!("{key}=\"{value}\": `{key}` can't be bound"),
287 );
288 }
289 };
290 out.attrs.push((format!("{RAW_CALL}{counter}"), call));
291 *counter += 1;
292 }
293 for child in &el.children {
294 out.children.push(bind_tree(child, counter)?);
295 }
296 Ok(out)
297}
298
299fn rust_str(s: &str) -> String {
300 format!("{s:?}")
301}
302
303pub fn generate(
306 root: &Element,
307 name: &str,
308 mod_id: Option<&str>,
309) -> Result<(String, Vec<String>), String> {
310 let ty = type_name(root, name)?;
311 for (key, _) in &root.attrs {
312 if !ROOT_ATTRS.contains(&key.as_str()) && !key.starts_with("d:") {
313 return err(
314 root,
315 format_args!("has no attribute `{key}` ({})", ROOT_ATTRS.join(", ")),
316 );
317 }
318 }
319 let size = |key: &str| -> Result<f32, String> {
320 match root.attr(key).map(str::trim) {
321 None | Some("") => Ok(0.0),
322 Some(v) => match v.parse::<f32>() {
323 Ok(n) if n >= 0.0 => Ok(n),
324 _ => err(
325 root,
326 format_args!("{key}=\"{v}\": expected a size in GUI pixels"),
327 ),
328 },
329 }
330 };
331 let design = (size("design_w")?, size("design_h")?);
332 let toolbox = match root.attr("toolbox").map(str::trim) {
333 None | Some("true") => true,
334 Some("false") => false,
335 Some(v) => {
336 return err(
337 root,
338 format_args!("toolbox=\"{v}\": expected true or false"),
339 );
340 }
341 };
342 let icon = root.attr("icon").unwrap_or_default().trim();
343 if !icon.is_empty() && !icon.starts_with('@') && !icon.contains(':') {
344 return err(
345 root,
346 format_args!(
347 "icon=\"{icon}\": expected an image source (item:apple, sprite:button, mod:gui/logo) or @Icons.name"
348 ),
349 );
350 }
351 let (props, body) = declarations(root)?;
352 let Some(body) = body else {
353 return err(
354 root,
355 "has no body — add one element (a Column, Row, Canvas, ...) after the declarations",
356 );
357 };
358 let mut counter = 0;
359 let bound = bind_tree(body, &mut counter)?;
360 let (tree, _ids) = yui::static_widget(&bound, false, 1, mod_id)?;
361 let doc = root.attr("description").unwrap_or_default();
362
363 let mut fields = String::new();
364 let mut init = String::new();
365 let mut decls = String::new();
366 for p in &props {
367 let rust_ty = KINDS
368 .iter()
369 .find(|(k, _)| *k == p.kind)
370 .map_or("String", |(_, t)| t);
371 if !p.doc.is_empty() {
372 let _ = writeln!(fields, " /// {}", p.doc.replace('\n', " "));
373 }
374 let _ = writeln!(fields, " pub {}: {rust_ty},", p.name);
375 let read = match p.kind.as_str() {
376 "text" => format!("p.text({})", rust_str(&p.name)),
377 "number" => format!("p.number({})", rust_str(&p.name)),
378 "int" => format!("p.int({})", rust_str(&p.name)),
379 "bool" => format!("p.bool({})", rust_str(&p.name)),
380 "color" => format!("p.color({})", rust_str(&p.name)),
381 _ => format!("p.word({}).to_string()", rust_str(&p.name)),
382 };
383 let _ = writeln!(init, " {}: {read},", p.name);
384 let decl = match p.kind.as_str() {
385 "bool" => format!("Prop::bool({}, {})", rust_str(&p.name), p.default.trim()),
386 "event" => format!("Prop::event({})", rust_str(&p.name)),
387 "choice" => format!(
388 "Prop::choice({}, &[{}], {})",
389 rust_str(&p.name),
390 p.options
391 .iter()
392 .map(|o| rust_str(o))
393 .collect::<Vec<_>>()
394 .join(", "),
395 rust_str(&p.default)
396 ),
397 k => format!("Prop::{k}({}, {})", rust_str(&p.name), rust_str(&p.default)),
398 };
399 let decl = if p.doc.is_empty() {
400 decl
401 } else {
402 format!("{decl}.doc({})", rust_str(&p.doc))
403 };
404 let decl = if p.category.is_empty() {
405 decl
406 } else {
407 format!("{decl}.category({})", rust_str(&p.category))
408 };
409 let _ = writeln!(decls, " {decl},");
410 }
411
412 let code = format!(
413 r#"// <auto-generated>
414// Generated by yog from src/{name}.yui — do not edit: changes are lost
415// on the next `yog build` / `yog check` / `yog ui gen`. Edit the .yui
416// file (Yog-IDLE's designer) instead.
417// </auto-generated>
418
419/// {doc_line}
420#[derive(Debug, Clone, PartialEq)]
421pub struct {ty} {{
422{fields}}}
423
424impl {ty} {{
425 /// The property values of an instance (defaults for what it doesn't set).
426 #[allow(unused_variables)]
427 pub fn from_props(p: &::yog_api::ui::component::Props) -> Self {{
428 Self {{
429{init} }}
430 }}
431
432 /// The component's widget tree for these values.
433 #[allow(unused_variables, clippy::needless_borrow, clippy::needless_borrows_for_generic_args)]
434 pub fn view(&self) -> ::yog_api::ui::Widget {{
435 #[allow(unused_imports)]
436 {UI_USE}
437 let vm = self;
438 {tree}
439 }}
440}}
441
442impl ::yog_api::ui::component::Component for {ty} {{
443 const NAME: &'static str = {name_lit};
444 const DOC: &'static str = {doc_lit};
445 const DISPLAY_NAME: &'static str = {display_lit};
446 const ICON: &'static str = {icon_lit};
447 const CATEGORY: &'static str = {category_lit};
448 const DESIGN_SIZE: (f32, f32) = ({design_w:?}, {design_h:?});
449 const TOOLBOX: bool = {toolbox};
450 const PROPS: &'static [::yog_api::ui::component::Prop] = {{
451 #[allow(unused_imports)]
452 use ::yog_api::ui::component::Prop;
453 &[
454{decls} ]
455 }};
456 fn build(props: &::yog_api::ui::component::Props) -> ::yog_api::ui::Widget {{
457 Self::from_props(props).view()
458 }}
459}}
460
461::yog_api::ui_component!({ty});
462"#,
463 doc_line = if doc.is_empty() {
464 format!("The `{ty}` component.")
465 } else {
466 doc.replace('\n', " ")
467 },
468 name_lit = rust_str(&ty),
469 doc_lit = rust_str(doc),
470 display_lit = rust_str(root.attr("display_name").unwrap_or_default()),
471 icon_lit = rust_str(icon),
472 category_lit = rust_str(root.attr("category").unwrap_or_default()),
473 design_w = design.0,
474 design_h = design.1,
475 );
476 Ok((code, Vec::new()))
477}
478
479fn is_component_file(path: &Path) -> bool {
481 std::fs::read_to_string(path)
482 .ok()
483 .and_then(|s| yui::parse_xml(&s).ok())
484 .is_some_and(|r| r.name == "Component")
485}
486
487pub fn write_index(root: &Path) -> Result<PathBuf, String> {
492 let mut out = String::from(
493 "// <auto-generated> by yog: component .yui files without a code-behind,\n\
494 // compiled in by `yog_api::ui_components!()` — do not edit. </auto-generated>\n",
495 );
496 let mut count = 0;
497 for file in yui::find_yui_files(&root.join("src")) {
498 if file.with_extension("rs").is_file() || !is_component_file(&file) {
499 continue;
500 }
501 let name = yui::include_name(root, &file);
502 let module = name.replace(['/', '-'], "_");
503 let _ = writeln!(
504 out,
505 "#[allow(unused_imports)]\npub mod {module} {{\n use super::*;\n include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{name}.g.rs\"));\n}}"
506 );
507 count += 1;
508 }
509 if count > 0 {
510 let lib = std::fs::read_to_string(root.join("src/lib.rs")).unwrap_or_default();
511 if !lib.contains("ui_components!") {
512 eprintln!(
513 " warning: {count} component(s) without a code-behind aren't compiled in — add `yog_api::ui_components!();` to src/lib.rs"
514 );
515 }
516 }
517 let path = root.join(INDEX_FILE);
518 crate::write_if_changed(&path, &out)?;
519 Ok(path)
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 fn codegen(src: &str) -> Result<String, String> {
527 generate(&yui::parse_xml(src)?, "ui/stat_bar", None).map(|(c, _)| c)
528 }
529
530 #[test]
531 fn generates_a_component() {
532 let code = codegen(r##"<Component description="A bar." icon="@Icons.bar" category="Bars" design_w="120" design_h="10" d:bg="#FF000000">
533 <Property name="label" default="HP" doc="Left text" category="Text"/>
534 <Property name="value" type="number" default="0.5"/>
535 <Property name="fill" type="color" default="#FF55FF55"/>
536 <Property name="size" type="choice" options="small, big"/>
537 <Property name="wide" type="bool" default="true"/>
538 <Event name="on_select"/>
539 <Row w="100%" gap="4" on_click="{x:Bind on_select}">
540 <Label text="{x:Bind label}" d:text="Sample" visible="{x:Bind wide}"/>
541 <Spacer w="{x:Bind bar_w()}" h="6" bg="{x:Bind fill}"/>
542 <Pill text="{x:Bind label}" tone="red"/>
543 </Row>
544 </Component>"##)
545 .unwrap();
546 for want in [
547 "pub struct StatBar {",
548 " /// Left text\n pub label: String,",
549 "pub value: f32,",
550 "pub fill: u32,",
551 "label: p.text(\"label\"),",
552 "size: p.word(\"size\").to_string(),",
553 "Prop::text(\"label\", \"HP\").doc(\"Left text\").category(\"Text\")",
554 "const DOC: &'static str = \"A bar.\";",
555 "const ICON: &'static str = \"@Icons.bar\";",
556 "const CATEGORY: &'static str = \"Bars\";",
557 "const DESIGN_SIZE: (f32, f32) = (120.0, 10.0);",
558 "const TOOLBOX: bool = true;",
559 "Prop::choice(\"size\", &[\"small\", \"big\"], \"small\")",
560 "Prop::bool(\"wide\", true)",
561 "Prop::event(\"on_select\")",
562 "const NAME: &'static str = \"StatBar\";",
563 ".with_text(::yog_api::ui::component::bind::text(&(vm.label)))",
564 ".visible(::yog_api::ui::component::bind::flag(vm.wide))",
565 ".w(::yog_api::ui::component::bind::units(&(vm.bar_w())))",
566 ".bg(::yog_api::ui::component::bind::color(&(vm.fill)))",
567 ".on_click(::yog_api::ui::component::bind::text(&(vm.on_select)))",
568 "component::build(\"Pill\", &[(\"text\", &*::yog_api::ui::component::bind::text(&(vm.label))), (\"tone\", \"red\")])",
569 "::yog_api::ui_component!(StatBar);",
570 ] {
571 assert!(code.contains(want), "missing {want:?} in\n{code}");
572 }
573 }
574
575 #[test]
576 fn color_properties_take_yres_colors() {
577 let mut refs = crate::yres::Refs::default();
578 refs.colors.insert("Colors.fill".into(), 0xFF01_0203);
579 crate::yui::set_resource_refs(&refs);
580 let code = codegen(
581 r#"<Component><Property name="fill" type="color" default="@Colors.fill"/><Row bg="@Colors.fill"/></Component>"#,
582 );
583 let screen = crate::yui::generate_for(
584 r#"<Screen id="m:s" width="10" height="10"><Example fill="@Colors.fill"/></Screen>"#,
585 "s",
586 None,
587 );
588 crate::yui::set_resource_refs(&crate::yres::Refs::default());
589 let code = code.unwrap();
590 assert!(
591 code.contains(r##"Prop::color("fill", "#FF010203")"##),
592 "{code}"
593 );
594 assert!(code.contains(".bg(0xFF010203)"), "{code}");
595 let (screen, _) = screen.unwrap();
596 assert!(screen.contains(r##"("fill", "#FF010203")"##), "{screen}");
597 }
598
599 #[test]
600 fn rejects_bad_components() {
601 for (src, msg) in [
602 (r#"<Component name="bad"><Row/></Component>"#, "PascalCase"),
603 (
604 r#"<Component><Property name="w"/><Row/></Component>"#,
605 "every element has",
606 ),
607 (
608 r#"<Component><Property name="halign"/><Row/></Component>"#,
609 "every element has",
610 ),
611 (
612 r#"<Component><Property name="margin"/><Row/></Component>"#,
613 "every element has",
614 ),
615 (
616 r#"<Component><Property name="a" type="float"/><Row/></Component>"#,
617 "expected one of",
618 ),
619 (
620 r#"<Component><Property name="a" type="number" default="x"/><Row/></Component>"#,
621 "expected a number",
622 ),
623 (r#"<Component><Row/><Row/></Component>"#, "one body element"),
624 (
625 r#"<Component doc="x"><Row/></Component>"#,
626 "no attribute `doc`",
627 ),
628 (
629 r#"<Component design_w="wide"><Row/></Component>"#,
630 "expected a size",
631 ),
632 (
633 r#"<Component toolbox="no"><Row/></Component>"#,
634 "expected true or false",
635 ),
636 (
637 r#"<Component icon="apple"><Row/></Component>"#,
638 "image source",
639 ),
640 (
641 r#"<Component><Event name="on_x" type="text"/><Row/></Component>"#,
642 "no attribute `type`",
643 ),
644 (
645 r#"<Component><Property name="a"/></Component>"#,
646 "has no body",
647 ),
648 (
649 r#"<Component><Row padding="{x:Bind a}"/></Component>"#,
650 "can't be bound",
651 ),
652 (
653 r#"<Component><Row bg="{x:Bind cx.close()}"/></Component>"#,
654 "cx",
655 ),
656 ] {
657 let e = codegen(src).unwrap_err();
658 assert!(e.contains(msg), "{src}: {e}");
659 }
660 }
661
662 #[test]
663 fn index_lists_components_without_code_behind() {
664 let dir = std::env::temp_dir().join(format!("yog-components-{}", std::process::id()));
665 let _ = std::fs::remove_dir_all(&dir);
666 std::fs::create_dir_all(dir.join("src/ui")).unwrap();
667 std::fs::write(dir.join("yog.toml"), "").unwrap();
668 std::fs::write(
669 dir.join("src/ui/pill.yui"),
670 "<Component><Label/></Component>",
671 )
672 .unwrap();
673 std::fs::write(dir.join("src/bar.yui"), "<Component><Label/></Component>").unwrap();
674 std::fs::write(dir.join("src/bar.rs"), "").unwrap();
675 std::fs::write(dir.join("src/main.yui"), r#"<Screen id="a:b"/>"#).unwrap();
676 let index = std::fs::read_to_string(write_index(&dir).unwrap()).unwrap();
677 assert!(
678 index.contains("pub mod ui_pill {") && index.contains("/generated/ui/pill.g.rs"),
679 "{index}"
680 );
681 assert!(!index.contains("bar") && !index.contains("main"), "{index}");
682 let _ = std::fs::remove_dir_all(&dir);
683 }
684}