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