Skip to main content

bevy_react/protocol/
grid.rs

1//! CSS grid track/template/placement string parsing, decoding straight into
2//! `bevy_ui` grid types via the `grid_fields!` deserializers.
3
4use std::fmt;
5
6use bevy::ui::{GridPlacement, GridTrack, RepeatedGridTrack};
7use serde::de::{self, Deserializer, Visitor};
8
9use super::decode_warn;
10
11/// Split a grid track list on whitespace while keeping `repeat(...)` groups
12/// (which contain spaces) intact.
13fn split_tracks(s: &str) -> Vec<String> {
14    let mut out = Vec::new();
15    let mut depth = 0usize;
16    let mut cur = String::new();
17    for ch in s.chars() {
18        match ch {
19            '(' => {
20                depth += 1;
21                cur.push(ch);
22            }
23            ')' => {
24                depth = depth.saturating_sub(1);
25                cur.push(ch);
26            }
27            c if c.is_whitespace() && depth == 0 => {
28                if !cur.is_empty() {
29                    out.push(std::mem::take(&mut cur));
30                }
31            }
32            c => cur.push(c),
33        }
34    }
35    if !cur.is_empty() {
36        out.push(cur);
37    }
38    out
39}
40
41/// Parse one sizing token (`"1fr"`, `"100px"`, `"50%"`, `"auto"`,
42/// `"min-content"`, `"max-content"`, `"2flex"`) into a `GridTrack`.
43fn single_track(token: &str) -> Option<GridTrack> {
44    let t = token.trim();
45    match t {
46        "auto" => return Some(GridTrack::auto()),
47        "min-content" => return Some(GridTrack::min_content()),
48        "max-content" => return Some(GridTrack::max_content()),
49        _ => {}
50    }
51    let parse = |num: &str| num.trim().parse::<f32>().ok();
52    if let Some(v) = t.strip_suffix("fr").and_then(parse) {
53        Some(GridTrack::fr(v))
54    } else if let Some(v) = t.strip_suffix("flex").and_then(parse) {
55        Some(GridTrack::flex(v))
56    } else if let Some(v) = t.strip_suffix("px").and_then(parse) {
57        Some(GridTrack::px(v))
58    } else {
59        t.strip_suffix('%').and_then(parse).map(GridTrack::percent)
60    }
61}
62
63/// Build a repeated track (`repeat(count, token)`), dispatching on the unit.
64fn repeated_track(count: u16, token: &str) -> Option<RepeatedGridTrack> {
65    let t = token.trim();
66    match t {
67        "auto" => return Some(RepeatedGridTrack::auto(count)),
68        "min-content" => return Some(RepeatedGridTrack::min_content(count)),
69        "max-content" => return Some(RepeatedGridTrack::max_content(count)),
70        _ => {}
71    }
72    let parse = |num: &str| num.trim().parse::<f32>().ok();
73    if let Some(v) = t.strip_suffix("fr").and_then(parse) {
74        Some(RepeatedGridTrack::fr(count, v))
75    } else if let Some(v) = t.strip_suffix("flex").and_then(parse) {
76        Some(RepeatedGridTrack::flex(count, v))
77    } else if let Some(v) = t.strip_suffix("px").and_then(parse) {
78        Some(RepeatedGridTrack::px(count as usize, v))
79    } else {
80        t.strip_suffix('%')
81            .and_then(parse)
82            .map(|v| RepeatedGridTrack::percent(count as usize, v))
83    }
84}
85
86/// Parse a CSS grid template (`"repeat(3, 1fr)"`, `"1fr 2fr 100px"`, `"auto"`).
87/// An unparsable token warns and is skipped; the rest of the template survives.
88fn parse_template(s: &str) -> Vec<RepeatedGridTrack> {
89    split_tracks(s)
90        .into_iter()
91        .filter_map(|tok| {
92            let parse_one = || {
93                if let Some(inner) = tok
94                    .strip_prefix("repeat(")
95                    .and_then(|t| t.strip_suffix(')'))
96                {
97                    let (count, track) = inner.split_once(',')?;
98                    repeated_track(count.trim().parse().ok()?, track)
99                } else {
100                    single_track(&tok).map(Into::into)
101                }
102            };
103            let parsed = parse_one();
104            if parsed.is_none() {
105                decode_warn(
106                    "gridTrack",
107                    &tok,
108                    &format!("ignoring unparsable grid track {tok:?}"),
109                );
110            }
111            parsed
112        })
113        .collect()
114}
115
116/// Parse an auto-track list (`grid-auto-rows`/`columns`); no `repeat()`.
117fn parse_auto_tracks(s: &str) -> Vec<GridTrack> {
118    split_tracks(s)
119        .iter()
120        .filter_map(|t| {
121            let parsed = single_track(t);
122            if parsed.is_none() {
123                decode_warn(
124                    "gridTrack",
125                    t,
126                    &format!("ignoring unparsable grid track {t:?}"),
127                );
128            }
129            parsed
130        })
131        .collect()
132}
133
134/// Fallible half of [`de_grid_placement`]: `None` on anything that must not
135/// reach `GridPlacement`'s panicking constructors. A zero anywhere in the value
136/// (invalid in CSS) aborts the whole placement (rather than degrading to a
137/// partial one, which would silently mis-place the item).
138fn try_grid_placement(s: &str) -> Option<GridPlacement> {
139    enum Token {
140        Num(i16),  // a nonzero line number
141        Span(u16), // a nonzero `span N`
142        Auto,
143        Invalid, // a zero line/span, or an unrecognized token
144    }
145    fn token(t: &str) -> Token {
146        let t = t.trim();
147        if t == "auto" {
148            return Token::Auto;
149        }
150        if let Some(n) = t.strip_prefix("span") {
151            return match n.trim().parse::<u16>() {
152                Ok(0) | Err(_) => Token::Invalid,
153                Ok(n) => Token::Span(n),
154            };
155        }
156        match t.parse::<i16>() {
157            Ok(0) | Err(_) => Token::Invalid,
158            Ok(n) => Token::Num(n),
159        }
160    }
161    use Token::*;
162    if let Some((a, b)) = s.split_once('/') {
163        return Some(match (token(a), token(b)) {
164            (Num(start), Span(span)) => GridPlacement::start_span(start, span),
165            (Auto, Span(span)) => GridPlacement::span(span),
166            (Num(start), Num(end)) => GridPlacement::start_end(start, end),
167            (Num(start), Auto) => GridPlacement::start(start),
168            (Auto, Num(end)) => GridPlacement::end(end),
169            (Auto, Auto) => GridPlacement::auto(),
170            _ => return None,
171        });
172    }
173    match token(s) {
174        Auto => Some(GridPlacement::auto()),
175        Span(span) => Some(GridPlacement::span(span)),
176        Num(line) => Some(GridPlacement::start(line)),
177        Invalid => None,
178    }
179}
180
181/// Shared shape of the three grid deserializers: string in, parsed value out,
182/// `null` → `None`, non-string → hard error (like the keyword fields).
183macro_rules! grid_fields {
184    ( $(
185        $(#[$meta:meta])*
186        fn $fn_name:ident($expect:literal) -> $ty:ty { $parse:expr }
187    )+ ) => { $(
188        $(#[$meta])*
189        pub(crate) fn $fn_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
190            struct V;
191            impl<'de> Visitor<'de> for V {
192                type Value = Option<$ty>;
193                fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
194                    f.write_str($expect)
195                }
196                fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
197                    let parse: fn(&str) -> $ty = $parse;
198                    Ok(Some(parse(s)))
199                }
200                fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
201                    Ok(None)
202                }
203                fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
204                    Ok(None)
205                }
206            }
207            d.deserialize_any(V)
208        }
209    )+ };
210}
211
212grid_fields! {
213    fn de_grid_template("a CSS grid template string") -> Vec<RepeatedGridTrack> {
214        parse_template
215    }
216    fn de_grid_auto_tracks("a grid auto-track list string") -> Vec<GridTrack> {
217        parse_auto_tracks
218    }
219    /// A zero grid line/span (invalid in CSS — and `GridPlacement`'s
220    /// constructors panic on it) or an unrecognized token warns and falls back
221    /// to `auto`.
222    fn de_grid_placement("a grid line placement string") -> GridPlacement {
223        |s| {
224            try_grid_placement(s).unwrap_or_else(|| {
225                decode_warn(
226                    "gridPlacement",
227                    s,
228                    &format!("unrecognized grid placement {s:?}"),
229                );
230                GridPlacement::default()
231            })
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::protocol::style::Style;
240
241    /// Grid templates/placements parse once at decode into the bevy types.
242    #[test]
243    fn grid_templates_and_placement_decode() {
244        let s: Style = serde_json::from_value(serde_json::json!({
245            "gridTemplateColumns": "1fr 2fr 100px",
246            "gridTemplateRows": "repeat(3, 1fr)",
247            "gridAutoRows": "auto 40px",
248        }))
249        .expect("grid template decodes");
250        assert_eq!(s.grid_template_columns.map(|t| t.len()), Some(3));
251        assert_eq!(s.grid_template_rows.map(|t| t.len()), Some(1));
252        assert_eq!(s.grid_auto_rows.map(|t| t.len()), Some(2));
253
254        // An unparsable track is skipped (warned); the rest survive.
255        let s: Style =
256            serde_json::from_value(serde_json::json!({ "gridTemplateRows": "1fr bogus 2fr" }))
257                .expect("bad track must not abort");
258        assert_eq!(s.grid_template_rows.map(|t| t.len()), Some(2));
259
260        let placed = |v: &str| {
261            let s: Style = serde_json::from_value(serde_json::json!({ "gridRow": v }))
262                .expect("grid placement decodes");
263            format!("{:?}", s.grid_row.unwrap())
264        };
265        let expect = |p: GridPlacement| format!("{p:?}");
266        assert_eq!(placed("1 / 3"), expect(GridPlacement::start_end(1, 3)));
267        assert_eq!(placed("span 2"), expect(GridPlacement::span(2)));
268        assert_eq!(
269            placed("2 / span 3"),
270            expect(GridPlacement::start_span(2, 3))
271        );
272        assert_eq!(placed("2 / 2"), expect(GridPlacement::start_end(2, 2)));
273        assert_eq!(placed("-1"), expect(GridPlacement::start(-1)));
274        assert_eq!(placed("2 / auto"), expect(GridPlacement::start(2)));
275        assert_eq!(placed("auto / 3"), expect(GridPlacement::end(3)));
276    }
277
278    /// A zero grid line/span is invalid CSS and panics `GridPlacement`'s
279    /// constructors — every zero-bearing form must warn and fall back to `auto`
280    /// at decode, never reach the constructor or degrade to a partial placement.
281    #[test]
282    fn grid_placement_zero_falls_back_to_auto() {
283        let placed = |v: &str| {
284            let s: Style = serde_json::from_value(serde_json::json!({ "gridRow": v }))
285                .expect("zero placement must not abort");
286            format!("{:?}", s.grid_row.unwrap())
287        };
288        let auto = format!("{:?}", GridPlacement::auto());
289        for s in ["0", "span 0", "0 / 2", "2 / 0", "0 / span 2", "2 / span 0"] {
290            assert_eq!(placed(s), auto, "input {s:?}");
291        }
292        // Unrecognized garbage also falls back rather than panicking.
293        assert_eq!(placed("garbage"), auto);
294    }
295}