Skip to main content

bevy_react/protocol/
background_image.rs

1//! The `backgroundImage` style wire types and their totalizing decoder.
2
3use serde::Deserialize;
4use serde::de::Deserializer;
5
6use super::animatable::Animatable;
7use super::decode_warn;
8use super::keywords::de_bg_image_mode;
9
10/// How an `image` fits its node. A bare string (`"auto"`/`"stretch"`) maps to the
11/// trivial `bevy_ui` modes; the `type`-tagged object forms map to bevy's 9-slice
12/// (`"sliced"`) and `"tiled"` scaling. Bevy-free; converted to `NodeImageMode` in
13/// `ui_map`.
14#[derive(Debug, Clone, Deserialize)]
15#[serde(untagged)]
16pub enum ImageMode {
17    /// `"auto"` or `"stretch"` (any unknown keyword falls back to `Auto`).
18    Keyword(String),
19    Spec(ImageModeSpec),
20}
21
22/// The object forms of [`ImageMode`], discriminated by their `type` field.
23#[derive(Debug, Clone, Deserialize)]
24#[serde(tag = "type", rename_all = "camelCase")]
25pub enum ImageModeSpec {
26    Sliced(SliceSpec),
27    Tiled(TiledSpec),
28}
29
30/// 9-slice scaling parameters, mirroring `bevy_sprite::TextureSlicer`.
31#[derive(Debug, Clone, Default, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct SliceSpec {
34    /// Border insets, in *source-texture pixels*, dividing the texture into nine
35    /// sections.
36    #[serde(default)]
37    pub border: SliceBorder,
38    /// How the center section scales (default: stretch).
39    #[serde(default)]
40    pub center_scale_mode: Option<SliceScale>,
41    /// How the four side sections scale (default: stretch).
42    #[serde(default)]
43    pub sides_scale_mode: Option<SliceScale>,
44    /// Maximum scale of the four corner sections (bevy default `1.0`).
45    #[serde(default)]
46    pub max_corner_scale: Option<f32>,
47}
48
49/// 9-slice border insets: a single number (uniform) or per-side, in *source-texture
50/// pixels*.
51#[derive(Debug, Clone, Default, Deserialize)]
52#[serde(untagged)]
53pub enum SliceBorder {
54    /// No border supplied → zero insets.
55    #[default]
56    Zero,
57    /// The same inset along every edge.
58    Uniform(f32),
59    /// Per-edge insets.
60    Sides {
61        #[serde(default)]
62        top: f32,
63        #[serde(default)]
64        right: f32,
65        #[serde(default)]
66        bottom: f32,
67        #[serde(default)]
68        left: f32,
69    },
70}
71
72/// How a 9-slice section scales when resized: `"stretch"` (the keyword) or
73/// `{ tile }`, where `tile` is the repeat `stretch_value`.
74#[derive(Debug, Clone, Deserialize)]
75#[serde(untagged)]
76pub enum SliceScale {
77    Keyword(String),
78    Tile { tile: f32 },
79}
80
81/// `"tiled"` scaling: the whole image repeats once stretched beyond `stretch_value`.
82#[derive(Debug, Clone, Default, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct TiledSpec {
85    #[serde(default)]
86    pub tile_x: bool,
87    #[serde(default)]
88    pub tile_y: bool,
89    /// Repeat threshold (bevy default `1.0`).
90    #[serde(default)]
91    pub stretch_value: Option<f32>,
92}
93
94/// A source sub-rect in texture pixels: top-left (`x`, `y`) plus `width`/`height`.
95/// Converted to a `bevy_math::Rect` (min/max corners) in `ui_map`.
96#[derive(Debug, Clone, Copy, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct SourceRect {
99    pub x: f32,
100    pub y: f32,
101    pub width: f32,
102    pub height: f32,
103}
104
105/// A uniform sprite-sheet grid plus the selected cell. Mirrors
106/// `TextureAtlasLayout::from_grid` (tile size, columns, rows, optional padding /
107/// offset, all in source-texture pixels) + `TextureAtlas.index`. Bevy-free;
108/// turned into a cached `TextureAtlasLayout` asset in `ui_map`.
109#[derive(Debug, Clone, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct AtlasSpec {
112    pub tile_width: u32,
113    pub tile_height: u32,
114    pub columns: u32,
115    pub rows: u32,
116    /// Padding between cells (`[x, y]` px), if any.
117    #[serde(default)]
118    pub padding: Option<[u32; 2]>,
119    /// Offset of the grid's top-left from the texture origin (`[x, y]` px).
120    #[serde(default)]
121    pub offset: Option<[u32; 2]>,
122    /// Which cell to display (row-major). Default `0`.
123    #[serde(default)]
124    pub index: usize,
125}
126
127/// Where a [`super::style::Style::background_image`] samples from: a bare string is an
128/// asset path (`AssetServer`-loaded, like an `image` element's `src`); the
129/// `{ texture }` object names an **app-registered texture** in
130/// `crate::portal::RenderTargets` (typically `RenderTargets::register` —
131/// bound late: an unknown name shows the transparent placeholder until the
132/// app registers it). Texture backgrounds are for **static** content — they
133/// don't participate in live-repaint tracking; continuously-updating render
134/// targets belong in a `<portal>` element.
135#[derive(Debug, Clone, Deserialize)]
136#[serde(untagged)]
137pub enum BackgroundImageSource {
138    Path(String),
139    Texture { texture: String },
140}
141
142/// The decoded `backgroundImage` style object. Deliberately NOT
143/// [`ImageMode`]: that type admits `"auto"` (whose intrinsic-size measure
144/// drives layout — a background must never do that) and `"sliced"`, and its
145/// unknown-keyword fallback is `Auto`. This spec's modes all map to
146/// layout-inert `NodeImageMode`s.
147#[derive(Debug, Clone, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct BackgroundImageSpec {
150    /// Required — a spec with nothing to paint is dropped at decode
151    /// (tint-only fills are `backgroundColor`'s job).
152    pub src: BackgroundImageSource,
153    /// Tint multiplied with the texture (hex); also where `opacity` folds.
154    /// Animatable via an inline `{ animated: interpolateColor(...) }` binding
155    /// (`AnimatableProperty::BackgroundImageTint` drives `ImageNode.color`
156    /// per frame; the static build then leaves the color at white).
157    #[serde(default)]
158    pub tint: Option<Animatable<String>>,
159    /// Fit: `"stretch"` (default — fill the box exactly) or the repeat
160    /// modes `"repeat"`/`"repeatX"`/`"repeatY"` (tile at the texture's
161    /// logical size × [`scale`](Self::scale)).
162    #[serde(default, deserialize_with = "de_bg_image_mode")]
163    pub mode: Option<BackgroundImageMode>,
164    /// Tile scale for the repeat modes, in logical-px terms (`1.0` = the
165    /// texture's own size at 1× DPI; DPI correction is applied by
166    /// `crate::background_image::sync_background_tile_scale`). Ignored — with
167    /// a warning — under `"stretch"`. Decodes an `{ animated }` wrapper but
168    /// the binding is inert in v1 (read via `static_val`).
169    #[serde(default)]
170    pub scale: Option<Animatable<f32>>,
171}
172
173/// [`BackgroundImageSpec::mode`] keywords. Every variant maps to a
174/// layout-inert `NodeImageMode` (`Stretch` or `Tiled`) — never `Auto`.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub enum BackgroundImageMode {
177    #[default]
178    Stretch,
179    Repeat,
180    RepeatX,
181    RepeatY,
182}
183
184impl BackgroundImageMode {
185    /// Whether this mode tiles at all (any repeat variant).
186    pub fn tiles(self) -> bool {
187        self != Self::Stretch
188    }
189
190    /// The per-axis tile flags (`tile_x`, `tile_y`) for `NodeImageMode::Tiled`.
191    pub fn tile_axes(self) -> (bool, bool) {
192        match self {
193            Self::Stretch => (false, false),
194            Self::Repeat => (true, true),
195            Self::RepeatX => (true, false),
196            Self::RepeatY => (false, true),
197        }
198    }
199}
200
201/// A style field that is either a static `T` or an inline animation binding —
202/// the `{ animated: <shared value | interpolate descriptor>, seed? }` wire
203/// form. The animated variant carries **no static value**: style read sites
204/// see the field as absent (the animation applier drives the target every
205/// frame), and `crate::animations` derives the node's `AnimatedBindings` from
206/// the merged style. `{ animated: sv }` reaches the wire with the shared
207/// value's `id` (its other enumerable props are ignored); a descriptor object
208/// is told apart by its `type` tag. A malformed wrapper warns (`styleBinding`)
209/// and decodes to an inert binding (shared id `0` is never allocated by JS),
210/// so one typo can't abort the batch.
211///
212/// The wrapper's optional **`seed`** (`{ animated: sv, seed: 10 }`) is the
213/// static value a consumer may decode in the wrapper's place. Style read
214/// sites ([`AnimatableField::static_val`]/[`static_ref`](AnimatableField::static_ref))
215/// deliberately ignore it — the driver owns the on-screen value — but SVG
216/// shape attrs render it ([`AnimatableField::static_or_seed`]) until a driver
217/// writes, mirroring the filter-param resolver's seed semantics
218/// (`crate::style_bindings::animated_param_seed`; filter/backdrop chain
219/// params never decode through this type — their param maps stay raw).
220//
221// `PartialEq` includes `seed` DELIBERATELY: shape-attr dirt depends on it —
222// the seed renders (`static_or_seed`), and the animation apply stage writes
223// driven values *into* the seed slot, so seed equality is what makes
224// compare-before-write + `Changed<SvgShape>` sound. For style fields (whose
225// read sites ignore the seed) a seed-only delta re-applies redundantly, but
226// the appliers' `set_if_neq` discipline absorbs it.
227/// Totalizing decode for [`super::style::Style::background_image`]: any malformed value —
228/// a bare string (there is no shorthand form), a spec missing `src`, a
229/// non-object — warns and decodes to `None` rather than aborting the whole
230/// batch (the repo-wide decode invariant). Also warns on a `scale` that a
231/// non-repeat `mode` would silently ignore.
232pub(crate) fn de_background_image<'de, D: Deserializer<'de>>(
233    d: D,
234) -> Result<Option<BackgroundImageSpec>, D::Error> {
235    let v = serde_json::Value::deserialize(d)?;
236    if v.is_null() {
237        return Ok(None);
238    }
239    match BackgroundImageSpec::deserialize(&v) {
240        Ok(spec) => {
241            if spec.scale.is_some() && !spec.mode.unwrap_or_default().tiles() {
242                decode_warn(
243                    "backgroundImage",
244                    &v.to_string(),
245                    "backgroundImage `scale` only applies to the repeat modes; ignored under \"stretch\"",
246                );
247            }
248            Ok(Some(spec))
249        }
250        Err(err) => {
251            decode_warn(
252                "backgroundImage",
253                &v.to_string(),
254                &format!("invalid backgroundImage (object with `src` required): {err}"),
255            );
256            Ok(None)
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::protocol::animatable::AnimatableField;
265    use crate::protocol::style::Style;
266
267    /// `backgroundImage` decode: both `src` forms, mode keywords, and the
268    /// totalizing fallbacks (unknown mode → `Stretch`, invalid value → `None`
269    /// without aborting the style).
270    #[test]
271    fn background_image_decodes() {
272        let s: Style = serde_json::from_value(serde_json::json!({
273            "backgroundImage": {
274                "src": "images/bg.png",
275                "mode": "repeatX",
276                "scale": 2.0,
277                "tint": "#ff0000",
278            }
279        }))
280        .unwrap();
281        let spec = s.background_image.expect("spec decodes");
282        assert!(matches!(&spec.src, BackgroundImageSource::Path(p) if p == "images/bg.png"));
283        assert_eq!(spec.mode, Some(BackgroundImageMode::RepeatX));
284        assert_eq!(spec.scale.static_val(), Some(2.0));
285        assert_eq!(spec.tint.static_ref().map(String::as_str), Some("#ff0000"));
286
287        // An `{ animated }` tint decodes as a binding: the static read sees
288        // the field as absent (the animation applier drives it per frame).
289        let s: Style = serde_json::from_value(serde_json::json!({
290            "backgroundImage": {
291                "src": "bg.png",
292                "tint": { "animated": { "id": 7 } },
293            }
294        }))
295        .unwrap();
296        let spec = s.background_image.expect("animated tint decodes");
297        assert!(spec.tint.static_ref().is_none());
298        assert!(spec.tint.binding().is_some());
299
300        let s: Style = serde_json::from_value(serde_json::json!({
301            "backgroundImage": { "src": { "texture": "minimap" } }
302        }))
303        .unwrap();
304        let spec = s.background_image.expect("texture source decodes");
305        assert!(
306            matches!(&spec.src, BackgroundImageSource::Texture { texture } if texture == "minimap")
307        );
308        assert_eq!(spec.mode, None);
309
310        // `<image>`-only keywords fall back to the layout-inert Stretch.
311        let s: Style = serde_json::from_value(serde_json::json!({
312            "backgroundImage": { "src": "bg.png", "mode": "auto" }
313        }))
314        .unwrap();
315        assert_eq!(
316            s.background_image.unwrap().mode,
317            Some(BackgroundImageMode::Stretch)
318        );
319
320        // A bare string (no shorthand form) or a spec without `src` drops the
321        // field, keeping sibling fields of the same style.
322        for bad in [
323            serde_json::json!("bg.png"),
324            serde_json::json!({ "tint": "red" }),
325        ] {
326            let s: Style = serde_json::from_value(serde_json::json!({
327                "backgroundImage": bad, "width": 10,
328            }))
329            .unwrap();
330            assert!(s.background_image.is_none());
331            assert!(s.width.is_some(), "sibling fields survive the bad value");
332        }
333    }
334
335    /// Repeat-axis mapping for `NodeImageMode::Tiled`.
336    #[test]
337    fn background_image_mode_axes() {
338        use BackgroundImageMode::*;
339        assert_eq!(Stretch.tile_axes(), (false, false));
340        assert_eq!(Repeat.tile_axes(), (true, true));
341        assert_eq!(RepeatX.tile_axes(), (true, false));
342        assert_eq!(RepeatY.tile_axes(), (false, true));
343        assert!(!Stretch.tiles() && Repeat.tiles());
344    }
345}