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