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}