use crate::services::image_service::get_or_load_image;
use crate::styles::paint::Colored;
use crate::styles::{CssID, CssSource, TagName};
use crate::widgets::{
Img, InputField, InputType, InputValue, UIGenID, UIWidgetState, WidgetId, WidgetKind,
};
use crate::{CurrentWidgetState, ExtendedUiConfiguration, ImageCache};
use bevy::asset::LoadState;
use bevy::camera::visibility::RenderLayers;
use bevy::prelude::*;
#[derive(Component)]
struct ImageBase;
#[derive(Component)]
struct AltTextNode;
#[derive(Component, Copy, Clone)]
struct AltTextChild(Entity);
#[derive(Component, Copy, Clone, Debug, PartialEq, Eq)]
enum ImgFallbackState {
None,
AltShown,
}
#[derive(Component, Debug, Clone, PartialEq, Eq)]
struct AltTextCached(String);
pub struct ImageWidget;
impl Plugin for ImageWidget {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
internal_node_creation_system,
sync_preview_source_from_file_input,
update_src,
sync_alt_text_with_image_state, )
.chain(),
);
}
}
fn sync_preview_source_from_file_input(
input_query: Query<(&CssID, &InputField, &InputValue), (With<InputField>, Changed<InputValue>)>,
mut img_query: Query<&mut Img, With<Img>>,
) {
for (input_id, input, input_value) in input_query.iter() {
if input.input_type != InputType::File || input.folder {
continue;
}
if !input_allows_image_preview(input) {
continue;
}
let value = input_value.0.trim();
if value.is_empty() || !is_supported_preview_source(value) {
continue;
}
let normalized = value.replace('\\', "/");
for mut img in img_query.iter_mut() {
if img.preview.as_deref() == Some(input_id.0.as_str())
&& img.src.as_deref() != Some(normalized.as_str())
{
img.src = Some(normalized.clone());
}
}
}
}
fn internal_node_creation_system(
mut commands: Commands,
query: Query<(Entity, &Img, Option<&CssSource>), (With<Img>, Without<ImageBase>)>,
config: Res<ExtendedUiConfiguration>,
asset_server: Res<AssetServer>,
mut image_cache: ResMut<ImageCache>,
mut images: ResMut<Assets<Image>>,
) {
let layer = config.render_layers.first().unwrap_or(&1);
for (entity, img, source_opt) in query.iter() {
let mut css_source = CssSource::default();
if let Some(source) = source_opt {
css_source = source.clone();
}
let mut image_node = ImageNode::default();
assign_image_from_src(
&mut image_node,
img,
&asset_server,
&mut image_cache,
&mut images,
);
commands
.entity(entity)
.insert((
Name::new(format!("Img-{}", img.entry)),
Node::default(),
WidgetId {
id: img.entry,
kind: WidgetKind::Img,
},
image_node,
BackgroundColor::default(),
BorderColor::default(),
BoxShadow::new(
Colored::TRANSPARENT,
Val::Px(0.),
Val::Px(0.),
Val::Px(0.),
Val::Px(0.),
),
ZIndex::default(),
Pickable::default(),
css_source,
TagName("img".to_string()),
RenderLayers::layer(*layer),
ImageBase,
))
.insert(ImgFallbackState::None)
.insert(AltTextCached(String::new()))
.observe(on_internal_click)
.observe(on_internal_cursor_entered)
.observe(on_internal_cursor_leave);
let src_empty = is_src_empty(img);
if src_empty {
let child = spawn_or_update_alt_text_child(&mut commands, entity, None, &img.alt);
if let Some(child) = child {
commands.entity(entity).insert(AltTextChild(child));
}
commands.entity(entity).insert(ImgFallbackState::AltShown);
}
}
}
fn update_src(
mut commands: Commands,
mut query: Query<
(
Entity,
&mut ImageNode,
&mut UIWidgetState,
&Img,
Option<&AltTextChild>,
&mut ImgFallbackState,
&mut AltTextCached,
),
(With<Img>, Changed<Img>),
>,
asset_server: Res<AssetServer>,
mut image_cache: ResMut<ImageCache>,
mut images: ResMut<Assets<Image>>,
) {
for (entity, mut image_node, _state, img, alt_child, mut fb_state, mut cached) in
query.iter_mut()
{
let existing_child = alt_child.map(|c| c.0);
assign_image_from_src(
&mut image_node,
img,
&asset_server,
&mut image_cache,
&mut images,
);
if is_src_empty(img) {
let child =
spawn_or_update_alt_text_child(&mut commands, entity, existing_child, &img.alt);
if let Some(child) = child {
commands.entity(entity).insert(AltTextChild(child));
set_cached_alt_if_changed(&mut commands, entity, &mut cached, &img.alt);
}
*fb_state = ImgFallbackState::AltShown;
} else {
*fb_state = ImgFallbackState::None;
}
}
}
fn sync_alt_text_with_image_state(
mut commands: Commands,
asset_server: Res<AssetServer>,
query: Query<
(
Entity,
&Img,
&ImageNode,
Option<&AltTextChild>,
&ImgFallbackState,
&AltTextCached,
),
With<Img>,
>,
) {
for (entity, img, image_node, alt_child, fb_state, cached) in query.iter() {
let existing_child = alt_child.map(|c| c.0);
if is_src_empty(img) {
continue;
}
match asset_server.get_load_state(image_node.image.id()) {
Some(LoadState::Loaded) => {
if *fb_state == ImgFallbackState::AltShown {
remove_alt_text_children(&mut commands, entity, existing_child);
commands.entity(entity).insert(ImgFallbackState::None);
debug!("Image loaded again, removing alt text: {:?}", entity);
}
}
Some(LoadState::Failed(_)) => {
if *fb_state != ImgFallbackState::AltShown {
let child = spawn_or_update_alt_text_child(
&mut commands,
entity,
existing_child,
&img.alt,
);
if let Some(child) = child {
commands.entity(entity).insert(AltTextChild(child));
let mut cached_local = cached.clone();
set_cached_alt_if_changed(
&mut commands,
entity,
&mut cached_local,
&img.alt,
);
}
commands.entity(entity).insert(ImgFallbackState::AltShown);
debug!("[WARN] Image failed to load, using alt text: {:?}", img.alt);
} else {
if cached.0 != img.alt.trim() {
if let Some(child) = existing_child {
commands
.entity(child)
.insert(Text::new(img.alt.trim().to_string()));
commands
.entity(entity)
.insert(AltTextCached(img.alt.trim().to_string()));
debug!("Alt text changed, updating child for Img: {:?}", entity);
}
}
}
}
_ => {
}
}
}
}
fn assign_image_from_src(
image_node: &mut ImageNode,
img: &Img,
asset_server: &Res<AssetServer>,
image_cache: &mut ImageCache,
images: &mut ResMut<Assets<Image>>,
) {
if let Some(path) = img.src.clone().filter(|s| !s.trim().is_empty()) {
let handle = get_or_load_image(path.as_str(), image_cache, images, asset_server);
image_node.image = handle;
}
}
fn input_allows_image_preview(input: &InputField) -> bool {
input.extensions.iter().any(|ext| {
let ext = ext.trim().trim_start_matches('.').to_ascii_lowercase();
matches!(ext.as_str(), "jpg" | "jpeg" | "png")
})
}
fn path_is_supported_preview_image(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.is_some_and(|ext| matches!(ext.as_str(), "jpg" | "jpeg" | "png"))
}
fn is_supported_preview_source(value: &str) -> bool {
value.starts_with("data:") || path_is_supported_preview_image(value)
}
fn is_src_empty(img: &Img) -> bool {
img.src
.as_ref()
.map(|s| s.trim().is_empty())
.unwrap_or(true)
}
fn set_cached_alt_if_changed(
commands: &mut Commands,
parent: Entity,
cached: &mut AltTextCached,
alt: &str,
) {
let alt = alt.trim().to_string();
if cached.0 != alt {
cached.0 = alt.clone();
commands.entity(parent).insert(AltTextCached(alt));
}
}
fn spawn_or_update_alt_text_child(
commands: &mut Commands,
parent: Entity,
existing_child: Option<Entity>,
alt: &str,
) -> Option<Entity> {
let alt = alt.trim();
if alt.is_empty() {
warn!("Alt text is empty for Img: {:?}", parent);
return existing_child;
}
if let Some(child) = existing_child {
commands.entity(child).insert(Text::new(alt.to_string()));
return Some(child);
}
let child = commands
.spawn((
AltTextNode,
Node::default(),
Text::new(alt.to_string()),
TextColor(Color::WHITE),
TextFont::default(),
TextLayout::default(),
))
.id();
commands.entity(parent).add_child(child);
Some(child)
}
fn remove_alt_text_children(
commands: &mut Commands,
parent: Entity,
existing_child: Option<Entity>,
) {
let Some(child) = existing_child else { return };
commands.entity(child).despawn();
commands.entity(parent).remove::<AltTextChild>();
}
fn on_internal_click(
mut trigger: On<Pointer<Click>>,
mut query: Query<(&mut UIWidgetState, &UIGenID), With<Img>>,
mut current_widget_state: ResMut<CurrentWidgetState>,
) {
if let Ok((mut state, gen_id)) = query.get_mut(trigger.entity) {
state.focused = true;
current_widget_state.widget_id = gen_id.0;
}
trigger.propagate(false);
}
fn on_internal_cursor_entered(
mut trigger: On<Pointer<Over>>,
mut query: Query<&mut UIWidgetState, With<Img>>,
) {
if let Ok(mut state) = query.get_mut(trigger.entity) {
state.hovered = true;
}
trigger.propagate(false);
}
fn on_internal_cursor_leave(
mut trigger: On<Pointer<Out>>,
mut query: Query<&mut UIWidgetState, With<Img>>,
) {
if let Ok(mut state) = query.get_mut(trigger.entity) {
state.hovered = false;
}
trigger.propagate(false);
}