Compare commits

...
Author SHA1 Message Date
irisandClaude Opus 5 0e7076a01c 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>
2026-09-13 19:09:50 -04:00
iris f62131eecf Merge canonical main after #11 2026-09-13 19:03:03 -04:00
iris 028521b419 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".
2026-09-13 04:01:22 -04:00
3 changed files with 250 additions and 19 deletions

No files matched your search

+5
View File
@@ -135,6 +135,11 @@ impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
));
}
/// What this widget registered, without running any of it.
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>(
&mut self,
id: impl IdLike,
+65 -19
View File
@@ -52,6 +52,19 @@ impl CursorSense {
pub fn is_dragging(&self) -> bool {
matches!(self, CursorSense::Pressing(CursorButton::Left))
}
/// 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 => {}
}
}
}
#[derive(Default, Clone)]
@@ -78,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();
@@ -164,17 +185,33 @@ impl SensorUi for UiRenderState {
// state like thing, but local to render state, and is passed to UiRsc events so you can
// update it there?
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;
}
sensed = true;
hovered_here |= over;
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();
@@ -191,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;
}
}
@@ -204,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 {
+180
View File
@@ -0,0 +1,180 @@
//! A widget takes only what it answers to: a button over a list takes the
//! click and leaves the scroll. 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,
}
}
/// A button covering a list, on the layer above it: the list scrolls, the
/// button clicks, and the returned flags say which fired.
fn button_over_list() -> (UiRenderState, SenseRsc, Rc<Cell<bool>>, Rc<Cell<bool>>) {
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 each child on its own layer, in order.
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);
(render, rsc, scrolled, clicked)
}
#[test]
fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
let (render, mut rsc, scrolled, clicked) = button_over_list();
let mut state = ();
let mut scroll = cursor_at((50.0, 50.0).into());
scroll.scroll_delta = (0.0, 10.0).into();
render.run_sensors(&mut rsc, &mut state, scroll, (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_at((50.0, 50.0).into());
click.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, click, (100.0, 100.0).into());
assert!(
clicked.get(),
"the button on top must still receive an actual click"
);
}
#[test]
fn a_click_and_a_scroll_in_one_frame_go_to_different_widgets() {
let (render, mut rsc, scrolled, clicked) = button_over_list();
let mut state = ();
let mut both = cursor_at((50.0, 50.0).into());
both.scroll_delta = (0.0, 10.0).into();
both.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, both, (100.0, 100.0).into());
assert!(
clicked.get(),
"the button takes the click it registered for"
);
assert!(
scrolled.get(),
"taking the click must not take the scroll with it"
);
}
#[test]
fn leaving_a_widget_still_ends_its_hover() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let widget = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let ended = Rc::new(Cell::new(false));
{
let ended = ended.clone();
rsc.register_event(widget.weak(), CursorSense::HoverEnd, move |_ctx, _rsc| {
ended.set(true);
});
}
let root = widget.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut state = ();
render.run_sensors(
&mut rsc,
&mut state,
cursor_at((50.0, 50.0).into()),
(100.0, 100.0).into(),
);
assert!(!ended.get(), "the cursor is still on it");
let mut gone = cursor_at((50.0, 50.0).into());
gone.exists = false;
render.run_sensors(&mut rsc, &mut state, gone, (100.0, 100.0).into());
assert!(ended.get(), "leaving a widget ends its hover");
}