iris: route pointer input per kind, so scroll falls through a hovered button

IRIS_TODO.md's "Input does not fall through by input type": run_sensors
treated "the cursor is over this widget" and "this widget consumed the
event" as the same check, so a widget registered only for click() still
blocked a Scroll meant for a list underneath it. Fixed by judging
consumption per input kind -- with nothing momentary happening this
frame the topmost hovered widget still wins (unchanged), but once a
scroll or a press/release is actually happening, only a widget whose
registered senses include a matching non-hover one (via the new
TypeEventManager::registered, which lists a widget's registrations
without running anything) can consume it.

iris/src/sense_tests.rs builds a button-over-a-list Stack with a plain
HasEvents impl (no GPU or window) and checks both directions: a scroll
over the button reaches the list, and a real click still reaches the
button. Confirmed to fail on the pre-fix code and pass after.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet committed 2026-09-04 23:52:37 -04:00
1 parent 8db0184384
commit 643daf5637
5 files changed
+256 -1

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
# iris: known problems and things still to build
Iris's own list for the library, recorded 2026-09-04 in her words where it
matters, so the agents working through RUST.md pick these up in a sensible
order rather than rediscovering them. Each item says where it sits in the
order and what "done" looks like. Tick and date them in place.
## Fix
- [x] **Input does not fall through by input type (2026-09-04).**
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
stop checking lower layers" from mere hover — a widget registered for
nothing but `click()` blocked a `Scroll` meant for whatever was behind
it, since "the cursor is over this widget" and "this widget handled the
event" were the same check. Fixed by judging consumption per input
kind: with no button transition and no scroll happening this frame
("momentary" activity), the topmost hovered widget still wins, same as
before; when something momentary *is* happening, only a widget whose
registered senses actually include a matching non-hover one (checked
via a new `TypeEventManager::registered`, which lists what a widget
registered without running anything) consumes it, so a widget with only
`Hovering`/click handlers can no longer block a scroll from reaching a
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
directions: a scroll over the button reaches the list, and a real click
still reaches the button — confirmed to fail on the pre-fix code and
pass after.
## Build
- [ ] **Benchmarks**, not unit tests, run on demand (a `benches/` or a
script under `iris/`, never in `cargo test`). The scenario that matters
most is a **message list** — chat apps and this app's transcript alike —
stressed with many messages and many images. One case in particular:
**resizing an input box** (typing enough text to grow it) that pushes a
long list of messages above it must stay very fast and recalculate
almost nothing — a move of everything above, not a re-layout. That is
exactly the O(1) move chain in LAYOUT.md; the benchmark is what proves
it. Done when the numbers are in this file with the command, and the
input-box case reports draws re-run, not just frame time.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent
mask, the way the move chain references a parent offset. Today masks
are independent regions. Design it beside the move chain (same shape:
a parent index and a bounded walk in the shader); do it when a real
widget needs it, not before.
- [ ] **Positions as a single float per scroll.** Iris raised, and half
rejected, letting a scroll update one float rather than positions:
input handling cares about most elements in a list, so absolute
positions must be computed on the CPU anyway. LAYOUT.md's design
already lands here (GPU walks the chain, CPU resolves on demand for
hit tests). Keep the CPU resolution lazy and per query; do not
materialise every row's absolute position per frame.
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
**modular — a piece of the library rather than a core part forced into
everything, the same way input is**. Whatever the mechanism, a widget
that does not animate must pay nothing and import nothing for it.
## Reconsider
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
to compose a widget from others (a button is the main case). With
sizing folded into `draw`, composing may be easy enough that `View` is
redundant. Decide after the layout change lands, by writing a button
both ways and keeping the one that is shorter to explain; delete the
other rather than keeping two ways.
+12
View File
@@ -135,6 +135,18 @@ impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
));
}
/// The event lists this widget was registered with (`register`'s
/// `event` argument, one per call), without running anything. Lets a
/// caller ask "would this widget's registrations match the current
/// state" separately from actually dispatching to it -- used by
/// `sense.rs` to decide whether a widget genuinely consumes a scroll
/// or press this frame (so a lower layer can still receive it if not)
/// without that decision being conflated with "the cursor happens to
/// be over it," which is all `run_fn` running something tells you.
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,
+52 -1
View File
@@ -52,6 +52,22 @@ impl CursorSense {
pub fn is_dragging(&self) -> bool {
matches!(self, CursorSense::Pressing(CursorButton::Left))
}
/// True for a sense that names a specific thing happening this frame
/// (a button transitioning, a scroll) as opposed to the ambient,
/// always-on-while-over `Hover*` family. Used to decide whether a
/// widget actually *consumes* an input for fall-through purposes: a
/// widget that merely highlights on hover must not be able to block a
/// scroll or a click meant for whatever is behind it, the way it
/// currently could when "the cursor is over this widget" and
/// "this widget handled the event" were the same check. See
/// `SensorUi::run_sensors`.
pub fn is_momentary(&self) -> bool {
!matches!(
self,
CursorSense::HoverStart | CursorSense::Hovering | CursorSense::HoverEnd
)
}
}
#[derive(Default, Clone)]
@@ -163,6 +179,14 @@ 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 *something specific* is happening this frame (a button
// transitioning, a scroll) as opposed to the cursor merely resting
// over widgets. Only this decides whether a widget can block a
// lower layer from also seeing the event -- see the `consumed`
// comment below.
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 +198,34 @@ 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 or click passes through it),
// but whether it *consumes* the input -- stopping a lower
// layer from also seeing it -- is judged per input kind
// (LAYOUT.md's coordinator asked for this alongside the
// hit-test rewrite, since both are about `resolved_region`
// and what "under the pointer" means): with nothing
// momentary happening, "in shape" is consumption, same as
// before (the topmost widget wins an idle hover). With a
// scroll or a press/release actually happening, only a
// widget that registered a matching non-hover sense
// consumes it -- a button that only registered `click()`
// must not 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
};
if consumed {
sensed = true;
}
let cursor = cursor.clone();
+2
View File
@@ -11,6 +11,8 @@ pub mod widget;
#[cfg(test)]
mod layout_tests;
#[cfg(test)]
mod sense_tests;
pub use iris_core as core;
pub use iris_macro as macros;
+124
View File
@@ -0,0 +1,124 @@
//! IRIS_TODO.md's "Input does not fall through by input type": a widget
//! that only registered `click()` used to also block a `Scroll` meant for
//! whatever is behind it, because `run_sensors` decided "consumed, stop
//! looking at lower layers" from mere hover, not from anything actually
//! matching. Exercised as a plain unit test for the same reason
//! `layout_tests.rs` is one: `UiRenderState` and a minimal `HasEvents`
//! impl need no GPU or window.
use crate::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,
// the case in IRIS_TODO.md's report.
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"
);
}