Skip to main content

bevy_react/protocol/
keywords.rs

1//! Keyword-valued style fields: the `keyword_fields!` table decodes each wire
2//! keyword straight into the `bevy_ui`/`bevy_text` enum it drives.
3
4use std::fmt;
5
6use bevy::text::{FontWeight, Justify, LineBreak};
7use bevy::ui::{
8    AlignContent, AlignItems, AlignSelf, BoxSizing, Display, FlexDirection, FlexWrap, FocusPolicy,
9    GridAutoFlow, JustifyContent, JustifyItems, JustifySelf, OverflowAxis, PositionType,
10};
11
12use serde::de::{self, Deserializer, Visitor};
13
14use super::background_image::BackgroundImageMode;
15use super::decode_warn;
16use super::style::LayerCache;
17
18/// Declares one `deserialize_with` fn per keyword-valued [`Style`] field,
19/// decoding the wire keyword straight into the `bevy_ui`/`bevy_text` enum it
20/// drives. An unrecognized keyword warns (naming the field and value) and falls
21/// back to the enum's bevy default — a typo must not abort the commit batch. A
22/// JSON `null` decodes to `None` (matching the former `Option<String>` fields);
23/// any other non-string value keeps hard-erroring, like [`Length`].
24macro_rules! keyword_fields {
25    ( $(
26        $(#[$meta:meta])*
27        fn $fn_name:ident($kind:literal) -> $ty:ty {
28            $( $($kw:literal)|+ => $variant:ident ),+ $(,)?
29        }
30    )+ ) => { $(
31        $(#[$meta])*
32        pub(crate) fn $fn_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
33            struct V;
34            impl<'de> Visitor<'de> for V {
35                type Value = Option<$ty>;
36                fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
37                    f.write_str(concat!("a `", $kind, "` keyword string"))
38                }
39                fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
40                    Ok(Some(match s {
41                        $( $($kw)|+ => <$ty>::$variant, )+
42                        _ => {
43                            decode_warn(
44                                $kind,
45                                s,
46                                &format!("unrecognized {} {s:?}", $kind),
47                            );
48                            <$ty>::default()
49                        }
50                    }))
51                }
52                fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
53                    Ok(None)
54                }
55                fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
56                    Ok(None)
57                }
58            }
59            d.deserialize_any(V)
60        }
61    )+ };
62}
63
64keyword_fields! {
65    fn de_display("display") -> Display {
66        "flex" => Flex, "grid" => Grid, "block" => Block, "none" => None,
67    }
68    fn de_layer_cache("cache") -> LayerCache {
69        "auto" => Auto, "always" => Always, "never" => Never,
70    }
71    fn de_box_sizing("boxSizing") -> BoxSizing {
72        "borderBox" | "border-box" => BorderBox,
73        "contentBox" | "content-box" => ContentBox,
74    }
75    fn de_position_type("positionType") -> PositionType {
76        "absolute" => Absolute, "relative" => Relative,
77    }
78    fn de_overflow_axis("overflow") -> OverflowAxis {
79        "visible" => Visible, "clip" => Clip, "hidden" => Hidden, "scroll" => Scroll,
80    }
81    // `start`/`end` are the physical variants, `flexStart`/`flexEnd` the
82    // flow-relative ones — they diverge in grid and reversed-flex containers,
83    // so the keywords must not collapse together. The alignment enums' bevy
84    // default is the keyword-less `Default` variant ("align per the layout
85    // spec"), which is also the unrecognized-keyword fallback.
86    fn de_align_items("alignItems") -> AlignItems {
87        "start" => Start, "end" => End,
88        "flexStart" => FlexStart, "flexEnd" => FlexEnd,
89        "center" => Center, "baseline" => Baseline, "stretch" => Stretch,
90    }
91    fn de_justify_items("justifyItems") -> JustifyItems {
92        "start" => Start, "end" => End,
93        "center" => Center, "baseline" => Baseline, "stretch" => Stretch,
94    }
95    fn de_align_self("alignSelf") -> AlignSelf {
96        "auto" => Auto, "start" => Start, "end" => End,
97        "flexStart" => FlexStart, "flexEnd" => FlexEnd,
98        "center" => Center, "baseline" => Baseline, "stretch" => Stretch,
99    }
100    fn de_justify_self("justifySelf") -> JustifySelf {
101        "auto" => Auto, "start" => Start, "end" => End,
102        "center" => Center, "baseline" => Baseline, "stretch" => Stretch,
103    }
104    fn de_align_content("alignContent") -> AlignContent {
105        "start" => Start, "end" => End,
106        "flexStart" => FlexStart, "flexEnd" => FlexEnd,
107        "center" => Center, "stretch" => Stretch,
108        "spaceBetween" => SpaceBetween, "spaceEvenly" => SpaceEvenly,
109        "spaceAround" => SpaceAround,
110    }
111    fn de_justify_content("justifyContent") -> JustifyContent {
112        "start" => Start, "end" => End,
113        "flexStart" => FlexStart, "flexEnd" => FlexEnd,
114        "center" => Center, "stretch" => Stretch,
115        "spaceBetween" => SpaceBetween, "spaceEvenly" => SpaceEvenly,
116        "spaceAround" => SpaceAround,
117    }
118    fn de_flex_direction("flexDirection") -> FlexDirection {
119        "row" => Row, "column" => Column,
120        "rowReverse" => RowReverse, "columnReverse" => ColumnReverse,
121    }
122    fn de_flex_wrap("flexWrap") -> FlexWrap {
123        "nowrap" | "noWrap" => NoWrap, "wrap" => Wrap, "wrapReverse" => WrapReverse,
124    }
125    fn de_grid_auto_flow("gridAutoFlow") -> GridAutoFlow {
126        "row" => Row, "column" => Column,
127        "rowDense" => RowDense, "columnDense" => ColumnDense,
128    }
129    // Unknown values fall back to `Pass` (bevy's default) so a typo stays
130    // click-through rather than silently swallowing pointer interaction.
131    fn de_focus_policy("focusPolicy") -> FocusPolicy {
132        "block" => Block, "pass" => Pass,
133    }
134    fn de_text_align("textAlign") -> Justify {
135        "left" => Left, "center" => Center, "right" => Right,
136        "justify" => Justified, "start" => Start, "end" => End,
137    }
138    fn de_line_break("lineBreak") -> LineBreak {
139        "wordBoundary" => WordBoundary, "anyCharacter" => AnyCharacter,
140        "wordOrCharacter" => WordOrCharacter, "noWrap" => NoWrap,
141    }
142    // Unknown keywords (incl. `<image>`-only modes like "auto"/"sliced") fall
143    // back to the layout-inert `Stretch`.
144    fn de_bg_image_mode("backgroundImage") -> BackgroundImageMode {
145        "stretch" => Stretch, "repeat" => Repeat,
146        "repeatX" => RepeatX, "repeatY" => RepeatY,
147    }
148}
149
150/// `fontWeight`: a named keyword or a numeric weight string (`"600"`). Not a
151/// [`keyword_fields!`] entry because of the numeric form. Unrecognized → warn +
152/// `NORMAL` (400).
153pub(crate) fn de_font_weight<'de, D: Deserializer<'de>>(
154    d: D,
155) -> Result<Option<FontWeight>, D::Error> {
156    struct V;
157    impl<'de> Visitor<'de> for V {
158        type Value = Option<FontWeight>;
159        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
160            f.write_str("a `fontWeight` keyword or numeric weight string")
161        }
162        fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
163            Ok(Some(match s {
164                "thin" => FontWeight::THIN,
165                "light" => FontWeight(300),
166                "normal" => FontWeight::NORMAL,
167                "medium" => FontWeight(500),
168                "semibold" => FontWeight(600),
169                "bold" => FontWeight::BOLD,
170                "black" => FontWeight::BLACK,
171                other => other.parse::<u16>().map(FontWeight).unwrap_or_else(|_| {
172                    decode_warn(
173                        "fontWeight",
174                        other,
175                        &format!("unrecognized fontWeight {other:?}"),
176                    );
177                    FontWeight::NORMAL
178                }),
179            }))
180        }
181        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
182            Ok(None)
183        }
184        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
185            Ok(None)
186        }
187    }
188    d.deserialize_any(V)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::protocol::style::Style;
195
196    /// Keyword style fields decode straight into their `bevy_ui`/`bevy_text`
197    /// enums; `start`/`end` map to the physical `Start`/`End` variants while
198    /// `flexStart`/`flexEnd` map to the flow-relative `FlexStart`/`FlexEnd`.
199    /// They diverge in grid and reversed-flex containers, so the keywords must
200    /// not collapse together.
201    #[test]
202    fn keyword_fields_decode_to_bevy_enums() {
203        let s: Style = serde_json::from_value(serde_json::json!({
204            "display": "grid",
205            "alignItems": "start",
206            "alignSelf": "flexStart",
207            "alignContent": "spaceBetween",
208            "justifyContent": "flexEnd",
209            "flexWrap": "nowrap",
210            "focusPolicy": "block",
211            "textAlign": "justify",
212            "lineBreak": "anyCharacter",
213        }))
214        .expect("keyword style decodes");
215        assert_eq!(s.display, Some(Display::Grid));
216        assert_eq!(s.align_items, Some(AlignItems::Start));
217        assert_eq!(s.align_self, Some(AlignSelf::FlexStart));
218        assert_eq!(s.align_content, Some(AlignContent::SpaceBetween));
219        assert_eq!(s.justify_content, Some(JustifyContent::FlexEnd));
220        assert_eq!(s.flex_wrap, Some(FlexWrap::NoWrap));
221        assert_eq!(s.focus_policy, Some(FocusPolicy::Block));
222        assert_eq!(s.text_align, Some(Justify::Justified));
223        assert_eq!(s.line_break, Some(LineBreak::AnyCharacter));
224
225        let s: Style = serde_json::from_value(serde_json::json!({
226            "alignItems": "flexStart",
227            "justifyContent": "start",
228            // both keyword spellings of boxSizing are accepted
229            "boxSizing": "border-box",
230            "flexWrap": "noWrap",
231        }))
232        .expect("alias keywords decode");
233        assert_eq!(s.align_items, Some(AlignItems::FlexStart));
234        assert_eq!(s.justify_content, Some(JustifyContent::Start));
235        assert_eq!(s.box_sizing, Some(BoxSizing::BorderBox));
236        assert_eq!(s.flex_wrap, Some(FlexWrap::NoWrap));
237    }
238
239    /// An unrecognized enum keyword falls back to the bevy default (and warns)
240    /// rather than aborting the batch or being silently dropped — a valid
241    /// sibling field still decodes.
242    #[test]
243    fn unknown_enum_keywords_fall_back_to_default() {
244        let s: Style = serde_json::from_value(serde_json::json!({
245            "display": "flx",
246            "alignItems": "centre",
247            "flexDirection": "sideways",
248            "textAlign": "middle",
249            "fontWeight": "heavyish",
250            "focusPolicy": "weird",
251            // A valid sibling proves the fallbacks didn't abort the Style.
252            "lineBreak": "wordBoundary",
253        }))
254        .expect("bad keywords must not abort deserialization");
255        assert_eq!(s.display, Some(Display::default()));
256        assert_eq!(s.align_items, Some(AlignItems::default()));
257        assert_eq!(s.flex_direction, Some(FlexDirection::default()));
258        assert_eq!(s.text_align, Some(Justify::default()));
259        assert_eq!(s.font_weight, Some(FontWeight::NORMAL));
260        assert_eq!(s.focus_policy, Some(FocusPolicy::Pass));
261        assert_eq!(s.line_break, Some(LineBreak::WordBoundary));
262    }
263
264    /// `fontWeight` takes a named keyword or a numeric weight string.
265    #[test]
266    fn font_weight_keywords_and_numeric() {
267        let fw = |v: serde_json::Value| {
268            serde_json::from_value::<Style>(serde_json::json!({ "fontWeight": v }))
269                .expect("fontWeight decodes")
270                .font_weight
271        };
272        assert_eq!(fw("bold".into()), Some(FontWeight::BOLD));
273        assert_eq!(fw("600".into()), Some(FontWeight(600)));
274        assert_eq!(fw("thin".into()), Some(FontWeight::THIN));
275    }
276}