bevy_react/
background_image.rs1use 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#[derive(Component, Clone, Debug)]
32pub struct RBackgroundTexture(pub String);
33
34#[derive(Component, Clone, Copy, Debug)]
39pub struct BackgroundTileScale(pub f32);
40
41pub 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 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 if let Some(tint) = spec.tint.static_ref() {
82 image.color = parse_color(tint);
83 }
84 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 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
112pub 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#[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 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
176pub(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 #[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 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 #[test]
264 fn tile_scale_tracks_dpi() {
265 let mut app = test_app();
266 let computed = ComputedNode {
267 inverse_scale_factor: 0.5, ..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}