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>>,
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 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;
#[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"
);
}
}