Skip to main content

eui_core/
tracks.rs

1//! Hand parser for the grid strings the whitelist admits (from the VAPP-4
2//! spike): `gridTemplateColumns/Rows` = `<n>px | <n> | <n>% | <n>fr | auto |
3//! min-content | max-content | minmax(a, b) | repeat(n|auto-fill|auto-fit, …)`,
4//! `gridTemplateAreas` = string rows, `gridArea` = an area name,
5//! `gridColumn/gridRow` = `a`, `a / b`, `span n`, a name.
6
7use taffy::prelude::*;
8use taffy::style::{
9    GridPlacement, GridTemplateArea, GridTemplateComponent, GridTemplateRepetition,
10    MaxTrackSizingFunction, MinTrackSizingFunction, RepetitionCount, TrackSizingFunction,
11};
12
13/// Split at top-level whitespace, or at top-level commas when `sep_comma`.
14fn split_top_level(s: &str, sep_comma: bool) -> Vec<String> {
15    let mut out = Vec::new();
16    let mut cur = String::new();
17    let mut depth = 0i32;
18    for ch in s.chars() {
19        match ch {
20            '(' => {
21                depth += 1;
22                cur.push(ch);
23            }
24            ')' => {
25                depth -= 1;
26                cur.push(ch);
27            }
28            ',' if sep_comma && depth == 0 => {
29                if !cur.trim().is_empty() {
30                    out.push(cur.trim().to_string());
31                }
32                cur.clear();
33            }
34            c if c.is_whitespace() && depth == 0 && !sep_comma => {
35                if !cur.trim().is_empty() {
36                    out.push(cur.trim().to_string());
37                }
38                cur.clear();
39            }
40            _ => cur.push(ch),
41        }
42    }
43    if !cur.trim().is_empty() {
44        out.push(cur.trim().to_string());
45    }
46    out
47}
48
49fn number_prefix(s: &str, suffix: &str) -> Option<f32> {
50    s.strip_suffix(suffix).and_then(|n| n.trim().parse::<f32>().ok())
51}
52
53fn min_fn(tok: &str) -> Result<MinTrackSizingFunction, String> {
54    Ok(match tok {
55        "auto" => MinTrackSizingFunction::auto(),
56        "min-content" => MinTrackSizingFunction::min_content(),
57        "max-content" => MinTrackSizingFunction::max_content(),
58        t => {
59            if let Some(v) = number_prefix(t, "px") {
60                MinTrackSizingFunction::length(v)
61            } else if let Some(v) = number_prefix(t, "%") {
62                MinTrackSizingFunction::percent(v / 100.0)
63            } else if let Ok(v) = t.parse::<f32>() {
64                MinTrackSizingFunction::length(v)
65            } else {
66                return Err(format!("unsupported min track size {t:?}"));
67            }
68        }
69    })
70}
71
72fn max_fn(tok: &str) -> Result<MaxTrackSizingFunction, String> {
73    Ok(match tok {
74        "auto" => MaxTrackSizingFunction::auto(),
75        "min-content" => MaxTrackSizingFunction::min_content(),
76        "max-content" => MaxTrackSizingFunction::max_content(),
77        t => {
78            if let Some(v) = number_prefix(t, "fr") {
79                MaxTrackSizingFunction::fr(v)
80            } else if let Some(v) = number_prefix(t, "px") {
81                MaxTrackSizingFunction::length(v)
82            } else if let Some(v) = number_prefix(t, "%") {
83                MaxTrackSizingFunction::percent(v / 100.0)
84            } else if let Ok(v) = t.parse::<f32>() {
85                MaxTrackSizingFunction::length(v)
86            } else {
87                return Err(format!("unsupported max track size {t:?}"));
88            }
89        }
90    })
91}
92
93/// One non-repeated track: `1fr` becomes `minmax(auto, 1fr)` like CSS.
94fn track(tok: &str) -> Result<TrackSizingFunction, String> {
95    if let Some(inner) = tok.strip_prefix("minmax(").and_then(|s| s.strip_suffix(')')) {
96        let parts = split_top_level(inner, true);
97        if parts.len() != 2 {
98            return Err(format!("minmax needs two arguments: {tok:?}"));
99        }
100        return Ok(minmax(min_fn(&parts[0])?, max_fn(&parts[1])?));
101    }
102    if tok.ends_with("fr") {
103        return Ok(minmax(MinTrackSizingFunction::auto(), max_fn(tok)?));
104    }
105    Ok(minmax(min_fn(tok)?, max_fn(tok)?))
106}
107
108/// `gridTemplateColumns` / `gridTemplateRows`.
109pub fn parse_tracks(s: &str) -> Result<Vec<GridTemplateComponent<String>>, String> {
110    let mut out = Vec::new();
111    for tok in split_top_level(s, false) {
112        if let Some(inner) = tok.strip_prefix("repeat(").and_then(|s| s.strip_suffix(')')) {
113            let parts = split_top_level(inner, true);
114            if parts.len() != 2 {
115                return Err(format!("repeat needs two arguments: {tok:?}"));
116            }
117            let count = match parts[0].as_str() {
118                "auto-fill" => RepetitionCount::AutoFill,
119                "auto-fit" => RepetitionCount::AutoFit,
120                n => RepetitionCount::Count(n.parse::<u16>().map_err(|_| format!("bad repeat count {n:?}"))?),
121            };
122            let tracks = split_top_level(&parts[1], false).iter().map(|t| track(t)).collect::<Result<Vec<_>, _>>()?;
123            let line_names = vec![Vec::new(); tracks.len() + 1];
124            out.push(GridTemplateComponent::Repeat(GridTemplateRepetition { count, tracks, line_names }));
125        } else {
126            out.push(GridTemplateComponent::Single(track(&tok)?));
127        }
128    }
129    Ok(out)
130}
131
132/// `gridTemplateAreas` rows → named areas (1-based lines, end exclusive).
133/// `.` is an empty cell. Non-rectangular areas are rejected.
134pub fn parse_areas(rows: &[String]) -> Result<Vec<GridTemplateArea<String>>, String> {
135    let mut areas: Vec<GridTemplateArea<String>> = Vec::new();
136    for (r, row) in rows.iter().enumerate() {
137        for (c, name) in row.split_whitespace().enumerate() {
138            if name == "." {
139                continue;
140            }
141            let row_line = (r + 1) as u16;
142            let col_line = (c + 1) as u16;
143            if let Some(a) = areas.iter_mut().find(|a| a.name == name) {
144                a.row_start = a.row_start.min(row_line);
145                a.row_end = a.row_end.max(row_line + 1);
146                a.column_start = a.column_start.min(col_line);
147                a.column_end = a.column_end.max(col_line + 1);
148            } else {
149                areas.push(GridTemplateArea {
150                    name: name.to_string(),
151                    row_start: row_line,
152                    row_end: row_line + 1,
153                    column_start: col_line,
154                    column_end: col_line + 1,
155                });
156            }
157        }
158    }
159    for a in &areas {
160        for r in a.row_start..a.row_end {
161            let cells: Vec<&str> = rows.get((r - 1) as usize).map(|row| row.split_whitespace().collect()).unwrap_or_default();
162            for c in a.column_start..a.column_end {
163                if cells.get((c - 1) as usize).copied() != Some(a.name.as_str()) {
164                    return Err(format!("grid area {:?} is not rectangular", a.name));
165                }
166            }
167        }
168    }
169    Ok(areas)
170}
171
172fn placement(tok: &str) -> Result<GridPlacement<String>, String> {
173    let tok = tok.trim();
174    if tok == "auto" {
175        return Ok(GridPlacement::Auto);
176    }
177    if let Some(n) = tok.strip_prefix("span ") {
178        return Ok(GridPlacement::Span(n.trim().parse::<u16>().map_err(|_| format!("bad span {tok:?}"))?));
179    }
180    if let Ok(n) = tok.parse::<i16>() {
181        return Ok(line(n));
182    }
183    Ok(GridPlacement::NamedLine(tok.to_string(), 0))
184}
185
186/// `gridColumn` / `gridRow`: `"2"`, `"1 / 3"`, `"1 / -1"`, `"span 2"`, `"nav"`.
187pub fn parse_placement(s: &str) -> Result<Line<GridPlacement<String>>, String> {
188    let parts: Vec<&str> = s.split('/').map(str::trim).collect();
189    match parts.as_slice() {
190        [one] => {
191            let start = placement(one)?;
192            let end = match &start {
193                GridPlacement::NamedLine(name, i) => GridPlacement::NamedLine(name.clone(), *i),
194                GridPlacement::Span(_) => start.clone(),
195                _ => GridPlacement::Auto,
196            };
197            Ok(Line { start, end })
198        }
199        [a, b] => Ok(Line { start: placement(a)?, end: placement(b)? }),
200        _ => Err(format!("unsupported grid placement {s:?}")),
201    }
202}
203
204/// `gridArea: "nav"` → both axes span the named area.
205pub fn parse_area_placement(name: &str) -> Line<GridPlacement<String>> {
206    Line { start: GridPlacement::NamedLine(name.to_string(), 0), end: GridPlacement::NamedLine(name.to_string(), 0) }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn parses_mixed_tracks() {
215        let t = parse_tracks("minmax(180px, 1fr) 2fr auto 120 repeat(2, 1fr 40px)").unwrap();
216        assert_eq!(t.len(), 5);
217        assert!(matches!(t[4], GridTemplateComponent::Repeat(_)));
218        let t = parse_tracks("repeat(3, minmax(0, 1fr))").unwrap();
219        assert_eq!(t.len(), 1);
220    }
221
222    #[test]
223    fn parses_areas() {
224        let a = parse_areas(&["nav main".into(), "nav footer".into()]).unwrap();
225        let nav = a.iter().find(|x| x.name == "nav").unwrap();
226        assert_eq!((nav.row_start, nav.row_end, nav.column_start, nav.column_end), (1, 3, 1, 2));
227    }
228
229    #[test]
230    fn rejects_l_shapes() {
231        assert!(parse_areas(&["a a".into(), "a b".into()]).is_err());
232    }
233
234    #[test]
235    fn parses_placements() {
236        assert!(matches!(parse_placement("1 / 3").unwrap().end, GridPlacement::Line(_)));
237        assert!(matches!(parse_placement("1 / -1").unwrap().end, GridPlacement::Line(_)));
238        assert!(matches!(parse_placement("span 2").unwrap().start, GridPlacement::Span(2)));
239        assert!(matches!(parse_placement("nav").unwrap().end, GridPlacement::NamedLine(_, 0)));
240    }
241}