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