use crate::animations::AnimatedNode;
use crate::canvas::{CanvasSurface, blank_canvas_image, clamp_physical_size};
use crate::portal::{RPortal, blank_portal_image};
use crate::surface::{RSurface, SurfaceVirtualPointer};
use accesskit::Role;
use bevy::a11y::AccessibilityNode;
use bevy::ecs::system::SystemParam;
use bevy::image::Image;
use bevy::input_focus::tab_navigation::TabIndex;
use bevy::input_focus::{AutoFocus, FocusGained, FocusLost};
use bevy::picking::events::{Click, Drag, Enter, Leave, Pointer, Press, Release};
use bevy::picking::pointer::{PointerButton, PointerId};
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::text::{EditableText, FontCx, LayoutCx, TextCursorStyle, TextEdit, TextEditChange};
use bevy::ui::FocusPolicy;
use bevy::ui::RelativeCursorPosition;
use bevy::ui::widget::NodeImageMode;
use bevy::ui::{ComputedNode, ScrollPosition, UiGlobalTransform};
use crate::anchor::{AnchorScaling, Anchored};
use crate::bridge::{
CanvasSizeTracker, FocusState, HoverState, JsBridge, PointerHandlers, RNode, ScrollListener,
ScrollStep, SpanKind, StyleVariants, WheelListener,
};
use crate::filter::{FilterAssets, FilterMaterial, FilterMaterialCache, filter_material};
use crate::plugin::Fonts;
use crate::protocol::{NodeId, Op, Outbound, Props, ROOT_ID, Style, UiEvent};
use crate::transition::{ScrollTransitionState, apply_scroll_transition};
use crate::ui_map::{
AtlasLayoutCache, apply_atlas, apply_opacity, apply_style, apply_style_masked,
apply_text_style, image_node, overlay_style, parse_color, resolved_text_style, text_layout,
};
#[derive(Resource, Default, Debug, Clone, Copy)]
pub struct OpApplyStats {
pub applied_count: u64,
pub last_ops: usize,
pub last_translate: std::time::Duration,
pub last_apply_end: Option<std::time::Instant>,
}
#[derive(SystemParam)]
pub struct UiAssets<'w> {
layouts: ResMut<'w, Assets<TextureAtlasLayout>>,
atlas_cache: ResMut<'w, AtlasLayoutCache>,
filter_materials: ResMut<'w, Assets<FilterMaterial>>,
filter_cache: ResMut<'w, FilterMaterialCache>,
filter_assets: Res<'w, FilterAssets>,
}
#[allow(clippy::too_many_arguments)]
pub fn apply_js_ops(
mut commands: Commands,
mut bridge: ResMut<JsBridge>,
assets: Res<AssetServer>,
fonts: Res<Fonts>,
mut images: ResMut<Assets<Image>>,
mut ui_assets: UiAssets,
children: Query<&Children>,
rnodes: Query<&RNode>,
buttons: Query<(), With<Button>>,
anchor_layer: Query<Entity, With<crate::anchor::AnchorLayer>>,
mut editables: Query<&mut EditableText>,
mut scroll_query: Query<(
&mut ScrollPosition,
&ComputedNode,
Option<&mut ScrollTransitionState>,
)>,
mut a11y_nodes: Query<&mut AccessibilityNode>,
text_roots: Query<(), With<Node>>,
mut stats: ResMut<OpApplyStats>,
) {
let mut ops: Vec<Op> = Vec::new();
while let Ok(batch) = bridge.ops_rx.try_recv() {
ops.extend(batch);
}
if ops.is_empty() {
return;
}
let op_count = ops.len();
#[cfg(not(target_arch = "wasm32"))]
let started = std::time::Instant::now();
debug!("applying {op_count} reconciler op(s)");
let mut dirty: HashSet<NodeId> = HashSet::new();
for op in ops {
match op {
Op::Reset => {
if let Some(&root) = bridge.nodes.get(&ROOT_ID)
&& let Ok(kids) = children.get(root)
{
for child in kids.iter() {
if anchor_layer.contains(child) {
if let Ok(overlays) = children.get(child) {
for overlay in overlays.iter() {
commands.entity(overlay).despawn();
}
}
} else {
commands.entity(child).despawn();
}
}
}
for id in bridge.surfaces.iter() {
if let Some(&e) = bridge.nodes.get(id) {
commands.entity(e).despawn();
}
}
bridge.nodes.retain(|&id, _| id == ROOT_ID);
bridge.props_cache.clear();
bridge.text_styles.clear();
bridge.spans.clear();
bridge.editable_inputs.clear();
bridge.surfaces.clear();
bridge.editable_values.clear();
bridge.editable_selections.clear();
bridge.editable_select_handlers.clear();
bridge.editable_focus_handlers.clear();
bridge.editable_pending_selection.clear();
bridge.scroll_positions.clear();
bridge.siblings.clear();
bridge.child_list.clear();
bridge.parent_of.clear();
bridge.surface_parent.clear();
bridge.child_surfaces.clear();
dirty.clear();
}
Op::Create {
id,
kind,
props,
text,
} => {
let entity = match kind.as_str() {
"text" => {
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
ec.insert(Text::new(text.clone().unwrap_or_default()));
apply_text_style(&mut ec, &props.style, &fonts);
if let Some(layout) = text_layout(&props.style) {
ec.insert(layout);
}
apply_anchor(&mut ec, &props);
ec.id()
}
"textSpan" => {
let mut ec =
commands.spawn((RNode(id), TextSpan(text.clone().unwrap_or_default())));
apply_text_style(&mut ec, &props.style, &fonts);
ec.id()
}
"canvas" => {
let handle = images.add(blank_canvas_image());
let mut node_img = ImageNode::new(handle);
node_img.image_mode = NodeImageMode::Stretch;
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
ec.insert((
node_img,
CanvasSurface::new(props.draw.clone().unwrap_or_default()),
CanvasSizeTracker::default(),
));
apply_style_variants(&mut ec, &props);
apply_pointer_handlers(&mut ec, &props);
apply_animated(&mut ec, &props);
apply_anchor(&mut ec, &props);
ec.id()
}
"portal" => {
let handle = images.add(blank_portal_image());
let mut node_img = ImageNode::new(handle);
node_img.image_mode = NodeImageMode::Stretch;
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
ec.insert((node_img, RPortal(props.target.clone().unwrap_or_default())));
apply_style_variants(&mut ec, &props);
apply_pointer_handlers(&mut ec, &props);
apply_animated(&mut ec, &props);
apply_anchor(&mut ec, &props);
ec.id()
}
"surface" => {
let style = overlay_style(&surface_root_base(), &props.style);
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &style);
ec.insert(RSurface(props.target.clone().unwrap_or_default()));
apply_anchor(&mut ec, &props);
ec.id()
}
"editableText" => {
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
let mut editable =
EditableText::new(props.value.as_deref().unwrap_or_default());
editable.max_characters = props.max_length;
editable.allow_newlines = props.multiline;
let (text_color, font, line_height, letter_spacing) =
resolved_text_style(&props.style, &fonts);
ec.insert((
editable,
text_color,
font,
line_height,
letter_spacing,
TextLayout {
linebreak: if props.multiline {
LineBreak::WordBoundary
} else {
LineBreak::NoWrap
},
..default()
},
TextCursorStyle {
color: text_color.0,
..default()
},
TabIndex(0),
AccessibilityNode(editable_a11y_node(&props)),
));
if props.autofocus {
ec.insert(AutoFocus);
}
apply_style_variants(&mut ec, &props);
apply_anchor(&mut ec, &props);
ec.id()
}
_ => spawn_element(
&mut commands,
id,
&kind,
&props,
&assets,
&mut ui_assets.layouts,
&mut ui_assets.atlas_cache,
&mut FilterCtx {
materials: &mut ui_assets.filter_materials,
cache: &mut ui_assets.filter_cache,
white: &ui_assets.filter_assets.white,
},
),
};
if matches!(kind.as_str(), "text" | "textSpan") {
bridge
.text_styles
.insert(id, resolved_text_style(&props.style, &fonts));
}
if kind == "textSpan" {
bridge.spans.insert(id, SpanKind::InlineStyled);
}
if kind == "editableText" {
bridge.editable_inputs.insert(id);
bridge
.editable_values
.insert(id, props.value.clone().unwrap_or_default());
register_editable_handlers(&mut bridge, id, &props);
queue_pending_selection(
&mut bridge,
id,
props.selection_start,
props.selection_end,
);
}
if kind == "surface" {
bridge.surfaces.insert(id);
}
{
let mut ec = commands.entity(entity);
apply_scroll_listener(&mut ec, &props);
apply_wheel_listener(&mut ec, &props);
apply_scroll_step(&mut ec, &props);
apply_scroll_transition(&mut ec, &props.style);
create_controlled_scroll(&mut bridge, &mut ec, id, &props);
}
bridge.nodes.insert(id, entity);
let (state, _) = props.split_events();
bridge.props_cache.insert(id, Box::new(state));
}
Op::CreateText { id, text } => {
let entity = commands
.spawn((Text::new(text), TextColor(Color::WHITE), RNode(id)))
.id();
bridge.nodes.insert(id, entity);
}
Op::CreateTextSpan { id, text } => {
let entity = commands.spawn((TextSpan(text), RNode(id))).id();
bridge.nodes.insert(id, entity);
bridge.spans.insert(id, SpanKind::RawInherited);
}
Op::Append { parent, child } => {
if bridge.surfaces.contains(&child) {
bridge.attach_surface(child, parent);
continue;
}
if let (Some(p), Some(c)) = (resolve(&bridge, parent), resolve(&bridge, child)) {
let same_parent = bridge.parent_of.get(&child) == Some(&parent);
bridge.append_child(parent, child);
if same_parent {
dirty.insert(parent);
} else {
commands.entity(p).add_child(c);
}
inherit_text_style(&mut commands, &bridge, parent, child, c);
}
}
Op::Insert {
parent,
child,
before,
} => {
if bridge.surfaces.contains(&child) {
bridge.attach_surface(child, parent);
continue;
}
if let (Some(p), Some(c)) = (resolve(&bridge, parent), resolve(&bridge, child)) {
let same_parent = bridge.parent_of.get(&child) == Some(&parent);
bridge.insert_before(parent, child, before);
if !same_parent {
commands.entity(p).add_child(c);
}
dirty.insert(parent);
inherit_text_style(&mut commands, &bridge, parent, child, c);
}
}
Op::Remove { parent: _, child } => {
let mut surfaces = bridge.surfaces_under(child);
if bridge.surfaces.contains(&child) {
bridge.detach_surface(child);
surfaces.push(child);
}
for s in surfaces {
if let Some(se) = resolve(&bridge, s) {
commands.entity(se).despawn();
}
bridge.detach(s);
bridge.forget_subtree(s);
}
if let Some(c) = resolve(&bridge, child) {
commands.entity(c).despawn();
bridge.detach(child);
bridge.forget_subtree(child);
}
}
Op::Update {
id,
props,
unset,
style_unset,
} => {
let Some(e) = resolve(&bridge, id) else {
continue;
};
let mut cached = bridge.props_cache.remove(&id).unwrap_or_else(|| {
warn!("delta update for uncached node {id}; merging onto defaults");
Box::default()
});
let (dirty, ev) = cached.merge_delta(props, &unset, &style_unset);
let props = cached;
use crate::protocol::style_groups as g;
if bridge.text_styles.contains_key(&id) {
let resolved = dirty.style.intersects(g::TEXT).then(|| {
let style = resolved_text_style(&props.style, &fonts);
bridge.text_styles.insert(id, style.clone());
style
});
let mut ec = commands.entity(e);
if let Some(style) = &resolved {
ec.insert(style.clone());
}
if text_roots.contains(e) {
apply_style_masked(&mut ec, &props.style, dirty.style);
}
if dirty.style.intersects(g::TEXT_LAYOUT)
&& let Some(layout) = text_layout(&props.style)
{
ec.insert(layout);
}
if dirty.anchor {
apply_anchor(&mut ec, &props);
}
if let Some(style) = resolved
&& let Ok(kids) = children.get(e)
{
for child in kids.iter() {
if let Ok(rnode) = rnodes.get(child)
&& bridge.spans.get(&rnode.0) == Some(&SpanKind::RawInherited)
{
commands.entity(child).insert(style.clone());
}
}
}
} else if bridge.editable_inputs.contains(&id) {
if let Some(new_val) = &ev.value {
if let Ok(mut editable) = editables.get_mut(e)
&& editable.value().to_string() != *new_val
{
editable.editor_mut().set_text(new_val);
editable.queue_edit(TextEdit::TextEnd(false));
}
bridge.editable_values.insert(id, new_val.clone());
}
if dirty.editable_handlers {
register_editable_handlers(&mut bridge, id, &props);
}
queue_pending_selection(&mut bridge, id, ev.selection_start, ev.selection_end);
if dirty.aria_label
&& let Ok(mut node) = a11y_nodes.get_mut(e)
{
match &props.aria_label {
Some(label) => node.set_label(label.clone()),
None => node.clear_label(),
}
}
let mut ec = commands.entity(e);
apply_style_masked(&mut ec, &props.style, dirty.style);
if dirty.any_style_variant() {
apply_style_variants(&mut ec, &props);
}
} else if bridge.surfaces.contains(&id) {
let mut ec = commands.entity(e);
if dirty.style.any() {
let style = overlay_style(&surface_root_base(), &props.style);
apply_style_masked(&mut ec, &style, dirty.style);
}
if dirty.target
&& let Some(name) = &props.target
{
ec.insert(RSurface(name.clone()));
}
if dirty.anchor {
apply_anchor(&mut ec, &props);
}
} else {
let mut ec = commands.entity(e);
apply_style_masked(&mut ec, &props.style, dirty.style);
if (dirty.image || dirty.style.intersects(g::FILTER)) && is_image(&props) {
let mut img = image_node(&props, &assets);
apply_atlas(
&mut img,
&props,
&mut ui_assets.layouts,
&mut ui_assets.atlas_cache,
);
ec.insert(img);
}
if dirty.image || dirty.style.intersects(g::FILTER | g::BACKGROUND) {
apply_filter(
&mut ec,
&props,
&assets,
&mut FilterCtx {
materials: &mut ui_assets.filter_materials,
cache: &mut ui_assets.filter_cache,
white: &ui_assets.filter_assets.white,
},
);
}
if let Some(cmds) = ev.draw {
ec.queue(move |mut entity: EntityWorldMut| {
if let Some(mut surface) = entity.get_mut::<CanvasSurface>() {
surface.set_display_list(cmds);
}
});
}
if dirty.target
&& let Some(target) = &props.target
{
ec.insert(RPortal(target.clone()));
}
if dirty.style.intersects(g::FOCUS_POLICY) && buttons.get(e).is_ok() {
apply_button_focus_default(&mut ec, &props.style);
}
if dirty.any_style_variant() {
apply_style_variants(&mut ec, &props);
}
if dirty.pointer {
apply_pointer_handlers(&mut ec, &props);
}
if dirty.scroll_listener {
apply_scroll_listener(&mut ec, &props);
}
if dirty.wheel {
apply_wheel_listener(&mut ec, &props);
}
if dirty.scroll_step {
apply_scroll_step(&mut ec, &props);
}
if dirty.style.intersects(g::SCROLL_TRANSITION) {
apply_scroll_transition(&mut ec, &props.style);
}
if dirty.animated {
apply_animated(&mut ec, &props);
}
if dirty.anchor {
apply_anchor(&mut ec, &props);
}
update_controlled_scroll(
&mut bridge,
&mut scroll_query,
e,
id,
ev.scroll_left,
ev.scroll_top,
);
}
bridge.props_cache.insert(id, props);
}
Op::UpdateText { id, text } => {
if let Some(e) = resolve(&bridge, id) {
if bridge.spans.contains_key(&id) {
commands.entity(e).insert(TextSpan(text));
} else {
commands.entity(e).insert(Text::new(text));
}
}
}
Op::Draw { id, cmds } => {
if let Some(e) = resolve(&bridge, id) {
commands.entity(e).queue(move |mut entity: EntityWorldMut| {
if let Some(mut surface) = entity.get_mut::<CanvasSurface>() {
surface.enqueue(cmds);
}
});
}
}
}
}
for parent in dirty {
let Some(p) = resolve(&bridge, parent) else {
continue;
};
let mut list: Vec<Entity> = Vec::new();
if parent == ROOT_ID
&& let Ok(layer) = anchor_layer.single()
{
list.push(layer);
}
list.extend(
bridge
.children_of(parent)
.filter_map(|id| resolve(&bridge, id)),
);
commands.entity(p).replace_children(&list);
}
stats.applied_count = stats.applied_count.wrapping_add(1);
stats.last_ops = op_count;
#[cfg(not(target_arch = "wasm32"))]
{
let end = std::time::Instant::now();
stats.last_translate = end.duration_since(started);
stats.last_apply_end = Some(end);
}
}
fn inherit_text_style(
commands: &mut Commands,
bridge: &JsBridge,
parent: NodeId,
child: NodeId,
child_entity: Entity,
) {
if bridge.spans.get(&child) != Some(&SpanKind::RawInherited) {
return;
}
if let Some(style) = bridge.text_styles.get(&parent).cloned() {
commands.entity(child_entity).insert(style);
}
}
fn surface_root_base() -> Option<Style> {
Some(Style {
width: Some(crate::protocol::Length::Percent(100.0)),
height: Some(crate::protocol::Length::Percent(100.0)),
..Default::default()
})
}
struct FilterCtx<'a> {
materials: &'a mut Assets<FilterMaterial>,
cache: &'a mut FilterMaterialCache,
white: &'a Handle<Image>,
}
fn apply_filter(ec: &mut EntityCommands, props: &Props, assets: &AssetServer, ctx: &mut FilterCtx) {
let Some(spec) = props.style.as_ref().and_then(|s| s.filter.as_ref()) else {
ec.remove::<MaterialNode<FilterMaterial>>();
return;
};
let opacity = props.style.as_ref().and_then(|s| s.opacity);
let base = props
.tint
.as_deref()
.or_else(|| {
props
.style
.as_ref()
.and_then(|s| s.background_color.as_deref())
})
.map(parse_color)
.unwrap_or(Color::WHITE);
let texture = match &props.src {
Some(path) => assets.load(path),
None => ctx.white.clone(),
};
let mat = filter_material(spec, texture, apply_opacity(base, opacity));
let handle = ctx.cache.handle(ctx.materials, mat);
if props.src.is_some() {
let mut img = image_node(props, assets);
img.color = img.color.with_alpha(0.0);
ec.insert(img);
} else {
ec.remove::<ImageNode>();
}
ec.remove::<BackgroundColor>();
ec.insert(MaterialNode(handle));
}
#[allow(clippy::too_many_arguments)]
fn spawn_element(
commands: &mut Commands,
id: NodeId,
kind: &str,
props: &Props,
assets: &AssetServer,
layouts: &mut Assets<TextureAtlasLayout>,
atlas_cache: &mut AtlasLayoutCache,
filter: &mut FilterCtx,
) -> Entity {
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
match kind {
"button" => {
ec.insert(Button);
apply_button_focus_default(&mut ec, &props.style);
}
"image" => {
let mut img = image_node(props, assets);
apply_atlas(&mut img, props, layouts, atlas_cache);
ec.insert(img);
}
_ => {}
}
apply_filter(&mut ec, props, assets, filter);
apply_style_variants(&mut ec, props);
apply_pointer_handlers(&mut ec, props);
apply_animated(&mut ec, props);
apply_anchor(&mut ec, props);
ec.id()
}
fn apply_animated(ec: &mut EntityCommands, props: &Props) {
match &props.animated {
Some(bindings) => {
ec.insert(AnimatedNode(bindings.clone()));
}
None => {
ec.remove::<AnimatedNode>();
}
}
}
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>();
}
}
}
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>();
}
}
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());
}
}
fn apply_scroll_listener(ec: &mut EntityCommands, props: &Props) {
if props.on_scroll {
ec.insert_if_new(ScrollListener);
} else {
ec.remove::<ScrollListener>();
}
}
fn apply_wheel_listener(ec: &mut EntityCommands, props: &Props) {
if props.on_wheel {
ec.insert_if_new(WheelListener);
} else {
ec.remove::<WheelListener>();
}
}
fn apply_scroll_step(ec: &mut EntityCommands, props: &Props) {
match props.scroll_step {
Some(step) => {
ec.insert(ScrollStep(step));
}
None => {
ec.remove::<ScrollStep>();
}
}
}
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));
bridge.scroll_positions.insert(id, pos);
} else if props.on_scroll {
bridge.scroll_positions.insert(id, Vec2::ZERO);
}
}
fn update_controlled_scroll(
bridge: &mut JsBridge,
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;
}
}
}
bridge.scroll_positions.insert(id, requested);
}
}
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 is_image(props: &Props) -> bool {
props.src.is_some()
|| props.tint.is_some()
|| props.image_mode.is_some()
|| props.flip_x
|| props.flip_y
|| props.source_rect.is_some()
|| props.atlas.is_some()
|| props.visual_box.is_some()
}
fn resolve(bridge: &JsBridge, id: NodeId) -> Option<Entity> {
bridge.nodes.get(&id).copied()
}
pub fn collect_ui_events(
bridge: Res<JsBridge>,
surface_pointer: Option<Res<SurfaceVirtualPointer>>,
mut clicks: MessageReader<Pointer<Click>>,
targets: Query<&RNode, With<Interaction>>,
child_of: Query<&ChildOf>,
) {
let mut seen: HashSet<(PointerId, Entity)> = HashSet::new();
for ev in clicks.read() {
if ev.button != PointerButton::Primary {
continue;
}
if surface_pointer
.as_ref()
.is_some_and(|p| ev.pointer_id == p.id)
{
continue;
}
if let Some(target) = climb(ev.entity, &child_of, |e| targets.contains(e))
&& seen.insert((ev.pointer_id, target))
&& let Ok(rnode) = targets.get(target)
{
debug!("click -> reconciler node {}", rnode.0);
send_ui_event(&bridge, rnode.0, "click", None, None, None);
}
}
}
#[allow(clippy::type_complexity)]
pub fn collect_scroll_events(
mut bridge: ResMut<JsBridge>,
query: Query<(&ScrollPosition, &RNode), (With<ScrollListener>, Changed<ScrollPosition>)>,
) {
for (scroll, rnode) in &query {
let id = rnode.0;
if bridge.scroll_positions.get(&id) == Some(&scroll.0) {
continue;
}
bridge.scroll_positions.insert(id, scroll.0);
debug!("scroll -> reconciler node {id}");
let _ = bridge.outbound_tx.send(Outbound::UiEvent {
event: UiEvent {
id,
kind: "scroll".to_string(),
scroll_top: Some(scroll.0.y),
scroll_left: Some(scroll.0.x),
..default()
},
});
}
}
#[allow(clippy::type_complexity)]
pub fn collect_canvas_resize_events(
bridge: Res<JsBridge>,
mut query: Query<
(&RNode, &ComputedNode, &mut CanvasSizeTracker),
(With<CanvasSurface>, Changed<ComputedNode>),
>,
) {
for (rnode, node, mut tracker) in &mut query {
let (w, h) = clamp_physical_size(node.size);
if w == 0 || h == 0 || tracker.0 == (w, h) {
continue;
}
tracker.0 = (w, h);
let scale = if node.inverse_scale_factor > 0.0 {
node.inverse_scale_factor
} else {
1.0
};
debug!("canvas resize -> reconciler node {}", rnode.0);
let _ = bridge.outbound_tx.send(Outbound::UiEvent {
event: UiEvent {
id: rnode.0,
kind: "resize".to_string(),
width: Some(w as f32 * scale),
height: Some(h as f32 * scale),
..default()
},
});
}
}
fn editable_a11y_node(props: &Props) -> accesskit::Node {
let role = if props.multiline {
Role::MultilineTextInput
} else {
Role::TextInput
};
let mut node = accesskit::Node::new(role);
if let Some(label) = &props.aria_label {
node.set_label(label.clone());
}
node.set_value(props.value.clone().unwrap_or_default());
node
}
fn set_membership(set: &mut HashSet<NodeId>, id: NodeId, present: bool) {
if present {
set.insert(id);
} else {
set.remove(&id);
}
}
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,
);
}
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));
}
}
pub fn on_text_edit_change(
change: On<TextEditChange>,
mut bridge: ResMut<JsBridge>,
editables: Query<(&EditableText, &RNode)>,
) {
let Ok((editable, rnode)) = editables.get(change.event_target()) else {
return;
};
let id = rnode.0;
let composing = editable.is_composing();
let value = editable.value().to_string();
if bridge.editable_values.get(&id) != Some(&value) {
bridge.editable_values.insert(id, value.clone());
debug!("change -> reconciler node {id}");
let _ = bridge.outbound_tx.send(Outbound::UiEvent {
event: UiEvent {
id,
kind: "change".to_string(),
value: Some(value),
composing: Some(composing),
..default()
},
});
}
if bridge.editable_select_handlers.contains(&id) {
let sel = editable.editor().raw_selection();
let anchor = sel.anchor().index();
let focus = sel.focus().index();
if bridge.editable_selections.get(&id) != Some(&(anchor, focus)) {
bridge.editable_selections.insert(id, (anchor, focus));
let direction = if anchor == focus {
"none"
} else if anchor < focus {
"forward"
} else {
"backward"
};
let _ = bridge.outbound_tx.send(Outbound::UiEvent {
event: UiEvent {
id,
kind: "select".to_string(),
selection_start: Some(anchor.min(focus)),
selection_end: Some(anchor.max(focus)),
selection_direction: Some(direction.to_string()),
composing: Some(composing),
..default()
},
});
}
}
}
pub fn on_focus_gained(
ev: On<FocusGained>,
bridge: ResMut<JsBridge>,
editables: Query<&RNode, With<EditableText>>,
mut focus_states: Query<&mut FocusState>,
) {
set_focus_state(&mut focus_states, ev.entity, true);
emit_focus_event(&bridge, &editables, ev.entity, "focus");
}
pub fn on_focus_lost(
ev: On<FocusLost>,
bridge: ResMut<JsBridge>,
editables: Query<&RNode, With<EditableText>>,
mut focus_states: Query<&mut FocusState>,
) {
set_focus_state(&mut focus_states, ev.entity, false);
emit_focus_event(&bridge, &editables, ev.entity, "blur");
}
fn set_focus_state(focus_states: &mut Query<&mut FocusState>, entity: Entity, focused: bool) {
if let Ok(mut state) = focus_states.get_mut(entity)
&& state.0 != focused
{
state.0 = focused;
}
}
fn emit_focus_event(
bridge: &JsBridge,
editables: &Query<&RNode, With<EditableText>>,
entity: Entity,
kind: &str,
) {
let Ok(rnode) = editables.get(entity) else {
return;
};
if !bridge.editable_focus_handlers.contains(&rnode.0) {
return;
}
let _ = bridge.outbound_tx.send(Outbound::UiEvent {
event: UiEvent {
id: rnode.0,
kind: kind.to_string(),
..default()
},
});
}
pub fn apply_pending_selections(
mut bridge: ResMut<JsBridge>,
mut editables: Query<&mut EditableText>,
mut font_cx: ResMut<FontCx>,
mut layout_cx: ResMut<LayoutCx>,
) {
if bridge.editable_pending_selection.is_empty() {
return;
}
let pending: Vec<(NodeId, (usize, usize))> =
bridge.editable_pending_selection.drain().collect();
for (id, (start, end)) in pending {
let Some(&entity) = bridge.nodes.get(&id) else {
continue;
};
let Ok(mut editable) = editables.get_mut(entity) else {
continue;
};
bridge.editable_selections.insert(id, (start, end));
editable
.editor_mut()
.driver(&mut font_cx.context, &mut layout_cx.0)
.select_byte_range(start, end);
}
}
pub fn sync_editable_a11y(
mut q: Query<(&EditableText, &mut AccessibilityNode), Changed<EditableText>>,
) {
for (editable, mut node) in &mut q {
node.set_value(editable.value().to_string());
}
}
const POINTER_BUTTONS: [(MouseButton, u8); 3] = [
(MouseButton::Left, 0),
(MouseButton::Middle, 1),
(MouseButton::Right, 2),
];
pub struct ActiveDrag {
entity: Option<Entity>,
button: MouseButton,
dom_button: u8,
last_pos: Vec2,
last_abs: Vec2,
}
impl Default for ActiveDrag {
fn default() -> Self {
Self {
entity: None,
button: MouseButton::Left,
dom_button: 0,
last_pos: Vec2::ZERO,
last_abs: Vec2::ZERO,
}
}
}
pub fn collect_pointer_events(
bridge: Res<JsBridge>,
buttons: Res<ButtonInput<MouseButton>>,
windows: Query<&Window>,
nodes: Query<(
Entity,
&RNode,
&Interaction,
&RelativeCursorPosition,
&PointerHandlers,
)>,
interactions: Query<&Interaction>,
mut capture: ResMut<crate::PointerCapture>,
mut drag: Local<ActiveDrag>,
) {
let emit = |rnode: &RNode, kind: &str, pos: Vec2, abs: Vec2, button: u8| {
let _ = bridge.outbound_tx.send(Outbound::UiEvent {
event: UiEvent {
id: rnode.0,
kind: kind.to_string(),
x: Some(pos.x),
y: Some(pos.y),
client_x: Some(abs.x),
client_y: Some(abs.y),
button: Some(button),
..default()
},
});
};
let cursor_abs = windows.iter().next().and_then(|w| w.cursor_position());
if drag.entity.is_none() {
'begin: for (mb, dom) in POINTER_BUTTONS {
if !buttons.just_pressed(mb) {
continue;
}
for (entity, rnode, interaction, rel, handlers) in &nodes {
let over = if mb == MouseButton::Left {
*interaction == Interaction::Pressed
} else {
*interaction != Interaction::None && rel.cursor_over()
};
if over {
let pos = normalized_01(rel).unwrap_or(drag.last_pos);
let abs = cursor_abs.unwrap_or(drag.last_abs);
drag.entity = Some(entity);
drag.button = mb;
drag.dom_button = dom;
drag.last_pos = pos;
drag.last_abs = abs;
if handlers.down {
emit(rnode, "pointerDown", pos, abs, dom);
}
break 'begin;
}
}
}
}
if buttons.pressed(drag.button)
&& let Some(entity) = drag.entity
&& let Ok((_, rnode, _, rel, handlers)) = nodes.get(entity)
{
let pos = normalized_01(rel).unwrap_or(drag.last_pos);
let abs = cursor_abs.unwrap_or(drag.last_abs);
drag.last_pos = pos;
drag.last_abs = abs;
if handlers.moved {
emit(rnode, "pointerMove", pos, abs, drag.dom_button);
}
}
if buttons.just_released(drag.button)
&& let Some(entity) = drag.entity.take()
&& let Ok((_, rnode, _, rel, handlers)) = nodes.get(entity)
{
let pos = normalized_01(rel).unwrap_or(drag.last_pos);
let abs = cursor_abs.unwrap_or(drag.last_abs);
if handlers.up {
emit(rnode, "pointerUp", pos, abs, drag.dom_button);
}
}
capture.dragging = drag.entity.is_some();
capture.over_ui = interactions.iter().any(|i| *i != Interaction::None);
}
#[allow(clippy::type_complexity)]
pub fn collect_hover_events(
bridge: Res<JsBridge>,
windows: Query<&Window>,
mut nodes: Query<
(
&Interaction,
&mut HoverState,
&PointerHandlers,
&RNode,
Option<&RelativeCursorPosition>,
),
Changed<Interaction>,
>,
) {
let cursor_abs = windows.iter().next().and_then(|w| w.cursor_position());
for (interaction, mut hover, handlers, rnode, rel) in &mut nodes {
let inside = *interaction != Interaction::None;
if inside == hover.0 {
continue; }
hover.0 = inside;
let kind = if inside {
"pointerEnter"
} else {
"pointerLeave"
};
if (inside && handlers.enter) || (!inside && handlers.leave) {
let pos = rel.and_then(normalized_01).unwrap_or(Vec2::ZERO);
let abs = cursor_abs.unwrap_or(Vec2::ZERO);
send_ui_event(&bridge, rnode.0, kind, Some(pos), Some(abs), None);
}
}
}
fn normalized_01(rel: &RelativeCursorPosition) -> Option<Vec2> {
rel.normalized
.map(|n| Vec2::new((n.x + 0.5).clamp(0.0, 1.0), (n.y + 0.5).clamp(0.0, 1.0)))
}
#[allow(clippy::type_complexity)]
pub fn apply_interaction_styles(
mut commands: Commands,
query: Query<
(
Entity,
Option<&Interaction>,
Option<&FocusState>,
&StyleVariants,
),
Or<(
Changed<Interaction>,
Changed<FocusState>,
Changed<StyleVariants>,
)>,
>,
) {
for (entity, interaction, focus, variants) in &query {
let mut style = match interaction {
Some(Interaction::Pressed) => overlay_style(
&overlay_style(&variants.base, &variants.hover),
&variants.press,
),
Some(Interaction::Hovered) => overlay_style(&variants.base, &variants.hover),
_ => variants.base.clone(),
};
if focus.is_some_and(|f| f.0) {
style = overlay_style(&style, &variants.focus);
}
apply_style(&mut commands.entity(entity), &style);
}
}
fn send_ui_event(
bridge: &JsBridge,
id: NodeId,
kind: &str,
pos: Option<Vec2>,
abs: Option<Vec2>,
button: Option<u8>,
) {
let _ = bridge.outbound_tx.send(Outbound::UiEvent {
event: UiEvent {
id,
kind: kind.to_string(),
x: pos.map(|p| p.x),
y: pos.map(|p| p.y),
client_x: abs.map(|a| a.x),
client_y: abs.map(|a| a.y),
button,
..default()
},
});
}
fn dom_button(button: PointerButton) -> u8 {
match button {
PointerButton::Primary => 0,
PointerButton::Middle => 1,
PointerButton::Secondary => 2,
}
}
fn surface_relative(
node: &ComputedNode,
transform: &UiGlobalTransform,
position: Vec2,
) -> Option<(Vec2, Vec2)> {
node.normalize_point(*transform, position).map(|n| {
(
Vec2::new((n.x + 0.5).clamp(0.0, 1.0), (n.y + 0.5).clamp(0.0, 1.0)),
position,
)
})
}
pub(crate) fn climb(
mut entity: Entity,
child_of: &Query<&ChildOf>,
is_target: impl Fn(Entity) -> bool,
) -> Option<Entity> {
loop {
if is_target(entity) {
return Some(entity);
}
entity = child_of.get(entity).ok()?.parent();
}
}
pub fn collect_surface_clicks(
bridge: Res<JsBridge>,
pointer: Option<Res<SurfaceVirtualPointer>>,
mut clicks: MessageReader<Pointer<Click>>,
targets: Query<&RNode, With<Interaction>>,
child_of: Query<&ChildOf>,
) {
let Some(pointer) = pointer else { return };
let mut seen: HashSet<Entity> = HashSet::new();
for ev in clicks.read() {
if ev.pointer_id != pointer.id || ev.button != PointerButton::Primary {
continue;
}
if let Some(target) = climb(ev.entity, &child_of, |e| targets.contains(e))
&& seen.insert(target)
&& let Ok(rnode) = targets.get(target)
{
debug!("surface click -> reconciler node {}", rnode.0);
send_ui_event(&bridge, rnode.0, "click", None, None, None);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn collect_surface_pointer_events(
bridge: Res<JsBridge>,
pointer: Option<Res<SurfaceVirtualPointer>>,
mut presses: MessageReader<Pointer<Press>>,
mut releases: MessageReader<Pointer<Release>>,
mut drags: MessageReader<Pointer<Drag>>,
nodes: Query<(&RNode, &PointerHandlers, &ComputedNode, &UiGlobalTransform)>,
child_of: Query<&ChildOf>,
) {
let Some(pointer) = pointer else { return };
let mut seen: HashSet<(Entity, PointerButton)> = HashSet::new();
let emit = |entity: Entity,
want: fn(&PointerHandlers) -> bool,
kind: &str,
at: Vec2,
button: PointerButton,
seen: &mut HashSet<(Entity, PointerButton)>| {
if let Some(target) = climb(entity, &child_of, |e| nodes.contains(e))
&& seen.insert((target, button))
&& let Ok((rnode, handlers, node, transform)) = nodes.get(target)
&& want(handlers)
&& let Some((pos, abs)) = surface_relative(node, transform, at)
{
send_ui_event(
&bridge,
rnode.0,
kind,
Some(pos),
Some(abs),
Some(dom_button(button)),
);
}
};
for ev in presses.read() {
if ev.pointer_id == pointer.id {
emit(
ev.entity,
|h| h.down,
"pointerDown",
ev.pointer_location.position,
ev.button,
&mut seen,
);
}
}
seen.clear();
for ev in drags.read() {
if ev.pointer_id == pointer.id {
emit(
ev.entity,
|h| h.moved,
"pointerMove",
ev.pointer_location.position,
ev.button,
&mut seen,
);
}
}
seen.clear();
for ev in releases.read() {
if ev.pointer_id == pointer.id {
emit(
ev.entity,
|h| h.up,
"pointerUp",
ev.pointer_location.position,
ev.button,
&mut seen,
);
}
}
}
pub fn collect_surface_hover_events(
bridge: Res<JsBridge>,
pointer: Option<Res<SurfaceVirtualPointer>>,
mut enters: MessageReader<Pointer<Enter>>,
mut leaves: MessageReader<Pointer<Leave>>,
nodes: Query<(&RNode, &PointerHandlers, &ComputedNode, &UiGlobalTransform)>,
) {
let Some(pointer) = pointer else { return };
let emit = |entity: Entity, want: fn(&PointerHandlers) -> bool, kind: &str, at: Vec2| {
if let Ok((rnode, handlers, node, transform)) = nodes.get(entity)
&& want(handlers)
&& let Some((pos, abs)) = surface_relative(node, transform, at)
{
send_ui_event(&bridge, rnode.0, kind, Some(pos), Some(abs), None);
}
};
for ev in enters.read() {
if ev.pointer_id == pointer.id {
emit(
ev.entity,
|h| h.enter,
"pointerEnter",
ev.pointer_location.position,
);
}
}
for ev in leaves.read() {
if ev.pointer_id == pointer.id {
emit(
ev.entity,
|h| h.leave,
"pointerLeave",
ev.pointer_location.position,
);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn apply_surface_interaction_styles(
mut commands: Commands,
pointer: Option<Res<SurfaceVirtualPointer>>,
mut enters: MessageReader<Pointer<Enter>>,
mut leaves: MessageReader<Pointer<Leave>>,
mut presses: MessageReader<Pointer<Press>>,
mut releases: MessageReader<Pointer<Release>>,
variants: Query<&StyleVariants>,
child_of: Query<&ChildOf>,
) {
let Some(pointer) = pointer else { return };
let mut restyle = |entity: Entity, style: Option<Style>| {
apply_style(&mut commands.entity(entity), &style);
};
let target = |entity: Entity| climb(entity, &child_of, |e| variants.contains(e));
for ev in leaves.read() {
if ev.pointer_id == pointer.id
&& let Ok(v) = variants.get(ev.entity)
{
restyle(ev.entity, v.base.clone());
}
}
for ev in enters.read() {
if ev.pointer_id == pointer.id
&& let Ok(v) = variants.get(ev.entity)
{
restyle(ev.entity, overlay_style(&v.base, &v.hover));
}
}
for ev in releases.read() {
if ev.pointer_id == pointer.id
&& ev.button == PointerButton::Primary
&& let Some(t) = target(ev.entity)
&& let Ok(v) = variants.get(t)
{
restyle(t, overlay_style(&v.base, &v.hover));
}
}
for ev in presses.read() {
if ev.pointer_id == pointer.id
&& ev.button == PointerButton::Primary
&& let Some(t) = target(ev.entity)
&& let Ok(v) = variants.get(t)
{
let pressed = overlay_style(&overlay_style(&v.base, &v.hover), &v.press);
restyle(t, pressed);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::JsBridge;
use crate::transition::TransitionInput;
use std::f32::consts::PI;
fn text_props(rotate: f32) -> Props {
serde_json::from_value(serde_json::json!({
"style": {
"transform": { "rotate": format!("{rotate}rad") },
"transition": { "transform": { "duration": 0.3 } },
}
}))
.expect("valid text props")
}
fn update_delta(id: NodeId, props: Props, unset: &[&str], style_unset: &[&str]) -> Op {
Op::Update {
id,
props,
unset: unset.iter().map(|s| s.to_string()).collect(),
style_unset: style_unset.iter().map(|s| s.to_string()).collect(),
}
}
fn op_app() -> (App, crossbeam_channel::Sender<Vec<Op>>) {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Image>();
app.init_asset::<TextureAtlasLayout>();
app.init_resource::<Fonts>();
app.init_resource::<OpApplyStats>();
app.init_resource::<AtlasLayoutCache>();
app.init_asset::<FilterMaterial>();
app.init_resource::<FilterMaterialCache>();
app.add_systems(Startup, crate::filter::init_filter_assets);
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
std::mem::forget(out_rx); let root = app.world_mut().spawn_empty().id();
app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
app.add_systems(Update, apply_js_ops);
(app, ops_tx)
}
#[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: Props::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 hover_events_fire_on_boundary_only() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let root = app.world_mut().spawn_empty().id();
app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
app.add_systems(Update, collect_hover_events);
let e = app
.world_mut()
.spawn((
Interaction::None,
HoverState(false),
PointerHandlers {
enter: true,
leave: true,
..default()
},
RNode(1),
))
.id();
let set = |app: &mut App, i: Interaction| {
*app.world_mut()
.entity_mut(e)
.get_mut::<Interaction>()
.unwrap() = i;
app.update();
};
app.update(); set(&mut app, Interaction::Hovered); set(&mut app, Interaction::Pressed); set(&mut app, Interaction::None);
let kinds: Vec<String> = std::iter::from_fn(|| out_rx.try_recv().ok())
.map(|o| match o {
Outbound::UiEvent { event } => {
assert_eq!(event.id, 1);
event.kind
}
other => panic!("expected a UiEvent, got {other:?}"),
})
.collect();
assert_eq!(kinds, vec!["pointerEnter", "pointerLeave"]);
}
#[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: Props::default(),
text: None,
},
Op::Create {
id: 2,
kind: "node".into(),
props: Props::default(),
text: None,
},
Op::Create {
id: 3,
kind: "button".into(),
props: 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"
);
}
fn click_location() -> bevy::picking::pointer::Location {
bevy::picking::pointer::Location {
target: bevy::camera::NormalizedRenderTarget::Image(Handle::<Image>::default().into()),
position: Vec2::ZERO,
}
}
fn click_app() -> (App, tokio::sync::mpsc::UnboundedReceiver<Outbound>) {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
std::mem::forget(_ops_tx); let root = app.world_mut().spawn_empty().id();
app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
app.add_message::<Pointer<Click>>();
(app, out_rx)
}
fn drain_clicks(out_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Outbound>) -> Vec<UiEvent> {
std::iter::from_fn(|| out_rx.try_recv().ok())
.map(|o| match o {
Outbound::UiEvent { event } => event,
other => panic!("expected a UiEvent, got {other:?}"),
})
.collect()
}
#[test]
fn picking_click_fires_once_primary_only() {
let (mut app, mut out_rx) = click_app();
app.add_systems(Update, collect_ui_events);
let owner = app.world_mut().spawn((RNode(1), Interaction::None)).id();
let leaf = app.world_mut().spawn(ChildOf(owner)).id();
let click = |entity, button| {
Pointer::new(
PointerId::Mouse,
click_location(),
Click {
button,
hit: bevy::picking::backend::HitData::new(Entity::PLACEHOLDER, 0.0, None, None),
duration: std::time::Duration::ZERO,
count: 1,
},
entity,
)
};
app.world_mut()
.write_message(click(leaf, PointerButton::Secondary));
app.world_mut()
.write_message(click(leaf, PointerButton::Primary));
app.world_mut()
.write_message(click(owner, PointerButton::Primary));
app.update();
let events = drain_clicks(&mut out_rx);
assert_eq!(
events.len(),
1,
"secondary filtered out; leaf + owner primary picks dedupe to one click"
);
assert_eq!(events[0].id, 1);
assert_eq!(events[0].kind, "click");
assert_eq!(
events[0].button, None,
"clicks carry no button (primary implied)"
);
}
#[test]
fn surface_pointer_clicks_are_not_main_clicks() {
let (mut app, mut out_rx) = click_app();
app.add_systems(Startup, crate::surface::init_surface_pointer);
app.add_systems(Update, (collect_ui_events, collect_surface_clicks));
app.update();
let owner = app.world_mut().spawn((RNode(7), Interaction::None)).id();
let surface_id = app.world().resource::<SurfaceVirtualPointer>().id;
app.world_mut().write_message(Pointer::new(
surface_id,
click_location(),
Click {
button: PointerButton::Primary,
hit: bevy::picking::backend::HitData::new(Entity::PLACEHOLDER, 0.0, None, None),
duration: std::time::Duration::ZERO,
count: 1,
},
owner,
));
app.update();
let events = drain_clicks(&mut out_rx);
assert_eq!(
events.len(),
1,
"exactly one click: surface-collected, not double-fired by collect_ui_events"
);
assert_eq!(events[0].id, 7);
assert_eq!(events[0].button, None, "clicks carry no button");
}
#[test]
fn text_update_reapplies_transform_target() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Image>();
app.init_asset::<TextureAtlasLayout>();
app.init_resource::<Fonts>();
app.init_resource::<OpApplyStats>();
app.init_resource::<AtlasLayoutCache>();
app.init_asset::<FilterMaterial>();
app.init_resource::<FilterMaterialCache>();
app.add_systems(Startup, crate::filter::init_filter_assets);
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let root = app.world_mut().spawn_empty().id();
app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
app.add_systems(Update, apply_js_ops);
ops_tx
.send(vec![Op::Create {
id: 1,
kind: "text".into(),
props: text_props(0.0),
text: None,
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
assert_eq!(
app.world()
.entity(e)
.get::<TransitionInput>()
.unwrap()
.rotate,
Some(0.0),
"create stamps the initial transform target"
);
ops_tx
.send(vec![update_delta(1, text_props(PI), &[], &[])])
.unwrap();
app.update();
assert_eq!(
app.world()
.entity(e)
.get::<TransitionInput>()
.unwrap()
.rotate,
Some(PI),
"a text re-render must refresh the transform target so it animates"
);
}
#[test]
fn update_text_on_inline_span_keeps_textspan() {
let (mut app, ops_tx, _root) = ordering_app();
ops_tx
.send(vec![
Op::Create {
id: 1,
kind: "text".into(),
props: Props::default(),
text: None,
},
Op::Create {
id: 2,
kind: "textSpan".into(),
props: Props::default(),
text: Some("0".into()),
},
Op::Append {
parent: 1,
child: 2,
},
])
.unwrap();
app.update();
ops_tx
.send(vec![Op::UpdateText {
id: 2,
text: "1".into(),
}])
.unwrap();
app.update();
let span = ent(&app, 2);
assert_eq!(
app.world().entity(span).get::<TextSpan>().map(|s| &*s.0),
Some("1"),
"the span's TextSpan must hold the updated text"
);
assert!(
app.world().entity(span).get::<Text>().is_none(),
"a span must never gain a Text component (that renders a duplicate)"
);
}
fn ordering_app() -> (App, crossbeam_channel::Sender<Vec<Op>>, Entity) {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Image>();
app.init_asset::<TextureAtlasLayout>();
app.init_resource::<Fonts>();
app.init_resource::<OpApplyStats>();
app.init_resource::<AtlasLayoutCache>();
app.init_asset::<FilterMaterial>();
app.init_resource::<FilterMaterialCache>();
app.add_systems(Startup, crate::filter::init_filter_assets);
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let root = app.world_mut().spawn_empty().id();
app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
app.add_systems(Update, apply_js_ops);
(app, ops_tx, root)
}
fn create_node(id: NodeId) -> Op {
Op::Create {
id,
kind: "node".into(),
props: Props::default(),
text: None,
}
}
fn ent(app: &App, id: NodeId) -> Entity {
app.world().resource::<JsBridge>().nodes[&id]
}
fn children_of(app: &App, parent: Entity) -> Vec<Entity> {
app.world()
.entity(parent)
.get::<Children>()
.map(|c| c.iter().collect())
.unwrap_or_default()
}
#[test]
fn append_builds_child_order() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1), create_node(2),
create_node(3),
create_node(4),
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
Op::Append {
parent: 1,
child: 3,
},
Op::Append {
parent: 1,
child: 4,
},
])
.unwrap();
app.update();
let parent = ent(&app, 1);
assert_eq!(
children_of(&app, parent),
vec![ent(&app, 2), ent(&app, 3), ent(&app, 4)],
);
}
#[test]
fn insert_reorders_existing_child() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1),
create_node(2),
create_node(3),
create_node(4),
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
Op::Append {
parent: 1,
child: 3,
},
Op::Append {
parent: 1,
child: 4,
},
])
.unwrap();
app.update();
tx.send(vec![Op::Insert {
parent: 1,
child: 4,
before: 2,
}])
.unwrap();
app.update();
let parent = ent(&app, 1);
assert_eq!(
children_of(&app, parent),
vec![ent(&app, 4), ent(&app, 2), ent(&app, 3)],
"C should move to the front: [C, A, B]"
);
}
#[test]
fn insert_new_child_in_the_middle() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1),
create_node(2),
create_node(3),
create_node(4),
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
Op::Append {
parent: 1,
child: 3,
},
Op::Append {
parent: 1,
child: 4,
},
])
.unwrap();
app.update();
tx.send(vec![
create_node(5),
Op::Insert {
parent: 1,
child: 5,
before: 3,
},
])
.unwrap();
app.update();
let parent = ent(&app, 1);
assert_eq!(
children_of(&app, parent),
vec![ent(&app, 2), ent(&app, 5), ent(&app, 3), ent(&app, 4)],
"D should land before B: [A, D, B, C]"
);
}
#[test]
fn insert_orders_within_a_single_batch() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(10), create_node(11), create_node(12), Op::Append {
parent: ROOT_ID,
child: 10,
},
Op::Append {
parent: 10,
child: 12,
}, Op::Insert {
parent: 10,
child: 11,
before: 12,
}, ])
.unwrap();
app.update();
let parent = ent(&app, 10);
assert_eq!(
children_of(&app, parent),
vec![ent(&app, 11), ent(&app, 12)],
"X must precede Y even though Children was unreadable mid-batch"
);
}
#[test]
fn mixed_batch_orders_correctly() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1),
create_node(2),
create_node(3),
create_node(4),
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
Op::Append {
parent: 1,
child: 3,
},
Op::Append {
parent: 1,
child: 4,
},
])
.unwrap();
app.update();
tx.send(vec![
create_node(5),
Op::Append {
parent: 1,
child: 5,
},
Op::Insert {
parent: 1,
child: 4,
before: 2,
},
Op::Remove {
parent: 1,
child: 3,
},
])
.unwrap();
app.update();
let parent = ent(&app, 1);
assert_eq!(
children_of(&app, parent),
vec![ent(&app, 4), ent(&app, 2), ent(&app, 5)],
"append + move + remove in one batch must land as [4, 2, 5]"
);
}
#[test]
fn move_between_parents_in_one_batch() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1), create_node(2), create_node(3),
create_node(4),
create_node(5),
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: ROOT_ID,
child: 2,
},
Op::Append {
parent: 1,
child: 3,
},
Op::Append {
parent: 1,
child: 4,
},
Op::Append {
parent: 2,
child: 5,
},
])
.unwrap();
app.update();
tx.send(vec![Op::Append {
parent: 2,
child: 3,
}])
.unwrap();
app.update();
let (a, b) = (ent(&app, 1), ent(&app, 2));
assert_eq!(
children_of(&app, a),
vec![ent(&app, 4)],
"the moved child must leave the old parent's Children"
);
assert_eq!(children_of(&app, b), vec![ent(&app, 5), ent(&app, 3)]);
assert_eq!(
app.world()
.entity(ent(&app, 3))
.get::<ChildOf>()
.map(|c| c.parent()),
Some(b),
"the moved child's ChildOf must point at the new parent"
);
}
#[test]
fn root_rebuild_preserves_anchor_layer() {
let (mut app, tx, root) = ordering_app();
let layer = app
.world_mut()
.spawn((crate::anchor::AnchorLayer, ChildOf(root)))
.id();
tx.send(vec![
create_node(1),
create_node(2),
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: ROOT_ID,
child: 2,
},
])
.unwrap();
app.update();
assert_eq!(
children_of(&app, root),
vec![layer, ent(&app, 1), ent(&app, 2)]
);
tx.send(vec![Op::Insert {
parent: ROOT_ID,
child: 2,
before: 1,
}])
.unwrap();
app.update();
assert_eq!(
children_of(&app, root),
vec![layer, ent(&app, 2), ent(&app, 1)],
"the AnchorLayer must survive root rebuilds as the first child"
);
}
#[test]
fn same_batch_create_under_removed_parent_despawns() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1),
Op::Append {
parent: ROOT_ID,
child: 1,
},
])
.unwrap();
app.update();
tx.send(vec![
create_node(2),
Op::Append {
parent: 1,
child: 2,
},
Op::Remove {
parent: ROOT_ID,
child: 1,
},
])
.unwrap();
app.update();
let survivors = app.world_mut().query::<&RNode>().iter(app.world()).count();
assert_eq!(
survivors, 0,
"the same-batch child must be despawned with its removed parent, not \
leaked as an orphaned root"
);
}
#[test]
fn remove_then_reorder_same_parent() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1),
create_node(2),
create_node(3),
create_node(4),
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
Op::Append {
parent: 1,
child: 3,
},
Op::Append {
parent: 1,
child: 4,
},
])
.unwrap();
app.update();
tx.send(vec![
Op::Remove {
parent: 1,
child: 3,
},
Op::Insert {
parent: 1,
child: 4,
before: 2,
},
])
.unwrap();
app.update();
let parent = ent(&app, 1);
assert_eq!(children_of(&app, parent), vec![ent(&app, 4), ent(&app, 2)]);
}
#[test]
fn portal_mounts_with_target_and_rebinds() {
use crate::portal::RPortal;
use bevy::ui::widget::ImageNode;
let (mut app, tx, _root) = ordering_app();
tx.send(vec![Op::Create {
id: 1,
kind: "portal".into(),
props: serde_json::from_value(serde_json::json!({ "target": "follow" }))
.expect("valid portal props"),
text: None,
}])
.unwrap();
app.update();
let e = ent(&app, 1);
assert_eq!(
app.world().entity(e).get::<RPortal>().map(|p| p.0.clone()),
Some("follow".to_string()),
"a portal carries its target name"
);
assert!(
app.world().entity(e).get::<ImageNode>().is_some(),
"a portal is backed by an ImageNode"
);
tx.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "target": "minimap" }))
.expect("valid portal props"),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world().entity(e).get::<RPortal>().map(|p| p.0.clone()),
Some("minimap".to_string()),
"an update rebinds the portal's target name"
);
}
#[test]
fn surface_mounts_detached_with_name() {
use crate::surface::RSurface;
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1), Op::Create {
id: 2,
kind: "surface".into(),
props: serde_json::from_value(serde_json::json!({ "target": "monitor" }))
.expect("valid surface props"),
text: None,
},
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
])
.unwrap();
app.update();
let surface = ent(&app, 2);
assert_eq!(
app.world()
.entity(surface)
.get::<RSurface>()
.map(|s| s.0.clone()),
Some("monitor".to_string()),
"a surface carries its name in RSurface"
);
assert!(
app.world().entity(surface).get::<ChildOf>().is_none(),
"a surface is a detached root — never parented into the on-screen tree"
);
assert!(
children_of(&app, ent(&app, 1)).is_empty(),
"the surface's React parent has no Bevy children"
);
tx.send(vec![update_delta(
2,
serde_json::from_value(serde_json::json!({ "target": "panel" }))
.expect("valid surface props"),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world()
.entity(surface)
.get::<RSurface>()
.map(|s| s.0.clone()),
Some("panel".to_string()),
"an update rebinds the surface name"
);
assert!(
app.world()
.entity(surface)
.get::<crate::portal::RPortal>()
.is_none(),
"a surface update must not stamp an RPortal (shared `target` field)"
);
}
#[test]
fn reset_preserves_anchor_layer_but_clears_its_overlays() {
use crate::anchor::AnchorLayer;
let (mut app, tx, root) = ordering_app();
let layer = app.world_mut().spawn((AnchorLayer, ChildOf(root))).id();
let overlay = app.world_mut().spawn((RNode(99), ChildOf(layer))).id();
tx.send(vec![Op::Reset]).unwrap();
app.update();
assert!(
app.world().entities().contains(layer),
"Op::Reset must preserve the persistent anchor layer"
);
assert!(
!app.world().entities().contains(overlay),
"Op::Reset must despawn overlays reparented under the anchor layer"
);
}
#[test]
fn reset_despawns_detached_surfaces() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
Op::Create {
id: 1,
kind: "surface".into(),
props: serde_json::from_value(serde_json::json!({ "target": "monitor" }))
.expect("valid surface props"),
text: None,
},
Op::Append {
parent: ROOT_ID,
child: 1,
},
])
.unwrap();
app.update();
let surface = ent(&app, 1);
assert!(app.world().entities().contains(surface));
tx.send(vec![Op::Reset]).unwrap();
app.update();
assert!(
!app.world().entities().contains(surface),
"Op::Reset must despawn the detached surface root"
);
assert!(
app.world().resource::<JsBridge>().surfaces.is_empty(),
"Op::Reset must clear surface bookkeeping"
);
}
#[test]
fn remove_ancestor_despawns_nested_surface() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1), Op::Create {
id: 2,
kind: "surface".into(),
props: serde_json::from_value(serde_json::json!({ "target": "monitor" }))
.expect("valid surface props"),
text: None,
},
create_node(3), Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
}, Op::Append {
parent: 2,
child: 3,
}, ])
.unwrap();
app.update();
let wrapper = ent(&app, 1);
let surface = ent(&app, 2);
let inner = ent(&app, 3);
assert!(app.world().entities().contains(surface));
tx.send(vec![Op::Remove {
parent: ROOT_ID,
child: 1,
}])
.unwrap();
app.update();
assert!(
!app.world().entities().contains(wrapper),
"the removed wrapper is despawned"
);
assert!(
!app.world().entities().contains(surface),
"the detached <surface> nested under the removed wrapper must be despawned"
);
assert!(
!app.world().entities().contains(inner),
"the surface's own subtree is despawned with it"
);
let bridge = app.world().resource::<JsBridge>();
assert!(bridge.surfaces.is_empty(), "surface bookkeeping is cleared");
assert!(
!bridge.nodes.contains_key(&2),
"the surface node id is forgotten"
);
assert!(
bridge.child_surfaces.is_empty() && bridge.surface_parent.is_empty(),
"surface parentage maps are cleared"
);
}
#[test]
fn remove_subtree_forgets_descendant_node_data() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1),
create_node(2),
Op::Create {
id: 3,
kind: "editableText".into(),
props: Props::default(),
text: None,
},
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
Op::Append {
parent: 2,
child: 3,
},
])
.unwrap();
app.update();
let mid = ent(&app, 2);
let leaf = ent(&app, 3);
assert!(
app.world()
.resource::<JsBridge>()
.editable_inputs
.contains(&3),
"the editableText descendant is tracked before removal"
);
tx.send(vec![Op::Remove {
parent: ROOT_ID,
child: 1,
}])
.unwrap();
app.update();
assert!(
!app.world().entities().contains(mid),
"the descendant mid node is despawned with the subtree"
);
assert!(
!app.world().entities().contains(leaf),
"the descendant leaf node is despawned with the subtree"
);
let bridge = app.world().resource::<JsBridge>();
assert!(
!bridge.nodes.contains_key(&1),
"the removed root is forgotten"
);
assert!(
!bridge.nodes.contains_key(&2),
"the descendant mid node id is forgotten (no stale entity handle)"
);
assert!(
!bridge.nodes.contains_key(&3),
"the descendant leaf node id is forgotten (no stale entity handle)"
);
assert!(
!bridge.editable_inputs.contains(&3),
"the descendant editableText is dropped from the editable_inputs set"
);
}
#[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 collect_scroll_events_emits_for_listener_only() {
use bevy::ecs::system::RunSystemOnce;
let mut world = World::new();
let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let root = world.spawn_empty().id();
world.insert_resource(JsBridge::new(ops_rx, out_tx, root));
world.spawn((
ScrollPosition(Vec2::new(0.0, 50.0)),
RNode(1),
ScrollListener,
));
world.spawn((ScrollPosition(Vec2::new(0.0, 70.0)), RNode(2)));
world.run_system_once(collect_scroll_events).unwrap();
match out_rx.try_recv().expect("a scroll event for the listener") {
Outbound::UiEvent { event } => {
assert_eq!(event.id, 1);
assert_eq!(event.kind, "scroll");
assert_eq!(event.scroll_top, Some(50.0));
assert_eq!(event.scroll_left, Some(0.0));
}
other => panic!("expected a UiEvent, got {other:?}"),
}
assert!(
out_rx.try_recv().is_err(),
"the non-listener node must not emit"
);
assert_eq!(
world.resource::<JsBridge>().scroll_positions.get(&1),
Some(&Vec2::new(0.0, 50.0))
);
}
#[test]
fn collect_scroll_events_dedups_controlled_writeback() {
use bevy::ecs::system::RunSystemOnce;
let mut world = World::new();
let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let root = world.spawn_empty().id();
world.insert_resource(JsBridge::new(ops_rx, out_tx, root));
world
.resource_mut::<JsBridge>()
.scroll_positions
.insert(1, Vec2::new(0.0, 50.0));
world.spawn((
ScrollPosition(Vec2::new(0.0, 50.0)),
RNode(1),
ScrollListener,
));
world.run_system_once(collect_scroll_events).unwrap();
assert!(
out_rx.try_recv().is_err(),
"a write-back equal to the recorded value must not echo back to 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"
);
}
#[test]
fn delta_update_skips_untouched_groups() {
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!({
"style": {
"backgroundColor": "red",
"width": 10,
"outline": { "color": "white" },
},
"hoverStyle": { "backgroundColor": "blue" },
"onClick": true,
}))
.unwrap(),
text: None,
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
let paint_ticks = |app: &App| {
let entity = app.world().entity(e);
(
entity
.get_change_ticks::<BackgroundColor>()
.unwrap()
.changed,
entity.get_change_ticks::<Outline>().unwrap().changed,
)
};
let variants_tick = |app: &App| {
app.world()
.entity(e)
.get_change_ticks::<StyleVariants>()
.unwrap()
.changed
};
let ticks_before = paint_ticks(&app);
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "style": { "width": 100 } })).unwrap(),
&[],
&[],
)])
.unwrap();
app.update();
{
let entity = app.world().entity(e);
assert_eq!(
entity.get::<Node>().unwrap().width,
Val::Px(100.0),
"the delta's own field must apply"
);
assert_eq!(
entity.get::<BackgroundColor>().unwrap().0,
crate::ui_map::parse_color("red"),
"untouched background survives a width-only delta"
);
assert!(
entity.get::<StyleVariants>().is_some(),
"variants survive (base mirrors the style, so it was rebuilt)"
);
assert!(
entity.get::<Interaction>().is_some(),
"the onClick Interaction survives"
);
}
assert_eq!(
ticks_before,
paint_ticks(&app),
"untouched paint groups must not even be marked changed"
);
let tick_before = variants_tick(&app);
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "onPointerDown": true })).unwrap(),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
tick_before,
variants_tick(&app),
"a handler-only delta must not re-insert StyleVariants"
);
}
#[test]
fn delta_style_unset_removes_component() {
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!({
"style": { "backgroundColor": "red", "width": 10 },
}))
.unwrap(),
text: None,
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
assert!(app.world().entity(e).get::<BackgroundColor>().is_some());
ops_tx
.send(vec![update_delta(
1,
Props::default(),
&[],
&["backgroundColor"],
)])
.unwrap();
app.update();
let entity = app.world().entity(e);
assert!(
entity.get::<BackgroundColor>().is_none(),
"an unset style field removes its component"
);
assert_eq!(
entity.get::<Node>().unwrap().width,
Val::Px(10.0),
"the retained width survives the unset"
);
}
#[test]
fn delta_unsets_reset_absent_fields() {
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!({
"style": { "backgroundColor": "red" },
"hoverStyle": { "backgroundColor": "blue" },
}))
.unwrap(),
text: None,
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
assert!(app.world().entity(e).get::<StyleVariants>().is_some());
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "style": { "width": 5 } })).unwrap(),
&["hoverStyle"],
&["backgroundColor"],
)])
.unwrap();
app.update();
let entity = app.world().entity(e);
assert!(
entity.get::<BackgroundColor>().is_none(),
"styleUnset resets the background"
);
assert!(
entity.get::<StyleVariants>().is_none(),
"unsetting the last variant style removes StyleVariants"
);
assert_eq!(
entity.get::<Node>().unwrap().width,
Val::Px(5.0),
"the delta's own field still applies"
);
}
#[test]
fn delta_update_does_not_replay_controlled_scroll() {
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": 40.0,
"style": { "overflowY": "scroll" },
}))
.unwrap(),
text: None,
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
app.world_mut()
.entity_mut(e)
.get_mut::<ScrollPosition>()
.unwrap()
.0 = Vec2::new(0.0, 7.0);
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "style": { "width": 50 } })).unwrap(),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world().entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 7.0),
"a width-only delta must not re-push the cached scrollTop"
);
}
#[test]
fn text_delta_gates_span_repropagation() {
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!({
"style": { "color": "red" },
}))
.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 span_tick = app
.world()
.entity(span)
.get_change_ticks::<TextColor>()
.unwrap()
.changed;
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(
serde_json::json!({ "style": { "transform": { "scale": 2.0 } } }),
)
.unwrap(),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world()
.entity(span)
.get_change_ticks::<TextColor>()
.unwrap()
.changed,
span_tick,
"a transform-only text delta must not re-propagate to spans"
);
ops_tx
.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "style": { "color": "blue" } }))
.unwrap(),
&[],
&[],
)])
.unwrap();
app.update();
let world = app.world();
assert_eq!(
world.entity(span).get::<TextColor>().unwrap().0,
crate::ui_map::parse_color("blue"),
"a color delta re-propagates to inheriting spans"
);
assert_eq!(
world.entity(root).get::<TextColor>().unwrap().0,
crate::ui_map::parse_color("blue")
);
}
#[test]
fn delta_toggles_pointer_handlers() {
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!({ "onPointerDown": true, "onPointerUp": true }),
)
.unwrap(),
text: None,
}])
.unwrap();
app.update();
let e = app.world().resource::<JsBridge>().nodes[&1];
ops_tx
.send(vec![update_delta(
1,
Props::default(),
&["onPointerUp"],
&[],
)])
.unwrap();
app.update();
let handlers = app
.world()
.entity(e)
.get::<PointerHandlers>()
.expect("one handler remains");
assert!(handlers.down && !handlers.up);
ops_tx
.send(vec![update_delta(
1,
Props::default(),
&["onPointerDown"],
&[],
)])
.unwrap();
app.update();
assert!(
app.world().entity(e).get::<PointerHandlers>().is_none(),
"unsetting the last handler clears the marker"
);
}
}