use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::ui::RelativeCursorPosition;
use bevy::ui::{ComputedNode, ScrollPosition};
use crate::animations::AnimatedNode;
use crate::bridge::{
ClickOwner, FocusState, HoverState, JsBridge, PointerHandlers, Restyle, ScrollListener,
ScrollStep, StyleVariants, WheelListener,
};
use crate::protocol::{
NodeId,
props::{Props, PropsDirty, UpdateEvents},
};
use crate::style::Style;
use crate::style::props::{BACKDROP_FILTER, CACHE, FILTER, MORPH_FILTER, TRANSFORM3D};
use crate::transition::ScrollTransitionState;
pub(crate) fn apply_animated(
ec: &mut EntityCommands,
animated: &mut HashSet<NodeId>,
id: NodeId,
props: &Props,
fresh: bool,
) {
crate::style_bindings::warn_variant_bindings(props);
let bindings = crate::style_bindings::derive_props_bindings(props);
crate::style_bindings::warn_gradient_transition_mix(props, bindings.as_ref());
match bindings {
Some(bindings) => {
if bindings.has_stage(crate::animations::props::PropStage::Ext) {
ec.insert_if_new(crate::ext::DrivenExtValues::default());
} else if !fresh {
ec.remove::<crate::ext::DrivenExtValues>();
}
ec.insert(AnimatedNode(bindings));
animated.insert(id);
}
None => {
if !fresh && !animated.is_empty() && animated.remove(&id) {
ec.remove::<(AnimatedNode, crate::ext::DrivenExtValues)>();
}
}
}
}
pub(super) fn apply_style_variants(
ec: &mut EntityCommands,
props: &Props,
base: &Option<Style>,
fresh: bool,
) {
if props.hover_style.is_some() || props.press_style.is_some() || props.focus_style.is_some() {
ec.insert(StyleVariants {
base: base.clone(),
hover: props.hover_style.clone(),
press: props.press_style.clone(),
focus: props.focus_style.clone(),
keys: variant_keys(props),
restyle: Restyle::Full,
});
if props.hover_style.is_some() || props.press_style.is_some() {
ec.insert_if_new(Interaction::default());
}
if props.focus_style.is_some() {
ec.insert_if_new(FocusState::default());
} else if !fresh {
ec.remove::<FocusState>();
}
} else if !fresh {
ec.remove::<(StyleVariants, FocusState)>();
}
}
pub(super) fn apply_style_variants_delta(
ec: &mut EntityCommands,
props: &Props,
base: &Option<Style>,
dirty: &PropsDirty,
) {
if !dirty.any_style_variant() {
return;
}
if dirty.hover_style || dirty.press_style || dirty.focus_style {
apply_style_variants(ec, props, base, false);
return;
}
if props.hover_style.is_some() || props.press_style.is_some() || props.focus_style.is_some() {
let base = base.clone();
let mask = dirty.style;
ec.queue(move |mut entity: EntityWorldMut| {
if let Some(mut variants) = entity.get_mut::<StyleVariants>() {
variants.base = base;
variants.restyle.note_base_delta(mask);
}
});
}
}
pub(crate) fn apply_pointer_handlers(
ec: &mut EntityCommands,
props: &Props,
flags: crate::ext::ElementFlags,
fresh: bool,
) {
let any_pointer = props.on_pointer_down
|| props.on_pointer_move
|| props.on_pointer_up
|| props.on_pointer_enter
|| props.on_pointer_leave;
if any_pointer {
let handlers = PointerHandlers {
down: props.on_pointer_down,
moved: props.on_pointer_move,
up: props.on_pointer_up,
enter: props.on_pointer_enter,
leave: props.on_pointer_leave,
};
if fresh {
ec.insert((handlers, RelativeCursorPosition::default()));
} else {
ec.insert(handlers)
.insert_if_new(RelativeCursorPosition::default());
}
} else if !fresh {
ec.remove::<(PointerHandlers, RelativeCursorPosition)>();
}
if props.on_pointer_enter || props.on_pointer_leave {
ec.insert_if_new(HoverState::default());
} else if !fresh {
ec.remove::<HoverState>();
}
if props.on_click || any_pointer {
ec.insert_if_new((Interaction::default(), ClickOwner));
} else if !fresh {
ec.remove::<ClickOwner>();
}
if flags.node_less {
node_less_pointer_slot(ec, props.on_click || any_pointer, fresh);
}
}
fn node_less_pointer_slot(ec: &mut EntityCommands, any: bool, fresh: bool) {
if any {
ec.insert_if_new(crate::ext::EventLocalPos::default());
} else if !fresh {
ec.remove::<(crate::ext::EventLocalPos, Interaction)>();
}
}
pub(super) fn warn_span_ignored(props: &Props) {
for (present, name) in [
(
props.all_styles().any(|s| s.get(&FILTER).is_some()),
"filter",
),
(
props
.all_styles()
.any(|s| s.get(&BACKDROP_FILTER).is_some()),
"backdropFilter",
),
(
props.all_styles().any(|s| s.get(&MORPH_FILTER).is_some()),
"morphFilter",
),
(
props.all_styles().any(|s| s.get(&TRANSFORM3D).is_some()),
"transform3d",
),
(props.all_styles().any(|s| s.get(&CACHE).is_some()), "cache"),
] {
if present {
crate::diag::report(
"spanLayerStyle",
name,
&format!(
"`{name}` on a nested <text> span is ignored — a span has no \
layout box to capture; put it on the enclosing <text> (or a \
wrapping <node>)"
),
);
}
}
}
pub(super) fn warn_ignored_styles(
info: &crate::element::ElementInfo,
styles: &crate::style::StyleRegistry,
props: &Props,
) {
if !info.ignored_styles.any() {
return;
}
for style in props.all_styles() {
if !style.keys().intersects(&info.ignored_styles) {
continue;
}
for (property, _) in style.iter() {
if styles
.id_of(property)
.is_some_and(|id| info.ignored_styles.contains(id))
{
let name = property.name();
crate::diag::report(
"styleIgnored",
name,
&format!("`{name}` has no effect on <{}>", info.name()),
);
}
}
}
}
pub(super) fn apply_scroll_listener(ec: &mut EntityCommands, props: &Props, fresh: bool) {
if props.on_scroll {
ec.insert_if_new(ScrollListener);
} else if !fresh {
ec.remove::<ScrollListener>();
}
}
pub(super) fn apply_wheel_listener(ec: &mut EntityCommands, props: &Props, fresh: bool) {
if props.on_wheel {
ec.insert_if_new(WheelListener);
} else if !fresh {
ec.remove::<WheelListener>();
}
}
pub(super) fn apply_scroll_step(ec: &mut EntityCommands, props: &Props, fresh: bool) {
match props.scroll_step {
Some(step) => {
ec.insert(ScrollStep(step));
}
None if !fresh => {
ec.remove::<ScrollStep>();
}
None => {}
}
}
pub(crate) fn stamp_common(
ec: &mut EntityCommands,
animated: &mut HashSet<NodeId>,
id: NodeId,
props: &Props,
base: &Option<Style>,
common: crate::element::Common,
flags: crate::ext::ElementFlags,
) {
use crate::element::Common;
if common.contains(Common::VARIANTS) {
apply_style_variants(ec, props, base, true);
}
if common.contains(Common::POINTER) {
apply_pointer_handlers(ec, props, flags, true);
}
apply_animated(ec, animated, id, props, true);
}
pub(super) fn variant_keys(props: &Props) -> crate::style::StyleDirty {
[&props.hover_style, &props.press_style, &props.focus_style]
.into_iter()
.flatten()
.fold(crate::style::StyleDirty::NONE, |keys, s| {
keys.union(s.keys())
})
}
pub(super) fn create_controlled_scroll(
bridge: &mut JsBridge,
ec: &mut EntityCommands,
id: NodeId,
props: &Props,
events: &UpdateEvents,
) {
if events.scroll_top.is_some() || events.scroll_left.is_some() {
let pos = Vec2::new(
events.scroll_left.unwrap_or(0.0),
events.scroll_top.unwrap_or(0.0),
);
ec.insert((
ScrollPosition(pos),
crate::scroll::PendingControlledScroll(pos),
));
bridge.scroll_positions.insert(id, pos);
} else if props.on_scroll {
bridge.scroll_positions.insert(id, Vec2::ZERO);
}
}
pub(super) fn update_controlled_scroll(
bridge: &mut JsBridge,
ec: &mut EntityCommands,
scroll_query: &mut Query<(
&mut ScrollPosition,
&ComputedNode,
Option<&mut ScrollTransitionState>,
)>,
e: Entity,
id: NodeId,
scroll_left: Option<f32>,
scroll_top: Option<f32>,
) {
if scroll_top.is_none() && scroll_left.is_none() {
return;
}
if let Ok((mut pos, computed, scroll_state)) = scroll_query.get_mut(e) {
let mut requested = crate::scroll::scroll_base(&pos, scroll_state.as_deref());
if let Some(x) = scroll_left {
requested.x = x;
}
if let Some(y) = scroll_top {
requested.y = y;
}
let clamped = requested.clamp(Vec2::ZERO, crate::scroll::scroll_range(computed));
crate::scroll::write_scroll(&mut pos, scroll_state, clamped);
if requested != clamped {
ec.insert(crate::scroll::PendingControlledScroll(requested));
}
bridge.scroll_positions.insert(id, requested);
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::{ent, op_app, update_delta};
use super::*;
use crate::protocol::op::Op;
use bevy::ui::FocusPolicy;
#[test]
fn node_onclick_attaches_interaction() {
let (mut app, ops_tx) = op_app();
ops_tx
.send(vec![
Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({ "onClick": true })).unwrap(),
text: None,
},
Op::Create {
id: 2,
kind: "node".into(),
props: Box::default(),
text: None,
},
])
.unwrap();
app.update();
let nodes = &app.world().resource::<JsBridge>().nodes;
let (clickable, inert) = (nodes[&1], nodes[&2]);
assert!(
app.world().entity(clickable).get::<Interaction>().is_some(),
"`onClick` alone must make a <node> clickable"
);
assert!(
app.world().entity(inert).get::<Interaction>().is_none(),
"a node with no handlers/hover/press must not gain an Interaction"
);
}
#[test]
fn click_owner_tracks_handlers_not_styling() {
use crate::bridge::ClickOwner;
let (mut app, ops_tx) = op_app();
ops_tx
.send(vec![
Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(
serde_json::json!({ "hoverStyle": { "opacity": 0.5 } }),
)
.unwrap(),
text: None,
},
Op::Create {
id: 2,
kind: "node".into(),
props: serde_json::from_value(
serde_json::json!({ "onClick": true, "hoverStyle": { "opacity": 0.5 } }),
)
.unwrap(),
text: None,
},
])
.unwrap();
app.update();
let nodes = app.world().resource::<JsBridge>().nodes.clone();
let (styled, clickable) = (nodes[&1], nodes[&2]);
assert!(
app.world().entity(styled).get::<Interaction>().is_some(),
"hover styling makes the node interactive"
);
assert!(
app.world().entity(styled).get::<ClickOwner>().is_none(),
"…but never a click owner"
);
assert!(app.world().entity(clickable).get::<ClickOwner>().is_some());
ops_tx
.send(vec![update_delta(2, Props::default(), &["onClick"], &[])])
.unwrap();
app.update();
let entity = app.world().entity(clickable);
assert!(
entity.get::<ClickOwner>().is_none(),
"unsetting the last handler drops click ownership"
);
assert!(
entity.get::<Interaction>().is_some(),
"the hover-style Interaction survives"
);
}
#[test]
fn text_root_stamps_interactivity() {
use crate::bridge::ClickOwner;
let (mut app, ops_tx) = op_app();
ops_tx
.send(vec![Op::Create {
id: 1,
kind: "text".into(),
props: serde_json::from_value(serde_json::json!({
"onClick": true,
"hoverStyle": { "color": "blue" },
}))
.unwrap(),
text: Some("label".into()),
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
let entity = app.world().entity(e);
assert!(
entity.get::<StyleVariants>().is_some(),
"a <text> hoverStyle stamps StyleVariants"
);
assert!(entity.get::<Interaction>().is_some());
assert!(
entity.get::<ClickOwner>().is_some(),
"onClick makes the <text> a click owner"
);
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "onPointerDown": true })).unwrap(),
&[],
&[],
)])
.unwrap();
app.update();
assert!(
app.world()
.entity(e)
.get::<PointerHandlers>()
.is_some_and(|h| h.down),
"a handler delta must reach a <text> root"
);
}
#[test]
fn pointer_enter_leave_stamps_hover_state() {
let (mut app, ops_tx) = op_app();
ops_tx
.send(vec![Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(
serde_json::json!({ "onPointerEnter": true, "onPointerLeave": true }),
)
.unwrap(),
text: None,
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
let entity = app.world().entity(e);
assert!(
entity.get::<Interaction>().is_some(),
"hover handlers must make the node interactive"
);
assert!(
entity.get::<HoverState>().is_some(),
"hover handlers must stamp a HoverState"
);
let handlers = entity.get::<PointerHandlers>().expect("PointerHandlers");
assert!(handlers.enter && handlers.leave);
}
#[test]
fn image_rendering_mode_is_stamped_for_explicit_modes_only() {
use crate::image_rendering::{ImageRendering, ImageRenderingMode};
let (mut app, ops_tx) = op_app();
let styled = |mode: &str| -> Box<Props> {
Box::new(
serde_json::from_value(serde_json::json!({ "style": { "imageRendering": mode } }))
.unwrap(),
)
};
let create = |id: NodeId, props: Box<Props>| Op::Create {
id,
kind: "image".into(),
props,
text: None,
};
ops_tx
.send(vec![
create(1, styled("trilinear")),
create(2, styled("auto")),
create(3, Box::default()),
])
.unwrap();
app.update();
let mode = |app: &App, id: NodeId| -> Option<ImageRendering> {
app.world()
.entity(ent(app, id))
.get::<ImageRenderingMode>()
.map(|m| m.0)
};
assert_eq!(mode(&app, 1), Some(ImageRendering::Trilinear));
assert_eq!(mode(&app, 2), None, "auto is passive");
assert_eq!(mode(&app, 3), None);
ops_tx
.send(vec![
update_delta(1, Props::default(), &[], &["imageRendering"]),
update_delta(3, *styled("nearest"), &[], &[]),
])
.unwrap();
app.update();
assert_eq!(mode(&app, 1), None, "styleUnset removes the mode");
assert_eq!(mode(&app, 3), Some(ImageRendering::Nearest));
}
#[test]
fn focus_policy_defaults_block_button_pass_node() {
let (mut app, ops_tx) = op_app();
let node_props =
|json: serde_json::Value| -> Props { serde_json::from_value(json).unwrap() };
ops_tx
.send(vec![
Op::Create {
id: 1,
kind: "button".into(),
props: Box::default(),
text: None,
},
Op::Create {
id: 2,
kind: "node".into(),
props: Box::default(),
text: None,
},
Op::Create {
id: 3,
kind: "button".into(),
props: Box::new(node_props(
serde_json::json!({ "style": { "focusPolicy": "pass" } }),
)),
text: None,
},
])
.unwrap();
app.update();
let fp = |app: &App, id: u32| -> Option<FocusPolicy> {
let e = app.world().resource::<JsBridge>().nodes[&id];
app.world().entity(e).get::<FocusPolicy>().copied()
};
let blocks = |app: &App, id: u32| -> Option<bool> {
let e = app.world().resource::<JsBridge>().nodes[&id];
app.world()
.entity(e)
.get::<bevy::picking::Pickable>()
.map(|p| p.should_block_lower)
};
assert_eq!(
fp(&app, 1),
Some(FocusPolicy::Block),
"button defaults to Block"
);
assert_eq!(blocks(&app, 1), Some(true), "button blocks picking too");
assert_eq!(
fp(&app, 2),
Some(FocusPolicy::Pass),
"node defaults to Pass"
);
assert_eq!(blocks(&app, 2), Some(false), "node passes picking too");
assert_eq!(
fp(&app, 3),
Some(FocusPolicy::Pass),
"explicit focusPolicy overrides the button default"
);
assert_eq!(
blocks(&app, 3),
Some(false),
"explicit pass unblocks picking on a button"
);
ops_tx
.send(vec![update_delta(
1,
Props::default(),
&[],
&["focusPolicy"],
)])
.unwrap();
app.update();
assert_eq!(
fp(&app, 1),
Some(FocusPolicy::Block),
"a re-rendered button keeps its Block default"
);
assert_eq!(
blocks(&app, 1),
Some(true),
"a re-rendered button keeps blocking picking"
);
}
#[test]
fn controlled_scroll_create_sets_position_and_listener() {
let (mut app, ops_tx) = op_app();
ops_tx
.send(vec![
Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({
"scrollTop": 50.0, "onScroll": true,
"style": { "overflowY": "scroll" }
}))
.unwrap(),
text: None,
},
Op::Create {
id: 2,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({ "onScroll": true })).unwrap(),
text: None,
},
Op::Create {
id: 3,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({ "scrollTop": 30.0 }))
.unwrap(),
text: None,
},
])
.unwrap();
app.update();
let nodes = app.world().resource::<JsBridge>().nodes.clone();
let (e1, e2, e3) = (nodes[&1], nodes[&2], nodes[&3]);
assert_eq!(
app.world().entity(e1).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 50.0)
);
assert!(app.world().entity(e1).get::<ScrollListener>().is_some());
assert!(app.world().entity(e2).get::<ScrollListener>().is_some());
assert!(
app.world().entity(e3).get::<ScrollListener>().is_none(),
"a controlled node with no onScroll must not be marked"
);
let bridge = app.world().resource::<JsBridge>();
assert_eq!(bridge.scroll_positions.get(&1), Some(&Vec2::new(0.0, 50.0)));
assert_eq!(bridge.scroll_positions.get(&2), Some(&Vec2::ZERO));
assert_eq!(bridge.scroll_positions.get(&3), Some(&Vec2::new(0.0, 30.0)));
}
#[test]
fn controlled_scroll_update_clamps_to_range() {
let (mut app, ops_tx) = op_app();
ops_tx
.send(vec![Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({
"onScroll": true, "style": { "overflowY": "scroll" }
}))
.unwrap(),
text: None,
}])
.unwrap();
app.update();
let e1 = app.world().resource::<JsBridge>().nodes[&1];
app.world_mut().entity_mut(e1).insert(ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 300.0),
inverse_scale_factor: 1.0,
..default()
});
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({
"onScroll": true, "scrollTop": 10000.0,
"style": { "overflowY": "scroll" }
}))
.unwrap(),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world().entity(e1).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 200.0),
"the written offset is clamped to the scrollable range"
);
assert_eq!(
app.world().resource::<JsBridge>().scroll_positions.get(&1),
Some(&Vec2::new(0.0, 10000.0)),
"the requested (pre-clamp) value is recorded so the read-back can correct React"
);
}
#[test]
fn controlled_scroll_with_transition_sets_target_not_position() {
let (mut app, ops_tx) = op_app();
let style = serde_json::json!({
"overflowY": "scroll", "transition": { "scroll": { "duration": 300 } }
});
ops_tx
.send(vec![Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({ "style": style })).unwrap(),
text: None,
}])
.unwrap();
app.update();
let e1 = app.world().resource::<JsBridge>().nodes[&1];
app.world_mut().entity_mut(e1).insert(ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 300.0),
inverse_scale_factor: 1.0,
..default()
});
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "scrollTop": 80.0, "style": style }))
.unwrap(),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world().entity(e1).get::<ScrollPosition>().unwrap().0,
Vec2::ZERO,
"a controlled change with a scroll transition must not snap the offset"
);
assert_eq!(
app.world()
.entity(e1)
.get::<ScrollTransitionState>()
.unwrap()
.target,
Vec2::new(0.0, 80.0),
"it sets the eased target instead"
);
}
}