use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::ui::FocusPolicy;
use bevy::ui::RelativeCursorPosition;
use bevy::ui::{ComputedNode, ScrollPosition};
use crate::anchor::{AnchorScaling, Anchored};
use crate::animations::AnimatedNode;
use crate::bridge::{
FocusState, HoverState, JsBridge, PointerHandlers, ScrollListener, ScrollStep, StyleVariants,
WheelListener,
};
use crate::protocol::{NodeId, props::Props, style::Style};
use crate::transition::ScrollTransitionState;
pub(super) fn apply_animated(ec: &mut EntityCommands, props: &Props) {
crate::style_bindings::warn_variant_bindings(props);
match crate::style_bindings::derive_props_bindings(props) {
Some(bindings) => {
ec.insert(AnimatedNode(bindings));
}
None => {
ec.remove::<AnimatedNode>();
}
}
}
pub(super) fn apply_anchor(ec: &mut EntityCommands, props: &Props) {
match &props.anchor {
Some(anchor) => match Entity::try_from_bits(anchor.entity as u64) {
Some(target) => {
let offset = anchor.offset.map(Vec3::from).unwrap_or(Vec3::ZERO);
ec.insert(Anchored {
target,
offset,
scale: anchor.scale.and_then(AnchorScaling::sanitized),
});
}
None => {
ec.remove::<Anchored>();
}
},
None => {
ec.remove::<Anchored>();
}
}
}
pub(super) fn apply_style_variants(ec: &mut EntityCommands, props: &Props) {
if props.hover_style.is_some() || props.press_style.is_some() || props.focus_style.is_some() {
ec.insert(StyleVariants {
base: props.style.clone(),
hover: props.hover_style.clone(),
press: props.press_style.clone(),
focus: props.focus_style.clone(),
});
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 {
ec.remove::<FocusState>();
}
} else {
ec.remove::<StyleVariants>();
ec.remove::<FocusState>();
}
}
pub(super) fn apply_pointer_handlers(ec: &mut EntityCommands, props: &Props) {
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 {
ec.insert(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,
});
ec.insert_if_new(RelativeCursorPosition::default());
} else {
ec.remove::<PointerHandlers>();
ec.remove::<RelativeCursorPosition>();
}
if props.on_pointer_enter || props.on_pointer_leave {
ec.insert_if_new(HoverState::default());
} else {
ec.remove::<HoverState>();
}
if props.on_click || any_pointer {
ec.insert_if_new(Interaction::default());
}
}
pub(super) fn apply_scroll_listener(ec: &mut EntityCommands, props: &Props) {
if props.on_scroll {
ec.insert_if_new(ScrollListener);
} else {
ec.remove::<ScrollListener>();
}
}
pub(super) fn apply_wheel_listener(ec: &mut EntityCommands, props: &Props) {
if props.on_wheel {
ec.insert_if_new(WheelListener);
} else {
ec.remove::<WheelListener>();
}
}
pub(super) fn apply_scroll_step(ec: &mut EntityCommands, props: &Props) {
match props.scroll_step {
Some(step) => {
ec.insert(ScrollStep(step));
}
None => {
ec.remove::<ScrollStep>();
}
}
}
pub(super) fn stamp_common(ec: &mut EntityCommands, props: &Props) {
apply_style_variants(ec, props);
apply_pointer_handlers(ec, props);
apply_animated(ec, props);
apply_anchor(ec, props);
}
pub(super) fn create_controlled_scroll(
bridge: &mut JsBridge,
ec: &mut EntityCommands,
id: NodeId,
props: &Props,
) {
if props.scroll_top.is_some() || props.scroll_left.is_some() {
let pos = Vec2::new(
props.scroll_left.unwrap_or(0.0),
props.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 = scroll_state.as_ref().map_or(pos.0, |s| s.target);
if let Some(x) = scroll_left {
requested.x = x;
}
if let Some(y) = scroll_top {
requested.y = y;
}
let max = (computed.content_size - computed.size + computed.scrollbar_size).max(Vec2::ZERO)
* computed.inverse_scale_factor;
let clamped = requested.clamp(Vec2::ZERO, max);
match scroll_state {
Some(mut state) => state.target = clamped,
None => {
if pos.0 != clamped {
pos.0 = clamped;
}
}
}
if requested != clamped {
ec.insert(crate::scroll::PendingControlledScroll(requested));
}
bridge.scroll_positions.insert(id, requested);
}
}
pub(super) fn apply_button_focus_default(ec: &mut EntityCommands, style: &Option<Style>) {
let has_explicit = style.as_ref().is_some_and(|s| s.focus_policy.is_some());
if !has_explicit {
ec.insert(FocusPolicy::Block);
ec.insert(bevy::picking::Pickable {
should_block_lower: true,
is_hoverable: true,
});
}
}
fn set_membership(set: &mut HashSet<NodeId>, id: NodeId, present: bool) {
if present {
set.insert(id);
} else {
set.remove(&id);
}
}
pub(super) fn register_editable_handlers(bridge: &mut JsBridge, id: NodeId, props: &Props) {
set_membership(&mut bridge.editable_select_handlers, id, props.on_select);
set_membership(
&mut bridge.editable_focus_handlers,
id,
props.on_focus || props.on_blur,
);
}
pub(super) fn queue_pending_selection(
bridge: &mut JsBridge,
id: NodeId,
start: Option<usize>,
end: Option<usize>,
) {
if let (Some(start), Some(end)) = (start, end) {
bridge.editable_pending_selection.insert(id, (start, end));
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::{op_app, update_delta};
use super::*;
use crate::protocol::op::Op;
#[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 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 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"
);
}
}