Skip to main content

bevy_react/protocol/
visual.rs

1//! Visual spec wire types: outline, shadows, line metrics, gradients, and
2//! per-side border colors.
3
4use std::fmt;
5
6use serde::Deserialize;
7use serde::de::{self, Deserializer, MapAccess, Visitor};
8
9use super::decode_warn;
10use super::units::{Angle, Length};
11
12/// Outline drawn around (outside) the node's border box.
13#[derive(Debug, Clone, Default, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct OutlineSpec {
16    #[serde(default)]
17    pub width: Option<Length>,
18    #[serde(default)]
19    pub offset: Option<Length>,
20    #[serde(default)]
21    pub color: Option<String>,
22}
23
24/// A single drop shadow.
25#[derive(Debug, Clone, Default, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct BoxShadowSpec {
28    #[serde(default)]
29    pub color: Option<String>,
30    #[serde(default)]
31    pub x_offset: Option<Length>,
32    #[serde(default)]
33    pub y_offset: Option<Length>,
34    #[serde(default)]
35    pub spread_radius: Option<Length>,
36    #[serde(default)]
37    pub blur_radius: Option<Length>,
38}
39
40/// A `boxShadow` value: one shadow or a stacked list (CSS `box-shadow: a, b, …`).
41#[derive(Debug, Clone, Deserialize)]
42#[serde(untagged)]
43pub enum BoxShadowList {
44    One(BoxShadowSpec),
45    Many(Vec<BoxShadowSpec>),
46}
47
48/// Line height for a `<text>`. A bare number is a multiple of the font size
49/// (`RelativeToFont`); a string carries a unit (`"20px"` absolute, `"1.5"` / `"1.5em"`
50/// a multiple); `{ "px": n }` is an absolute pixel height (legacy object form).
51#[derive(Debug, Clone, Deserialize)]
52#[serde(untagged)]
53pub enum LineHeightSpec {
54    Relative(f32),
55    Px { px: f32 },
56    Str(String),
57}
58
59/// Letter spacing for a `<text>`. A bare number is logical pixels; a string carries
60/// a unit (`"2px"`, `"0.1rem"`/`"0.1em"` for a font-size multiple, or `"normal"`);
61/// `{ "rem": n }` is a multiple of the font size (legacy object form).
62#[derive(Debug, Clone, Deserialize)]
63#[serde(untagged)]
64pub enum LetterSpacingSpec {
65    Px(f32),
66    Rem { rem: f32 },
67    Str(String),
68}
69
70/// A single text drop shadow. `offsetX`/`offsetY` are displacement in logical
71/// pixels (absent → bevy's default of `4.0`); `color` defaults to bevy's
72/// translucent black when unset.
73#[derive(Debug, Clone, Default, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct TextShadowSpec {
76    #[serde(default)]
77    pub color: Option<String>,
78    #[serde(default)]
79    pub offset_x: Option<f32>,
80    #[serde(default)]
81    pub offset_y: Option<f32>,
82}
83
84/// A single color stop for a linear/radial gradient. `position` is where the
85/// color sits along the gradient line (a [`Length`]); absent → auto-spaced.
86/// `hint` is the `0.0..=1.0` interpolation midpoint between this stop and the
87/// next (default `0.5`).
88#[derive(Debug, Clone, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct GradientStop {
91    pub color: String,
92    #[serde(default)]
93    pub position: Option<Length>,
94    #[serde(default)]
95    pub hint: Option<f32>,
96}
97
98/// A single color stop for a conic gradient. `angle` is the stop's angle in
99/// **degrees** (absent → auto-spaced); `hint` as in [`GradientStop`].
100#[derive(Debug, Clone, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct AngularStop {
103    pub color: String,
104    #[serde(default)]
105    pub angle: Option<Angle>,
106    #[serde(default)]
107    pub hint: Option<f32>,
108}
109
110/// Radial/conic gradient center, given as a named anchor (`"center"`, `"top"`,
111/// `"topLeft"`, …). Arbitrary `Val`-offset centers are not yet supported.
112pub type GradientPosition = String;
113
114/// Color space the gradient interpolates in (`"oklab"` (default), `"oklch"`,
115/// `"oklchLong"`, `"srgb"`, `"linearRgb"`, `"hsl"`, `"hslLong"`, `"hsv"`,
116/// `"hsvLong"`).
117pub type ColorSpace = String;
118
119/// The size/shape of a radial gradient. Either a keyword
120/// (`"closestSide" | "farthestSide" | "closestCorner" | "farthestCorner"`,
121/// default `"closestCorner"`) or an explicit `{ circle }` / `{ ellipse }`.
122#[derive(Debug, Clone, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub enum RadialShapeSpec {
125    Keyword(String),
126    Circle { circle: Length },
127    Ellipse { ellipse: [Length; 2] },
128}
129
130#[derive(Debug, Clone, Default, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct LinearGradientSpec {
133    /// Gradient line angle (number = degrees, or a unit string; `0` = to top,
134    /// increasing clockwise).
135    #[serde(default)]
136    pub angle: Option<Angle>,
137    #[serde(default)]
138    pub stops: Vec<GradientStop>,
139    #[serde(default)]
140    pub color_space: Option<ColorSpace>,
141}
142
143#[derive(Debug, Clone, Default, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct RadialGradientSpec {
146    #[serde(default)]
147    pub position: Option<GradientPosition>,
148    #[serde(default)]
149    pub shape: Option<RadialShapeSpec>,
150    #[serde(default)]
151    pub stops: Vec<GradientStop>,
152    #[serde(default)]
153    pub color_space: Option<ColorSpace>,
154}
155
156#[derive(Debug, Clone, Default, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct ConicGradientSpec {
159    /// Start angle (number = degrees, or a unit string).
160    #[serde(default)]
161    pub start: Option<Angle>,
162    #[serde(default)]
163    pub position: Option<GradientPosition>,
164    #[serde(default)]
165    pub stops: Vec<AngularStop>,
166    #[serde(default)]
167    pub color_space: Option<ColorSpace>,
168}
169
170/// One gradient, discriminated by its `type` field on the wire.
171#[derive(Debug, Clone, Deserialize)]
172#[serde(tag = "type", rename_all = "camelCase")]
173pub enum GradientSpec {
174    Linear(LinearGradientSpec),
175    Radial(RadialGradientSpec),
176    Conic(ConicGradientSpec),
177}
178
179/// A `backgroundGradient`/`borderGradient` value: one gradient or a layered list.
180#[derive(Debug, Clone, Deserialize)]
181#[serde(untagged)]
182pub enum GradientList {
183    One(GradientSpec),
184    Many(Vec<GradientSpec>),
185}
186
187/// Border color: a single CSS color applied to all four sides, or a
188/// `{ top, right, bottom, left }` object setting sides individually. Omitted
189/// sides decode to `None` (painted transparent — bevy's `BorderColor` default).
190///
191/// Unlike [`super::units::Rect`], a multi-value string (`"red green blue"`) is **not** accepted:
192/// CSS color functions contain spaces (`rgb(1 2 3)`), so whitespace-splitting
193/// would be ambiguous. Per-side colors go through the object form only.
194#[derive(Debug, Clone, PartialEq, Default)]
195pub struct BorderColorSpec {
196    pub top: Option<String>,
197    pub right: Option<String>,
198    pub bottom: Option<String>,
199    pub left: Option<String>,
200}
201
202impl BorderColorSpec {
203    /// One color on every side (the back-compat scalar form).
204    fn uniform(s: String) -> Self {
205        BorderColorSpec {
206            top: Some(s.clone()),
207            right: Some(s.clone()),
208            bottom: Some(s.clone()),
209            left: Some(s),
210        }
211    }
212}
213
214impl<'de> Deserialize<'de> for BorderColorSpec {
215    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
216        struct BorderColorVisitor;
217        impl<'de> Visitor<'de> for BorderColorVisitor {
218            type Value = BorderColorSpec;
219            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
220                f.write_str("a CSS color string or a {top,right,bottom,left} object of colors")
221            }
222            fn visit_str<E: de::Error>(self, s: &str) -> Result<BorderColorSpec, E> {
223                Ok(BorderColorSpec::uniform(s.to_owned()))
224            }
225            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<BorderColorSpec, A::Error> {
226                let mut spec = BorderColorSpec::default();
227                while let Some(key) = map.next_key::<String>()? {
228                    let v = map.next_value::<String>()?;
229                    match key.as_str() {
230                        "top" => spec.top = Some(v),
231                        "right" => spec.right = Some(v),
232                        "bottom" => spec.bottom = Some(v),
233                        "left" => spec.left = Some(v),
234                        // An unknown side key must not throw (that aborts the whole
235                        // commit batch) — `v` is already consumed, so warn and skip.
236                        _ => decode_warn(
237                            "borderColor",
238                            &key,
239                            &format!(
240                                "unknown borderColor side {key:?}; ignoring (expected top/right/bottom/left)"
241                            ),
242                        ),
243                    }
244                }
245                Ok(spec)
246            }
247        }
248        d.deserialize_any(BorderColorVisitor)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use crate::protocol::animatable::AnimatableField;
255    use crate::protocol::style::Style;
256
257    /// `borderColor` decodes from a scalar (uniform, back-compat) or a per-side
258    /// object; omitted sides stay `None`, and an unknown side key is rejected.
259    #[test]
260    fn border_color_scalar_and_per_side() {
261        // Scalar string → every side set (the historical form).
262        let uniform: Style =
263            serde_json::from_str(r#"{ "borderColor": "white" }"#).expect("scalar decodes");
264        let bc = uniform
265            .border_color
266            .static_ref()
267            .expect("border_color present");
268        assert_eq!(bc.top.as_deref(), Some("white"));
269        assert_eq!(bc.right.as_deref(), Some("white"));
270        assert_eq!(bc.bottom.as_deref(), Some("white"));
271        assert_eq!(bc.left.as_deref(), Some("white"));
272
273        // Object form sets only the named sides; the rest stay None (transparent).
274        let sided: Style =
275            serde_json::from_str(r##"{ "borderColor": { "top": "#f00", "left": "blue" } }"##)
276                .expect("object decodes");
277        let bc = sided
278            .border_color
279            .static_ref()
280            .expect("border_color present");
281        assert_eq!(bc.top.as_deref(), Some("#f00"));
282        assert_eq!(bc.left.as_deref(), Some("blue"));
283        assert_eq!(bc.right, None);
284        assert_eq!(bc.bottom, None);
285
286        // An unknown side key is ignored (warned), not rejected: throwing here would
287        // abort the whole commit batch and wedge the reconciler. A valid sibling key
288        // still applies; the unknown one leaves all sides at their default (None).
289        let bogus: Style =
290            serde_json::from_str(r#"{ "borderColor": { "middle": "red", "top": "blue" } }"#)
291                .expect("unknown side key must not abort deserialization");
292        let bc = bogus
293            .border_color
294            .static_ref()
295            .expect("border_color present");
296        assert_eq!(bc.top.as_deref(), Some("blue"));
297        assert_eq!(bc.right, None);
298        assert_eq!(bc.bottom, None);
299        assert_eq!(bc.left, None);
300    }
301}