Consume by layer, not by widget

Replaces the taking mechanism with `CursorSenses::consumes`, which
decides only whether a layer stops the input reaching the layer below.
Nothing is removed from the cursor, and senses on one layer no longer
block each other: every sensor the pointer is inside runs.

Where the cursor rests stops at the top layer under it. Something
happening to the cursor stops only at a widget that answers to it, so a
click-only child does not swallow a scroll -- which is what `main` gets
wrong, where any hovered sensor blocks the layer below.

A widget the cursor has left still hears its hover ending, but is handed
no press or scroll: that input landed somewhere else. This is a hit test
rather than a consumption rule, and without it a press beside a button
fires the button it just left.

`a_click_and_a_scroll_in_one_frame_go_to_different_widgets` goes with the
per-kind taking it tested. Of the five that remain, two fail on `main`.
This commit is contained in:
iris committed 2026-09-13 20:20:25 -04:00
1 parent 3ab9c922fd
commit f3fd9417d4
2 files changed
+43 -83

No files matched your search

+42 -61
View File
@@ -39,6 +39,19 @@ impl Event for CursorSenses {
} }
} }
impl CursorSenses {
/// Whether a widget with these senses stops the input reaching the layer
/// below it. Where the cursor rests stops at the top layer that is under
/// it; something happening to the cursor stops only at a widget that
/// answers to that, so a click-only child does not swallow a scroll.
fn consumes(&self, cursor: &CursorState, hover: ActivationState, momentary: bool) -> bool {
if !momentary {
return true;
}
should_run(self, cursor, hover).is_some_and(|sense| sense.is_momentary())
}
}
impl CursorSense { impl CursorSense {
pub fn click() -> Self { pub fn click() -> Self {
Self::PressStart(CursorButton::Left) Self::PressStart(CursorButton::Left)
@@ -53,17 +66,10 @@ impl CursorSense {
matches!(self, CursorSense::Pressing(CursorButton::Left)) matches!(self, CursorSense::Pressing(CursorButton::Left))
} }
/// Takes what this sense answers to out of `cursor`, so a widget below /// Whether this sense is about something happening to the cursor, rather
/// does not also get it. Hovering takes nothing: it goes to the topmost /// than about where it rests.
/// widget in shape, which is not a question about the input. fn is_momentary(&self) -> bool {
fn take(&self, cursor: &mut CursorState) { !matches!(self, Self::HoverStart | Self::Hovering | Self::HoverEnd)
match self {
Self::PressStart(button) | Self::Pressing(button) | Self::PressEnd(button) => {
*cursor.buttons.select_mut(button) = ActivationState::Off
}
Self::Scroll => cursor.scroll_delta = Vec2::ZERO,
Self::HoverStart | Self::Hovering | Self::HoverEnd => {}
}
} }
} }
@@ -91,14 +97,6 @@ impl CursorButtons {
} }
} }
pub fn select_mut(&mut self, button: &CursorButton) -> &mut ActivationState {
match button {
CursorButton::Left => &mut self.left,
CursorButton::Right => &mut self.right,
CursorButton::Middle => &mut self.middle,
}
}
pub fn end_frame(&mut self) { pub fn end_frame(&mut self) {
self.left.end_frame(); self.left.end_frame();
self.middle.end_frame(); self.middle.end_frame();
@@ -117,6 +115,11 @@ impl CursorButtons {
} }
impl CursorState { impl CursorState {
/// Whether anything is happening to the cursor beyond where it rests.
pub fn has_momentary_input(&self) -> bool {
self.scroll_delta != Vec2::ZERO || self.buttons.iter().any(|(_, state)| !state.is_off())
}
pub fn end_frame(&mut self) { pub fn end_frame(&mut self) {
self.buttons.end_frame(); self.buttons.end_frame();
self.scroll_delta = Vec2::ZERO; self.scroll_delta = Vec2::ZERO;
@@ -185,31 +188,20 @@ impl SensorUi for UiRenderState {
// 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?
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);
// Narrowed as it descends: a widget takes what it answers to, and let momentary = cursor.has_momentary_input();
// what is left is what the layers below see.
let mut cursor = cursor;
let mut hovered = false;
for layer in self.layers.indices().rev() { for layer in self.layers.indices().rev() {
let mut below = cursor.clone(); let mut consumed = false;
let mut hovered_here = false;
for (id, sensor) in active.get_mut(&layer).into_flat_iter() { for (id, sensor) in active.get_mut(&layer).into_flat_iter() {
let shape = self.active.get(id).unwrap().region; let shape = self.active.get(id).unwrap().region;
let region = shape.to_px(window_size); let region = shape.to_px(window_size);
let over = cursor.exists && region.contains(cursor.pos); let in_shape = cursor.exists && region.contains(cursor.pos);
// Hover goes to the topmost widget in shape and no further. sensor.hover.update(in_shape);
sensor.hover.update(over && !hovered); if sensor.hover == ActivationState::Off {
// `is_off` would be wrong here: it counts `End`, which is
// the one frame a hover-end handler has to run on.
if !over && sensor.hover == ActivationState::Off {
continue; continue;
} }
hovered_here |= over; // A widget the cursor has left still hears its hover ending,
// but a press or a scroll landing elsewhere is not its input.
// Momentary input belongs to whatever the cursor is on. A let cursor = match in_shape {
// widget it has just left still hears its hover ending, but
// a press landing elsewhere is neither its press to receive
// nor its press to take.
let cursor = match over {
true => cursor.clone(), true => cursor.clone(),
false => CursorState { false => CursorState {
pos: cursor.pos, pos: cursor.pos,
@@ -217,14 +209,12 @@ impl SensorUi for UiRenderState {
..Default::default() ..Default::default()
}, },
}; };
consumed = consumed
for senses in rsc.events_mut().get_type::<CursorSense>().registered(*id) { || rsc
for sense in senses.iter() { .events_mut()
if matches(sense, &cursor, sensor.hover) { .get_type::<CursorSense>()
sense.take(&mut below); .registered(*id)
} .any(|senses| senses.consumes(&cursor, sensor.hover, momentary));
}
}
let data = CursorData { let data = CursorData {
pos: cursor.pos - region.top_left, pos: cursor.pos - region.top_left,
@@ -239,9 +229,7 @@ impl SensorUi for UiRenderState {
}; };
rsc.run_event::<CursorSense>(*id, data, state); rsc.run_event::<CursorSense>(*id, data, state);
} }
hovered |= hovered_here; if consumed {
cursor = below;
if hovered && !is_momentary(&cursor) {
break; break;
} }
} }
@@ -254,14 +242,8 @@ pub fn should_run(
cursor: &CursorState, cursor: &CursorState,
hover: ActivationState, hover: ActivationState,
) -> Option<CursorSense> { ) -> Option<CursorSense> {
senses for sense in senses.iter() {
.iter() if match sense {
.find(|sense| matches(sense, cursor, hover))
.copied()
}
fn matches(sense: &CursorSense, cursor: &CursorState, hover: ActivationState) -> bool {
match sense {
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(), CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(),
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(), CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(),
CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(), CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(),
@@ -269,12 +251,11 @@ fn matches(sense: &CursorSense, cursor: &CursorState, hover: ActivationState) ->
CursorSense::Hovering => hover.is_on(), CursorSense::Hovering => hover.is_on(),
CursorSense::HoverEnd => hover.is_end(), CursorSense::HoverEnd => hover.is_end(),
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO, CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
} {
return Some(*sense);
} }
} }
None
/// Whether anything is happening to the cursor beyond where it rests.
fn is_momentary(cursor: &CursorState) -> bool {
cursor.scroll_delta != Vec2::ZERO || cursor.buttons.iter().any(|(_, state)| !state.is_off())
} }
impl ActivationState { impl ActivationState {
+1 -22
View File
@@ -1,4 +1,4 @@
//! Input across layers: what a widget takes, what passes through it, and //! Input across layers: what stops at a layer, what passes through it, and
//! where hovering stops. These drive `run_sensors` directly, which needs no //! where hovering stops. These drive `run_sensors` directly, which needs no
//! GPU and no window. //! GPU and no window.
@@ -167,27 +167,6 @@ fn a_scroll_passes_through_every_widget_that_does_not_want_it() {
assert_eq!(overlay_clicked.take(), []); assert_eq!(overlay_clicked.take(), []);
} }
#[test]
fn a_click_and_a_scroll_in_one_frame_go_to_different_widgets() {
let mut ui = Ui::new();
let (list, button) = (full(&mut ui), full(&mut ui));
let scrolled = ui.listen(&list, CursorSense::Scroll);
let clicked = ui.listen(&button, CursorSense::click());
ui.stack(vec![list.any(), button.any()]);
let mut cursor = ui.cursor((50.0, 50.0));
cursor.scroll_delta = (0.0, 10.0).into();
cursor.buttons.left = ActivationState::Start;
ui.run(cursor);
assert_eq!(clicked.take(), [CursorSense::click()]);
assert_eq!(
scrolled.take(),
[CursorSense::Scroll],
"taking the click must not take the scroll with it"
);
}
#[test] #[test]
fn only_the_topmost_listener_takes_a_press() { fn only_the_topmost_listener_takes_a_press() {
let mut ui = Ui::new(); let mut ui = Ui::new();