Compare commits

..
Author SHA1 Message Date
iris f3fd9417d4 Consume by layer, not by widget
Replaces the taking mechanism with `CursorSenses::consumes`, which
decides only whether a layer stops the input reaching the layer below.
Nothing is removed from the cursor, and senses on one layer no longer
block each other: every sensor the pointer is inside runs.

Where the cursor rests stops at the top layer under it. Something
happening to the cursor stops only at a widget that answers to it, so a
click-only child does not swallow a scroll -- which is what `main` gets
wrong, where any hovered sensor blocks the layer below.

A widget the cursor has left still hears its hover ending, but is handed
no press or scroll: that input landed somewhere else. This is a hit test
rather than a consumption rule, and without it a press beside a button
fires the button it just left.

`a_click_and_a_scroll_in_one_frame_go_to_different_widgets` goes with the
per-kind taking it tested. Of the five that remain, two fail on `main`.
2026-09-13 20:20:25 -04:00
iris 3ab9c922fd Merge upstream/main (#14) into split/12-pointer-routing 2026-09-13 20:17:06 -04:00
iris-aiandiris 32b10383d8 Rename the Sized widget to SetSize (#14)
`Sized` shadowed the marker trait, so a `?Sized` bound in any crate that imports the prelude failed to resolve -- a compile error in someone else's code that nothing here would have caught. It was already biting inside iris: `default/mod.rs`, `widget/ptr.rs` and `widget/text/build.rs` all imported `std::marker::Sized` explicitly to get out from under it, which they no longer need.

`SetSize` rather than `FixedSize` because the size it sets need not be fixed -- `width(rest(2))` (a flex weight) and `width(rel(0.5))` (half the parent) build the same widget, and both are more common than `sized((100, 100))`. It also pairs with the `MaxSize` beside it in that module: one sets a length, the other caps it. The builders are unchanged.

`tests/prelude_bounds.rs` is a compile-level guard -- it fails to build if the prelude shadows `Sized` again, which I checked by reverting `src/` under it:

```
error[E0404]: expected trait, found struct `Sized`
 --> tests/prelude_bounds.rs:8:22
  |
8 | fn takes_unsized<T: ?Sized>(_: &T) {}
  |                      ^^^^^ not a trait
```

The pad tab of the tabs example -- the one built out of `sized` and the flexible widths -- renders pixel-identical to before the rename.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: iris/iris#14
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 20:16:17 -04:00
irisandClaude Opus 5 71ba3723ff Keep momentary input on the widget the cursor is on
Tests across layers, as asked, and the fifth one found a defect older than
this branch: a press fired on a widget the cursor had just left, because the
frame its hover ends is a frame it still gets dispatched on, and `should_run`
only ever looked at the cursor. A button in the corner of a list therefore
clicked when the press landed anywhere else in the row.

A widget that is not under the cursor now sees a cursor with nothing
momentary in it, which settles both halves of the question at once: it is not
its press to receive, and not its press to take from the layers below.

`CursorSense` and `CursorButton` derive `Debug`, so a failure says which
sense fired rather than `left != right`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:22:56 -04:00
iris 0a14df2cc3 Merge canonical main after the wgpu 30 upgrade 2026-09-13 19:16:16 -04:00
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 284 additions and 7 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>( pub fn run_fn<'a>(
&mut self, &mut self,
id: impl IdLike, id: impl IdLike,
+45 -7
View File
@@ -4,14 +4,14 @@ use std::{
rc::Rc, rc::Rc,
}; };
#[derive(Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum CursorButton { pub enum CursorButton {
Left, Left,
Right, Right,
Middle, Middle,
} }
#[derive(Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum CursorSense { pub enum CursorSense {
PressStart(CursorButton), PressStart(CursorButton),
Pressing(CursorButton), Pressing(CursorButton),
@@ -39,6 +39,19 @@ impl Event for CursorSenses {
} }
} }
impl CursorSenses {
/// Whether a widget with these senses stops the input reaching the layer
/// below it. Where the cursor rests stops at the top layer that is under
/// it; something happening to the cursor stops only at a widget that
/// answers to that, so a click-only child does not swallow a scroll.
fn consumes(&self, cursor: &CursorState, hover: ActivationState, momentary: bool) -> bool {
if !momentary {
return true;
}
should_run(self, cursor, hover).is_some_and(|sense| sense.is_momentary())
}
}
impl CursorSense { impl CursorSense {
pub fn click() -> Self { pub fn click() -> Self {
Self::PressStart(CursorButton::Left) Self::PressStart(CursorButton::Left)
@@ -52,6 +65,12 @@ impl CursorSense {
pub fn is_dragging(&self) -> bool { pub fn is_dragging(&self) -> bool {
matches!(self, CursorSense::Pressing(CursorButton::Left)) matches!(self, CursorSense::Pressing(CursorButton::Left))
} }
/// Whether this sense is about something happening to the cursor, rather
/// than about where it rests.
fn is_momentary(&self) -> bool {
!matches!(self, Self::HoverStart | Self::Hovering | Self::HoverEnd)
}
} }
#[derive(Default, Clone)] #[derive(Default, Clone)]
@@ -96,6 +115,11 @@ impl CursorButtons {
} }
impl CursorState { impl CursorState {
/// Whether anything is happening to the cursor beyond where it rests.
pub fn has_momentary_input(&self) -> bool {
self.scroll_delta != Vec2::ZERO || self.buttons.iter().any(|(_, state)| !state.is_off())
}
pub fn end_frame(&mut self) { pub fn end_frame(&mut self) {
self.buttons.end_frame(); self.buttons.end_frame();
self.scroll_delta = Vec2::ZERO; self.scroll_delta = Vec2::ZERO;
@@ -164,8 +188,9 @@ impl SensorUi for UiRenderState {
// state like thing, but local to render state, and is passed to UiRsc events so you can // state like thing, but local to render state, and is passed to UiRsc events so you can
// update it there? // update it there?
let mut active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active); let mut active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
let momentary = cursor.has_momentary_input();
for layer in self.layers.indices().rev() { for layer in self.layers.indices().rev() {
let mut sensed = false; let mut consumed = false;
for (id, sensor) in active.get_mut(&layer).into_flat_iter() { for (id, sensor) in active.get_mut(&layer).into_flat_iter() {
let shape = self.active.get(id).unwrap().region; let shape = self.active.get(id).unwrap().region;
let region = shape.to_px(window_size); let region = shape.to_px(window_size);
@@ -174,9 +199,22 @@ impl SensorUi for UiRenderState {
if sensor.hover == ActivationState::Off { if sensor.hover == ActivationState::Off {
continue; continue;
} }
sensed = true; // A widget the cursor has left still hears its hover ending,
// but a press or a scroll landing elsewhere is not its input.
let cursor = cursor.clone(); let cursor = match in_shape {
true => cursor.clone(),
false => CursorState {
pos: cursor.pos,
exists: cursor.exists,
..Default::default()
},
};
consumed = consumed
|| rsc
.events_mut()
.get_type::<CursorSense>()
.registered(*id)
.any(|senses| senses.consumes(&cursor, sensor.hover, momentary));
let data = CursorData { let data = CursorData {
pos: cursor.pos - region.top_left, pos: cursor.pos - region.top_left,
@@ -191,7 +229,7 @@ impl SensorUi for UiRenderState {
}; };
rsc.run_event::<CursorSense>(*id, data, state); rsc.run_event::<CursorSense>(*id, data, state);
} }
if sensed { if consumed {
break; break;
} }
} }
+234
View File
@@ -0,0 +1,234 @@
//! Input across layers: what stops at a layer, what passes through it, and
//! where hovering stops. These drive `run_sensors` directly, which needs no
//! GPU and no window.
use iris::prelude::*;
use std::{cell::RefCell, 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
}
}
const WINDOW: f32 = 100.0;
/// Every sense that has fired on one widget since it was last read.
#[derive(Default, Clone)]
struct Fired(Rc<RefCell<Vec<CursorSense>>>);
impl Fired {
fn take(&self) -> Vec<CursorSense> {
std::mem::take(&mut self.0.borrow_mut())
}
}
struct Ui {
rsc: SenseRsc,
render: UiRenderState,
state: (),
}
impl Ui {
fn new() -> Self {
Self {
rsc: SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
},
render: UiRenderState::new(),
state: (),
}
}
fn listen<W: Widget + ?core::marker::Sized + 'static>(
&mut self,
widget: &StrongWidget<W>,
senses: impl Into<CursorSenses>,
) -> Fired {
let fired = Fired::default();
let sink = fired.clone();
self.rsc
.register_event(widget.weak(), senses.into(), move |ctx, _rsc| {
sink.0.borrow_mut().push(ctx.data.sense)
});
fired
}
/// Stacks the widgets bottom first, each on its own layer, and lays them
/// out in a square window.
fn stack(&mut self, children: Vec<StrongWidget>) {
let root = self
.rsc
.ui
.widgets
.add_strong(Stack {
children,
size: StackSize::default(),
})
.any();
self.render.resize((WINDOW, WINDOW));
self.render.update(&root, &mut self.rsc);
}
fn cursor(&mut self, at: (f32, f32)) -> CursorState {
CursorState {
pos: at.into(),
exists: true,
buttons: Default::default(),
scroll_delta: Vec2::ZERO,
}
}
fn run(&mut self, cursor: CursorState) {
self.render.run_sensors(
&mut self.rsc,
&mut self.state,
cursor,
(WINDOW, WINDOW).into(),
);
}
}
fn full(ui: &mut Ui) -> StrongWidget<Rect> {
rect(UiColor::WHITE).add_strong(&mut ui.rsc)
}
#[test]
fn hover_stops_at_the_topmost_widget() {
let mut ui = Ui::new();
let (bottom, middle, top) = (full(&mut ui), full(&mut ui), full(&mut ui));
let bottom_hover = ui.listen(&bottom, CursorSense::HoverStart);
let middle_hover = ui.listen(&middle, CursorSense::HoverStart);
let top_hover = ui.listen(&top, CursorSense::HoverStart);
ui.stack(vec![bottom.any(), middle.any(), top.any()]);
let cursor = ui.cursor((50.0, 50.0));
ui.run(cursor);
assert_eq!(top_hover.take(), [CursorSense::HoverStart]);
assert_eq!(
middle_hover.take(),
[],
"hover is not shared with a layer below"
);
assert_eq!(bottom_hover.take(), []);
}
#[test]
fn a_scroll_passes_through_every_widget_that_does_not_want_it() {
let mut ui = Ui::new();
let (list, button, overlay) = (full(&mut ui), full(&mut ui), full(&mut ui));
let scrolled = ui.listen(&list, CursorSense::Scroll);
let clicked = ui.listen(&button, CursorSense::click());
let overlay_clicked = ui.listen(&overlay, CursorSense::click());
ui.stack(vec![list.any(), button.any(), overlay.any()]);
let mut cursor = ui.cursor((50.0, 50.0));
cursor.scroll_delta = (0.0, 10.0).into();
ui.run(cursor);
assert_eq!(
scrolled.take(),
[CursorSense::Scroll],
"two layers of click-only widgets do not stop a scroll"
);
assert_eq!(clicked.take(), []);
assert_eq!(overlay_clicked.take(), []);
}
#[test]
fn only_the_topmost_listener_takes_a_press() {
let mut ui = Ui::new();
let (below, above) = (full(&mut ui), full(&mut ui));
let below_clicked = ui.listen(&below, CursorSense::click());
let above_clicked = ui.listen(&above, CursorSense::click());
ui.stack(vec![below.any(), above.any()]);
let mut cursor = ui.cursor((50.0, 50.0));
cursor.buttons.left = ActivationState::Start;
ui.run(cursor);
assert_eq!(above_clicked.take(), [CursorSense::click()]);
assert_eq!(below_clicked.take(), [], "one press goes to one widget");
}
#[test]
fn a_press_beside_the_button_reaches_the_layer_below() {
let mut ui = Ui::new();
let list = full(&mut ui);
// The row above the list covers it, but only its left half is the button.
let button = full(&mut ui);
let gap = full(&mut ui);
let list_clicked = ui.listen(&list, CursorSense::click());
let button_clicked = ui.listen(&button, CursorSense::click());
let row = ui.rsc.ui.widgets.add_strong(Span {
children: vec![button.any(), gap.any()],
dir: Dir::RIGHT,
gap: 0.0,
});
ui.stack(vec![list.any(), row.any()]);
let mut on_button = ui.cursor((20.0, 50.0));
on_button.buttons.left = ActivationState::Start;
ui.run(on_button);
assert_eq!(button_clicked.take(), [CursorSense::click()]);
assert_eq!(list_clicked.take(), []);
let mut beside_it = ui.cursor((80.0, 50.0));
beside_it.buttons.left = ActivationState::Start;
ui.run(beside_it);
assert_eq!(button_clicked.take(), [], "the cursor is not on the button");
assert_eq!(
list_clicked.take(),
[CursorSense::click()],
"a press beside the button belongs to what is under it"
);
}
#[test]
fn leaving_a_widget_still_ends_its_hover() {
let mut ui = Ui::new();
let widget = full(&mut ui);
let hover = ui.listen(&widget, CursorSense::HoverStart | CursorSense::HoverEnd);
ui.stack(vec![widget.any()]);
let cursor = ui.cursor((50.0, 50.0));
ui.run(cursor);
assert_eq!(hover.take(), [CursorSense::HoverStart]);
let mut gone = ui.cursor((50.0, 50.0));
gone.exists = false;
ui.run(gone);
assert_eq!(hover.take(), [CursorSense::HoverEnd]);
}