1use std::fmt::Write as _;
32use std::path::{Path, PathBuf};
33
34use yog_ids::prop_kind as pk;
35use yog_ids::yui_attr as at;
36use yog_ids::yui_component as ca;
37
38use crate::ui::yui::{self, Element, RAW_CALL, RAW_VALUE, UI_USE, err};
39use yog_data::Diagnostic;
40
41pub const INDEX_FILE: &str = yog_ids::concatcp!(
43 yog_ids::project::GENERATED,
44 yog_ids::sep::PATH,
45 "components.g.rs"
46);
47
48struct KindSpec {
51 word: &'static str,
52 rust: &'static str,
53 typed: bool,
54}
55
56const KINDS: [KindSpec; 7] = [
58 KindSpec {
59 word: pk::TEXT,
60 rust: "String",
61 typed: true,
62 },
63 KindSpec {
64 word: pk::NUMBER,
65 rust: "f32",
66 typed: true,
67 },
68 KindSpec {
69 word: pk::INT,
70 rust: "i64",
71 typed: true,
72 },
73 KindSpec {
74 word: pk::BOOL,
75 rust: "bool",
76 typed: true,
77 },
78 KindSpec {
79 word: pk::COLOR,
80 rust: "u32",
81 typed: true,
82 },
83 KindSpec {
84 word: pk::CHOICE,
85 rust: "String",
86 typed: false,
87 },
88 KindSpec {
89 word: pk::IMAGE,
90 rust: "String",
91 typed: false,
92 },
93];
94
95const WHITE: &str = "#FFFFFFFF";
97const OPTION_SEP: char = ',';
99
100#[derive(Debug, Clone)]
101struct Prop {
102 name: String,
103 kind: String,
105 default: String,
106 doc: String,
107 category: String,
108 options: Vec<String>,
109}
110
111const ROOT_ATTRS: &[&str] = &[
113 ca::NAME,
114 ca::DISPLAY_NAME,
115 ca::DESCRIPTION,
116 ca::ICON,
117 ca::CATEGORY,
118 ca::DESIGN_W,
119 ca::DESIGN_H,
120 ca::TOOLBOX,
121];
122const PROP_ATTRS: &[&str] = &[
124 ca::NAME,
125 ca::TYPE,
126 ca::DEFAULT,
127 ca::OPTIONS,
128 ca::DOC,
129 ca::CATEGORY,
130];
131const EVENT_ATTRS: &[&str] = &[ca::NAME, ca::DOC, ca::CATEGORY];
132
133fn is_ident(s: &str) -> bool {
135 s.chars()
136 .next()
137 .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
138 && s.chars()
139 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
140}
141
142fn type_name(root: &Element, include_name: &str) -> Result<String, Diagnostic> {
144 let name = match root.attr(ca::NAME).map(str::trim).filter(|s| !s.is_empty()) {
145 Some(n) => n.to_string(),
146 None => yog_codegen::pascal(
147 include_name
148 .rsplit(yog_ids::sep::PATH)
149 .next()
150 .unwrap_or(include_name),
151 ),
152 };
153 let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
154 && name.chars().all(|c| c.is_ascii_alphanumeric());
155 if !ok {
156 return err(
157 root,
158 format_args!("name=\"{name}\": a component's name is PascalCase (StatBar)"),
159 );
160 }
161 Ok(name)
162}
163
164pub(crate) fn default_of(kind: &str, options: &[String]) -> String {
166 match kind {
167 pk::NUMBER | pk::INT => "0".into(),
168 pk::BOOL => yog_ids::yui_value::FALSE.into(),
169 pk::COLOR => WHITE.into(),
170 pk::CHOICE => options.first().cloned().unwrap_or_default(),
171 _ => String::new(),
172 }
173}
174
175pub(crate) fn options_of(written: Option<&str>) -> Vec<String> {
177 written
178 .unwrap_or_default()
179 .split(OPTION_SEP)
180 .map(|s| s.trim().to_string())
181 .filter(|s| !s.is_empty())
182 .collect()
183}
184
185fn declarations(root: &Element) -> Result<(Vec<Prop>, Option<&Element>), Diagnostic> {
187 let mut props: Vec<Prop> = Vec::new();
188 let mut body = None;
189 for child in &root.children {
190 if matches!(
191 child.name.as_str(),
192 yog_ids::yui::PROPERTY | yog_ids::yui::EVENT
193 ) {
194 let p = declaration(child)?;
195 if props.iter().any(|q| q.name == p.name) {
196 return err(child, format_args!("`{}` is declared twice", p.name));
197 }
198 props.push(p);
199 } else if body.is_some() {
200 return err(
201 child,
202 "a component has one body element (wrap several in a Column, Row or Canvas)",
203 );
204 } else {
205 body = Some(child);
206 }
207 }
208 Ok((props, body))
209}
210
211fn declaration(child: &Element) -> Result<Prop, Diagnostic> {
213 let event = child.name == yog_ids::yui::EVENT;
214 let allowed = if event { EVENT_ATTRS } else { PROP_ATTRS };
215 if let Some((key, _)) = child
216 .attrs
217 .iter()
218 .find(|(k, _)| !allowed.contains(&k.as_str()))
219 {
220 return err(
221 child,
222 format_args!("has no attribute `{key}` ({})", allowed.join(", ")),
223 );
224 }
225 let name = child
226 .attr(ca::NAME)
227 .map(str::trim)
228 .unwrap_or_default()
229 .to_string();
230 if !is_ident(&name) {
231 return err(
232 child,
233 format_args!("name=\"{name}\": expected a snake_case name"),
234 );
235 }
236 if yui::is_common_attr(&name) {
237 return err(
238 child,
239 format_args!("`{name}` is an attribute every element has — pick another name"),
240 );
241 }
242 let kind = if event {
243 pk::EVENT.to_string()
244 } else {
245 declared_kind(child)?
246 };
247 let options = options_of(child.attr(ca::OPTIONS));
248 if kind == pk::CHOICE && options.is_empty() {
249 return err(child, "a choice needs options=\"a, b, c\"");
250 }
251 let default = checked_default(child, &kind, &options)?;
252 let text = |key: &str| child.attr(key).unwrap_or_default().to_string();
253 Ok(Prop {
254 name,
255 kind,
256 default,
257 doc: text(ca::DOC),
258 category: text(ca::CATEGORY),
259 options,
260 })
261}
262
263fn declared_kind(child: &Element) -> Result<String, Diagnostic> {
265 let kind = child.attr(ca::TYPE).unwrap_or(pk::TEXT).trim().to_string();
266 if !KINDS.iter().any(|k| k.word == kind) {
267 let known: Vec<&str> = KINDS.iter().map(|k| k.word).collect();
268 return err(
269 child,
270 format_args!("type=\"{kind}\": expected one of {}", known.join(", ")),
271 );
272 }
273 Ok(kind)
274}
275
276fn checked_default(child: &Element, kind: &str, options: &[String]) -> Result<String, Diagnostic> {
279 let default = child
280 .attr(ca::DEFAULT)
281 .map_or_else(|| default_of(kind, options), str::to_string);
282 let v = default.trim();
283 let wrong = match kind {
284 pk::NUMBER => v.parse::<f32>().is_err().then_some("expected a number"),
285 pk::INT => v
286 .parse::<i64>()
287 .is_err()
288 .then_some("expected a whole number"),
289 pk::BOOL => (!matches!(v, yog_ids::yui_value::TRUE | yog_ids::yui_value::FALSE))
290 .then_some("expected true or false"),
291 pk::COLOR => {
293 return yui::color_attr(child, ca::DEFAULT, &default).map(|c| format!("#{c:08X}"));
294 }
295 pk::CHOICE => (!options.contains(&default)).then_some("isn't one of the options"),
296 _ => None,
297 };
298 match wrong {
299 Some(why) if kind == pk::CHOICE => err(child, format_args!("default=\"{default}\" {why}")),
300 Some(why) => err(child, format_args!("default=\"{default}\": {why}")),
301 None => Ok(default),
302 }
303}
304
305const IMAGE_STAND_IN: &str = "item:air";
308const SURFACE_STAND_IN: &str = "yog:none";
309const CONV: &str = "::yog_api::ui::component::bind";
311
312fn bind_tree(el: &Element, counter: &mut usize) -> Result<Element, Diagnostic> {
315 let kind = el.name.as_str();
316 if kind == yog_ids::yui::COMPONENT || kind == yog_ids::yui::SCREEN {
317 return err(el, "can't be inside a component");
318 }
319 let mut out = Element {
320 name: el.name.clone(),
321 attrs: Vec::new(),
322 children: Vec::new(),
323 line: el.line,
324 };
325 for (key, value) in el
327 .attrs
328 .iter()
329 .filter(|(k, _)| !k.starts_with(yog_ids::yui_markup::DESIGN))
330 {
331 let Some(rust) = crate::ui::vm::compile_bind(value) else {
332 out.attrs.push((key.clone(), value.clone()));
333 continue;
334 };
335 let e = rust.map_err(|e| {
336 Diagnostic::from(format!("<{kind}> {key}=\"{value}\": {e}")).at_line(el.line)
337 })?;
338 if !yui::is_builtin(kind) && !yui::is_common_attr(key) {
339 out.attrs
341 .push((key.clone(), format!("{RAW_VALUE}{CONV}::text(&({e}))")));
342 continue;
343 }
344 if let Some(stand_in) = stand_in(kind, key) {
345 out.attrs.push((at::SOURCE.into(), stand_in.into()));
346 }
347 let call = bound_call(kind, key, &e).map_or_else(
348 || {
349 err(
350 el,
351 format_args!("{key}=\"{value}\": `{key}` can't be bound"),
352 )
353 },
354 Ok,
355 )?;
356 out.attrs.push((format!("{RAW_CALL}{counter}"), call));
357 *counter += 1;
358 }
359 for child in &el.children {
360 out.children.push(bind_tree(child, counter)?);
361 }
362 Ok(out)
363}
364
365fn stand_in(kind: &str, key: &str) -> Option<&'static str> {
367 match (kind, key) {
368 (yog_ids::yui::SURFACE, at::SOURCE) => Some(SURFACE_STAND_IN),
369 (yog_ids::yui::IMAGE, at::SOURCE) => Some(IMAGE_STAND_IN),
370 _ => None,
371 }
372}
373
374fn bound_call(kind: &str, key: &str, e: &str) -> Option<String> {
377 use yog_ids::yui as y;
378 let conv = |f: &str| format!("{CONV}::{f}(&({e}))");
379 Some(match key {
380 at::TEXT if matches!(kind, y::LABEL | y::BUTTON | y::TEXT_BOX) => {
381 format!("with_text({})", conv("text"))
382 }
383 at::PLACEHOLDER if kind == y::TEXT_BOX => format!("{key}({})", conv("text")),
384 at::SOURCE if matches!(kind, y::SURFACE | y::IMAGE) => {
385 format!("with_source({})", conv("text"))
386 }
387 at::ITEM if matches!(kind, y::ITEM_SLOT | y::INV_SLOT) => {
388 format!("with_source({})", conv("text"))
389 }
390 at::COUNT if kind == y::INV_SLOT => format!("with_count({})", conv("count")),
391 at::BG | at::COLOR | at::FOCUS_COLOR => format!("{key}({})", conv("color")),
392 at::TINT if kind == y::IMAGE => format!("{key}({})", conv("color")),
393 at::ENABLED | at::VISIBLE | at::SHADOW => format!("{key}({CONV}::flag({e}))"),
394 at::W | at::H => format!("{key}({})", conv("units")),
395 at::GAP | at::FLEX | at::MIN_W | at::MIN_H | at::FONT_SCALE => {
396 format!("{key}({})", conv("number"))
397 }
398 at::ON_CLICK | at::ON_CHANGE | at::ON_SUBMIT => format!("{key}({})", conv("text")),
399 _ => return None,
400 })
401}
402
403struct Meta<'a> {
405 doc: &'a str,
406 display_name: &'a str,
407 icon: &'a str,
408 category: &'a str,
409 design: (f32, f32),
410 toolbox: bool,
411}
412
413const RES_MARK: char = '@';
415
416impl<'a> Meta<'a> {
417 fn read(root: &'a Element) -> Result<Meta<'a>, Diagnostic> {
418 if let Some((key, _)) = root.attrs.iter().find(|(k, _)| {
419 !ROOT_ATTRS.contains(&k.as_str()) && !k.starts_with(yog_ids::yui_markup::DESIGN)
420 }) {
421 return err(
422 root,
423 format_args!("has no attribute `{key}` ({})", ROOT_ATTRS.join(", ")),
424 );
425 }
426 let toolbox = match root.attr(ca::TOOLBOX).map(str::trim) {
427 None | Some(yog_ids::yui_value::TRUE) => true,
428 Some(yog_ids::yui_value::FALSE) => false,
429 Some(v) => {
430 return err(
431 root,
432 format_args!("toolbox=\"{v}\": expected true or false"),
433 );
434 }
435 };
436 let icon = root.attr(ca::ICON).unwrap_or_default().trim();
437 if !icon.is_empty()
438 && !icon.starts_with(RES_MARK)
439 && !icon.contains(yog_ids::sep::NAMESPACE)
440 {
441 return err(
442 root,
443 format_args!(
444 "icon=\"{icon}\": expected an image source (item:apple, sprite:button, mod:gui/logo) or @Icons.name"
445 ),
446 );
447 }
448 let text = |key: &str| root.attr(key).unwrap_or_default();
449 Ok(Meta {
450 doc: text(ca::DESCRIPTION),
451 display_name: text(ca::DISPLAY_NAME),
452 icon,
453 category: text(ca::CATEGORY),
454 design: (
455 design_size(root, ca::DESIGN_W)?,
456 design_size(root, ca::DESIGN_H)?,
457 ),
458 toolbox,
459 })
460 }
461}
462
463fn design_size(root: &Element, key: &str) -> Result<f32, Diagnostic> {
465 match root.attr(key).map(str::trim) {
466 None | Some("") => Ok(0.0),
467 Some(v) => match v.parse::<f32>() {
468 Ok(n) if n >= 0.0 => Ok(n),
469 _ => err(
470 root,
471 format_args!("{key}=\"{v}\": expected a size in GUI pixels"),
472 ),
473 },
474 }
475}
476
477fn prop_code(p: &Prop) -> (String, String, String) {
479 let lit = |s: &str| format!("{s:?}");
480 let spec = KINDS.iter().find(|k| k.word == p.kind);
481 let mut field = String::new();
482 if !p.doc.is_empty() {
483 let _ = writeln!(field, " /// {}", p.doc.replace('\n', " "));
484 }
485 let _ = writeln!(
486 field,
487 " pub {}: {},",
488 p.name,
489 spec.map_or("String", |k| k.rust)
490 );
491 let read = match spec {
492 Some(k) if k.typed => format!("p.{}({})", k.word, lit(&p.name)),
493 _ => format!("p.word({}).to_string()", lit(&p.name)),
494 };
495 let init = format!(" {}: {read},\n", p.name);
496 let mut decl = match p.kind.as_str() {
497 pk::BOOL => format!("Prop::bool({}, {})", lit(&p.name), p.default.trim()),
498 pk::EVENT => format!("Prop::event({})", lit(&p.name)),
499 pk::CHOICE => format!(
500 "Prop::choice({}, &[{}], {})",
501 lit(&p.name),
502 p.options
503 .iter()
504 .map(|o| lit(o))
505 .collect::<Vec<_>>()
506 .join(", "),
507 lit(&p.default)
508 ),
509 k => format!("Prop::{k}({}, {})", lit(&p.name), lit(&p.default)),
510 };
511 if !p.doc.is_empty() {
512 let _ = write!(decl, ".doc({})", lit(&p.doc));
513 }
514 if !p.category.is_empty() {
515 let _ = write!(decl, ".category({})", lit(&p.category));
516 }
517 (field, init, format!(" {decl},\n"))
518}
519
520pub(crate) fn generate(
523 root: &Element,
524 name: &str,
525 mod_id: Option<&str>,
526) -> Result<(String, Vec<Diagnostic>), Diagnostic> {
527 let ty = type_name(root, name)?;
528 let meta = Meta::read(root)?;
529 let (props, body) = declarations(root)?;
530 let Some(body) = body else {
531 return err(
532 root,
533 "has no body — add one element (a Column, Row, Canvas, ...) after the declarations",
534 );
535 };
536 let bound = bind_tree(body, &mut 0)?;
537 let (tree, _ids) = yui::static_widget(&bound, false, 1, mod_id)?;
538 Ok((component_code(name, &ty, &meta, &props, &tree), Vec::new()))
539}
540
541fn component_code(name: &str, ty: &str, meta: &Meta, props: &[Prop], tree: &str) -> String {
543 let (mut fields, mut init, mut decls) = (String::new(), String::new(), String::new());
544 for (f, i, d) in props.iter().map(prop_code) {
545 fields.push_str(&f);
546 init.push_str(&i);
547 decls.push_str(&d);
548 }
549 let lit = |s: &str| format!("{s:?}");
550 let doc_line = if meta.doc.is_empty() {
551 format!("The `{ty}` component.")
552 } else {
553 meta.doc.replace('\n', " ")
554 };
555 let values = [
556 ("HEADER", crate::ui::generated_header(name)),
557 ("NAME", name.to_string()),
558 ("DOC_LINE", doc_line),
559 ("TY", ty.to_string()),
560 ("FIELDS", fields),
561 ("INIT", init),
562 ("UI_USE", UI_USE.to_string()),
563 ("TREE", tree.to_string()),
564 ("NAME_LIT", lit(ty)),
565 ("DOC_LIT", lit(meta.doc)),
566 ("DISPLAY_LIT", lit(meta.display_name)),
567 ("ICON_LIT", lit(meta.icon)),
568 ("CATEGORY_LIT", lit(meta.category)),
569 ("DESIGN_W", format!("{:?}", meta.design.0)),
570 ("DESIGN_H", format!("{:?}", meta.design.1)),
571 ("TOOLBOX", meta.toolbox.to_string()),
572 ("DECLS", decls),
573 ];
574 crate::project::rust_code::fill(TEMPLATE, &values)
575}
576
577const TEMPLATE: &str = include_str!("component.g.rs.in");
579fn is_component_file(path: &Path) -> bool {
581 std::fs::read_to_string(path)
582 .ok()
583 .and_then(|s| yui::parse_xml(&s).ok())
584 .is_some_and(|r| r.name == yog_ids::yui::COMPONENT && !crate::ui::book_page::is_book_ui(&r))
585}
586
587const COMPONENTS_MACRO: &str = "ui_components!";
589
590pub fn write_index(root: &Path) -> Result<(PathBuf, Option<String>), Diagnostic> {
595 let mut out = crate::ui::generated_header("**/*");
598 let src = root.join(yog_ids::project::SRC);
599 let mut count = 0;
600 for file in yui::find_yui_files(&src) {
601 if file.with_extension("rs").is_file() || !is_component_file(&file) {
602 continue;
603 }
604 let name = yui::include_name(root, &file);
605 let module = yog_ids::rust_name(&name.replace(yog_ids::sep::PATH, yog_ids::sep::WORD));
606 let _ = writeln!(
607 out,
608 "#[allow(unused_imports)]\npub mod {module} {{\n use super::*;\n include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{name}.g.rs\"));\n}}"
610 );
611 count += 1;
612 }
613 let lib = std::fs::read_to_string(src.join("lib.rs")).unwrap_or_default();
614 let warning = (count > 0 && !lib.contains(COMPONENTS_MACRO))
615 .then(|| format!("{count} component(s) without a code-behind aren't compiled in — add `yog_api::ui_components!();` to src/lib.rs"));
616 let path = root.join(INDEX_FILE);
617 crate::write_if_changed(&path, &out)?;
618 Ok((path, warning))
619}
620
621#[cfg(test)]
622mod tests;