Skip to main content

yog_cli/design/
markup.rs

1//! A `.yui` tree (as the designer holds it) → the `yog_ui::Widget` tree the
2//! generated `.g.rs` builds in game — the same builder calls `yog
3//! build` emits (yog-cli `yui.rs`), made directly instead of as source —
4//! wrapped like the generated `root_with` (the screen panel, anchored).
5
6use std::collections::HashMap;
7
8use serde::Deserialize;
9use yog_gfx::Color;
10use yog_ui::widget::{self, Widget};
11use yog_ui::{Align, Dock, FlexDir, FocusStyle, HAlign, Track, Units, VAlign};
12
13/// One element of the designer's document (`YuiNode` in `yui.ts`).
14#[derive(Debug, Clone, Deserialize)]
15pub struct YuiNode {
16    pub uid: String,
17    pub kind: String,
18    #[serde(default)]
19    pub attrs: HashMap<String, String>,
20    #[serde(default)]
21    pub children: Vec<YuiNode>,
22}
23
24impl YuiNode {
25    pub fn attr(&self, key: &str) -> Option<&str> {
26        self.attrs.get(key).map(String::as_str)
27    }
28}
29
30const CONTAINERS: &[&str] = &[yog_ids::yui::SCREEN, yog_ids::yui::CANVAS, yog_ids::yui::COLUMN, yog_ids::yui::ROW, yog_ids::yui::SCROLL_VIEW, yog_ids::yui::GRID];
31
32pub fn is_container(kind: &str) -> bool {
33    CONTAINERS.contains(&kind)
34}
35
36/// A property element (`<Grid.RowDefinitions>`) — part of its parent, not a widget.
37pub fn is_property_element(node: &YuiNode) -> bool {
38    node.kind.contains('.')
39}
40
41/// The children that are widgets (property elements skipped).
42pub fn widget_children(node: &YuiNode) -> impl Iterator<Item = &YuiNode> {
43    node.children.iter().filter(|c| !is_property_element(c))
44}
45
46/// One row/column size: `Auto`, `*`, `2*`, `26` (yog-cli's `yui_grid`).
47fn track(v: &str) -> Option<Track> {
48    let v = v.trim();
49    if v.eq_ignore_ascii_case("auto") {
50        return Some(Track::auto());
51    }
52    if let Some(w) = v.strip_suffix('*') {
53        let w = if w.trim().is_empty() {
54            1.0
55        } else {
56            w.trim().parse().ok()?
57        };
58        return Some(Track::star(w));
59    }
60    v.parse().ok().map(Track::px)
61}
62
63/// A grid's rows (`row`) or columns: `rows="Auto,*"` or `<Grid.RowDefinitions>`.
64/// Sizes it can't read count as `*`, so the canvas still draws while typing.
65fn tracks(node: &YuiNode, row: bool) -> Option<Vec<Track>> {
66    let (attr, prop, size, min, max) = if row {
67        (
68            "rows",
69            "Grid.RowDefinitions",
70            "height",
71            "min_height",
72            "max_height",
73        )
74    } else {
75        (
76            "columns",
77            "Grid.ColumnDefinitions",
78            "width",
79            "min_width",
80            "max_width",
81        )
82    };
83    if let Some(v) = node.attr(attr) {
84        return Some(
85            v.split(',')
86                .map(|p| track(p).unwrap_or(Track::star(1.0)))
87                .collect(),
88        );
89    }
90    let defs = node.children.iter().find(|c| c.kind == prop)?;
91    Some(
92        defs.children
93            .iter()
94            .map(|d| {
95                let mut t = track(d.attr(size).unwrap_or("*")).unwrap_or(Track::star(1.0));
96                if let Some(n) = num(d.attr(min)) {
97                    t = t.min(n);
98                }
99                if let Some(n) = num(d.attr(max)) {
100                    t = t.max(n);
101                }
102                t
103            })
104            .collect(),
105    )
106}
107
108fn cell_index(v: Option<&str>) -> Option<u16> {
109    v?.trim().parse().ok()
110}
111
112fn num(v: Option<&str>) -> Option<f32> {
113    v?.trim().parse().ok()
114}
115
116/// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB`: the `.yui` compiler's rule.
117pub use crate::yui::color;
118
119fn size(v: &str) -> Option<Units> {
120    let v = v.trim();
121    if v.is_empty() || v == "auto" {
122        return None;
123    }
124    if let Some(p) = v.strip_suffix('%') {
125        return p.trim().parse().ok().map(Units::Percent);
126    }
127    v.parse().ok().map(Units::Px)
128}
129
130fn padding(v: &str) -> Option<[f32; 4]> {
131    let parts: Vec<f32> = v
132        .split([' ', ','])
133        .filter(|s| !s.is_empty())
134        .map(|s| s.parse().ok())
135        .collect::<Option<_>>()?;
136    match parts.as_slice() {
137        [a] => Some([*a; 4]),
138        [v, h] => Some([*v, *h, *v, *h]),
139        [t, r, b, l] => Some([*t, *r, *b, *l]),
140        _ => None,
141    }
142}
143
144/// What a text attribute shows: `@lang:key` is a translation key (shown as
145/// the key — the designer has no game language), `@@x` the literal `@x`.
146pub fn text(value: Option<&str>) -> String {
147    let v = value.unwrap_or_default();
148    match v.strip_prefix('@') {
149        Some(rest) if rest.starts_with('@') => rest.to_string(),
150        Some(rest) if rest.starts_with("lang:") => rest["lang:".len()..].to_string(),
151        _ => v.to_string(),
152    }
153}
154
155/// What `yog build` does to a screen before building it (and the design
156/// view's own: `d:` values for bound ones): `<Screen.Resources>`
157/// (`@Name` values, `<Style x:Key>` with `based_on`) applied — a style's
158/// attributes on every element naming it, its own winning — and yog-kit's
159/// elements as the widgets they become (`ToggleButton` → `Button`,
160/// `ListView` → `ScrollView`, `ItemsControl`/`ContentPresenter` → a column
161/// or row). Unknown names are left as they are (`yog check` reports them).
162pub fn apply_resources(root: &mut YuiNode) {
163    let mut values: HashMap<String, String> = HashMap::new();
164    let mut styles: HashMap<String, Vec<(String, String)>> = HashMap::new();
165    if let Some(res) = root.children.iter().find(|c| c.kind.ends_with(".Resources")) {
166        let mut pending: Vec<&YuiNode> = Vec::new();
167        for r in &res.children {
168            let Some(key) = r.attr("x:Key") else { continue };
169            if r.kind == yog_ids::yui::STYLE {
170                pending.push(r);
171            } else if let Some(v) = r.attr(yog_ids::yui_attr::VALUE) {
172                values.insert(key.to_string(), v.to_string());
173            }
174        }
175        let local = |v: &str, values: &HashMap<String, String>| match v.strip_prefix('@') {
176            Some(n) if !n.contains('.') && !n.starts_with('@') => values.get(n).cloned().unwrap_or_else(|| v.to_string()),
177            _ => v.to_string(),
178        };
179        // Styles, `based_on` chains in any order.
180        while !pending.is_empty() {
181            let before = pending.len();
182            let mut rest = Vec::new();
183            for st in pending {
184                let key = st.attr("x:Key").unwrap_or_default().to_string();
185                let mut attrs = match st.attr(yog_ids::yui_attr::BASED_ON).map(|b| b.trim().trim_start_matches('@')) {
186                    Some(b) => match styles.get(b) {
187                        Some(a) => a.clone(),
188                        None => {
189                            rest.push(st);
190                            continue;
191                        }
192                    },
193                    None => Vec::new(),
194                };
195                let mut own: Vec<(&String, &String)> = st.attrs.iter().collect();
196                own.sort();
197                for (k, v) in own {
198                    if matches!(k.as_str(), "x:Key" | "target" | "based_on") {
199                        continue;
200                    }
201                    attrs.retain(|(ek, _)| ek != k);
202                    attrs.push((k.clone(), local(v, &values)));
203                }
204                styles.insert(key, attrs);
205            }
206            if rest.len() == before {
207                break;
208            }
209            pending = rest;
210        }
211    }
212    fn walk(n: &mut YuiNode, values: &HashMap<String, String>, styles: &HashMap<String, Vec<(String, String)>>) {
213        // Bound values are the game's to compute: in the designer an
214        // attribute shows its design-time value (`d:text="Sample"`), else
215        // none — as if unset (a text box shows its placeholder).
216        let design: Vec<(String, String)> =
217            n.attrs.iter().filter_map(|(k, v)| Some((k.strip_prefix("d:")?.to_string(), v.clone()))).collect();
218        n.attrs.retain(|k, v| !k.starts_with("d:") && !(v.trim_start().starts_with("{x:Bind") || v.trim_start().starts_with("{Binding")));
219        n.attrs.extend(design);
220        for v in n.attrs.values_mut() {
221            if let Some(name) = v.strip_prefix('@')
222                && !name.contains('.')
223                && !name.starts_with('@')
224                && let Some(r) = values.get(name)
225            {
226                *v = r.clone();
227            }
228        }
229        if let Some(name) = n.attrs.remove(yog_ids::yui_attr::STYLE)
230            && let Some(attrs) = styles.get(name.trim().trim_start_matches('@'))
231        {
232            for (k, v) in attrs {
233                n.attrs.entry(k.clone()).or_insert_with(|| v.clone());
234            }
235        }
236        if let Some(name) = n.attrs.get("x:Name").cloned() {
237            n.attrs.entry(yog_ids::yui_attr::ID.into()).or_insert(name);
238        }
239        let horizontal = n.attr(yog_ids::yui_attr::ORIENTATION).is_some_and(|o| o.trim() == "horizontal");
240        n.kind = match n.kind.as_str() {
241            yog_ids::yui::TOGGLE_BUTTON => yog_ids::yui::BUTTON,
242            yog_ids::yui::LIST_VIEW => yog_ids::yui::SCROLL_VIEW,
243            yog_ids::yui::ITEMS_CONTROL if horizontal => yog_ids::yui::ROW,
244            yog_ids::yui::ITEMS_CONTROL | yog_ids::yui::CONTENT_PRESENTER => yog_ids::yui::COLUMN,
245            k => k,
246        }
247        .to_string();
248        for c in &mut n.children {
249            walk(c, values, styles);
250        }
251    }
252    walk(root, &values, &styles);
253}
254
255/// `<Screen theme>`'s defaults on every node that doesn't set the attribute
256/// itself — as `yog build` applies them.
257pub fn apply_theme(root: &mut YuiNode) {
258    let Some(theme) = root.attr(yog_ids::yui_attr::THEME).and_then(yog_ui::theme::Theme::builtin) else {
259        return;
260    };
261    fn walk(n: &mut YuiNode, theme: &yog_ui::theme::Theme) {
262        for (k, v) in theme.attrs(&n.kind) {
263            n.attrs.entry(k.clone()).or_insert_with(|| v.clone());
264        }
265        for c in &mut n.children {
266            walk(c, theme);
267        }
268    }
269    walk(root, &theme);
270}
271
272/// Builds the widget for `node`. `in_canvas`: the parent places children
273/// absolutely. `scroll`: preview scroll offsets by uid.
274pub fn widget(node: &YuiNode, in_canvas: bool, scroll: &HashMap<String, f32>) -> Widget {
275    let a = |k: &str| node.attr(k);
276    let mut w = match node.kind.as_str() {
277        yog_ids::yui::SCREEN | yog_ids::yui::CANVAS | yog_ids::yui::COLUMN => widget::panel(FlexDir::Column),
278        yog_ids::yui::ROW => widget::panel(FlexDir::Row),
279        yog_ids::yui::GRID => widget::grid(),
280        yog_ids::yui::SCROLL_VIEW => widget::scroll_view(),
281        yog_ids::yui::LABEL => widget::label(text(a(yog_ids::yui_attr::TEXT))),
282        yog_ids::yui::BUTTON => widget::button(text(a(yog_ids::yui_attr::TEXT))),
283        yog_ids::yui::ITEM_SLOT => widget::item_slot(a(yog_ids::yui_attr::ITEM).unwrap_or_default()),
284        // A recipe layout's places (yog_formats::book::layout): a slot showing the
285        // design-time sample (`d:item`), the count `d:text` or 2.
286        yog_ids::yui::RECIPE_SLOT | yog_ids::yui::RECIPE_RESULT => widget::item_slot(a(yog_ids::yui_attr::ITEM).unwrap_or_default()),
287        yog_ids::yui::RECIPE_COUNT => widget::label(a(yog_ids::yui_attr::TEXT).unwrap_or("2")),
288        yog_ids::yui::INV_SLOT => widget::inv_slot(
289            a(yog_ids::yui_attr::ITEM).unwrap_or_default(),
290            a(yog_ids::yui_attr::COUNT).and_then(|c| c.trim().parse().ok()).unwrap_or(1),
291        ),
292        yog_ids::yui::SLOT => widget::slot(a(yog_ids::yui_attr::INDEX).and_then(|i| i.trim().parse().ok()).unwrap_or(0)),
293        yog_ids::yui::PLAYER_INVENTORY => widget::player_inventory(),
294        yog_ids::yui::IMAGE => super::images::widget(node),
295        // Drawn by the mod's code in game: an empty box here.
296        yog_ids::yui::SURFACE => widget::surface(a(yog_ids::yui_attr::SOURCE).unwrap_or_default()),
297        yog_ids::yui::TEXT_BOX => widget::text_box(text(a(yog_ids::yui_attr::TEXT))),
298        _ => widget::spacer(),
299    };
300    if node.kind == yog_ids::yui::SCREEN {
301        if let Some(v) = num(a(yog_ids::yui_attr::WIDTH)) {
302            w = w.w(Units::Px(v));
303        }
304        if let Some(v) = num(a(yog_ids::yui_attr::HEIGHT)) {
305            w = w.h(Units::Px(v));
306        }
307    }
308    if node.kind == yog_ids::yui::GRID {
309        if let Some(rows) = tracks(node, true) {
310            w = w.rows(rows);
311        }
312        if let Some(cols) = tracks(node, false) {
313            w = w.cols(cols);
314        }
315        if let Some(n) = num(a(yog_ids::yui_attr::ROW_SPACING)) {
316            w = w.row_spacing(n);
317        }
318        if let Some(n) = num(a(yog_ids::yui_attr::COLUMN_SPACING)) {
319            w = w.col_spacing(n);
320        }
321    }
322    let (row, col) = (cell_index(a(yog_ids::yui_attr::GRID_ROW)), cell_index(a(yog_ids::yui_attr::GRID_COLUMN)));
323    if row.is_some() || col.is_some() {
324        w = w.cell(row.unwrap_or(0), col.unwrap_or(0));
325    }
326    let (rs, cs) = (
327        cell_index(a(yog_ids::yui_attr::GRID_ROWSPAN)),
328        cell_index(a(yog_ids::yui_attr::GRID_COLUMNSPAN)),
329    );
330    if rs.is_some() || cs.is_some() {
331        w = w.span(rs.unwrap_or(1), cs.unwrap_or(1));
332    }
333    let (x, y) = (num(a(yog_ids::yui_attr::X)), num(a(yog_ids::yui_attr::Y)));
334    if in_canvas || x.is_some() || y.is_some() {
335        w = w.at(x.unwrap_or(0.0), y.unwrap_or(0.0));
336    }
337    // Attribute order doesn't matter for these builder calls but follows the file's, like the generator.
338    let mut keys: Vec<&String> = node.attrs.keys().collect();
339    keys.sort();
340    for key in keys {
341        let v = node.attrs[key].as_str();
342        w = match key.as_str() {
343            "id" if node.kind != yog_ids::yui::SCREEN => w.id(v),
344            "on_click" => w.on_click(v),
345            "w" => size(v).map_or(w.clone(), |s| w.clone().w(s)),
346            "h" => size(v).map_or(w.clone(), |s| w.clone().h(s)),
347            "min_w" => num(Some(v)).map_or(w.clone(), |n| w.clone().min_w(n)),
348            "min_h" => num(Some(v)).map_or(w.clone(), |n| w.clone().min_h(n)),
349            "flex" => num(Some(v)).map_or(w.clone(), |n| w.clone().flex(n)),
350            "gap" => num(Some(v)).map_or(w.clone(), |n| w.clone().gap(n)),
351            "font_scale" => num(Some(v)).map_or(w.clone(), |n| w.clone().font_scale(n)),
352            "dock" => w.dock(match v.trim() {
353                "fill" => Dock::Fill,
354                "left" => Dock::Left,
355                "right" => Dock::Right,
356                "top" => Dock::Top,
357                "bottom" => Dock::Bottom,
358                _ => Dock::None,
359            }),
360            "align" => w.align(match v.trim() {
361                "center" => Align::Center,
362                "end" => Align::End,
363                _ => Align::Start,
364            }),
365            "focus_style" => w.focus_style(match v.trim() {
366                "fill" => FocusStyle::Fill,
367                "none" => FocusStyle::None,
368                _ => FocusStyle::Outline,
369            }),
370            "padding" => match padding(v) {
371                Some([t, r, b, l]) => w.padding(t, r, b, l),
372                None => w,
373            },
374            "margin" => match padding(v) {
375                Some([t, r, b, l]) => w.margin(t, r, b, l),
376                None => w,
377            },
378            "halign" => match v.trim() {
379                "left" => w.halign(HAlign::Left),
380                "center" => w.halign(HAlign::Center),
381                "right" => w.halign(HAlign::Right),
382                "stretch" => w.halign(HAlign::Stretch),
383                _ => w,
384            },
385            "valign" => match v.trim() {
386                "top" => w.valign(VAlign::Top),
387                "center" => w.valign(VAlign::Center),
388                "bottom" => w.valign(VAlign::Bottom),
389                "stretch" => w.valign(VAlign::Stretch),
390                _ => w,
391            },
392            "bg" => match color(v) {
393                Some(c) => w.bg(Color(c)),
394                // `sprite:panel`, `mymod:gui/frame`: an image background.
395                None if v.contains(':') => w.bg_image(v.trim()),
396                None => w,
397            },
398            "color" => match color(v) {
399                Some(c) => w.color(Color(c)),
400                None => w,
401            },
402            "focus_color" => match color(v) {
403                Some(c) => w.focus_color(Color(c)),
404                None => w,
405            },
406            "shadow" => w.shadow(v.trim() == "true"),
407            "enabled" => w.enabled(v.trim() != "false"),
408            yog_ids::yui_attr::FONT if !v.trim().is_empty() => w.font(v.trim()),
409            "placeholder" => w.placeholder(text(Some(v))),
410            "max_length" => match v.trim().parse() {
411                Ok(n) => w.max_length(n),
412                Err(_) => w,
413            },
414            "on_change" => w.on_change(v),
415            "on_submit" => w.on_submit(v),
416            "no_wrap" if v.trim() == "true" => w.no_wrap(),
417            "ellipsis" if v.trim() == "true" => w.ellipsis(),
418            _ => w,
419        };
420    }
421    if node.kind == yog_ids::yui::SCROLL_VIEW
422        && let Some(offset) = scroll.get(&node.uid)
423    {
424        w = w.scroll_y(*offset);
425    }
426    if is_container(&node.kind) {
427        let canvas = matches!(node.kind.as_str(), yog_ids::yui::SCREEN | yog_ids::yui::CANVAS);
428        for child in widget_children(node) {
429            w = w.child(widget(child, canvas, scroll));
430        }
431    }
432    w
433}
434
435/// The anchored screen (`root_with`): a `screen_w × screen_h` panel with the
436/// content placed per `anchor`.
437/// All of the window, as a percentage.
438const WHOLE: f32 = 100.0;
439
440pub fn screen(
441    root: &YuiNode,
442    screen_w: f32,
443    screen_h: f32,
444    scroll: &HashMap<String, f32>,
445) -> Widget {
446    let width = num(root.attr(yog_ids::yui_attr::WIDTH)).unwrap_or(widget::SCREEN_W);
447    let height = num(root.attr(yog_ids::yui_attr::HEIGHT)).unwrap_or(widget::SCREEN_H);
448    let content = widget(root, false, scroll);
449    let cx = ((screen_w - width) / 2.0).floor();
450    let cy = ((screen_h - height) / 2.0).floor();
451    let anchor = root.attr(yog_ids::yui_attr::ANCHOR).unwrap_or("center").trim();
452    let content = if anchor == "fill" {
453        content
454            .w(Units::Percent(WHOLE))
455            .h(Units::Percent(WHOLE))
456            .at(0.0, 0.0)
457    } else {
458        let (ax, ay) = match anchor {
459            "top_left" => (0.0, 0.0),
460            "top" => (cx, 0.0),
461            "top_right" => (screen_w - width, 0.0),
462            "left" => (0.0, cy),
463            "right" => (screen_w - width, cy),
464            "bottom_left" => (0.0, screen_h - height),
465            "bottom" => (cx, screen_h - height),
466            "bottom_right" => (screen_w - width, screen_h - height),
467            _ => (cx, cy),
468        };
469        content.at(ax.max(0.0), ay.max(0.0))
470    };
471    widget::panel(FlexDir::Column)
472        .w(Units::Px(screen_w))
473        .h(Units::Px(screen_h))
474        .child(content)
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    fn node(kind: &str, attrs: &[(&str, &str)], children: Vec<YuiNode>) -> YuiNode {
482        YuiNode {
483            uid: kind.into(),
484            kind: kind.into(),
485            attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
486            children,
487        }
488    }
489
490    #[test]
491    fn styles_resources_and_kit_elements_apply_as_yog_build_does() {
492        let mut root = node(
493            "Screen",
494            &[],
495            vec![
496                node(
497                    "Screen.Resources",
498                    &[],
499                    vec![
500                        node("Color", &[("x:Key", "Accent"), ("value", "#FF112233")], vec![]),
501                        node("Style", &[("x:Key", "Base"), ("target", "Button"), ("h", "16")], vec![]),
502                        node("Style", &[("x:Key", "Filter"), ("based_on", "@Base"), ("w", "58"), ("color", "@Accent")], vec![]),
503                    ],
504                ),
505                node("ToggleButton", &[("style", "@Filter"), ("w", "64"), ("text", "{x:Bind label()}"), ("d:text", "Yog")], vec![]),
506                node("TextBox", &[("text", "{x:Bind search, Mode=TwoWay}")], vec![]),
507            ],
508        );
509        apply_resources(&mut root);
510        let b = &root.children[1];
511        assert_eq!(b.kind, "Button");
512        assert_eq!(b.attr("w"), Some("64"), "its own attribute wins");
513        assert_eq!(b.attr("h"), Some("16"), "from the base style");
514        assert_eq!(b.attr("color"), Some("#FF112233"));
515        assert_eq!(b.attr("text"), Some("Yog"), "the design-time value");
516        assert_eq!(root.children[2].attr("text"), None, "a bound value shows nothing");
517    }
518}