use bevy::prelude::*;
use crate::bridge::{FocusState, ReactNode, Restyle, StyleVariants};
use crate::ui_map::overlay_style;
#[allow(clippy::type_complexity)]
pub fn apply_interaction_styles(
mut commands: Commands,
mut query: Query<
(
Entity,
Option<Ref<Interaction>>,
Option<Ref<FocusState>>,
&mut StyleVariants,
Option<&crate::layer::PromotedLayer>,
Option<&crate::ext::ElementFlags>,
),
Or<(
Changed<Interaction>,
Changed<FocusState>,
Changed<StyleVariants>,
)>,
>,
rnodes: Query<&ReactNode>,
texts: Query<(), With<Text>>,
assets: Res<AssetServer>,
bridge: Option<Res<crate::bridge::JsBridge>>,
fonts: Option<Res<crate::plugin::Fonts>>,
) {
use crate::style::{StyleDirty, WriterMask};
let default_fonts = crate::plugin::Fonts::default();
let fonts = fonts.as_deref().unwrap_or(&default_fonts);
let styles = bridge
.as_ref()
.map_or(crate::ext::core_registry(), |b| &*b.ext)
.styles();
for (entity, interaction, focus, mut variants, promoted, flags) in &mut query {
let pending = std::mem::replace(
&mut variants.bypass_change_detection().restyle,
Restyle::Idle,
);
let state_changed = interaction.as_ref().is_some_and(|i| i.is_changed())
|| focus.as_ref().is_some_and(|f| f.is_changed());
let interaction = interaction.as_deref().copied();
let focused = focus.as_deref().is_some_and(|f| f.0);
let changed = match pending {
Restyle::Base(dirty) if !state_changed => dirty,
Restyle::Base(dirty) => dirty.union(variants.keys),
Restyle::Idle if state_changed => variants.keys,
_ => StyleDirty::ALL,
};
let writers = if changed.is_all() {
WriterMask::ALL
} else {
styles.writers_for(&changed)
};
if matches!(pending, Restyle::Base(_))
&& !state_changed
&& !focused
&& matches!(interaction, None | Some(Interaction::None))
{
continue;
}
let mut style = match interaction {
Some(Interaction::Pressed) => overlay_style(
overlay_style(variants.base.as_ref(), variants.hover.as_ref()).as_ref(),
variants.press.as_ref(),
),
Some(Interaction::Hovered) => {
overlay_style(variants.base.as_ref(), variants.hover.as_ref())
}
_ => variants.base.as_ref().cloned(),
};
if focused {
style = overlay_style(style.as_ref(), variants.focus.as_ref());
}
let rnode = rnodes.get(entity).ok();
let _diag = rnode.map(|r| crate::diag::node_scope(r.0));
let kind = match (bridge.as_ref(), rnode) {
(Some(b), Some(r)) => b.shared_tags.kind_cow(r.0),
_ => std::borrow::Cow::Borrowed("node"),
};
let info = bridge
.as_ref()
.map_or(crate::ext::core_registry(), |b| &*b.ext)
.element_or_fallback(&kind);
let is_text = texts.contains(entity);
let attrs = match (bridge.as_ref(), rnode) {
(Some(b), Some(r)) => b.props_cache.get(&r.0).map(|p| &p.attrs),
_ => None,
}
.unwrap_or(crate::element::Attrs::empty());
let wctx = crate::style::WriterCtx {
promoted: promoted.is_some(),
fresh: false,
kind: &kind,
flags: flags.copied().unwrap_or(info.decl.flags),
assets: &assets,
fonts,
styles,
element: info,
attrs,
events: crate::element::Attrs::empty(),
id: rnode.map_or(0, |r| r.0),
};
let invalidation = styles.invalidation(
&changed,
&crate::style::OldValues::default(),
style.as_ref().unwrap_or(crate::style::Style::empty()),
&crate::style::NodeCtx {
promoted: promoted.is_some(),
kind: &kind,
},
);
let own = info.writers_for(&changed, crate::element::AttrDirty::NONE);
let mut ec = commands.entity(entity);
crate::ui_map::apply_style_masked(&mut ec, &style, writers, own, &wctx, invalidation);
let color_half = writers.intersects(styles.masks.text_color);
let font_half = writers.intersects(styles.masks.text_font);
if is_text
&& (color_half || font_half)
&& let (Some(bridge), Some(rnode)) = (bridge.as_ref(), rnode)
{
let resolved =
crate::ui_map::resolved_text_style(style.as_ref(), fonts, promoted.is_some());
let kids: Vec<_> = bridge.children_of(rnode.0).collect();
for kid in kids {
if bridge.spans.get(&kid) == Some(&crate::bridge::SpanKind::RawInherited)
&& let Some(&kid_entity) = bridge.nodes.get(&kid)
{
crate::ui_map::apply_resolved_text_style(
&mut commands.entity(kid_entity),
&resolved,
color_half,
font_half,
);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::{op_app, update_delta};
use super::*;
use crate::bridge::JsBridge;
use crate::protocol::op::Op;
use bevy::ui::widget::ImageNode;
#[test]
fn text_hover_variant_recolors_glyphs() {
use crate::protocol::op::Op;
let (mut app, ops_tx) = op_app();
app.add_systems(
Update,
apply_interaction_styles.after(crate::reconcile::apply_js_ops),
);
ops_tx
.send(vec![
Op::Create {
id: 1,
kind: "text".into(),
props: serde_json::from_value(serde_json::json!({
"style": { "color": "red" },
"hoverStyle": { "color": "blue" },
}))
.unwrap(),
text: None,
},
Op::CreateTextSpan {
id: 2,
text: "run".into(),
},
Op::Append {
parent: 1,
child: 2,
},
])
.unwrap();
app.update();
let bridge = app.world().resource::<JsBridge>();
let (root, span) = (bridge.nodes[&1], bridge.nodes[&2]);
let color = |app: &App, e: Entity| {
app.world()
.entity(e)
.get::<bevy::text::TextColor>()
.unwrap()
.0
};
assert_eq!(color(&app, root), crate::ui_map::parse_color("red"));
app.world_mut()
.entity_mut(root)
.insert(Interaction::Hovered);
app.update();
assert_eq!(
color(&app, root),
crate::ui_map::parse_color("blue"),
"hover-in recolors the glyphs"
);
assert_eq!(
color(&app, span),
crate::ui_map::parse_color("blue"),
"…and the inheriting bare-string span"
);
app.world_mut().entity_mut(root).insert(Interaction::None);
app.update();
assert_eq!(
color(&app, root),
crate::ui_map::parse_color("red"),
"hover-out restores the base color"
);
}
#[test]
fn hover_variant_swaps_background_image() {
let (mut app, ops_tx) = op_app();
app.add_systems(
Update,
apply_interaction_styles.after(crate::reconcile::apply_js_ops),
);
ops_tx
.send(vec![
Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({
"style": { "backgroundImage": { "src": "a.png" } },
"hoverStyle": { "backgroundImage": { "src": "b.png" } },
}))
.unwrap(),
text: None,
},
Op::Create {
id: 2,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({
"hoverStyle": { "backgroundImage": { "src": "b.png" } },
}))
.unwrap(),
text: None,
},
])
.unwrap();
app.update();
let bridge = app.world().resource::<JsBridge>();
let (e1, e2) = (bridge.nodes[&1], bridge.nodes[&2]);
let base_handle = app
.world()
.entity(e1)
.get::<ImageNode>()
.unwrap()
.image
.clone();
app.world_mut().entity_mut(e1).insert(Interaction::Hovered);
app.world_mut().entity_mut(e2).insert(Interaction::Hovered);
app.update();
let hover_handle = app
.world()
.entity(e1)
.get::<ImageNode>()
.unwrap()
.image
.clone();
assert_ne!(
base_handle, hover_handle,
"hover swaps the background image handle"
);
assert!(
app.world().entity(e2).get::<ImageNode>().is_some(),
"a hover-only spec mounts the image on hover-in"
);
app.world_mut().entity_mut(e1).insert(Interaction::None);
app.world_mut().entity_mut(e2).insert(Interaction::None);
app.update();
assert_eq!(
app.world().entity(e1).get::<ImageNode>().unwrap().image,
base_handle,
"hover-out restores the base image"
);
assert!(
app.world().entity(e2).get::<ImageNode>().is_none(),
"hover-out removes the image when the base has no spec"
);
}
#[test]
fn dropping_a_shown_variant_restores_the_base() {
let (mut app, ops_tx) = op_app();
app.add_systems(
Update,
apply_interaction_styles.after(crate::reconcile::apply_js_ops),
);
ops_tx
.send(vec![Op::Create {
id: 1,
kind: "button".into(),
props: serde_json::from_value(serde_json::json!({
"hoverStyle": { "backgroundColor": "blue" },
}))
.unwrap(),
text: None,
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
let bg = |app: &App| app.world().entity(e).get::<BackgroundColor>().map(|c| c.0);
let base = bg(&app);
app.world_mut().entity_mut(e).insert(Interaction::Hovered);
app.update();
assert_eq!(bg(&app), Some(crate::ui_map::parse_color("blue")));
ops_tx
.send(vec![update_delta(
1,
Default::default(),
&["hoverStyle"],
&[],
)])
.unwrap();
app.update();
assert_eq!(bg(&app), base, "the hover background is cleared");
}
}