use bevy::input::mouse::{AccumulatedMouseScroll, MouseScrollUnit};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, ScrollPosition, UiGlobalTransform, UiStack};
use bevy::window::PrimaryWindow;
use crate::bridge::{JsBridge, RNode, ScrollStep, WheelListener};
use crate::plugin::PointerCapture;
use crate::protocol::{Outbound, UiEvent};
use crate::transition::ScrollTransitionState;
const LINE_HEIGHT: f32 = 20.0;
fn is_scroll_container(node: &Node) -> bool {
node.overflow.x == OverflowAxis::Scroll || node.overflow.y == OverflowAxis::Scroll
}
#[allow(clippy::type_complexity)]
pub fn apply_scroll(
accumulated: Res<AccumulatedMouseScroll>,
windows: Query<&Window, With<PrimaryWindow>>,
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;
}
let Ok(window) = windows.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
let cursor = cursor * window.scale_factor();
for &entity in ui_stack.uinodes.iter().rev() {
let Ok((computed, transform, node, mut pos, step, scroll_state)) =
scrollables.get_mut(entity)
else {
continue;
};
if !computed.contains_point(*transform, cursor) {
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 = (computed.content_size - computed.size + computed.scrollbar_size).max(Vec2::ZERO)
* computed.inverse_scale_factor;
let base = scroll_state.as_ref().map_or(pos.0, |s| s.target);
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 {
match scroll_state {
Some(mut state) => state.target = next,
None => pos.0 = next,
}
capture.over_ui = true;
break;
}
}
}
pub fn collect_wheel_events(
accumulated: Res<AccumulatedMouseScroll>,
windows: Query<&Window, With<PrimaryWindow>>,
ui_stack: Res<UiStack>,
bridge: Res<JsBridge>,
listeners: Query<(&ComputedNode, &UiGlobalTransform, &RNode), With<WheelListener>>,
mut capture: ResMut<PointerCapture>,
) {
if accumulated.delta == Vec2::ZERO {
return;
}
let Ok(window) = windows.single() else {
return;
};
let Some(cursor_logical) = window.cursor_position() else {
return;
};
let cursor = cursor_logical * window.scale_factor();
let delta_mode = match accumulated.unit {
MouseScrollUnit::Line => "line",
MouseScrollUnit::Pixel => "pixel",
};
for &entity in ui_stack.uinodes.iter().rev() {
let Ok((computed, transform, rnode)) = listeners.get(entity) else {
continue;
};
if !computed.contains_point(*transform, cursor) {
continue;
}
let size = computed.size;
let min = transform.translation - size * 0.5;
let norm = ((cursor - min) / size).clamp(Vec2::ZERO, Vec2::ONE);
let _ = bridge.outbound_tx.send(Outbound::UiEvent {
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()
},
});
capture.over_ui = true;
break;
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
fn run_with_content(cursor: Vec2, start: Vec2, wheel: Vec2, content_h: f32) -> (Vec2, bool) {
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 over_ui = world.resource::<PointerCapture>().over_ui;
(pos, over_ui)
}
fn run(cursor: Vec2, start: Vec2, wheel: Vec2) -> (Vec2, bool) {
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, over_ui) = 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!(over_ui, "scrolling should claim the pointer for the UI");
}
#[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 ignores_wheel_when_cursor_is_outside_the_container() {
let (pos, over_ui) = 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!(!over_ui);
}
#[test]
fn does_not_claim_wheel_when_content_fits() {
let (pos, over_ui) = 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!(
!over_ui,
"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"
);
}
fn run_wheel(cursor: Vec2, wheel: Vec2, unit: MouseScrollUnit) -> (Option<UiEvent>, bool) {
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>>();
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((
RNode(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 over_ui = world.resource::<PointerCapture>().over_ui;
(event, over_ui)
}
#[test]
fn wheel_over_listener_emits_raw_delta_and_claims() {
let (event, over_ui) = 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!(
over_ui,
"handling the wheel must claim the pointer for the UI"
);
}
#[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, over_ui) = 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!(!over_ui);
}
#[test]
fn zero_wheel_emits_nothing() {
let (event, over_ui) =
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!(!over_ui);
}
}