Skip to main content

bevy_react/
background_image.rs

1//! The `backgroundImage` style: an image painted as part of a node's **own**
2//! background stack — over `backgroundColor` and `backgroundGradient`, under
3//! the node's content (bevy_ui's fixed per-node paint order) — by inserting an
4//! [`ImageNode`] on the same entity. Never `NodeImageMode::Auto`, so the image
5//! contributes nothing to layout and a late-loading asset causes no reflow.
6//!
7//! The build needs `AssetServer`, which `apply_style_masked` doesn't hold, so
8//! [`apply_background_image`] is called from the reconcile sites (and the
9//! interaction restyle systems) instead of from an `apply_style_masked` arm —
10//! the same split as an `image` element's `image_node` build. Elements that
11//! own their entity's `ImageNode` (`image`, `canvas`, `portal`) are guarded by
12//! `JsBridge::foreign_images` at those call sites.
13
14use bevy::image::TRANSPARENT_IMAGE_HANDLE;
15use bevy::prelude::*;
16use bevy::ui::widget::NodeImageMode;
17
18use crate::protocol::{
19    animatable::AnimatableField, background_image::BackgroundImageSource, props::Props,
20    style::Style, style::StyleDirty, style::style_groups,
21};
22use crate::ui_map::{apply_opacity, parse_color};
23
24/// Marks a node whose background image samples a render target registered in
25/// [`crate::portal::RenderTargets`]. [`bind_background_textures`] keeps the
26/// entity's [`ImageNode`] pointed at the registry's texture for this name
27/// (transparent placeholder while unregistered — the node binds late, like a
28/// `<portal>`). Unlike a portal, a background never becomes the target's
29/// `binder`, so `Resolution::Auto` targets stay at their initial size unless a
30/// portal also shows them — prefer `Resolution::Fixed` targets here.
31#[derive(Component, Clone, Debug)]
32pub struct RBackgroundTexture(pub String);
33
34/// The logical tile scale of a repeat-mode background image (`scale`, default
35/// `1.0`). [`sync_background_tile_scale`] multiplies it by the node's scale
36/// factor into `NodeImageMode::Tiled.stretch_value`, so `1.0` tiles at the
37/// texture's own size in *logical* px on every display (CSS semantics).
38#[derive(Component, Clone, Copy, Debug)]
39pub struct BackgroundTileScale(pub f32);
40
41/// Apply the style's `backgroundImage` onto the entity: insert/update or
42/// remove the [`ImageNode`] (plus the [`RBackgroundTexture`] /
43/// [`BackgroundTileScale`] markers) per the spec. Skips entirely unless the
44/// `BG_IMAGE` group is dirty. Callers guarantee the entity's `ImageNode` is
45/// not element-owned (see `JsBridge::foreign_images`); on a node that never
46/// had the style, the removes are no-ops (same pattern as the
47/// `BackgroundColor` arm in `apply_style_masked`).
48pub fn apply_background_image(
49    ec: &mut EntityCommands,
50    style: &Option<Style>,
51    dirty: StyleDirty,
52    promoted: bool,
53    assets: &AssetServer,
54) {
55    if !dirty.intersects(style_groups::BG_IMAGE) {
56        return;
57    }
58    let spec = match style.as_ref() {
59        Some(s) => s.background_image.as_ref(),
60        None => None,
61    };
62    let Some(spec) = spec else {
63        ec.remove::<(ImageNode, RBackgroundTexture, BackgroundTileScale)>();
64        return;
65    };
66    let mut image = match &spec.src {
67        BackgroundImageSource::Path(path) => {
68            // A stale marker would let `bind_background_textures` stomp the
69            // asset handle — clear it whenever the source is a path.
70            ec.remove::<RBackgroundTexture>();
71            ImageNode::new(assets.load(path.clone()))
72        }
73        BackgroundImageSource::Texture { texture } => {
74            ec.insert(RBackgroundTexture(texture.clone()));
75            ImageNode::new(TRANSPARENT_IMAGE_HANDLE)
76        }
77    };
78    // An animated tint reads as absent here (white base) — the animation
79    // applier (`AnimatableProperty::BackgroundImageTint`) drives the color
80    // every frame instead.
81    if let Some(tint) = spec.tint.static_ref() {
82        image.color = parse_color(tint);
83    }
84    // `opacity` folds into the tint alpha exactly like `image_node_promoted`:
85    // suppressed on a promoted layer root (group alpha applies at composite).
86    if !promoted {
87        image.color = apply_opacity(
88            image.color,
89            style.as_ref().and_then(|s| s.opacity.static_val()),
90        );
91    }
92    let mode = spec.mode.unwrap_or_default();
93    if mode.tiles() {
94        // `stretch_value` is written in logical terms here;
95        // `sync_background_tile_scale` applies the DPI correction (it also
96        // reacts to this very insert via `Changed<ImageNode>`).
97        let scale = spec.scale.static_val().unwrap_or(1.0);
98        let (tile_x, tile_y) = mode.tile_axes();
99        image.image_mode = NodeImageMode::Tiled {
100            tile_x,
101            tile_y,
102            stretch_value: scale,
103        };
104        ec.insert(BackgroundTileScale(scale));
105    } else {
106        image.image_mode = NodeImageMode::Stretch;
107        ec.remove::<BackgroundTileScale>();
108    }
109    ec.insert(image);
110}
111
112/// Point every background-texture node's [`ImageNode`] at the registry
113/// texture for its [`RBackgroundTexture`] name (or the shared transparent
114/// placeholder while unregistered) — the `backgroundImage` analogue of
115/// `bind_portals`, minus the `binder` recording (that is portal
116/// `Resolution::Auto` sizing semantics; a background never sizes its target).
117/// Only writes on change; a real swap marks layer content dirty so an
118/// enclosing cached layer repaints the late-bound pixels.
119pub fn bind_background_textures(
120    mut commands: Commands,
121    targets: Res<crate::portal::RenderTargets>,
122    mut nodes: Query<(Entity, &RBackgroundTexture, &mut ImageNode)>,
123) {
124    for (entity, marker, mut node) in &mut nodes {
125        let desired = targets.get(&marker.0).unwrap_or(TRANSPARENT_IMAGE_HANDLE);
126        if node.image != desired {
127            node.image = desired;
128            crate::layer::mark_content_dirty(&mut commands.entity(entity));
129        }
130    }
131}
132
133/// Keep a repeat-mode background's `stretch_value` equal to
134/// `scale × scale factor`, so `scale: 1` tiles at the texture's own size in
135/// *logical* px on every display (CSS semantics; bevy's tiling is in physical
136/// terms). Reacts to `Changed<ImageNode>` too — every restyle (delta, hover
137/// flip) re-inserts the node with the *logical* value — and settles via
138/// compare-before-write (the corrected write's own `Changed` echo no-ops on
139/// the next pass).
140#[allow(clippy::type_complexity)]
141pub fn sync_background_tile_scale(
142    mut nodes: Query<
143        (&ComputedNode, &BackgroundTileScale, &mut ImageNode),
144        Or<(
145            Changed<ComputedNode>,
146            Changed<BackgroundTileScale>,
147            Changed<ImageNode>,
148        )>,
149    >,
150) {
151    for (computed, tile, mut node) in &mut nodes {
152        let scale_factor = computed.inverse_scale_factor().recip();
153        if !scale_factor.is_finite() || scale_factor <= 0.0 {
154            // Not laid out yet — the layout pass will change `ComputedNode`
155            // and re-run this.
156            continue;
157        }
158        let want = tile.0 * scale_factor;
159        let current = match &node.image_mode {
160            NodeImageMode::Tiled { stretch_value, .. } => *stretch_value,
161            _ => continue,
162        };
163        if (current - want).abs() <= 1e-4 {
164            continue;
165        }
166        if let NodeImageMode::Tiled {
167            ref mut stretch_value,
168            ..
169        } = node.image_mode
170        {
171            *stretch_value = want;
172        }
173    }
174}
175
176/// Report a `backgroundImage` present (in any style slot) on an element whose
177/// `ImageNode` belongs to the element itself — the style is ignored there.
178/// Callers hold the [`crate::diag`] node scope, so the devtools inspector can
179/// flag the offending row.
180pub(crate) fn warn_ignored(element: &'static str, props: &Props) {
181    let Some(spec) = props.all_styles().find_map(|s| s.background_image.as_ref()) else {
182        return;
183    };
184    let value = match &spec.src {
185        BackgroundImageSource::Path(p) => p.as_str(),
186        BackgroundImageSource::Texture { texture } => texture.as_str(),
187    };
188    let msg = format!(
189        "backgroundImage is ignored on `{element}` — the element owns its ImageNode; \
190         use the element's own props instead"
191    );
192    warn!("{msg}");
193    crate::diag::report("backgroundImage", value, &msg);
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::portal::{RenderTargetSpec, RenderTargets};
200    use bevy::asset::AssetPlugin;
201
202    fn test_app() -> App {
203        let mut app = App::new();
204        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
205        app.init_asset::<Image>();
206        app.init_resource::<RenderTargets>();
207        app.add_systems(
208            Update,
209            (bind_background_textures, sync_background_tile_scale),
210        );
211        app
212    }
213
214    /// A background-texture node starts on the transparent placeholder, binds
215    /// to the registered target, and reverts when the target is removed.
216    #[test]
217    fn binds_and_reverts_to_placeholder() {
218        let mut app = test_app();
219        let node = app
220            .world_mut()
221            .spawn((
222                RBackgroundTexture("minimap".into()),
223                ImageNode::new(TRANSPARENT_IMAGE_HANDLE),
224            ))
225            .id();
226        app.update();
227        assert_eq!(
228            app.world().entity(node).get::<ImageNode>().unwrap().image,
229            TRANSPARENT_IMAGE_HANDLE,
230            "an unregistered name shows the transparent placeholder"
231        );
232
233        let target_handle =
234            app.world_mut()
235                .resource_scope(|world, mut targets: Mut<RenderTargets>| {
236                    let mut images = world.resource_mut::<Assets<Image>>();
237                    targets
238                        .create(&mut images, "minimap", RenderTargetSpec::default())
239                        .handle
240                });
241        app.update();
242        assert_eq!(
243            app.world().entity(node).get::<ImageNode>().unwrap().image,
244            target_handle,
245            "the background binds once the target registers"
246        );
247        // (Unlike `bind_portals`, no `binder` is recorded — the system only
248        // reads the registry via `get`, so it *can't* touch sizing state.)
249
250        app.world_mut()
251            .resource_mut::<RenderTargets>()
252            .remove("minimap");
253        app.update();
254        assert_eq!(
255            app.world().entity(node).get::<ImageNode>().unwrap().image,
256            TRANSPARENT_IMAGE_HANDLE,
257            "a removed target reverts to the placeholder"
258        );
259    }
260
261    /// `stretch_value` = logical scale × the node's scale factor, kept live
262    /// on DPI change, and it settles (its own write echo no-ops).
263    #[test]
264    fn tile_scale_tracks_dpi() {
265        let mut app = test_app();
266        let computed = ComputedNode {
267            inverse_scale_factor: 0.5, // a 2× display
268            ..Default::default()
269        };
270        let node = app
271            .world_mut()
272            .spawn((
273                computed,
274                BackgroundTileScale(2.0),
275                ImageNode::new(TRANSPARENT_IMAGE_HANDLE).with_mode(NodeImageMode::Tiled {
276                    tile_x: true,
277                    tile_y: true,
278                    stretch_value: 2.0,
279                }),
280            ))
281            .id();
282        app.update();
283        let stretch = |app: &App| match app
284            .world()
285            .entity(node)
286            .get::<ImageNode>()
287            .unwrap()
288            .image_mode
289        {
290            NodeImageMode::Tiled { stretch_value, .. } => stretch_value,
291            _ => panic!("expected Tiled"),
292        };
293        assert_eq!(stretch(&app), 4.0, "scale 2 on a 2× display → stretch 4");
294        app.update();
295        assert_eq!(stretch(&app), 4.0, "the corrected value settles");
296
297        app.world_mut()
298            .entity_mut(node)
299            .get_mut::<ComputedNode>()
300            .unwrap()
301            .inverse_scale_factor = 1.0;
302        app.update();
303        assert_eq!(stretch(&app), 2.0, "a DPI change re-derives the stretch");
304    }
305}