Take input per kind, rather than deciding it once a frame

Reviewing this against the process we agreed: the title claimed per-kind
routing and the code decided it once for the whole frame. A scroll and a
click in the same frame both went to the button, because a widget that
matched any momentary sense consumed everything.

Consumption is now removing an input from the cursor the layers below see.
`CursorSense::take` states what each sense takes -- exhaustively, so a new
sense has to answer the question rather than inherit a default -- and
`is_momentary` is gone with the enumeration it was written on. `should_run`
and consumption share one matcher instead of two copies of the table.

Two tests, each checked to fail without the change: a click and a scroll in
one frame reach different widgets, and leaving a widget still ends its hover.
The second is a regression this review caught in its own first draft, where
the skip condition used `is_off`, which counts `End` -- the one frame a
hover-end handler has to run on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-13 19:09:50 -04:00
1 parent f62131eecf
commit 0e7076a01c
3 files changed
+138 -76

No files matched your search

+62 -55
View File
@@ -53,16 +53,17 @@ impl CursorSense {
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
)
/// Takes what this sense answers to out of `cursor`, so a widget below
/// does not also get it. Hovering takes nothing: it goes to the topmost
/// widget in shape, which is not a question about the input.
fn take(&self, cursor: &mut CursorState) {
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 => {}
}
}
}
@@ -90,6 +91,14 @@ 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) {
self.left.end_frame();
self.middle.end_frame();
@@ -175,45 +184,34 @@ 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);
// Narrowed as it descends: a widget takes what it answers to, and
// what is left is what the layers below see.
let mut cursor = cursor;
let mut hovered = false;
for layer in self.layers.indices().rev() {
let mut sensed = false;
let mut below = cursor.clone();
let mut hovered_here = false;
for (id, sensor) in active.get_mut(&layer).into_flat_iter() {
let shape = self.active.get(id).unwrap().region;
let region = shape.to_px(window_size);
let in_shape = cursor.exists && region.contains(cursor.pos);
sensor.hover.update(in_shape);
if sensor.hover == ActivationState::Off {
let over = cursor.exists && region.contains(cursor.pos);
// Hover goes to the topmost widget in shape and no further.
sensor.hover.update(over && !hovered);
// `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;
}
hovered_here |= over;
// 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;
for senses in rsc.events_mut().get_type::<CursorSense>().registered(*id) {
for sense in senses.iter() {
if matches(sense, &cursor, sensor.hover) {
sense.take(&mut below);
}
}
}
let cursor = cursor.clone();
@@ -230,7 +228,9 @@ impl SensorUi for UiRenderState {
};
rsc.run_event::<CursorSense>(*id, data, state);
}
if sensed {
hovered |= hovered_here;
cursor = below;
if hovered && !is_momentary(&cursor) {
break;
}
}
@@ -243,20 +243,27 @@ pub fn should_run(
cursor: &CursorState,
hover: ActivationState,
) -> Option<CursorSense> {
for sense in senses.iter() {
if match sense {
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(),
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(),
CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(),
CursorSense::HoverStart => hover.is_start(),
CursorSense::Hovering => hover.is_on(),
CursorSense::HoverEnd => hover.is_end(),
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
} {
return Some(*sense);
}
senses
.iter()
.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::Pressing(button) => cursor.buttons.select(button).is_on(),
CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(),
CursorSense::HoverStart => hover.is_start(),
CursorSense::Hovering => hover.is_on(),
CursorSense::HoverEnd => hover.is_end(),
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
}
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 {