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:
1 parent
8db0184384
commit
643daf5637
5 files changed
+255
No files matched your search
@@ -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.
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user