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