use bevy::picking::events::{Click, Pointer};
use bevy::picking::pointer::{PointerButton, PointerId};
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::ui::RelativeCursorPosition;
use bevy::ui::{ComputedNode, ScrollPosition, UiGlobalTransform};
use crate::bridge::{CanvasSizeTracker, JsBridge, RNode, ScrollListener};
use crate::canvas::{CanvasSurface, clamp_physical_size};
use crate::protocol::{NodeId, outbound::Outbound, outbound::UiEvent};
use crate::surface::SurfaceVirtualPointer;
pub fn collect_ui_events(
bridge: Res<JsBridge>,
surface_pointer: Option<Res<SurfaceVirtualPointer>>,
touch_scroll: Option<Res<crate::touch_scroll::TouchScrollState>>,
mut clicks: MessageReader<Pointer<Click>>,
targets: Query<&RNode, With<Interaction>>,
child_of: Query<&ChildOf>,
) {
let mut topmost: HashMap<PointerId, (f32, Entity)> = HashMap::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 PointerId::Touch(id) = ev.pointer_id
&& touch_scroll.as_ref().is_some_and(|s| s.is_suppressed(id))
{
continue;
}
if let Some(target) = climb(ev.entity, &child_of, |e| targets.contains(e)) {
let candidate = (ev.hit.depth, target);
topmost
.entry(ev.pointer_id)
.and_modify(|best| {
if candidate.0 < best.0 {
*best = candidate;
}
})
.or_insert(candidate);
}
}
for (_, (_, target)) in topmost {
if 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()
},
});
}
}
pub(super) 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()
},
});
}
pub(super) fn dom_button(button: PointerButton) -> u8 {
match button {
PointerButton::Primary => 0,
PointerButton::Middle => 1,
PointerButton::Secondary => 2,
}
}
pub(super) 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)))
}
pub(super) 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();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::op::Op;
use crate::reconcile::collect_surface_clicks;
#[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"
);
}
fn click_location() -> bevy::picking::pointer::Location {
bevy::picking::pointer::Location {
target: bevy::camera::NormalizedRenderTarget::Image(
Handle::<bevy::image::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 nested_onclick_owners_click_topmost_only() {
let (mut app, mut out_rx) = click_app();
app.add_systems(Update, collect_ui_events);
let outer = app.world_mut().spawn((RNode(1), Interaction::None)).id();
let inner = app
.world_mut()
.spawn((RNode(2), Interaction::None, ChildOf(outer)))
.id();
let leaf = app.world_mut().spawn(ChildOf(inner)).id();
let click = |entity, depth| {
Pointer::new(
PointerId::Mouse,
click_location(),
Click {
button: PointerButton::Primary,
hit: bevy::picking::backend::HitData::new(
Entity::PLACEHOLDER,
depth,
None,
None,
),
duration: std::time::Duration::ZERO,
count: 1,
},
entity,
)
};
app.world_mut().write_message(click(outer, 0.00002));
app.world_mut().write_message(click(leaf, 0.0));
app.world_mut().write_message(click(inner, 0.00001));
app.update();
let events = drain_clicks(&mut out_rx);
assert_eq!(
events.len(),
1,
"a click over nested owners must fire only the topmost owner"
);
assert_eq!(events[0].id, 2, "the inner (topmost) node owns the click");
}
#[test]
fn surface_nested_onclick_owners_click_topmost_only() {
let (mut app, mut out_rx) = click_app();
app.add_systems(Startup, crate::surface::init_surface_pointer);
app.add_systems(Update, collect_surface_clicks);
app.update();
let outer = app.world_mut().spawn((RNode(1), Interaction::None)).id();
let inner = app
.world_mut()
.spawn((RNode(2), Interaction::None, ChildOf(outer)))
.id();
let surface_id = app.world().resource::<SurfaceVirtualPointer>().id;
let click = |entity, depth| {
Pointer::new(
surface_id,
click_location(),
Click {
button: PointerButton::Primary,
hit: bevy::picking::backend::HitData::new(
Entity::PLACEHOLDER,
depth,
None,
None,
),
duration: std::time::Duration::ZERO,
count: 1,
},
entity,
)
};
app.world_mut().write_message(click(outer, 0.00001));
app.world_mut().write_message(click(inner, 0.0));
app.update();
let events = drain_clicks(&mut out_rx);
assert_eq!(
events.len(),
1,
"a surface click over nested owners must fire only the topmost owner"
);
assert_eq!(events[0].id, 2, "the inner (topmost) node owns the click");
}
#[test]
fn handlerless_shape_click_falls_to_svg_root() {
let (mut app, mut out_rx) = click_app();
app.add_systems(Update, collect_ui_events);
let root = app.world_mut().spawn((RNode(1), Interaction::None)).id();
let shape = app
.world_mut()
.spawn((
RNode(2),
crate::svg::SvgShape {
kind: crate::svg::ShapeKind::Circle,
attrs: crate::svg::ShapeAttrs::default(),
},
ChildOf(root),
))
.id();
app.world_mut().write_message(Pointer::new(
PointerId::Mouse,
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,
},
shape,
));
app.update();
let events = drain_clicks(&mut out_rx);
assert_eq!(events.len(), 1, "the click resolves to exactly one owner");
assert_eq!(
events[0].id, 1,
"a handler-less shape's click belongs to the <svg> root"
);
}
#[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");
}
}