Route pointer input per kind, so a scroll falls through a hovered button

`run_sensors` decided that a widget had consumed the frame's input from
hover alone: if the cursor was inside its shape, no lower layer saw
anything. So a button sitting over a list swallowed the list's scroll,
having registered nothing but `click()`.

Being in shape still runs a widget -- a hover highlight has to fire on the
topmost thing under the cursor regardless -- but consuming is now judged
per input kind. With nothing momentary happening the behaviour is
unchanged and the topmost widget wins the hover; with a scroll or a press
happening, only a widget that registered a matching momentary sense
consumes it.

`TypeEventManager::registered` is what makes that askable: what a widget
would match is a different question from dispatching to it, and `run_fn`
can only answer the second.

tests/pointer_routing.rs drives `run_sensors` directly, with no GPU and no
window. It fails on the unfixed code with "a scroll over the button must
still reach the list underneath it".
This commit is contained in:
iris committed 2026-09-13 04:01:22 -04:00
1 parent 0f6a28b4dd
commit 028521b419
3 files changed
+170 -1

No files matched your search

+40 -1
View File
@@ -52,6 +52,18 @@ impl CursorSense {
pub fn is_dragging(&self) -> bool {
matches!(self, CursorSense::Pressing(CursorButton::Left))
}
/// True for a sense that names something happening this frame (a
/// button transitioning, a scroll), as opposed to the ambient
/// `Hover*` family that is on for as long as the cursor rests there.
/// Only a momentary sense can consume an input -- see
/// `SensorUi::run_sensors`.
pub fn is_momentary(&self) -> bool {
!matches!(
self,
CursorSense::HoverStart | CursorSense::Hovering | CursorSense::HoverEnd
)
}
}
#[derive(Default, Clone)]
@@ -163,6 +175,11 @@ impl SensorUi for UiRenderState {
// this would probably be done through a generic parameter that adds yet another rsc /
// state like thing, but local to render state, and is passed to UiRsc events so you can
// update it there?
// Whether anything momentary is happening this frame at all; see
// `consumed` below, which is only judged per kind when it is.
let momentary_active =
cursor.scroll_delta != Vec2::ZERO || cursor.buttons.iter().any(|(_, a)| !a.is_off());
let mut active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
for layer in self.layers.indices().rev() {
let mut sensed = false;
@@ -174,7 +191,29 @@ impl SensorUi for UiRenderState {
if sensor.hover == ActivationState::Off {
continue;
}
sensed = true;
// A widget in shape always still runs: a hover-only
// highlight must fire on the topmost thing under the
// cursor even while a scroll passes through it. What is
// judged per input kind is whether it *consumes* that
// input, stopping a lower layer from seeing it. With
// nothing momentary happening, being in shape is
// consumption and the topmost widget wins the hover; with
// a scroll or a press happening, only a widget that
// registered a matching momentary sense consumes it, so a
// button registered for `click()` alone cannot block a
// scroll meant for the list behind it.
let consumed = if momentary_active {
rsc.events_mut()
.get_type::<CursorSense>()
.registered(*id)
.any(|senses| {
matches!(should_run(senses, &cursor, sensor.hover), Some(s) if s.is_momentary())
})
} else {
true
};
sensed |= consumed;
let cursor = cursor.clone();