use bevy::image::TRANSPARENT_IMAGE_HANDLE;
use bevy::prelude::*;
use bevy::ui::widget::NodeImageMode;
use crate::protocol::{
animatable::AnimatableField, background_image::BackgroundImageSource, props::Props,
style::Style, style::StyleDirty, style::style_groups,
};
use crate::ui_map::{apply_opacity, parse_color};
#[derive(Component, Clone, Debug)]
pub struct RBackgroundTexture(pub String);
#[derive(Component, Clone, Copy, Debug)]
pub struct BackgroundTileScale(pub f32);
pub fn apply_background_image(
ec: &mut EntityCommands,
style: &Option<Style>,
dirty: StyleDirty,
promoted: bool,
assets: &AssetServer,
) {
if !dirty.intersects(style_groups::BG_IMAGE) {
return;
}
let spec = match style.as_ref() {
Some(s) => s.background_image.as_ref(),
None => None,
};
let Some(spec) = spec else {
ec.remove::<(ImageNode, RBackgroundTexture, BackgroundTileScale)>();
return;
};
let mut image = match &spec.src {
BackgroundImageSource::Path(path) => {
ec.remove::<RBackgroundTexture>();
ImageNode::new(assets.load(path.clone()))
}
BackgroundImageSource::Texture { texture } => {
ec.insert(RBackgroundTexture(texture.clone()));
ImageNode::new(TRANSPARENT_IMAGE_HANDLE)
}
};
if let Some(tint) = spec.tint.static_ref() {
image.color = parse_color(tint);
}
if !promoted {
image.color = apply_opacity(
image.color,
style.as_ref().and_then(|s| s.opacity.static_val()),
);
}
let mode = spec.mode.unwrap_or_default();
if mode.tiles() {
let scale = spec.scale.static_val().unwrap_or(1.0);
let (tile_x, tile_y) = mode.tile_axes();
image.image_mode = NodeImageMode::Tiled {
tile_x,
tile_y,
stretch_value: scale,
};
ec.insert(BackgroundTileScale(scale));
} else {
image.image_mode = NodeImageMode::Stretch;
ec.remove::<BackgroundTileScale>();
}
ec.insert(image);
}
pub fn bind_background_textures(
mut commands: Commands,
targets: Res<crate::portal::RenderTargets>,
mut nodes: Query<(Entity, &RBackgroundTexture, &mut ImageNode)>,
) {
for (entity, marker, mut node) in &mut nodes {
let desired = targets.get(&marker.0).unwrap_or(TRANSPARENT_IMAGE_HANDLE);
if node.image != desired {
node.image = desired;
crate::layer::mark_content_dirty(&mut commands.entity(entity));
}
}
}
#[allow(clippy::type_complexity)]
pub fn sync_background_tile_scale(
mut nodes: Query<
(&ComputedNode, &BackgroundTileScale, &mut ImageNode),
Or<(
Changed<ComputedNode>,
Changed<BackgroundTileScale>,
Changed<ImageNode>,
)>,
>,
) {
for (computed, tile, mut node) in &mut nodes {
let scale_factor = computed.inverse_scale_factor().recip();
if !scale_factor.is_finite() || scale_factor <= 0.0 {
continue;
}
let want = tile.0 * scale_factor;
let current = match &node.image_mode {
NodeImageMode::Tiled { stretch_value, .. } => *stretch_value,
_ => continue,
};
if (current - want).abs() <= 1e-4 {
continue;
}
if let NodeImageMode::Tiled {
ref mut stretch_value,
..
} = node.image_mode
{
*stretch_value = want;
}
}
}
pub(crate) fn warn_ignored(element: &'static str, props: &Props) {
let Some(spec) = props.all_styles().find_map(|s| s.background_image.as_ref()) else {
return;
};
let value = match &spec.src {
BackgroundImageSource::Path(p) => p.as_str(),
BackgroundImageSource::Texture { texture } => texture.as_str(),
};
let msg = format!(
"backgroundImage is ignored on `{element}` — the element owns its ImageNode; \
use the element's own props instead"
);
warn!("{msg}");
crate::diag::report("backgroundImage", value, &msg);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::portal::{RenderTargetSpec, RenderTargets};
use bevy::asset::AssetPlugin;
fn test_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Image>();
app.init_resource::<RenderTargets>();
app.add_systems(
Update,
(bind_background_textures, sync_background_tile_scale),
);
app
}
#[test]
fn binds_and_reverts_to_placeholder() {
let mut app = test_app();
let node = app
.world_mut()
.spawn((
RBackgroundTexture("minimap".into()),
ImageNode::new(TRANSPARENT_IMAGE_HANDLE),
))
.id();
app.update();
assert_eq!(
app.world().entity(node).get::<ImageNode>().unwrap().image,
TRANSPARENT_IMAGE_HANDLE,
"an unregistered name shows the transparent placeholder"
);
let target_handle =
app.world_mut()
.resource_scope(|world, mut targets: Mut<RenderTargets>| {
let mut images = world.resource_mut::<Assets<Image>>();
targets
.create(&mut images, "minimap", RenderTargetSpec::default())
.handle
});
app.update();
assert_eq!(
app.world().entity(node).get::<ImageNode>().unwrap().image,
target_handle,
"the background binds once the target registers"
);
app.world_mut()
.resource_mut::<RenderTargets>()
.remove("minimap");
app.update();
assert_eq!(
app.world().entity(node).get::<ImageNode>().unwrap().image,
TRANSPARENT_IMAGE_HANDLE,
"a removed target reverts to the placeholder"
);
}
#[test]
fn tile_scale_tracks_dpi() {
let mut app = test_app();
let computed = ComputedNode {
inverse_scale_factor: 0.5, ..Default::default()
};
let node = app
.world_mut()
.spawn((
computed,
BackgroundTileScale(2.0),
ImageNode::new(TRANSPARENT_IMAGE_HANDLE).with_mode(NodeImageMode::Tiled {
tile_x: true,
tile_y: true,
stretch_value: 2.0,
}),
))
.id();
app.update();
let stretch = |app: &App| match app
.world()
.entity(node)
.get::<ImageNode>()
.unwrap()
.image_mode
{
NodeImageMode::Tiled { stretch_value, .. } => stretch_value,
_ => panic!("expected Tiled"),
};
assert_eq!(stretch(&app), 4.0, "scale 2 on a 2× display → stretch 4");
app.update();
assert_eq!(stretch(&app), 4.0, "the corrected value settles");
app.world_mut()
.entity_mut(node)
.get_mut::<ComputedNode>()
.unwrap()
.inverse_scale_factor = 1.0;
app.update();
assert_eq!(stretch(&app), 2.0, "a DPI change re-derives the stretch");
}
}