Skip to main content

yog_cli/
yui_grid.rs

1//! `<Grid>` in `.yui` — WPF's `Grid`: rows and columns, children placed in
2//! cells with attached `Grid.Row` / `Grid.Column` / `Grid.RowSpan` /
3//! `Grid.ColumnSpan`.
4//!
5//! ```xml
6//! <Grid rows="Auto,*,26" columns="160,*" row_spacing="2" column_spacing="4">
7//!   <Label Grid.ColumnSpan="2" text="Title"/>
8//!   <ScrollView Grid.Row="1"/>
9//!   <Label Grid.Row="1" Grid.Column="1" text="Details" valign="top"/>
10//! </Grid>
11//! ```
12//!
13//! Definitions with limits use WPF's property-element form:
14//!
15//! ```xml
16//! <Grid>
17//!   <Grid.ColumnDefinitions>
18//!     <ColumnDefinition width="2*" min_width="80"/>
19//!     <ColumnDefinition width="*" max_width="200"/>
20//!   </Grid.ColumnDefinitions>
21//! </Grid>
22//! ```
23//!
24//! [`normalize`] runs on the parsed document before any generator: it
25//! checks all of this and turns it into builder calls (raw-call attributes),
26//! so the screen, view-model and component generators need no Grid logic.
27
28use crate::yui::{Element, RAW_CALL, err};
29use yog_data::Diagnostic;
30use yog_ids::{yui, yui_attr as at};
31use yog_ui::GridLength;
32
33const ATTACHED: &[&str] = &[
34    at::GRID_ROW,
35    at::GRID_COLUMN,
36    at::GRID_ROWSPAN,
37    at::GRID_COLUMNSPAN,
38];
39
40fn num(v: &str) -> Option<f32> {
41    v.trim()
42        .parse::<f32>()
43        .ok()
44        .filter(|n| n.is_finite() && *n >= 0.0)
45}
46
47/// A grid's rows or its columns, and the names each is written with
48/// (`rows="Auto,*"`, `<Grid.RowDefinitions>`, a definition's `height`).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(crate) enum Axis {
51    Row,
52    Column,
53}
54
55impl Axis {
56    pub(crate) const BOTH: [Axis; 2] = [Axis::Row, Axis::Column];
57
58    /// The grid's shorthand attribute: `rows`, `columns`.
59    pub(crate) fn shorthand(self) -> &'static str {
60        match self {
61            Axis::Row => at::ROWS,
62            Axis::Column => at::COLUMNS,
63        }
64    }
65
66    /// The grid's property element: `Grid.RowDefinitions`.
67    pub(crate) fn definitions(self) -> &'static str {
68        match self {
69            Axis::Row => yui::ROW_DEFINITIONS,
70            Axis::Column => yui::COLUMN_DEFINITIONS,
71        }
72    }
73
74    /// One definition inside it: `RowDefinition`.
75    fn definition(self) -> &'static str {
76        match self {
77            Axis::Row => yui::ROW_DEFINITION,
78            Axis::Column => yui::COLUMN_DEFINITION,
79        }
80    }
81
82    /// A definition's size, least and most: `height`, `min_height`, `max_height`.
83    pub(crate) fn sizes(self) -> [&'static str; 3] {
84        match self {
85            Axis::Row => [at::HEIGHT, at::MIN_HEIGHT, at::MAX_HEIGHT],
86            Axis::Column => [at::WIDTH, at::MIN_WIDTH, at::MAX_WIDTH],
87        }
88    }
89
90    /// What one of it is called in a message.
91    fn noun(self) -> &'static str {
92        match self {
93            Axis::Row => "row",
94            Axis::Column => "column",
95        }
96    }
97}
98
99/// One row/column size: `Auto`, `*`, `2*`, `26` → a `Track` expression.
100fn length(v: &str) -> Option<String> {
101    Some(match GridLength::parse(v)? {
102        GridLength::Auto => "::yog_api::ui::Track::auto()".into(),
103        GridLength::Star(w) => format!("::yog_api::ui::Track::star({w:?})"),
104        GridLength::Px(px) => format!("::yog_api::ui::Track::px({px:?})"),
105    })
106}
107
108/// `rows="Auto,*,26"` → track expressions.
109fn compact(el: &Element, key: &str, value: &str, what: &str) -> Result<Vec<String>, Diagnostic> {
110    if value.trim_start().starts_with('{') {
111        return err(
112            el,
113            format_args!("{key}=\"{value}\": grid definitions can't be bound"),
114        );
115    }
116    value
117        .split(',')
118        .map(|part| {
119            length(part).ok_or_else(|| {
120                Diagnostic::from(format!("<{}> {key}=\"{value}\": `{}` isn't a {what} size — expected Auto, *, 2* or pixels (26)", el.name, part.trim())).at_line(el.line)
121            })
122        })
123        .collect()
124}
125
126/// `<Grid.RowDefinitions>` → track expressions.
127fn elements(prop: &Element, axis: Axis) -> Result<Vec<String>, Diagnostic> {
128    let def = axis.definition();
129    let [size, min, max] = axis.sizes();
130    if !prop.attrs.is_empty() {
131        return err(prop, "takes no attributes");
132    }
133    let mut out = Vec::new();
134    for d in &prop.children {
135        if d.name != def {
136            return err(
137                d,
138                format_args!("isn't allowed in <{}> (expected <{def}>)", prop.name),
139            );
140        }
141        if !d.children.is_empty() {
142            return err(d, "can't have children");
143        }
144        for (k, _) in &d.attrs {
145            if ![size, min, max].contains(&k.as_str()) {
146                let lower = k
147                    .chars()
148                    .next()
149                    .is_some_and(char::is_uppercase)
150                    .then(|| crate::yui::snake(k));
151                return match lower.filter(|l| [size, min, max].contains(&l.as_str())) {
152                    Some(l) => err(
153                        d,
154                        format_args!("has no attribute `{k}` — .yui attributes are lowercase: {l}"),
155                    ),
156                    None => err(
157                        d,
158                        format_args!("has no attribute `{k}` (expected {size}, {min}, {max})"),
159                    ),
160                };
161            }
162        }
163        let v = d.attr(size).unwrap_or("*");
164        let mut t = length(v).ok_or_else(|| {
165            Diagnostic::from(format!(
166                "<{def}> {size}=\"{v}\": expected Auto, *, 2* or pixels (26)"
167            ))
168            .at_line(d.line)
169        })?;
170        for (key, call) in [(min, "min"), (max, "max")] {
171            if let Some(v) = d.attr(key) {
172                let n = num(v).ok_or_else(|| {
173                    Diagnostic::from(format!(
174                        "<{def}> {key}=\"{v}\": expected a number of pixels"
175                    ))
176                    .at_line(d.line)
177                })?;
178                t.push_str(&format!(".{call}({n:?})"));
179            }
180        }
181        out.push(t);
182    }
183    Ok(out)
184}
185
186fn index(el: &Element, key: &str, v: &str, min: u16) -> Result<u16, Diagnostic> {
187    v.trim()
188        .parse::<u16>()
189        .ok()
190        .filter(|n| *n >= min)
191        .ok_or_else(|| {
192            let what = if min == 0 {
193                "a whole number (0 = the first)"
194            } else {
195                "a whole number, at least 1"
196            };
197            Diagnostic::from(format!("<{}> {key}=\"{v}\": expected {what}", el.name))
198                .at_line(el.line)
199        })
200}
201
202fn plural(n: usize, what: &str) -> String {
203    if n == 1 {
204        format!("1 {what}")
205    } else {
206        format!("{n} {what}s")
207    }
208}
209
210/// A grid's rows and columns as builder arguments, when it says them.
211#[derive(Default)]
212struct Tracks {
213    rows: Option<Vec<String>>,
214    cols: Option<Vec<String>>,
215}
216
217impl Tracks {
218    fn slot(&mut self, axis: Axis) -> &mut Option<Vec<String>> {
219        match axis {
220            Axis::Row => &mut self.rows,
221            Axis::Column => &mut self.cols,
222        }
223    }
224}
225
226/// What a bound value starts with: `{x:Bind …}`.
227const BINDING_START: char = '{';
228/// What a grid's member properties start with: `Grid.`.
229const GRID_MEMBER: &str = yog_ids::concatcp!(yui::GRID, yog_ids::sep::DOT);
230
231/// Checks and lowers every `<Grid>` and `Grid.*` attached property under
232/// `el` (see the module docs).
233pub fn normalize(el: &mut Element) -> Result<(), Diagnostic> {
234    let is_grid = el.name == yui::GRID;
235    let mut tracks = definitions(el, is_grid)?;
236    if is_grid {
237        let calls = grid_calls(el, &mut tracks)?;
238        push_calls(el, calls, "g");
239    }
240    let size = (
241        tracks.rows.as_ref().map_or(1, Vec::len),
242        tracks.cols.as_ref().map_or(1, Vec::len),
243    );
244    let name = el.name.clone();
245    for child in &mut el.children {
246        cell(child, is_grid.then_some(size), &name)?;
247    }
248    for child in &mut el.children {
249        normalize(child)?;
250    }
251    Ok(())
252}
253
254/// `<Grid.RowDefinitions>` / `<Grid.ColumnDefinitions>` taken out of
255/// `el`'s children (only a grid has them, once each).
256fn definitions(el: &mut Element, is_grid: bool) -> Result<Tracks, Diagnostic> {
257    let mut tracks = Tracks::default();
258    let mut kept = Vec::with_capacity(el.children.len());
259    for child in std::mem::take(&mut el.children) {
260        let axis = Axis::BOTH
261            .into_iter()
262            .find(|a| a.definitions() == child.name);
263        let axis = match (axis, child.name.as_str()) {
264            (Some(axis), _) => axis,
265            (None, n) if n.starts_with(GRID_MEMBER) => {
266                return err(
267                    &child,
268                    "isn't a Grid property (expected <Grid.RowDefinitions> or <Grid.ColumnDefinitions>)",
269                );
270            }
271            _ => {
272                kept.push(child);
273                continue;
274            }
275        };
276        if !is_grid {
277            return err(
278                &child,
279                format_args!("only goes directly inside a <Grid>, not <{}>", el.name),
280            );
281        }
282        if tracks.slot(axis).is_some() {
283            return err(&child, "is given twice");
284        }
285        *tracks.slot(axis) = Some(elements(&child, axis)?);
286    }
287    el.children = kept;
288    Ok(tracks)
289}
290
291/// A grid's own attributes (`rows`, `columns`, spacings) as builder calls.
292fn grid_calls(el: &mut Element, tracks: &mut Tracks) -> Result<Vec<String>, Diagnostic> {
293    let mut calls = Vec::new();
294    let mut attrs = Vec::new();
295    for (k, v) in std::mem::take(&mut el.attrs) {
296        match k.as_str() {
297            at::ROWS | at::COLUMNS => {
298                let axis = if k == at::ROWS {
299                    Axis::Row
300                } else {
301                    Axis::Column
302                };
303                if tracks.slot(axis).is_some() {
304                    let prop = axis.definitions();
305                    return err(
306                        el,
307                        format_args!("{k}=\"...\" and <{prop}> both define the {k} — keep one"),
308                    );
309                }
310                *tracks.slot(axis) = Some(compact(el, &k, &v, axis.noun())?);
311            }
312            at::ROW_SPACING | at::COLUMN_SPACING => {
313                let n = num(&v).ok_or_else(|| {
314                    Diagnostic::from(format!("<Grid> {k}=\"{v}\": expected a number of pixels"))
315                        .at_line(el.line)
316                })?;
317                let m = if k == at::ROW_SPACING {
318                    "row_spacing"
319                } else {
320                    "col_spacing"
321                };
322                calls.push(format!("{m}({n:?})"));
323            }
324            _ => attrs.push((k, v)),
325        }
326    }
327    el.attrs = attrs;
328    if let Some(r) = &tracks.rows {
329        calls.push(format!("rows([{}])", r.join(", ")));
330    }
331    if let Some(c) = &tracks.cols {
332        calls.push(format!("cols([{}])", c.join(", ")));
333    }
334    Ok(calls)
335}
336
337/// A child's attached `Grid.*` properties: its cell (`grid`: the grid's
338/// rows and columns; none: its parent isn't one, and the child can't say them).
339fn cell(child: &mut Element, grid: Option<(usize, usize)>, parent: &str) -> Result<(), Diagnostic> {
340    let mut cell = [0u16, 0, 1, 1];
341    let mut any = false;
342    let mut attrs = Vec::new();
343    for (k, v) in std::mem::take(&mut child.attrs) {
344        let Some(i) = ATTACHED.iter().position(|a| *a == k) else {
345            if k.starts_with(GRID_MEMBER) {
346                return err(
347                    child,
348                    format_args!("has no attribute `{k}` (attached: {})", ATTACHED.join(", ")),
349                );
350            }
351            attrs.push((k, v));
352            continue;
353        };
354        if grid.is_none() {
355            return err(
356                child,
357                format_args!(
358                    "{k}: only children of a <Grid> have grid cells (this is in a <{parent}>)"
359                ),
360            );
361        }
362        if v.trim_start().starts_with(BINDING_START) {
363            return err(
364                child,
365                format_args!("{k}=\"{v}\": grid cells can't be bound"),
366            );
367        }
368        cell[i] = index(child, &k, &v, if i < 2 { 0 } else { 1 })?;
369        any = true;
370    }
371    child.attrs = attrs;
372    let Some((n_rows, n_cols)) = grid else {
373        return Ok(());
374    };
375    let [r, c, rs, cs] = cell;
376    for (start, span, n, what, key) in [
377        (r, rs, n_rows, Axis::Row.noun(), at::GRID_ROW),
378        (c, cs, n_cols, Axis::Column.noun(), at::GRID_COLUMN),
379    ] {
380        let (start, span) = (start as usize, span as usize);
381        if start >= n {
382            return err(
383                child,
384                format_args!(
385                    "{key}=\"{start}\" is past the grid's last {what} (it has {}, numbered from 0)",
386                    plural(n, what)
387                ),
388            );
389        }
390        if start + span > n {
391            return err(
392                child,
393                format_args!(
394                    "{key}Span=\"{span}\" from {what} {start} runs past the grid's last {what} (it has {})",
395                    plural(n, what)
396                ),
397            );
398        }
399    }
400    if any {
401        let mut calls = vec![format!("cell({r}, {c})")];
402        if (rs, cs) != (1, 1) {
403            calls.push(format!("span({rs}, {cs})"));
404        }
405        push_calls(child, calls, "c");
406    }
407    Ok(())
408}
409
410/// Adds builder calls as raw-call attributes (see [`RAW_CALL`]).
411fn push_calls(el: &mut Element, calls: Vec<String>, tag: &str) {
412    for (i, c) in calls.into_iter().enumerate() {
413        el.attrs.push((format!("{RAW_CALL}{tag}{i}"), c));
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use crate::yui;
420    use yog_data::Diagnostic;
421
422    fn codegen(body: &str) -> Result<String, Diagnostic> {
423        yui::generate(
424            &format!("<Screen id=\"m:s\" width=\"100\" height=\"100\">{body}</Screen>"),
425            "s",
426        )
427    }
428
429    #[test]
430    fn compact_and_element_forms() {
431        let code = codegen(
432            r#"<Grid rows="Auto, *, 26" row_spacing="2" column_spacing="4">
433                 <Grid.ColumnDefinitions>
434                   <ColumnDefinition width="2*" min_width="80"/>
435                   <ColumnDefinition max_width="200"/>
436                 </Grid.ColumnDefinitions>
437                 <Label text="a" Grid.Row="1" Grid.Column="1" halign="right" margin="2 4"/>
438                 <Label text="b" Grid.RowSpan="3"/>
439                 <Label text="c"/>
440               </Grid>"#,
441        )
442        .unwrap();
443        assert!(code.contains("widget::grid()"), "{code}");
444        assert!(code.contains(".row_spacing(2.0)"), "{code}");
445        assert!(code.contains(".col_spacing(4.0)"), "{code}");
446        assert!(
447            code.contains(".rows([::yog_api::ui::Track::auto(), ::yog_api::ui::Track::star(1.0), ::yog_api::ui::Track::px(26.0)])"),
448            "{code}"
449        );
450        assert!(
451            code.contains(".cols([::yog_api::ui::Track::star(2.0).min(80.0), ::yog_api::ui::Track::star(1.0).max(200.0)])"),
452            "{code}"
453        );
454        assert!(code.contains(".cell(1, 1)"), "{code}");
455        assert!(
456            code.contains(".halign(::yog_api::ui::HAlign::Right)"),
457            "{code}"
458        );
459        assert!(code.contains(".margin(2.0, 4.0, 2.0, 4.0)"), "{code}");
460        assert!(
461            code.contains(".cell(0, 0)\n") && code.contains(".span(3, 1)"),
462            "{code}"
463        );
464    }
465
466    #[test]
467    fn errors() {
468        let e = |body: &str| codegen(body).unwrap_err();
469        assert!(
470            e(r#"<Grid rows="abc"/>"#)
471                .message
472                .contains("`abc` isn't a row size")
473        );
474        assert!(e(r#"<Grid><Grid.RowDefinitions><RowDefinition Height="Auto"/></Grid.RowDefinitions></Grid>"#)
475            .message.contains("lowercase: height"));
476        assert!(e(
477            r#"<Grid><Grid.RowDefinitions><RowDefinition height="x"/></Grid.RowDefinitions></Grid>"#
478        )
479        .message.contains("height=\"x\""));
480        assert!(
481            e(r#"<Grid rows="*,*"><Label Grid.Row="2"/></Grid>"#)
482                .message
483                .contains("past the grid's last row (it has 2 rows")
484        );
485        assert!(
486            e(r#"<Grid columns="*,*"><Label Grid.Column="1" Grid.ColumnSpan="2"/></Grid>"#)
487                .message
488                .contains("Grid.ColumnSpan=\"2\" from column 1")
489        );
490        assert!(
491            e(r#"<Grid><Label Grid.Row="1"/></Grid>"#)
492                .message
493                .contains("it has 1 row,")
494        );
495        assert!(
496            e(r#"<Column><Label Grid.Row="0"/></Column>"#)
497                .message
498                .contains("only children of a <Grid>")
499        );
500        assert!(
501            e(r#"<Grid rows="*"><Grid.RowDefinitions/></Grid>"#)
502                .message
503                .contains("keep one")
504        );
505        assert!(
506            e(r#"<Column><Grid.RowDefinitions/></Column>"#)
507                .message
508                .contains("directly inside a <Grid>")
509        );
510        assert!(
511            e(r#"<Grid><Label Grid.RowSpan="0"/></Grid>"#)
512                .message
513                .contains("at least 1")
514        );
515        assert!(
516            e(r#"<Grid><Label HorizontalAlignment="Left"/></Grid>"#)
517                .message
518                .contains("halign")
519        );
520        assert!(
521            e(r#"<Grid><Label halign="middle"/></Grid>"#)
522                .message
523                .contains("left, center, right, stretch")
524        );
525    }
526}