use bevy::prelude::*;
use bevy::ui::RelativeCursorPosition;
use super::events::{normalized_01, send_ui_event};
use crate::bridge::{HoverState, JsBridge, PointerHandlers, RNode};
use crate::protocol::{outbound::Outbound, outbound::UiEvent};
use crate::svg::SvgUserPos;
fn event_pos(user: Option<&SvgUserPos>, rel: Option<&RelativeCursorPosition>) -> Option<Vec2> {
user.and_then(|u| u.0)
.or_else(|| rel.and_then(normalized_01))
}
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,
}
}
}
#[allow(clippy::type_complexity)]
pub fn collect_pointer_events(
bridge: Res<JsBridge>,
buttons: Res<ButtonInput<MouseButton>>,
windows: Query<&Window>,
nodes: Query<(
Entity,
&RNode,
&Interaction,
&RelativeCursorPosition,
&PointerHandlers,
// SVG shapes report x/y in user units instead (see [`event_pos`]).
Option<&SvgUserPos>,
)>,
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, user) in &nodes {
let over = if mb == MouseButton::Left {
*interaction == Interaction::Pressed
} else {
*interaction != Interaction::None && rel.cursor_over()
};
if over {
let pos = event_pos(user, Some(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, user)) = nodes.get(entity)
{
let pos = event_pos(user, Some(rel)).unwrap_or(drag.last_pos);
let abs = cursor_abs.unwrap_or(drag.last_abs);
let cursor_moved = abs != drag.last_abs;
drag.last_pos = pos;
drag.last_abs = abs;
if cursor_moved && 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, user)) = nodes.get(entity)
{
let pos = event_pos(user, Some(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);
capture.wheel_captured = false;
}
#[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>,
// SVG shapes report x/y in user units instead (see [`event_pos`]).
Option<&SvgUserPos>,
),
Changed<Interaction>,
>,
) {
let cursor_abs = windows.iter().next().and_then(|w| w.cursor_position());
for (interaction, mut hover, handlers, rnode, rel, user) 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 = event_pos(user, rel).unwrap_or(Vec2::ZERO);
let abs = cursor_abs.unwrap_or(Vec2::ZERO);
send_ui_event(&bridge, rnode.0, kind, Some(pos), Some(abs), None);
}
}
}
#[cfg(test)]
mod tests {
use bevy::image::Image;
use bevy::picking::events::{Click, Pointer};
use bevy::picking::pointer::{PointerButton, PointerId};
use super::super::events::collect_ui_events;
use super::super::surface_events::collect_surface_clicks;
use super::*;
use crate::protocol::{op::Op, outbound::UiEvent};
use crate::surface::SurfaceVirtualPointer;
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 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 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 pointer_move_only_fires_on_cursor_movement() {
let (mut app, mut out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<crate::PointerCapture>();
app.add_systems(Update, collect_pointer_events);
let mut window = Window::default();
window.set_physical_cursor_position(Some(bevy::math::DVec2::new(100.0, 100.0)));
let win = app.world_mut().spawn(window).id();
let node = app
.world_mut()
.spawn((
RNode(1),
Interaction::Pressed,
RelativeCursorPosition {
cursor_over: true,
normalized: Some(Vec2::ZERO),
},
PointerHandlers {
down: true,
moved: true,
up: true,
..default()
},
))
.id();
let kinds = |rx: &mut tokio::sync::mpsc::UnboundedReceiver<Outbound>| {
drain_clicks(rx)
.into_iter()
.map(|e| e.kind)
.collect::<Vec<_>>()
};
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.press(MouseButton::Left);
app.update();
assert_eq!(kinds(&mut out_rx), ["pointerDown"]);
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.clear();
app.update();
assert_eq!(kinds(&mut out_rx), Vec::<String>::new());
app.world_mut()
.get_mut::<Window>(win)
.unwrap()
.set_physical_cursor_position(Some(bevy::math::DVec2::new(110.0, 100.0)));
app.world_mut()
.get_mut::<RelativeCursorPosition>(node)
.unwrap()
.normalized = Some(Vec2::new(0.05, 0.0));
app.update();
assert_eq!(kinds(&mut out_rx), ["pointerMove"]);
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.release(MouseButton::Left);
app.update();
assert_eq!(kinds(&mut out_rx), ["pointerUp"]);
}
#[test]
fn pointer_capture_wheel_claim_resets_each_frame() {
let (mut app, _out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<crate::PointerCapture>();
app.add_systems(Update, collect_pointer_events);
app.world_mut()
.resource_mut::<crate::PointerCapture>()
.wheel_captured = true;
app.update();
assert!(
!app.world()
.resource::<crate::PointerCapture>()
.wheel_captured,
"a frame with no wheel claim must clear `wheel_captured`"
);
}
#[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 svg_user_pos_overrides_pointer_event_coords() {
let (mut app, mut out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<crate::PointerCapture>();
app.add_systems(Update, collect_pointer_events);
let mut window = Window::default();
window.set_physical_cursor_position(Some(bevy::math::DVec2::new(100.0, 100.0)));
app.world_mut().spawn(window);
let node = app
.world_mut()
.spawn((
RNode(1),
Interaction::Pressed,
RelativeCursorPosition {
cursor_over: true,
normalized: Some(Vec2::ZERO),
},
PointerHandlers {
down: true,
up: true,
..default()
},
crate::svg::SvgUserPos(Some(Vec2::new(42.0, 17.0))),
))
.id();
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.press(MouseButton::Left);
app.update();
let events = drain_clicks(&mut out_rx);
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "pointerDown");
assert_eq!(
(events[0].x, events[0].y),
(Some(42.0), Some(17.0)),
"a present user pos wins over the normalized position"
);
app.world_mut()
.get_mut::<crate::svg::SvgUserPos>(node)
.unwrap()
.0 = None;
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.clear();
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.release(MouseButton::Left);
app.update();
let events = drain_clicks(&mut out_rx);
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "pointerUp");
assert_eq!(
(events[0].x, events[0].y),
(Some(0.5), Some(0.5)),
"an empty user-pos slot falls back to normalized coordinates"
);
}
#[test]
fn svg_user_pos_overrides_hover_event_coords() {
let (mut app, mut out_rx) = click_app();
app.add_systems(Update, collect_hover_events);
let e = app
.world_mut()
.spawn((
Interaction::None,
HoverState(false),
PointerHandlers {
enter: true,
..default()
},
RNode(1),
RelativeCursorPosition {
cursor_over: true,
normalized: Some(Vec2::ZERO),
},
crate::svg::SvgUserPos(Some(Vec2::new(30.0, 60.0))),
))
.id();
app.update(); *app.world_mut().get_mut::<Interaction>(e).unwrap() = Interaction::Hovered;
app.update();
let events = drain_clicks(&mut out_rx);
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "pointerEnter");
assert_eq!(
(events[0].x, events[0].y),
(Some(30.0), Some(60.0)),
"enter coordinates ride the user-space cursor"
);
}
#[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");
}
}