Event System
This page explains how input events flow through Guido.
Event Flow
Wayland → Platform → App → Widget Tree
│
├─ MouseMove
├─ MouseEnter/MouseLeave
├─ MouseDown/MouseUp
└─ Scroll/ScrollEnd
Event Types
Mouse Movement
Event::MouseMove { at, pointer };
Event::MouseEnter { at };
Event::MouseLeave
Tracked for hover states. The platform layer determines which widget the cursor is over.
Mouse Buttons
Event::MouseDown { at, button, pointer, count };
Event::MouseUp { at, button }
Used for click detection and pressed states. count is which press of a run
this one is — 2 for the second of a double click — and the platform works it
out where the presses enter, in src/platform/clicks.rs: Wayland says nothing
about double clicks, so both the Wayland platform and the headless recorder
pass what they queue through one ClickCounter.
Which Pointer
A move and a press carry a PointerKind — Mouse or Finger. Touch is folded
into the pointer pipeline, so a finger arrives as an ordinary MouseDown and
every widget that wants to treat the two alike gets that for free.
One field, not a parallel touch event family: Event::Scroll carries a
ScrollSource on the same principle. Almost nothing reads it. What does is a
scroller, because a finger dragging its content scrolls it and a mouse doing
the same must go on selecting text — see
Scrolling.
Scrolling
Event::Scroll { at, delta_x, delta_y, source };
Event::ScrollEnd { at }
at- Pointer position, orNone: see Where an event isdelta_x- Horizontal scroll amount, in pixelsdelta_y- Vertical scroll amount, in pixelssource-Wheel,FingerorContinuous
ScrollEnd is the end of a scroll gesture — the finger lifting off a touchpad,
which the compositor reports as wl_pointer.axis_stop. It carries no delta
because nothing moved.
Only Finger is guaranteed to produce one. The protocol says a wl_pointer
sequence from a Wheel or Continuous source may or may not be terminated,
and that clients “should treat scroll sequences from these scroll sources as
unterminated by default” — so a Continuous gesture may simply never end, and a
wheel may occasionally send a stop even though nothing was held down.
It is what decides when momentum scrolling may begin. Without it the end of a gesture has to be guessed from a gap between samples, and a slow scroll is made of gaps. A source that never terminates therefore never gets momentum, which is the honest answer rather than a guess.
Event Routing
No event is offered to every widget. Where each one goes is worked out
centrally, the way Flutter routes its events, and a widget’s event is only
called for what was routed to it:
| Event | Reaches |
|---|---|
| A pointer event with a position — move, press, release, wheel, a scroll’s end, enter | The widgets that can be under the point, and those the pointer record still owes one |
| A pointer event without one — the surface leave, or what a collapsed transform passes down | The widgets the pointer record owes one, and nothing else |
| A key, and the surface gaining or losing the keyboard | The focused widget, then each of its ancestors; a key press or release nobody there took then goes to the containers that declared on_key_down or on_key_up, the innermost first, until one answers Handled; a Tab nobody took then moves the focus |
| A paste | The widget that asked for it |
The widgets that can be under the point are found the way paint finds what it can see: a container whose children are ordered along an axis searches them by their laid-out bounds, grown by how far any of them draws outside its box, so a transformed child is found where it is drawn. Children in no order are tested one by one against the same reach.
The pointer record keeps two sets of offers, as Flutter’s MouseTracker and
GestureBinding do. The last event’s offers make a widget the pointer just
left see the move that takes its hover away, however far the pointer went. The
press’s offers, kept until the release, make a drag reach the widget it started
on wherever it goes. A mouse has one press route however many of its buttons
are down: the first button pressed decides it, it is kept until the last one is
released, and a press or release of another button in between goes along that
route rather than to whatever is under the pointer. A point a clipping
container clips away reaches only the record, and without its position — except
for the widget a press holds, which keeps it for its drag and its release.
Within a container the children are offered an event from the one drawn on top to the one drawn first, and the first the point is on stops it, whether or not it took it: what that child covers is not under the point. If it did not take the event, the container handles it itself. So the innermost widget hears an event first, and its ancestors after it — with one exception: a scroller answers its own scrollbar, and a content drag it has already won, before its children are asked.
A container is hit where the point is inside its shape and it draws there — a
visible fill or gradient, a border, a backdrop blur — or listens there — a
handler, a state layer, a cursor — or one of its children is hit. A disabled
container asks none of its children and handles nothing, but where it draws it
is still hit, and covers what is beneath it; a text field is hit wherever the
point is inside it. One that
only lays out is not, so a full-size ZStack layer that aligns its content
lets the point through to the layers beneath it. This is Flutter’s rule; the
DOM, GTK and Qt hit every box under the point and make passing through
something to declare. A sibling the point is covered from is told what a point
clipped away tells it: its hover falls, and a press it holds keeps the
position. The cursor and the hover follow the same walk, so they agree with the
click about which widget is under the pointer.
Hit Testing
Basic Hit Test
fn contains(&self, x: f32, y: f32) -> bool {
x >= self.x && x <= self.x + self.width &&
y >= self.y && y <= self.y + self.height
}
With Corner Radius
Clicks outside rounded corners don’t register:
extern crate guido;
use guido::prelude::*;
fn main() {
let radius = 8.0f32;
// SDF-based hit test
let dist = sdf_rounded_rect(point, bounds, radius, k);
dist <= 0.0 // Inside if distance is negative
;
}
With Transforms
The transform is undone before the point is compared against the laid-out bounds — and a transform that has collapsed the widget onto a line has no inverse to undo it with:
fn contains(&self, at: Option<Point>) -> bool {
// `untransform_point` answers None where the transform has no inverse,
// and `contains_at` answers false for a point that is not there.
self.bounds.contains_at(untransform_point(&self.transform, at))
}
Where an event is
A pointer event carries at: Option<Point>, and the None is the interesting
half. It means one of two things, and a consumer wants the same answer from
both: a keyboard or focus event never had a position, and a pointer event that
descended into a subtree scaled to nothing has lost the one it had.
scale(0.0) squashes the plane onto a line. The map stops being one-to-one —
every point in the box lands on top of every other — so there is no inverse,
and no coordinate that could stand for “nowhere”: every number is somewhere,
and a descendant that rotates or mirrors would carry a far-away sentinel back
into the visible half-plane.
So the position is simply absent, and every bounds test below answers no:
container()
.width(80.0)
.height(40.0)
.scale(Scale::new(1.0, 0.0)) // collapsed: takes no click, hover or scroll
.on_click(|| unreachable!())
The event still travels. A press given up and a hover cleared are things only
a delivered event can do, so a container inside a menu that collapses mid-press
hears the release and drops its own pressed state — the state layer stops
painting, the ripple is cancelled. What it does not do is call on_click or
on_mouse_up: those say where the release landed, and it landed nowhere. An
application pairing on_mouse_down with on_mouse_up to track a press of its
own should expect the down without the up, and clear on the same signal that
collapsed the box.
A key or a focus change is untouched, having never had a position to lose.
Event Handlers
Containers register callbacks:
extern crate guido;
use guido::prelude::*;
fn main() {
container()
.on_click(|| println!("Clicked!"))
.on_hover(|hovered| println!("Hover: {}", hovered))
.on_scroll(|dx, dy, source| println!("Scroll"))
;
}
Internally stored as optional closures:
pub struct Container {
on_click: Option<Box<dyn Fn()>>,
on_hover: Option<Box<dyn Fn(bool)>>,
on_scroll: Option<Box<dyn Fn(f32, f32, ScrollSource)>>,
}
State Layer Integration
The state layer system uses events internally:
- MouseEnter → Set hover state true
- MouseLeave → Set hover state false, and pressed state false with the ripple cancelled rather than completed — a press that ends this way activated nothing, which is also how a cancelled touch gesture ends
- MouseDown → Set pressed state true, record click point
- MouseUp → Set pressed state false, trigger ripple contraction
fn event(&mut self, tree: &mut Tree, id: WidgetId, event: &Event) -> EventResponse {
match event {
Event::MouseEnter { .. } => {
self.flags.update(|f| f.insert(InteractionFlags::HOVERED));
}
Event::MouseDown { at: Some(at), .. } => {
self.flags.update(|f| f.insert(InteractionFlags::PRESSED));
// The event's own moment, not the wall clock: a ripple begins
// when the press arrived, and `event_instant` is the only thing
// that knows when that was. `as_frame_start` is the crossing —
// the ripple then grows on frames.
self.ripple.start(at.x, at.y, tree.event_instant().as_frame_start());
}
// ...
}
EventResponse::Ignored
}
The flags live in a signal rather than a plain field, so that a descendant resolving a state layer subscribes to them — see Interactivity.
EventResponse
Widgets return whether the point is on them, and whether they took the event:
pub enum EventResponse {
Ignored, // Not on me: ask the sibling beneath
Hit, // On me, not taken: the siblings beneath are covered,
// the ancestors still handle it
Handled, // Taken: stop propagation
}
Platform Integration
Wayland Events
The platform layer receives Wayland protocol events:
// From wl_pointer
fn pointer_motion(x: f32, y: f32) {
self.cursor_x = x;
self.cursor_y = y;
self.dispatch(Event::MouseMove {
at: Some(Point::new(x, y)),
pointer: PointerKind::Mouse,
});
}
fn pointer_button(button: u32, state: ButtonState) {
match state {
ButtonState::Pressed => self.dispatch(Event::MouseDown { ... }),
ButtonState::Released => self.dispatch(Event::MouseUp { ... }),
}
}
Event Loop
Uses calloop for event loop integration:
extern crate guido;
use guido::prelude::*;
fn main() {
// Main loop
loop {
// 1. Process Wayland events
event_queue.dispatch_pending()?;
// 2. Layout and paint
widget.layout(constraints);
widget.paint(&mut ctx);
// 3. Render to screen
renderer.render(&ctx);
}
;
}