Skip to main content

bevy_react/protocol/
units.rs

1//! The unit-bearing wire types: [`Length`], [`Angle`], [`Time`],
2//! [`FontSize`], [`Rect`] — parsed once at the serde boundary.
3
4use std::fmt;
5
6use serde::Deserialize;
7use serde::de::{self, Deserializer, MapAccess, Visitor};
8
9use super::decode_warn;
10
11/// A length value mirroring `bevy_ui::Val`, parsed from the wire form (a number
12/// is logical pixels; a string carries an explicit unit).
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum Length {
15    Auto,
16    Px(f32),
17    Percent(f32),
18    Vw(f32),
19    Vh(f32),
20    VMin(f32),
21    VMax(f32),
22}
23
24impl Default for Length {
25    fn default() -> Self {
26        Length::Px(0.0)
27    }
28}
29
30/// Parse a CSS-ish length token (`"auto"`, `"10px"`, `"50%"`, `"100vw"`, `"5"`).
31fn parse_length(s: &str) -> Result<Length, String> {
32    let s = s.trim();
33    if s.eq_ignore_ascii_case("auto") {
34        return Ok(Length::Auto);
35    }
36    // `vmin`/`vmax` before `vw`/`vh` is unnecessary (suffixes are distinct), but
37    // `%` is checked last so numeric parsing handles the bare-number case.
38    type LengthCtor = fn(f32) -> Length;
39    let units: [(&str, LengthCtor); 6] = [
40        ("px", Length::Px),
41        ("vmin", Length::VMin),
42        ("vmax", Length::VMax),
43        ("vw", Length::Vw),
44        ("vh", Length::Vh),
45        ("%", Length::Percent),
46    ];
47    for (suffix, ctor) in units {
48        if let Some(num) = s.strip_suffix(suffix) {
49            let v: f32 = num
50                .trim()
51                .parse()
52                .map_err(|_| format!("invalid length {s:?}"))?;
53            return Ok(ctor(v));
54        }
55    }
56    s.parse::<f32>()
57        .map(Length::Px)
58        .map_err(|_| format!("invalid length {s:?}"))
59}
60
61impl<'de> Deserialize<'de> for Length {
62    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
63        struct LengthVisitor;
64        impl<'de> Visitor<'de> for LengthVisitor {
65            type Value = Length;
66            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
67                f.write_str("a number (logical pixels) or a CSS length string")
68            }
69            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Length, E> {
70                Ok(Length::Px(v as f32))
71            }
72            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Length, E> {
73                Ok(Length::Px(v as f32))
74            }
75            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Length, E> {
76                Ok(Length::Px(v as f32))
77            }
78            fn visit_str<E: de::Error>(self, s: &str) -> Result<Length, E> {
79                Ok(parse_length(s).unwrap_or_else(|e| {
80                    decode_warn("length", s, &e);
81                    Length::default()
82                }))
83            }
84        }
85        d.deserialize_any(LengthVisitor)
86    }
87}
88
89/// An angle, parsed from the wire as a number (read as **degrees**, the CSS
90/// convention) or a unit string (`"45deg"`, `"1.5rad"`, `"0.25turn"`, `"100grad"`).
91/// Stored internally as radians — the unit Bevy's gradient and transform APIs want.
92#[derive(Debug, Clone, Copy, PartialEq, Default)]
93pub struct Angle(f32);
94
95impl Angle {
96    /// This angle in radians.
97    pub fn radians(self) -> f32 {
98        self.0
99    }
100
101    /// An angle from radians (the internal unit) — the write half of
102    /// [`Self::radians`], for engine code re-emitting eased values.
103    pub fn from_radians(radians: f32) -> Self {
104        Angle(radians)
105    }
106}
107
108/// Parse a CSS angle token into radians. A bare number is degrees; a suffix of
109/// `deg`/`grad`/`turn`/`rad` selects the unit (`grad` is matched before `rad`
110/// since `"100grad"` also ends in `"rad"`).
111fn parse_angle(s: &str) -> Result<f32, String> {
112    use std::f32::consts::{PI, TAU};
113    let s = s.trim();
114    type AngleConv = fn(f32) -> f32;
115    let units: [(&str, AngleConv); 4] = [
116        ("deg", f32::to_radians),
117        ("grad", |v| v * PI / 200.0),
118        ("turn", |v| v * TAU),
119        ("rad", |v| v),
120    ];
121    for (suffix, conv) in units {
122        if let Some(num) = s.strip_suffix(suffix) {
123            let v: f32 = num
124                .trim()
125                .parse()
126                .map_err(|_| format!("invalid angle {s:?}"))?;
127            return Ok(conv(v));
128        }
129    }
130    s.parse::<f32>()
131        .map(f32::to_radians)
132        .map_err(|_| format!("invalid angle {s:?}"))
133}
134
135impl<'de> Deserialize<'de> for Angle {
136    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
137        struct AngleVisitor;
138        impl Visitor<'_> for AngleVisitor {
139            type Value = Angle;
140            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
141                f.write_str("a number (degrees) or a CSS angle string")
142            }
143            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Angle, E> {
144                Ok(Angle((v as f32).to_radians()))
145            }
146            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Angle, E> {
147                Ok(Angle((v as f32).to_radians()))
148            }
149            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Angle, E> {
150                Ok(Angle((v as f32).to_radians()))
151            }
152            fn visit_str<E: de::Error>(self, s: &str) -> Result<Angle, E> {
153                Ok(parse_angle(s).map(Angle).unwrap_or_else(|e| {
154                    decode_warn("angle", s, &e);
155                    Angle::default()
156                }))
157            }
158        }
159        d.deserialize_any(AngleVisitor)
160    }
161}
162
163/// A time/duration, parsed from the wire as a number (read as **milliseconds**,
164/// the JS-facing unit) or a unit string (`"200ms"`, `"0.2s"`). Stored as seconds —
165/// the unit the animations engine and the transition driver consume.
166#[derive(Debug, Clone, Copy, PartialEq, Default)]
167pub struct Time(f32);
168
169impl Time {
170    /// Construct from a value already in seconds.
171    pub fn from_secs(secs: f32) -> Self {
172        Time(secs)
173    }
174    /// This duration in seconds.
175    pub fn seconds(self) -> f32 {
176        self.0
177    }
178}
179
180/// Parse a CSS time token into seconds. A bare number is milliseconds; a suffix of
181/// `ms`/`s` selects the unit (`ms` is matched before `s` since `"200ms"` also ends
182/// in `"s"`).
183fn parse_time(s: &str) -> Result<f32, String> {
184    let s = s.trim();
185    if let Some(num) = s.strip_suffix("ms") {
186        return num
187            .trim()
188            .parse::<f32>()
189            .map(|v| v / 1000.0)
190            .map_err(|_| format!("invalid time {s:?}"));
191    }
192    if let Some(num) = s.strip_suffix('s') {
193        return num
194            .trim()
195            .parse::<f32>()
196            .map_err(|_| format!("invalid time {s:?}"));
197    }
198    s.parse::<f32>()
199        .map(|v| v / 1000.0)
200        .map_err(|_| format!("invalid time {s:?}"))
201}
202
203impl<'de> Deserialize<'de> for Time {
204    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
205        struct TimeVisitor;
206        impl Visitor<'_> for TimeVisitor {
207            type Value = Time;
208            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
209                f.write_str("a number (milliseconds) or a CSS time string")
210            }
211            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Time, E> {
212                Ok(Time(v as f32 / 1000.0))
213            }
214            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Time, E> {
215                Ok(Time(v as f32 / 1000.0))
216            }
217            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Time, E> {
218                Ok(Time(v as f32 / 1000.0))
219            }
220            fn visit_str<E: de::Error>(self, s: &str) -> Result<Time, E> {
221                Ok(parse_time(s).map(Time).unwrap_or_else(|e| {
222                    decode_warn("time", s, &e);
223                    Time::default()
224                }))
225            }
226        }
227        d.deserialize_any(TimeVisitor)
228    }
229}
230
231/// A font size mirroring `bevy_text::FontSize`, parsed from the wire as a number
232/// (logical pixels) or a unit string (`"24px"`, `"100vw"`/`vh`/`vmin`/`vmax`,
233/// `"1.5rem"`). `rem` is relative to bevy's `RemSize` resource (default 20px).
234/// (CSS `em` has no `bevy_text` equivalent, so it is not accepted.)
235#[derive(Debug, Clone, Copy, PartialEq)]
236pub enum FontSize {
237    Px(f32),
238    Vw(f32),
239    Vh(f32),
240    VMin(f32),
241    VMax(f32),
242    Rem(f32),
243}
244
245/// Parse a font-size token (`"24px"`, `"100vw"`, `"1.5rem"`, or a bare number read
246/// as pixels). Suffixes are checked longest-first where they'd otherwise alias
247/// (`vmin`/`vmax` before `vw`/`vh`).
248fn parse_font_size(s: &str) -> Result<FontSize, String> {
249    let s = s.trim();
250    type FsCtor = fn(f32) -> FontSize;
251    let units: [(&str, FsCtor); 6] = [
252        ("px", FontSize::Px),
253        ("rem", FontSize::Rem),
254        ("vmin", FontSize::VMin),
255        ("vmax", FontSize::VMax),
256        ("vw", FontSize::Vw),
257        ("vh", FontSize::Vh),
258    ];
259    for (suffix, ctor) in units {
260        if let Some(num) = s.strip_suffix(suffix) {
261            let v: f32 = num
262                .trim()
263                .parse()
264                .map_err(|_| format!("invalid fontSize {s:?}"))?;
265            return Ok(ctor(v));
266        }
267    }
268    s.parse::<f32>()
269        .map(FontSize::Px)
270        .map_err(|_| format!("invalid fontSize {s:?}"))
271}
272
273impl<'de> Deserialize<'de> for FontSize {
274    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
275        struct FontSizeVisitor;
276        impl Visitor<'_> for FontSizeVisitor {
277            type Value = FontSize;
278            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
279                f.write_str("a number (logical pixels) or a font-size unit string")
280            }
281            fn visit_f64<E: de::Error>(self, v: f64) -> Result<FontSize, E> {
282                Ok(FontSize::Px(v as f32))
283            }
284            fn visit_i64<E: de::Error>(self, v: i64) -> Result<FontSize, E> {
285                Ok(FontSize::Px(v as f32))
286            }
287            fn visit_u64<E: de::Error>(self, v: u64) -> Result<FontSize, E> {
288                Ok(FontSize::Px(v as f32))
289            }
290            fn visit_str<E: de::Error>(self, s: &str) -> Result<FontSize, E> {
291                Ok(parse_font_size(s).unwrap_or_else(|e| {
292                    decode_warn("fontSize", s, &e);
293                    FontSize::Px(0.0)
294                }))
295            }
296        }
297        d.deserialize_any(FontSizeVisitor)
298    }
299}
300
301/// Four sides (or corners), each a [`Length`]. Accepts a number, a CSS shorthand
302/// string, or a `{ top, right, bottom, left }` object on the wire.
303#[derive(Debug, Clone, Copy, PartialEq, Default)]
304pub struct Rect {
305    pub top: Length,
306    pub right: Length,
307    pub bottom: Length,
308    pub left: Length,
309}
310
311impl Rect {
312    fn uniform(v: Length) -> Self {
313        Rect {
314            top: v,
315            right: v,
316            bottom: v,
317            left: v,
318        }
319    }
320
321    /// Expand 1–4 CSS values into four sides (top, right, bottom, left).
322    fn from_shorthand(values: &[Length]) -> Result<Self, String> {
323        Ok(match values {
324            [a] => Rect::uniform(*a),
325            [a, b] => Rect {
326                top: *a,
327                bottom: *a,
328                right: *b,
329                left: *b,
330            },
331            [a, b, c] => Rect {
332                top: *a,
333                right: *b,
334                left: *b,
335                bottom: *c,
336            },
337            [a, b, c, d] => Rect {
338                top: *a,
339                right: *b,
340                bottom: *c,
341                left: *d,
342            },
343            _ => return Err("expected 1–4 length values".into()),
344        })
345    }
346}
347
348impl<'de> Deserialize<'de> for Rect {
349    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
350        struct RectVisitor;
351        impl<'de> Visitor<'de> for RectVisitor {
352            type Value = Rect;
353            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
354                f.write_str("a number, a CSS shorthand string, or a {top,right,bottom,left} object")
355            }
356            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Rect, E> {
357                Ok(Rect::uniform(Length::Px(v as f32)))
358            }
359            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Rect, E> {
360                Ok(Rect::uniform(Length::Px(v as f32)))
361            }
362            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Rect, E> {
363                Ok(Rect::uniform(Length::Px(v as f32)))
364            }
365            fn visit_str<E: de::Error>(self, s: &str) -> Result<Rect, E> {
366                // A bad token or value-count must not throw (that aborts the whole
367                // commit batch and wedges the reconciler) — warn and fall back.
368                let values: Vec<Length> = s
369                    .split_whitespace()
370                    .map(|tok| {
371                        parse_length(tok).unwrap_or_else(|e| {
372                            decode_warn("rect", tok, &e);
373                            Length::default()
374                        })
375                    })
376                    .collect();
377                Ok(Rect::from_shorthand(&values).unwrap_or_else(|e| {
378                    decode_warn("rect", s, &format!("invalid rect {s:?}: {e}"));
379                    Rect::default()
380                }))
381            }
382            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Rect, A::Error> {
383                let mut rect = Rect::default();
384                while let Some(key) = map.next_key::<String>()? {
385                    let v = map.next_value::<Length>()?;
386                    match key.as_str() {
387                        "top" => rect.top = v,
388                        "right" => rect.right = v,
389                        "bottom" => rect.bottom = v,
390                        "left" => rect.left = v,
391                        // An unknown side key must not throw (that aborts the whole
392                        // commit batch) — `v` is already consumed, so warn and skip.
393                        _ => decode_warn(
394                            "rect",
395                            &key,
396                            &format!(
397                                "unknown rect side {key:?}; ignoring (expected top/right/bottom/left)"
398                            ),
399                        ),
400                    }
401                }
402                Ok(rect)
403            }
404        }
405        d.deserialize_any(RectVisitor)
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::protocol::animatable::AnimatableField;
413    use crate::protocol::style::Style;
414    use crate::protocol::transform::Transform;
415
416    /// Angles parse from a bare number (degrees) or a unit string, always landing
417    /// in radians.
418    #[test]
419    fn angle_units() {
420        use std::f32::consts::{PI, TAU};
421        let parse = |v: serde_json::Value| serde_json::from_value::<Angle>(v).unwrap().radians();
422        assert!((parse(serde_json::json!(180)) - PI).abs() < 1e-5);
423        assert!((parse(serde_json::json!("180deg")) - PI).abs() < 1e-5);
424        assert!((parse(serde_json::json!("3.14159rad")) - PI).abs() < 1e-4);
425        assert!((parse(serde_json::json!("0.5turn")) - PI).abs() < 1e-5);
426        assert!((parse(serde_json::json!("400grad")) - TAU).abs() < 1e-5);
427    }
428
429    /// A malformed unit string in any unit-bearing field must **not** fail the
430    /// whole `Style` (and thus the whole commit batch): it decodes to the type's
431    /// default and warns. A good value alongside it still decodes correctly.
432    #[test]
433    fn bad_unit_values_fall_back_instead_of_aborting() {
434        // Bad `width` (unknown unit) → default, sibling `height` intact.
435        let s: Style = serde_json::from_str(r#"{ "width": "100pixels", "height": "40px" }"#)
436            .expect("a bad length must not abort deserialization");
437        assert_eq!(s.width.static_val(), Some(Length::default()));
438        assert_eq!(s.height.static_val(), Some(Length::Px(40.0)));
439
440        // Bad `fontSize` → default `Px(0.0)`.
441        let s: Style = serde_json::from_str(r#"{ "fontSize": "16pxx" }"#)
442            .expect("bad fontSize must not abort");
443        assert_eq!(s.font_size, Some(FontSize::Px(0.0)));
444
445        // Bad transform `rotate` (angle) → default `Angle(0)`, valid `translateX` intact.
446        let t: Transform = serde_json::from_str(r#"{ "rotate": "45degg", "translateX": "50%" }"#)
447            .expect("bad angle must not abort");
448        assert_eq!(t.rotate.static_val(), Some(Angle::default()));
449        assert_eq!(t.translate_x.static_val(), Some(Length::Percent(50.0)));
450
451        // Rect shorthand (`padding`/`margin`/`border`/`borderRadius`): a bad token
452        // defaults just that side; a good shorthand still decodes; a bad value-count
453        // defaults the whole rect. None of these abort (the reported `padding: "16asd"`).
454        let s: Style =
455            serde_json::from_str(r#"{ "padding": "16asd" }"#).expect("bad rect must not abort");
456        assert_eq!(s.padding, Some(Rect::default()));
457
458        let s: Style = serde_json::from_str(r#"{ "padding": "8px 16asd" }"#)
459            .expect("partial-bad rect must not abort");
460        // top/bottom = 8px (good), right/left = default (the bad token).
461        assert_eq!(
462            s.padding,
463            Some(Rect {
464                top: Length::Px(8.0),
465                bottom: Length::Px(8.0),
466                right: Length::default(),
467                left: Length::default(),
468            })
469        );
470
471        let s: Style = serde_json::from_str(r#"{ "padding": "8px 16px" }"#)
472            .expect("valid two-value shorthand decodes");
473        assert_eq!(
474            s.padding,
475            Some(Rect {
476                top: Length::Px(8.0),
477                bottom: Length::Px(8.0),
478                right: Length::Px(16.0),
479                left: Length::Px(16.0),
480            })
481        );
482
483        // Too many values (>4) → whole rect falls back to default, no abort.
484        let s: Style = serde_json::from_str(r#"{ "padding": "1px 2px 3px 4px 5px" }"#)
485            .expect("bad value-count must not abort");
486        assert_eq!(s.padding, Some(Rect::default()));
487    }
488}