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