use bevy::prelude::*;
use bevy::ui::{ComputedNode, RelativeCursorPosition, UiGlobalTransform};
use super::events::normalized_01;
use crate::bridge::{JsBridge, PointerHandlers, RNode};
use crate::protocol::{outbound::Outbound, outbound::UiEvent};
use crate::svg::SvgUserPos;
pub(super) 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),
];
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum DragSource {
Mouse { button: MouseButton, dom_button: u8 },
Touch { id: u64 },
}
impl DragSource {
fn dom_button(&self) -> u8 {
match self {
DragSource::Mouse { dom_button, .. } => *dom_button,
DragSource::Touch { .. } => 0,
}
}
}
#[derive(Resource, Default)]
pub(crate) struct ActiveDrag {
binding: Option<(Entity, DragSource)>,
last_pos: Vec2,
last_abs: Vec2,
}
impl ActiveDrag {
pub(crate) fn begin(&mut self, entity: Entity, source: DragSource, pos: Vec2, abs: Vec2) {
self.binding = Some((entity, source));
self.last_pos = pos;
self.last_abs = abs;
}
fn end(&mut self) -> Option<(Entity, DragSource)> {
self.binding.take()
}
pub(crate) fn touch_id(&self) -> Option<u64> {
match self.binding {
Some((_, DragSource::Touch { id })) => Some(id),
_ => None,
}
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn collect_pointer_events(
bridge: Res<JsBridge>,
buttons: Res<ButtonInput<MouseButton>>,
touches: Res<bevy::input::touch::Touches>,
windows: Query<&Window>,
nodes: Query<(
Entity,
&RNode,
&Interaction,
&RelativeCursorPosition,
&PointerHandlers,
// SVG shapes report x/y in user units instead (see [`event_pos`]).
Option<&SvgUserPos>,
// Node geometry, for the touch-begin hit-test (required components of
// `Node`, so every production handler node carries them).
&ComputedNode,
&UiGlobalTransform,
)>,
interactions: Query<&Interaction>,
mut capture: ResMut<crate::PointerCapture>,
mut drag: ResMut<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.binding.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);
let source = DragSource::Mouse {
button: mb,
dom_button: dom,
};
drag.begin(entity, source, pos, abs);
if handlers.down {
emit(rnode, "pointerDown", pos, abs, dom);
}
break 'begin;
}
}
}
}
if drag.binding.is_none()
&& let Some(scale) = windows.iter().next().map(|w| w.scale_factor())
{
'begin_touch: for touch in touches.iter_just_pressed() {
let point = touch.position() * scale;
for (entity, rnode, interaction, rel, handlers, user, computed, transform) in &nodes {
if *interaction != Interaction::Pressed
|| !computed.contains_point(*transform, point)
{
continue;
}
let pos = event_pos(user, Some(rel)).unwrap_or(drag.last_pos);
let abs = touch.position();
drag.begin(entity, DragSource::Touch { id: touch.id() }, pos, abs);
if handlers.down {
emit(rnode, "pointerDown", pos, abs, 0);
}
break 'begin_touch;
}
}
}
let held = match drag.binding {
Some((_, DragSource::Touch { id })) => touches.get_pressed(id).is_some(),
Some((_, DragSource::Mouse { button, .. })) => buttons.pressed(button),
None => false,
};
if held
&& let Some((entity, source)) = drag.binding
&& let Ok((_, rnode, _, rel, handlers, user, _, _)) = nodes.get(entity)
{
let pos = event_pos(user, Some(rel)).unwrap_or(drag.last_pos);
let abs = match source {
DragSource::Touch { id } => touches
.get_pressed(id)
.map(|t| t.position())
.unwrap_or(drag.last_abs),
DragSource::Mouse { .. } => 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, source.dom_button());
}
}
let released = match drag.binding {
Some((_, DragSource::Touch { id })) => {
touches.just_released(id) || touches.just_canceled(id)
}
Some((_, DragSource::Mouse { button, .. })) => buttons.just_released(button),
None => false,
};
if released
&& let Some((entity, source)) = drag.end()
&& let Ok((_, rnode, _, rel, handlers, user, _, _)) = nodes.get(entity)
{
let pos = event_pos(user, Some(rel)).unwrap_or(drag.last_pos);
let abs = match source {
DragSource::Touch { id } => touches
.get_released(id)
.map(|t| t.position())
.unwrap_or(drag.last_abs),
DragSource::Mouse { .. } => cursor_abs.unwrap_or(drag.last_abs),
};
if handlers.up {
emit(rnode, "pointerUp", pos, abs, source.dom_button());
}
}
capture.dragging = drag.binding.is_some();
capture.over_ui = interactions.iter().any(|i| *i != Interaction::None);
capture.wheel_captured = false;
}
#[cfg(test)]
mod tests {
use bevy::picking::events::{Click, Pointer};
use super::*;
use crate::protocol::{op::Op, outbound::UiEvent};
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 pointer_move_only_fires_on_cursor_movement() {
let (mut app, mut out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<bevy::input::touch::Touches>();
app.init_resource::<crate::PointerCapture>();
app.init_resource::<ActiveDrag>();
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()
},
ComputedNode {
size: Vec2::new(200.0, 200.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(100.0, 100.0)),
))
.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 touch_drag_emits_pointer_events() {
use bevy::input::touch::{TouchInput, TouchPhase, Touches, touch_screen_input_system};
let (mut app, mut out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<Touches>();
app.init_resource::<crate::PointerCapture>();
app.init_resource::<ActiveDrag>();
app.add_message::<TouchInput>();
app.add_systems(
Update,
(touch_screen_input_system, collect_pointer_events).chain(),
);
let win = app.world_mut().spawn(Window::default()).id();
let node = app
.world_mut()
.spawn((
RNode(1),
Interaction::None,
RelativeCursorPosition {
cursor_over: false,
normalized: None,
},
PointerHandlers {
down: true,
moved: true,
up: true,
..default()
},
ComputedNode {
size: Vec2::new(200.0, 200.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(100.0, 100.0)),
))
.id();
let touch = |phase, x: f32| TouchInput {
phase,
position: Vec2::new(x, 100.0),
window: win,
force: None,
id: 7,
};
let events = |rx: &mut tokio::sync::mpsc::UnboundedReceiver<Outbound>| drain_clicks(rx);
let dragging = |app: &App| app.world().resource::<crate::PointerCapture>().dragging;
app.world_mut()
.write_message(touch(TouchPhase::Started, 100.0));
{
let mut e = app.world_mut().entity_mut(node);
*e.get_mut::<Interaction>().unwrap() = Interaction::Pressed;
*e.get_mut::<RelativeCursorPosition>().unwrap() = RelativeCursorPosition {
cursor_over: true,
normalized: Some(Vec2::ZERO),
};
}
app.update();
let down = events(&mut out_rx);
assert_eq!(
down.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(),
["pointerDown"]
);
assert_eq!(
down[0].client_x,
Some(100.0),
"absolute coordinates ride the touch position, not a window cursor"
);
assert!(
dragging(&app),
"a touch drag claims PointerCapture::dragging"
);
app.update();
assert!(events(&mut out_rx).is_empty());
app.world_mut()
.write_message(touch(TouchPhase::Moved, 110.0));
app.world_mut()
.get_mut::<RelativeCursorPosition>(node)
.unwrap()
.normalized = Some(Vec2::new(0.05, 0.0));
app.update();
let moved = events(&mut out_rx);
assert_eq!(
moved.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(),
["pointerMove"]
);
assert_eq!(moved[0].client_x, Some(110.0));
app.world_mut()
.write_message(touch(TouchPhase::Ended, 110.0));
app.update();
assert_eq!(
events(&mut out_rx)
.iter()
.map(|e| e.kind.as_str())
.collect::<Vec<_>>(),
["pointerUp"]
);
assert!(!dragging(&app));
}
#[test]
fn touch_binds_only_the_touch_over_the_node() {
use bevy::input::touch::{TouchInput, TouchPhase, Touches, touch_screen_input_system};
let (mut app, mut out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<Touches>();
app.init_resource::<crate::PointerCapture>();
app.init_resource::<ActiveDrag>();
app.add_message::<TouchInput>();
app.add_systems(
Update,
(touch_screen_input_system, collect_pointer_events).chain(),
);
let win = app.world_mut().spawn(Window::default()).id();
app.world_mut().spawn((
RNode(1),
Interaction::Pressed,
RelativeCursorPosition {
cursor_over: true,
normalized: Some(Vec2::ZERO),
},
PointerHandlers {
down: true,
moved: true,
..default()
},
ComputedNode {
size: Vec2::new(200.0, 200.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(100.0, 100.0)),
));
let touch = |id: u64, phase, pos: Vec2| TouchInput {
phase,
position: pos,
window: win,
force: None,
id,
};
app.world_mut()
.write_message(touch(7, TouchPhase::Started, Vec2::new(500.0, 500.0)));
app.world_mut()
.write_message(touch(8, TouchPhase::Started, Vec2::new(100.0, 100.0)));
app.update();
let down = drain_clicks(&mut out_rx);
assert_eq!(
down.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(),
["pointerDown"]
);
assert_eq!(
down[0].client_x,
Some(100.0),
"the drag binds to the finger over the node, whatever the iteration order"
);
app.world_mut()
.write_message(touch(7, TouchPhase::Moved, Vec2::new(520.0, 500.0)));
app.update();
assert!(
drain_clicks(&mut out_rx).is_empty(),
"the unbound finger's movement is silent"
);
app.world_mut()
.write_message(touch(8, TouchPhase::Moved, Vec2::new(110.0, 100.0)));
app.update();
let moved = drain_clicks(&mut out_rx);
assert_eq!(
moved.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(),
["pointerMove"]
);
assert_eq!(moved[0].client_x, Some(110.0));
}
#[test]
fn touch_elsewhere_does_not_bind_pressed_node() {
use bevy::input::touch::{TouchInput, TouchPhase, Touches, touch_screen_input_system};
let (mut app, mut out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<Touches>();
app.init_resource::<crate::PointerCapture>();
app.init_resource::<ActiveDrag>();
app.add_message::<TouchInput>();
app.add_systems(
Update,
(touch_screen_input_system, collect_pointer_events).chain(),
);
let win = app.world_mut().spawn(Window::default()).id();
app.world_mut().spawn((
RNode(1),
Interaction::Pressed,
RelativeCursorPosition {
cursor_over: true,
normalized: Some(Vec2::ZERO),
},
PointerHandlers {
down: true,
..default()
},
ComputedNode {
size: Vec2::new(200.0, 200.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(100.0, 100.0)),
));
app.world_mut().write_message(TouchInput {
phase: TouchPhase::Started,
position: Vec2::new(500.0, 500.0),
window: win,
force: None,
id: 7,
});
app.update();
assert!(
drain_clicks(&mut out_rx).is_empty(),
"no pointerDown for a touch outside the Pressed node"
);
assert!(!app.world().resource::<crate::PointerCapture>().dragging);
}
#[test]
fn canceled_touch_pointer_up_reports_last_touch_position() {
use bevy::input::touch::{TouchInput, TouchPhase, Touches, touch_screen_input_system};
let (mut app, mut out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<Touches>();
app.init_resource::<crate::PointerCapture>();
app.init_resource::<ActiveDrag>();
app.add_message::<TouchInput>();
app.add_systems(
Update,
(touch_screen_input_system, collect_pointer_events).chain(),
);
let mut window = Window::default();
window.set_physical_cursor_position(Some(bevy::math::DVec2::new(200.0, 50.0)));
let win = app.world_mut().spawn(window).id();
app.world_mut().spawn((
RNode(1),
Interaction::Pressed,
RelativeCursorPosition {
cursor_over: true,
normalized: Some(Vec2::ZERO),
},
PointerHandlers {
up: true,
..default()
},
ComputedNode {
size: Vec2::new(200.0, 200.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(100.0, 100.0)),
));
let touch = |phase, x: f32| TouchInput {
phase,
position: Vec2::new(x, 100.0),
window: win,
force: None,
id: 7,
};
app.world_mut()
.write_message(touch(TouchPhase::Started, 100.0));
app.update();
app.world_mut()
.write_message(touch(TouchPhase::Moved, 110.0));
app.update();
drain_clicks(&mut out_rx);
app.world_mut()
.write_message(touch(TouchPhase::Canceled, 110.0));
app.update();
let up = drain_clicks(&mut out_rx);
assert_eq!(
up.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(),
["pointerUp"],
"a canceled touch still ends the drag with a pointerUp"
);
assert_eq!(
up[0].client_x,
Some(110.0),
"the pointerUp reports the finger's last position, not the idle mouse cursor"
);
assert!(!app.world().resource::<crate::PointerCapture>().dragging);
}
#[test]
fn pointer_capture_wheel_claim_resets_each_frame() {
let (mut app, _out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<bevy::input::touch::Touches>();
app.init_resource::<crate::PointerCapture>();
app.init_resource::<ActiveDrag>();
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 svg_user_pos_overrides_pointer_event_coords() {
let (mut app, mut out_rx) = click_app();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<bevy::input::touch::Touches>();
app.init_resource::<crate::PointerCapture>();
app.init_resource::<ActiveDrag>();
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))),
ComputedNode {
size: Vec2::new(200.0, 200.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(100.0, 100.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"
);
}
}