use bevy::camera::{NormalizedRenderTarget, RenderTarget};
use bevy::ecs::system::SystemParam;
use bevy::input::mouse::{AccumulatedMouseScroll, MouseScrollUnit};
use bevy::picking::pointer::{PointerId, PointerLocation};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, ComputedUiTargetCamera, ScrollPosition, UiGlobalTransform, UiStack};
use bevy::window::PrimaryWindow;
use crate::bridge::{JsBridge, ReactNode, ScrollStep, WheelListener};
use crate::plugin::PointerCapture;
use crate::protocol::{outbound::Outbound, outbound::UiEvent};
use crate::transition::ScrollTransitionState;
const LINE_HEIGHT: f32 = 20.0;
#[derive(SystemParam)]
pub struct WheelCursors<'w, 's> {
windows: Query<'w, 's, &'static Window, With<PrimaryWindow>>,
cameras: Query<'w, 's, &'static RenderTarget, With<Camera>>,
pointers: Query<'w, 's, (&'static PointerId, &'static PointerLocation)>,
virtual_pointers: Option<Res<'w, crate::ext::VirtualPointers>>,
target_cameras: Query<'w, 's, &'static ComputedUiTargetCamera>,
}
#[derive(Clone, Copy)]
struct WheelCursor {
physical: Vec2,
logical: Vec2,
}
impl WheelCursors<'_, '_> {
fn candidates(&self, ui_stack: &UiStack) -> Vec<(Entity, WheelCursor)> {
let window = self.windows.single().ok().and_then(|w| {
let logical = w.cursor_position()?;
Some(WheelCursor {
physical: logical * w.scale_factor(),
logical,
})
});
let on_images: Vec<(AssetId<Image>, WheelCursor)> = self
.pointers
.iter()
.filter(|(id, _)| {
self.virtual_pointers
.as_ref()
.is_some_and(|v| v.contains(**id))
})
.filter_map(|(_, location)| {
let location = location.location()?;
let NormalizedRenderTarget::Image(image) = &location.target else {
return None;
};
Some((
image.handle.id(),
WheelCursor {
physical: location.position * image.scale_factor,
logical: location.position,
},
))
})
.collect();
let cursor_of = |entity: Entity| -> Option<(bool, WheelCursor)> {
let camera = self.target_cameras.get(entity).ok().and_then(|c| c.get());
match camera.and_then(|c| self.cameras.get(c).ok()) {
Some(RenderTarget::Image(image)) => on_images
.iter()
.find(|(id, _)| *id == image.handle.id())
.map(|(_, cursor)| (true, *cursor)),
_ => window.map(|cursor| (false, cursor)),
}
};
let mut window_nodes = Vec::new();
let mut image_nodes = Vec::new();
for &entity in ui_stack.uinodes.iter().rev() {
match cursor_of(entity) {
Some((false, cursor)) => window_nodes.push((entity, cursor)),
Some((true, cursor)) => image_nodes.push((entity, cursor)),
None => {}
}
}
window_nodes.extend(image_nodes);
window_nodes
}
}
pub(crate) fn is_scroll_container(node: &Node) -> bool {
node.overflow.x == OverflowAxis::Scroll || node.overflow.y == OverflowAxis::Scroll
}
pub(crate) fn scroll_range(computed: &ComputedNode) -> Vec2 {
let border = computed.border.min_inset + computed.border.max_inset;
(computed.content_size + computed.scrollbar_size.min(computed.size) - computed.size + border)
.max(Vec2::ZERO)
* computed.inverse_scale_factor
}
pub(crate) fn scroll_base(pos: &ScrollPosition, state: Option<&ScrollTransitionState>) -> Vec2 {
state.map_or(pos.0, |s| s.target)
}
pub(crate) fn write_scroll(
pos: &mut ScrollPosition,
state: Option<Mut<ScrollTransitionState>>,
next: Vec2,
) {
match state {
Some(mut state) => {
if state.target != next {
state.target = next;
}
}
None => {
if pos.0 != next {
pos.0 = next;
}
}
}
}
#[allow(clippy::type_complexity)]
pub fn apply_scroll(
accumulated: Res<AccumulatedMouseScroll>,
cursors: WheelCursors,
ui_stack: Res<UiStack>,
mut scrollables: Query<(
&ComputedNode,
&UiGlobalTransform,
&Node,
&mut ScrollPosition,
Option<&ScrollStep>,
Option<&mut ScrollTransitionState>,
)>,
wheel_listeners: Query<(), With<WheelListener>>,
mut capture: ResMut<PointerCapture>,
) {
if accumulated.delta == Vec2::ZERO {
return;
}
for (entity, cursor) in cursors.candidates(&ui_stack) {
let Ok((computed, transform, node, mut pos, step, scroll_state)) =
scrollables.get_mut(entity)
else {
continue;
};
if !computed.contains_point(*transform, cursor.physical) {
continue; }
if wheel_listeners.contains(entity) {
return;
}
if !is_scroll_container(node) {
continue; }
let delta = match accumulated.unit {
MouseScrollUnit::Line => accumulated.delta * step.map(|s| s.0).unwrap_or(LINE_HEIGHT),
MouseScrollUnit::Pixel => accumulated.delta,
};
let max = scroll_range(computed);
let base = scroll_base(&pos, scroll_state.as_deref());
let mut next = base;
let mut consumed = false;
if node.overflow.x == OverflowAxis::Scroll && delta.x != 0.0 && max.x > 0.0 {
next.x = (base.x - delta.x).clamp(0.0, max.x);
consumed = true;
}
if node.overflow.y == OverflowAxis::Scroll && delta.y != 0.0 && max.y > 0.0 {
next.y = (base.y - delta.y).clamp(0.0, max.y);
consumed = true;
}
if consumed {
write_scroll(&mut pos, scroll_state, next);
capture.wheel_captured = true;
break;
}
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct PendingControlledScroll(pub Vec2);
pub fn settle_controlled_scroll(
mut commands: Commands,
mut pending: Query<(
Entity,
&PendingControlledScroll,
&ComputedNode,
&mut ScrollPosition,
Option<&mut ScrollTransitionState>,
)>,
) {
for (entity, request, computed, mut pos, scroll_state) in &mut pending {
let clamped = request.0.clamp(Vec2::ZERO, scroll_range(computed));
write_scroll(&mut pos, scroll_state, clamped);
commands.entity(entity).remove::<PendingControlledScroll>();
}
}
pub fn collect_wheel_events(
accumulated: Res<AccumulatedMouseScroll>,
cursors: WheelCursors,
ui_stack: Res<UiStack>,
bridge: Res<JsBridge>,
listeners: Query<(&ComputedNode, &UiGlobalTransform, &ReactNode), With<WheelListener>>,
mut capture: ResMut<PointerCapture>,
keys: Option<Res<ButtonInput<KeyCode>>>,
) {
if accumulated.delta == Vec2::ZERO {
return;
}
let delta_mode = match accumulated.unit {
MouseScrollUnit::Line => "line",
MouseScrollUnit::Pixel => "pixel",
};
for (entity, cursor) in cursors.candidates(&ui_stack) {
let Ok((computed, transform, rnode)) = listeners.get(entity) else {
continue;
};
if !computed.contains_point(*transform, cursor.physical) {
continue;
}
let size = computed.size;
let min = transform.translation - size * 0.5;
let norm = ((cursor.physical - min) / size).clamp(Vec2::ZERO, Vec2::ONE);
let mut event = UiEvent {
id: rnode.0,
kind: "wheel".to_string(),
x: Some(norm.x),
y: Some(norm.y),
client_x: Some(cursor.logical.x),
client_y: Some(cursor.logical.y),
delta_x: Some(accumulated.delta.x),
delta_y: Some(accumulated.delta.y),
delta_mode: Some(delta_mode.to_string()),
..default()
};
crate::reconcile::Modifiers::read(keys.as_deref()).stamp(&mut event);
let _ = bridge.outbound_tx.send(Outbound::UiEvent { event });
capture.wheel_captured = true;
break;
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
#[test]
fn scroll_range_is_zero_when_content_fits() {
let fits = ComputedNode {
size: Vec2::new(100.0, 100.0),
content_size: Vec2::new(100.0, 100.0),
inverse_scale_factor: 1.0,
..default()
};
assert_eq!(scroll_range(&fits), Vec2::ZERO);
let overflowing = ComputedNode {
size: Vec2::new(100.0, 100.0),
content_size: Vec2::new(100.0, 300.0),
inverse_scale_factor: 1.0,
..default()
};
assert_eq!(scroll_range(&overflowing), Vec2::new(0.0, 200.0));
}
fn run_with_content(
cursor: Vec2,
start: Vec2,
wheel: Vec2,
content_h: f32,
) -> (Vec2, PointerCapture) {
let mut world = World::new();
let mut window = Window::default();
window.set_physical_cursor_position(Some(cursor.as_dvec2()));
world.spawn((window, PrimaryWindow));
let container = world
.spawn((
Node {
overflow: Overflow::scroll_y(),
..default()
},
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, content_h),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
ScrollPosition(start),
))
.id();
let child = world
.spawn((
Node::default(),
ComputedNode {
size: Vec2::new(80.0, 20.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
ScrollPosition::default(),
))
.id();
world.insert_resource(UiStack {
uinodes: vec![container, child],
partition: Vec::new(),
});
world.insert_resource(AccumulatedMouseScroll {
unit: MouseScrollUnit::Line,
delta: wheel,
});
world.insert_resource(PointerCapture::default());
world.run_system_once(apply_scroll).unwrap();
let pos = world.entity(container).get::<ScrollPosition>().unwrap().0;
let capture = *world.resource::<PointerCapture>();
(pos, capture)
}
fn run(cursor: Vec2, start: Vec2, wheel: Vec2) -> (Vec2, PointerCapture) {
run_with_content(cursor, start, wheel, 300.0)
}
fn run_with(extra: impl Bundle, wheel: Vec2) -> (World, Entity) {
let mut world = World::new();
let mut window = Window::default();
window.set_physical_cursor_position(Some(Vec2::new(300.0, 200.0).as_dvec2()));
world.spawn((window, PrimaryWindow));
let container = world
.spawn((
Node {
overflow: Overflow::scroll_y(),
..default()
},
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 300.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
ScrollPosition::default(),
extra,
))
.id();
world.insert_resource(UiStack {
uinodes: vec![container],
partition: Vec::new(),
});
world.insert_resource(AccumulatedMouseScroll {
unit: MouseScrollUnit::Line,
delta: wheel,
});
world.insert_resource(PointerCapture::default());
world.run_system_once(apply_scroll).unwrap();
(world, container)
}
#[test]
fn scroll_step_scales_the_wheel_delta() {
let (world, container) = run_with(ScrollStep(40.0), Vec2::new(0.0, -1.0));
let pos = world.entity(container).get::<ScrollPosition>().unwrap().0;
assert_eq!(pos, Vec2::new(0.0, 40.0));
}
#[test]
fn wheel_feeds_target_when_scroll_transition_present() {
let (world, container) = run_with(ScrollTransitionState::default(), Vec2::new(0.0, -1.0));
assert_eq!(
world.entity(container).get::<ScrollPosition>().unwrap().0,
Vec2::ZERO,
"the wheel must not move the offset directly when easing"
);
assert_eq!(
world
.entity(container)
.get::<ScrollTransitionState>()
.unwrap()
.target,
Vec2::new(0.0, 20.0),
"the wheel accumulates into the eased target"
);
}
#[test]
fn scrolls_container_when_cursor_is_over_its_child_text() {
let (pos, capture) = run(Vec2::new(300.0, 200.0), Vec2::ZERO, Vec2::new(0.0, -1.0));
assert_eq!(pos, Vec2::new(0.0, LINE_HEIGHT));
assert!(
capture.wheel_captured,
"a consumed wheel must claim the wheel channel"
);
assert!(
!capture.over_ui,
"consuming the wheel is not a hover claim — `over_ui` stays untouched \
so world drag/press gating is unaffected"
);
}
#[test]
fn clamps_at_the_bottom() {
let (pos, _) = run(
Vec2::new(300.0, 200.0),
Vec2::new(0.0, 190.0),
Vec2::new(0.0, -1.0),
);
assert_eq!(pos, Vec2::new(0.0, 200.0));
}
#[test]
fn clamps_at_the_top() {
let (pos, _) = run(
Vec2::new(300.0, 200.0),
Vec2::new(0.0, 10.0),
Vec2::new(0.0, 1.0),
);
assert_eq!(pos, Vec2::new(0.0, 0.0));
}
#[test]
fn surface_ui_scrolls_under_its_virtual_pointer_only() {
use bevy::camera::ImageRenderTarget;
use bevy::picking::pointer::Location;
use bevy::reflect::{FromReflect, structs::DynamicStruct};
let run = |pointer_at: Option<Vec2>| {
let mut world = World::new();
world.init_resource::<bevy::picking::pointer::PointerMap>();
let mut window = Window::default();
window.set_physical_cursor_position(Some(Vec2::new(300.0, 200.0).as_dvec2()));
world.spawn((window, PrimaryWindow));
let image = ImageRenderTarget {
handle: Handle::<Image>::default(),
scale_factor: 1.0,
};
let camera = world
.spawn((Camera::default(), RenderTarget::Image(image.clone())))
.id();
let mut target = DynamicStruct::default();
target.insert("camera", Some(camera));
let container = world
.spawn((
Node {
overflow: Overflow::scroll_y(),
..default()
},
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 300.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
ScrollPosition::default(),
ComputedUiTargetCamera::from_reflect(&target).unwrap(),
))
.id();
if let Some(position) = pointer_at {
let id = PointerId::Custom(bevy::asset::uuid::Uuid::from_u128(7));
let mut pointers = crate::ext::VirtualPointers::default();
pointers.register(id);
world.insert_resource(pointers);
world.spawn((
id,
PointerLocation::new(Location {
target: NormalizedRenderTarget::Image(image),
position,
}),
));
}
world.insert_resource(UiStack {
uinodes: vec![container],
partition: Vec::new(),
});
world.insert_resource(AccumulatedMouseScroll {
unit: MouseScrollUnit::Line,
delta: Vec2::new(0.0, -1.0),
});
world.insert_resource(PointerCapture::default());
world.run_system_once(apply_scroll).unwrap();
world.entity(container).get::<ScrollPosition>().unwrap().0
};
assert_eq!(run(None), Vec2::ZERO, "the window cursor must not reach it");
assert_eq!(
run(Some(Vec2::new(250.0, 180.0))),
Vec2::new(0.0, LINE_HEIGHT)
);
assert_eq!(
run(Some(Vec2::splat(-1.0))),
Vec2::ZERO,
"parked off-bounds"
);
}
#[test]
fn ignores_wheel_when_cursor_is_outside_the_container() {
let (pos, capture) = run(
Vec2::new(50.0, 50.0),
Vec2::new(0.0, 30.0),
Vec2::new(0.0, -1.0),
);
assert_eq!(pos, Vec2::new(0.0, 30.0));
assert!(!capture.wheel_captured);
}
#[test]
fn does_not_claim_wheel_when_content_fits() {
let (pos, capture) = run_with_content(
Vec2::new(300.0, 200.0),
Vec2::ZERO,
Vec2::new(0.0, -1.0),
100.0,
);
assert_eq!(pos, Vec2::ZERO);
assert!(
!capture.wheel_captured,
"a container with nothing to scroll must not claim the wheel"
);
}
#[test]
fn wheel_listener_on_top_blocks_ancestor_scroll() {
let mut world = World::new();
let mut window = Window::default();
window.set_physical_cursor_position(Some(Vec2::new(300.0, 200.0).as_dvec2()));
world.spawn((window, PrimaryWindow));
let container = world
.spawn((
Node {
overflow: Overflow::scroll_y(),
..default()
},
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 300.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
ScrollPosition::default(),
))
.id();
let child = world
.spawn((
Node::default(),
ComputedNode {
size: Vec2::new(80.0, 20.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
ScrollPosition::default(),
WheelListener,
))
.id();
world.insert_resource(UiStack {
uinodes: vec![container, child],
partition: Vec::new(),
});
world.insert_resource(AccumulatedMouseScroll {
unit: MouseScrollUnit::Line,
delta: Vec2::new(0.0, -1.0),
});
world.insert_resource(PointerCapture::default());
world.run_system_once(apply_scroll).unwrap();
assert_eq!(
world.entity(container).get::<ScrollPosition>().unwrap().0,
Vec2::ZERO,
"an onWheel node on top must block its ancestor scroll container"
);
}
#[test]
fn settle_reclamps_pending_request_with_fresh_geometry() {
let mut world = World::new();
let container = world
.spawn((
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 500.0),
inverse_scale_factor: 1.0,
..default()
},
ScrollPosition(Vec2::new(0.0, 200.0)),
PendingControlledScroll(Vec2::new(0.0, 1e9)),
))
.id();
world.run_system_once(settle_controlled_scroll).unwrap();
assert_eq!(
world.entity(container).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 400.0),
"the raw request must re-clamp against fresh geometry"
);
assert!(
world
.entity(container)
.get::<PendingControlledScroll>()
.is_none(),
"the marker is one-shot"
);
}
#[test]
fn settle_feeds_transition_target_and_in_range_is_silent() {
let mut world = World::new();
let eased = world
.spawn((
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 500.0),
inverse_scale_factor: 1.0,
..default()
},
ScrollPosition(Vec2::new(0.0, 50.0)),
ScrollTransitionState::default(),
PendingControlledScroll(Vec2::new(0.0, 1e9)),
))
.id();
world
.entity_mut(eased)
.get_mut::<ScrollTransitionState>()
.unwrap()
.target = Vec2::new(0.0, 200.0);
let in_range = world
.spawn((
ComputedNode {
size: Vec2::new(200.0, 100.0),
content_size: Vec2::new(200.0, 500.0),
inverse_scale_factor: 1.0,
..default()
},
ScrollPosition(Vec2::new(0.0, 120.0)),
PendingControlledScroll(Vec2::new(0.0, 120.0)),
))
.id();
world.run_system_once(settle_controlled_scroll).unwrap();
assert_eq!(
world
.entity(eased)
.get::<ScrollTransitionState>()
.unwrap()
.target,
Vec2::new(0.0, 400.0),
"easing: the settle corrects the target"
);
assert_eq!(
world.entity(eased).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 50.0),
"easing: the live offset is the drive system's job"
);
assert_eq!(
world.entity(in_range).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 120.0),
"an in-range request settles to itself"
);
assert!(
world
.entity(in_range)
.get::<PendingControlledScroll>()
.is_none()
);
}
fn run_wheel(
cursor: Vec2,
wheel: Vec2,
unit: MouseScrollUnit,
) -> (Option<UiEvent>, PointerCapture) {
let mut world = World::new();
let mut window = Window::default();
window.set_physical_cursor_position(Some(cursor.as_dvec2()));
world.spawn((window, PrimaryWindow));
let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<crate::protocol::op::Op>>();
let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let root = world.spawn_empty().id();
world.insert_resource(JsBridge::new(ops_rx, out_tx, root));
let node = world
.spawn((
ReactNode(7),
ComputedNode {
size: Vec2::new(200.0, 100.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
WheelListener,
))
.id();
world.insert_resource(UiStack {
uinodes: vec![node],
partition: Vec::new(),
});
world.insert_resource(AccumulatedMouseScroll { unit, delta: wheel });
world.insert_resource(PointerCapture::default());
world.run_system_once(collect_wheel_events).unwrap();
let event = out_rx.try_recv().ok().map(|o| match o {
Outbound::UiEvent { event } => event,
other => panic!("expected a UiEvent, got {other:?}"),
});
let capture = *world.resource::<PointerCapture>();
(event, capture)
}
#[test]
fn wheel_over_listener_emits_raw_delta_and_claims() {
let (event, capture) = run_wheel(
Vec2::new(300.0, 200.0),
Vec2::new(3.0, -2.0),
MouseScrollUnit::Line,
);
let event = event.expect("a wheel event over the listener");
assert_eq!(event.id, 7);
assert_eq!(event.kind, "wheel");
assert_eq!(event.delta_x, Some(3.0));
assert_eq!(event.delta_y, Some(-2.0));
assert_eq!(event.delta_mode.as_deref(), Some("line"));
assert_eq!(event.x, Some(0.5));
assert_eq!(event.y, Some(0.5));
assert_eq!(event.client_x, Some(300.0));
assert_eq!(event.client_y, Some(200.0));
assert!(
capture.wheel_captured,
"a handled wheel must claim the wheel channel"
);
assert!(
!capture.over_ui,
"handling the wheel is not a hover claim — `over_ui` stays untouched"
);
}
#[test]
fn wheel_reports_pixel_unit_for_trackpads() {
let (event, _) = run_wheel(
Vec2::new(300.0, 200.0),
Vec2::new(0.0, 12.0),
MouseScrollUnit::Pixel,
);
assert_eq!(
event.expect("a wheel event").delta_mode.as_deref(),
Some("pixel")
);
}
#[test]
fn wheel_outside_listener_emits_nothing() {
let (event, capture) = run_wheel(
Vec2::new(50.0, 50.0),
Vec2::new(0.0, -1.0),
MouseScrollUnit::Line,
);
assert!(event.is_none(), "no listener under the cursor");
assert!(!capture.wheel_captured);
}
#[test]
fn zero_wheel_emits_nothing() {
let (event, capture) =
run_wheel(Vec2::new(300.0, 200.0), Vec2::ZERO, MouseScrollUnit::Line);
assert!(event.is_none(), "a zero delta must not emit a wheel event");
assert!(!capture.wheel_captured);
}
}