Skip to main content

yog_cli/design/
components.rs

1//! A mod's `.yui` components (`src/**/*.yui` with a `<Component>` root —
2//! Yog-IDLE's `docs/yui.md`, "Components"), read straight from their
3//! markup: no build needed to list them in the toolbox, to edit one in the
4//! designer, or to draw an instance whose bindings only name its own
5//! properties (`text="{x:Bind label}"`) — the instance is expanded in place,
6//! its values substituted. Components whose bindings call code
7//! (`{x:Bind caption()}`) are *compiled*: the designer host draws those with
8//! the mod's library ([`custom`](super::custom)).
9
10use yog_ids::Format;
11use std::collections::HashMap;
12use std::collections::hash_map::DefaultHasher;
13use std::hash::{Hash, Hasher};
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex, OnceLock, PoisonError};
16use std::time::SystemTime;
17
18use quick_xml::events::{BytesStart, Event};
19use quick_xml::{Reader, XmlVersion};
20
21use super::host::{ComponentInfo, PropInfo};
22use super::markup::YuiNode;
23
24/// One component of the mod.
25#[derive(Debug, Clone)]
26pub struct Def {
27    /// Name, doc, properties and the `.yui` it's declared in.
28    pub info: ComponentInfo,
29    /// The body element (`None`: none yet — drawn empty).
30    pub body: Option<YuiNode>,
31    /// Some binding calls code — only the mod's library can draw it.
32    pub compiled: bool,
33}
34
35impl Def {
36    pub fn find<'a>(defs: &'a [Def], name: &str) -> Option<&'a Def> {
37        defs.iter().find(|d| d.info.name == name)
38    }
39}
40
41const DECLARATIONS: &[&str] = &[yog_ids::yui::PROPERTY, yog_ids::yui::EVENT];
42
43/// Joins an expanded instance's uid and its inner element's path: never in
44/// a document's own uids, so the designer can tell the inside of an
45/// instance apart (and select the instance instead).
46pub const INNER: char = '/';
47
48// ── Parsing ────────────────────────────────────────────────────────────────────
49
50fn element(e: &BytesStart, uid: String) -> Result<YuiNode, String> {
51    let kind = e.name().into_inner().to_string();
52    let mut attrs = HashMap::new();
53    for a in e.attributes() {
54        let a = a.map_err(|e| e.to_string())?;
55        let value = a
56            .normalized_value(XmlVersion::Implicit1_0)
57            .map_err(|e| e.to_string())?;
58        attrs.insert(
59            a.key.into_inner().to_string(),
60            value.into_owned(),
61        );
62    }
63    Ok(YuiNode {
64        uid,
65        kind,
66        attrs,
67        children: Vec::new(),
68    })
69}
70
71/// A `.yui` document's element tree, uids `c0`, `c0/1`, ...
72pub fn parse_tree(text: &str) -> Result<YuiNode, String> {
73    let mut reader = Reader::from_str(text);
74    let mut stack: Vec<YuiNode> = Vec::new();
75    let mut root = None;
76    let uid = |stack: &[YuiNode]| match stack.last() {
77        Some(parent) => format!("{}{INNER}{}", parent.uid, parent.children.len()),
78        None => "c0".to_string(),
79    };
80    loop {
81        match reader.read_event().map_err(|e| e.to_string())? {
82            Event::Start(e) => {
83                let node = element(&e, uid(&stack))?;
84                stack.push(node);
85            }
86            Event::Empty(e) => {
87                let node = element(&e, uid(&stack))?;
88                match stack.last_mut() {
89                    Some(parent) => parent.children.push(node),
90                    None => root = Some(node),
91                }
92            }
93            Event::End(_) => {
94                let node = stack.pop().ok_or("unbalanced end tag")?;
95                match stack.last_mut() {
96                    Some(parent) => parent.children.push(node),
97                    None => root = Some(node),
98                }
99            }
100            Event::Eof => break,
101            _ => {}
102        }
103        if root.is_some() && stack.is_empty() {
104            break;
105        }
106    }
107    root.ok_or_else(|| "no root element".to_string())
108}
109
110/// `stat_bar` → `StatBar`.
111fn pascal(stem: &str) -> String {
112    stem.split(['_', '-'])
113        .map(|w| {
114            let mut c = w.chars();
115            c.next()
116                .map(|f| f.to_uppercase().collect::<String>() + c.as_str())
117                .unwrap_or_default()
118        })
119        .collect()
120}
121
122/// The component a `<Component>` tree declares (`None`: not a component —
123/// a book's kind of page or a recipe layout isn't one to place on screens).
124pub fn definition(root: &YuiNode, file: &Path) -> Option<Def> {
125    if root.kind != yog_ids::yui::COMPONENT || root.attr("book_page").is_some() || root.attr(yog_ids::yui_attr::RECIPE_LAYOUT).is_some() {
126        return None;
127    }
128    let name = root
129        .attr("name")
130        .map(str::trim)
131        .filter(|n| !n.is_empty())
132        .map(str::to_string)
133        .unwrap_or_else(|| pascal(&file.file_stem().unwrap_or_default().to_string_lossy()));
134    let props: Vec<PropInfo> = root
135        .children
136        .iter()
137        .filter(|c| DECLARATIONS.contains(&c.kind.as_str()))
138        .map(|c| {
139            let kind = if c.kind == yog_ids::yui::EVENT {
140                "event".to_string()
141            } else {
142                c.attr("type").unwrap_or("text").trim().to_string()
143            };
144            let options: Vec<String> = c
145                .attr("options")
146                .unwrap_or_default()
147                .split(',')
148                .map(|s| s.trim().to_string())
149                .filter(|s| !s.is_empty())
150                .collect();
151            let default =
152                c.attr("default")
153                    .map(str::to_string)
154                    .unwrap_or_else(|| match kind.as_str() {
155                        "number" | "int" => "0".into(),
156                        "bool" => "false".into(),
157                        "color" => "#FFFFFFFF".into(),
158                        "choice" => options.first().cloned().unwrap_or_default(),
159                        _ => String::new(),
160                    });
161            PropInfo {
162                name: c.attr("name").unwrap_or_default().trim().to_string(),
163                kind,
164                default,
165                doc: c.attr("doc").unwrap_or_default().to_string(),
166                category: c.attr("category").unwrap_or_default().to_string(),
167                options,
168            }
169        })
170        .collect();
171    let body = root
172        .children
173        .iter()
174        .find(|c| !DECLARATIONS.contains(&c.kind.as_str()))
175        .cloned();
176    let names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
177    let compiled = body.as_ref().is_some_and(|b| calls_code(b, &names));
178    let text = |key: &str| root.attr(key).unwrap_or_default().trim().to_string();
179    let size = |key: &str| {
180        root.attr(key)
181            .and_then(|v| v.trim().parse::<f32>().ok())
182            .filter(|n| *n > 0.0)
183            .unwrap_or(0.0)
184    };
185    let info = ComponentInfo {
186        name,
187        description: text("description"),
188        display_name: text("display_name"),
189        icon: text("icon"),
190        category: text("category"),
191        design_w: size("design_w"),
192        design_h: size("design_h"),
193        toolbox: root.attr("toolbox").is_none_or(|v| v.trim() != "false"),
194        props,
195        file: Some(file.to_string_lossy().into_owned()),
196    };
197    Some(Def {
198        info,
199        body,
200        compiled,
201    })
202}
203
204// ── Bindings ───────────────────────────────────────────────────────────────────
205
206/// The expression of a `{x:Bind expr[, options]}` value.
207pub fn binding(value: &str) -> Option<&str> {
208    let inner = value.trim().strip_prefix("{x:Bind")?.strip_suffix('}')?;
209    let mut depth = 0i32;
210    let mut quote = false;
211    for (i, c) in inner.char_indices() {
212        match c {
213            '\'' => quote = !quote,
214            '(' if !quote => depth += 1,
215            ')' if !quote => depth -= 1,
216            ',' if !quote && depth == 0 => return Some(inner[..i].trim()),
217            _ => {}
218        }
219    }
220    Some(inner.trim())
221}
222
223fn calls_code(node: &YuiNode, props: &[&str]) -> bool {
224    node.attrs
225        .values()
226        .filter_map(|v| binding(v))
227        .any(|e| !props.contains(&e))
228        || node.children.iter().any(|c| calls_code(c, props))
229}
230
231/// `node` and its subtree with every binding to a property replaced by its
232/// value in `values`, and design-time `d:attr` values applied. A binding
233/// that calls code shows as `{expr}` in a text, and is dropped elsewhere.
234pub fn bind_values(node: &YuiNode, values: &HashMap<String, String>) -> YuiNode {
235    let mut attrs = HashMap::new();
236    for (k, v) in &node.attrs {
237        if k.starts_with("d:") {
238            continue;
239        }
240        match binding(v) {
241            None => {
242                attrs.insert(k.clone(), v.clone());
243            }
244            Some(e) => {
245                if let Some(value) = values.get(e) {
246                    attrs.insert(k.clone(), value.clone());
247                } else if matches!(k.as_str(), "text" | "placeholder") {
248                    attrs.insert(k.clone(), format!("{{{e}}}"));
249                }
250            }
251        }
252    }
253    for (k, v) in &node.attrs {
254        if let Some(k) = k.strip_prefix("d:") {
255            attrs.insert(k.to_string(), v.clone());
256        }
257    }
258    YuiNode {
259        uid: node.uid.clone(),
260        kind: node.kind.clone(),
261        attrs,
262        children: node
263            .children
264            .iter()
265            .map(|c| bind_values(c, values))
266            .collect(),
267    }
268}
269
270fn defaults(def: &Def) -> HashMap<String, String> {
271    def.info
272        .props
273        .iter()
274        .map(|p| (p.name.clone(), p.default.clone()))
275        .collect()
276}
277
278fn with_uids(node: &mut YuiNode, uid: &str) {
279    node.uid = uid.to_string();
280    for (i, c) in node.children.iter_mut().enumerate() {
281        with_uids(c, &format!("{uid}{INNER}{i}"));
282    }
283}
284
285/// An instance of a markup-only component as the tree it stands for: the
286/// body with the instance's property values, the instance's own attributes
287/// (position, size, style — as `yog build` applies them to the built
288/// widget) over the body root's, the instance's uid on the root and
289/// `uid/i/j` inside.
290pub fn expand(def: &Def, instance: &YuiNode) -> YuiNode {
291    let mut values = defaults(def);
292    let mut own = HashMap::new();
293    for (k, v) in &instance.attrs {
294        if def.info.props.iter().any(|p| &p.name == k) {
295            values.insert(k.clone(), v.clone());
296        } else {
297            own.insert(k.clone(), v.clone());
298        }
299    }
300    let Some(body) = &def.body else {
301        return YuiNode {
302            uid: instance.uid.clone(),
303            kind: yog_ids::yui::SPACER.into(),
304            attrs: own,
305            children: Vec::new(),
306        };
307    };
308    let mut tree = bind_values(body, &values);
309    with_uids(&mut tree, &instance.uid);
310    tree.attrs.extend(own);
311    tree
312}
313
314/// The name a component being designed is checked under (it has no file of its own yet).
315const COMPONENT_FILE: &str = "component";
316
317/// A `<Component>` document as the designer draws it: a screen of its
318/// design size holding the body, bound to the properties' defaults.
319pub fn design_root(root: &YuiNode) -> YuiNode {
320    let def = definition(root, Path::new(&Format::Ui.file(COMPONENT_FILE)));
321    let values = def.as_ref().map(defaults).unwrap_or_default();
322    // A recipe layout is its own size; a component's design size is a preview's.
323    let layout = root.attr(yog_ids::yui_attr::RECIPE_LAYOUT).is_some();
324    let size = |own: &str, design: &str, default: &'static str| {
325        root.attr(if layout { own } else { design }).unwrap_or(default).to_string()
326    };
327    let mut attrs = HashMap::from([
328        ("width".to_string(), size("width", "design_w", "120")),
329        ("height".to_string(), size("height", "design_h", "40")),
330        ("anchor".to_string(), "center".to_string()),
331    ]);
332    if let Some(bg) = root.attr("d:bg") {
333        attrs.insert(yog_ids::yui_attr::BG.into(), bg.to_string());
334    }
335    let children = root
336        .children
337        .iter()
338        .filter(|c| !DECLARATIONS.contains(&c.kind.as_str()))
339        .map(|c| bind_values(c, &values))
340        .collect();
341    YuiNode {
342        uid: root.uid.clone(),
343        kind: yog_ids::yui::SCREEN.into(),
344        attrs,
345        children,
346    }
347}
348
349// ── The mod's components ─────────────────────────────────────────────────────────
350
351fn yui_files(mod_root: &Path) -> Vec<PathBuf> {
352    let mut out: Vec<PathBuf> = ignore::WalkBuilder::new(mod_root.join("src"))
353        .hidden(false)
354        .build()
355        .flatten()
356        .map(|e| e.into_path())
357        .filter(|p| p.is_file() && Format::Ui.is(p))
358        .collect();
359    out.sort();
360    out
361}
362
363fn fingerprint(files: &[PathBuf]) -> u64 {
364    let mut h = DefaultHasher::new();
365    for f in files {
366        f.hash(&mut h);
367        if let Ok(m) = std::fs::metadata(f) {
368            m.len().hash(&mut h);
369            m.modified()
370                .ok()
371                .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
372                .hash(&mut h);
373        }
374    }
375    h.finish()
376}
377
378type Scanned = (u64, Arc<Vec<Def>>);
379
380/// The mod's components, re-read when its `.yui` files change.
381pub fn scan(mod_root: &Path) -> Arc<Vec<Def>> {
382    scan_with_revision(mod_root).1
383}
384
385/// [`scan`] and a fingerprint of the `.yui` files it read.
386pub fn scan_with_revision(mod_root: &Path) -> (u64, Arc<Vec<Def>>) {
387    static CACHE: OnceLock<Mutex<HashMap<PathBuf, Scanned>>> = OnceLock::new();
388    let files = yui_files(mod_root);
389    let fp = fingerprint(&files);
390    let cache = CACHE.get_or_init(Default::default);
391    if let Some((seen, defs)) = cache
392        .lock()
393        .unwrap_or_else(PoisonError::into_inner)
394        .get(mod_root)
395        && *seen == fp
396    {
397        return (fp, defs.clone());
398    }
399    let defs: Vec<Def> = files
400        .iter()
401        .filter_map(|f| {
402            let text = std::fs::read_to_string(f).ok()?;
403            // Cheap check first: most .yui files are screens.
404            if !text.contains("<Component") {
405                return None;
406            }
407            definition(&parse_tree(&text).ok()?, f)
408        })
409        .collect();
410    let defs = Arc::new(defs);
411    cache
412        .lock()
413        .unwrap_or_else(PoisonError::into_inner)
414        .insert(mod_root.to_path_buf(), (fp, defs.clone()));
415    (fp, defs)
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    const PILL: &str = r##"<?xml version="1.0"?>
423<!-- a tag -->
424<Component name="Pill" description="@Strings.pill" icon="item:name_tag" design_w="40" design_h="12" toolbox="false">
425    <Property name="label" type="text" default="Yog" doc="Text" category="Data"/>
426    <Property name="fill" type="color" default="#FF2D4A8A"/>
427    <Event name="on_pick"/>
428    <Row bg="{x:Bind fill}" padding="1" on_click="{x:Bind on_pick}" d:min_w="30">
429        <Label text="{x:Bind label}" color="#FFFFFF"/>
430    </Row>
431</Component>"##;
432
433    #[test]
434    fn reads_a_component() {
435        let tree = parse_tree(PILL).unwrap();
436        let def = definition(&tree, Path::new("src/pill.yui")).unwrap();
437        assert_eq!(def.info.name, "Pill");
438        assert_eq!(def.info.props.len(), 3);
439        assert_eq!(def.info.props[2].kind, "event");
440        assert_eq!(def.info.props[0].category, "Data");
441        assert_eq!(
442            (def.info.description.as_str(), def.info.icon.as_str()),
443            ("@Strings.pill", "item:name_tag")
444        );
445        assert_eq!(
446            (def.info.design_w, def.info.design_h, def.info.toolbox),
447            (40.0, 12.0, false)
448        );
449        assert!(!def.compiled);
450        assert_eq!(def.body.as_ref().unwrap().children[0].uid, "c0/3/0");
451
452        let inst = YuiNode {
453            uid: "n7".into(),
454            kind: "Pill".into(),
455            attrs: HashMap::from([
456                ("x".into(), "4".into()),
457                ("label".into(), "Hi".into()),
458                ("bg".into(), "#FF000000".into()),
459            ]),
460            children: vec![],
461        };
462        let t = expand(&def, &inst);
463        assert_eq!(t.uid, "n7");
464        assert_eq!(t.kind, "Row");
465        assert_eq!(
466            t.attr("bg"),
467            Some("#FF000000"),
468            "the instance's own attributes win"
469        );
470        assert_eq!(t.attr("x"), Some("4"));
471        assert_eq!(t.attr("on_click"), Some(""));
472        assert_eq!(t.attr("min_w"), Some("30"));
473        assert_eq!(t.children[0].uid, "n7/0");
474        assert_eq!(t.children[0].attr("text"), Some("Hi"));
475
476        let screen = design_root(&tree);
477        assert_eq!(
478            (
479                screen.kind.as_str(),
480                screen.attr("width"),
481                screen.children.len()
482            ),
483            ("Screen", Some("40"), 1)
484        );
485        assert_eq!(screen.children[0].attr("bg"), Some("#FF2D4A8A"));
486        assert_eq!(screen.children[0].uid, "c0/3", "the document's uids stay");
487    }
488
489    #[test]
490    fn code_bindings_make_a_component_compiled() {
491        let tree = parse_tree(r##"<Component><Property name="v" type="number"/><Label text="{x:Bind caption(), Mode=OneTime}" d:color="#FF0000"/></Component>"##).unwrap();
492        let def = definition(&tree, Path::new("src/stat_bar.yui")).unwrap();
493        assert_eq!(def.info.name, "StatBar");
494        assert!(def.compiled);
495        let screen = design_root(&tree);
496        assert_eq!(screen.children[0].attr("text"), Some("{caption()}"));
497        assert_eq!(screen.children[0].attr("color"), Some("#FF0000"));
498        assert!(
499            definition(
500                &parse_tree("<Screen id='a:b'/>").unwrap(),
501                Path::new("a.yui")
502            )
503            .is_none()
504        );
505    }
506}