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

+9
View File
@@ -135,6 +135,15 @@ impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
)); ));
} }
/// The event lists this widget was registered with, without running
/// anything. Asking what a widget would match is a separate question
/// from dispatching to it: input routing needs the first to decide
/// whether a widget consumes an event, and `run_fn` can only answer
/// the second.
pub fn registered(&self, id: WidgetId) -> impl Iterator<Item = &E> {
self.map.get(&id).into_iter().flatten().map(|(e, _)| e)
}
pub fn run_fn<'a>( pub fn run_fn<'a>(
&mut self, &mut self,
id: impl IdLike, id: impl IdLike,
+40 -1
View File
@@ -52,6 +52,18 @@ impl CursorSense {
pub fn is_dragging(&self) -> bool { pub fn is_dragging(&self) -> bool {
matches!(self, CursorSense::Pressing(CursorButton::Left)) 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)] #[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 / // 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 // state like thing, but local to render state, and is passed to UiRsc events so you can
// update it there? // 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); let mut active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
for layer in self.layers.indices().rev() { for layer in self.layers.indices().rev() {
let mut sensed = false; let mut sensed = false;
@@ -174,7 +191,29 @@ impl SensorUi for UiRenderState {
if sensor.hover == ActivationState::Off { if sensor.hover == ActivationState::Off {
continue; 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(); let cursor = cursor.clone();
+121
View File
@@ -0,0 +1,121 @@
//! A widget that registered only `click()` used to block a `Scroll` meant
//! for whatever is behind it: `run_sensors` decided "consumed, stop looking
//! at lower layers" from mere hover rather than from anything actually
//! matching the input. These drive `run_sensors` directly, which needs no
//! GPU and no window.
use iris::prelude::*;
use std::{cell::Cell, rc::Rc};
struct SenseRsc {
ui: UiData,
events: EventManager<SenseRsc>,
}
impl UiRsc for SenseRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
}
}
impl HasState for SenseRsc {
type State = ();
}
impl HasEvents for SenseRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
fn cursor_at(pos: Vec2) -> CursorState {
CursorState {
pos,
exists: true,
buttons: Default::default(),
scroll_delta: Vec2::ZERO,
}
}
#[test]
fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Both cover the whole window: the button "sitting over" the list.
let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let list_weak = list.weak();
let button = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let button_weak = button.weak();
let scrolled = Rc::new(Cell::new(false));
let clicked = Rc::new(Cell::new(false));
{
let scrolled = scrolled.clone();
rsc.register_event(list_weak, CursorSense::Scroll, move |_ctx, _rsc| {
scrolled.set(true);
});
}
{
let clicked = clicked.clone();
rsc.register_event(button_weak, CursorSense::click(), move |_ctx, _rsc| {
clicked.set(true);
});
}
// A Stack draws its children on separate layers in order, which is
// exactly the "one thing drawn over another" shape `run_sensors`
// walks top layer first.
let root = rsc
.ui
.widgets
.add_strong(Stack {
children: vec![list.any(), button.any()],
size: StackSize::default(),
})
.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut state = ();
let mut scroll_cursor = cursor_at((50.0, 50.0).into());
scroll_cursor.scroll_delta = (0.0, 10.0).into();
render.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into());
assert!(
scrolled.get(),
"a scroll over the button must still reach the list underneath it"
);
assert!(
!clicked.get(),
"a scroll is not a click; the button must not have fired"
);
let mut click_cursor = cursor_at((50.0, 50.0).into());
click_cursor.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into());
assert!(
clicked.get(),
"the button on top must still receive an actual click"
);
}