From 643daf56377c5030e4448fa2f061e0e07dc116f0 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 23:52:37 -0400 Subject: [PATCH] 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 --- IRIS_TODO.md | 66 ++++++++++++++++++ iris/core/src/event/manager.rs | 12 ++++ iris/src/default/sense.rs | 53 +++++++++++++- iris/src/lib.rs | 2 + iris/src/sense_tests.rs | 124 +++++++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 IRIS_TODO.md create mode 100644 iris/src/sense_tests.rs diff --git a/IRIS_TODO.md b/IRIS_TODO.md new file mode 100644 index 0000000..bb2e411 --- /dev/null +++ b/IRIS_TODO.md @@ -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. diff --git a/iris/core/src/event/manager.rs b/iris/core/src/event/manager.rs index 840fcb8..9f04a32 100644 --- a/iris/core/src/event/manager.rs +++ b/iris/core/src/event/manager.rs @@ -135,6 +135,18 @@ impl TypeEventManager { )); } + /// 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 { + self.map.get(&id).into_iter().flatten().map(|(e, _)| e) + } + pub fn run_fn<'a>( &mut self, id: impl IdLike, diff --git a/iris/src/default/sense.rs b/iris/src/default/sense.rs index 712bd50..a6ed6e1 100644 --- a/iris/src/default/sense.rs +++ b/iris/src/default/sense.rs @@ -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::().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::() + .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(); diff --git a/iris/src/lib.rs b/iris/src/lib.rs index d0282cf..af7a6b9 100644 --- a/iris/src/lib.rs +++ b/iris/src/lib.rs @@ -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; diff --git a/iris/src/sense_tests.rs b/iris/src/sense_tests.rs new file mode 100644 index 0000000..92c9be5 --- /dev/null +++ b/iris/src/sense_tests.rs @@ -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, +} + +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.events + } + fn events_mut(&mut self) -> &mut EventManager { + &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" + ); +}