Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, or None: see Where an event is
  • delta_x - Horizontal scroll amount, in pixels
  • delta_y - Vertical scroll amount, in pixels
  • source - Wheel, Finger or Continuous

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:

EventReaches
A pointer event with a position — move, press, release, wheel, a scroll’s end, enterThe 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 downThe widgets the pointer record owes one, and nothing else
A key, and the surface gaining or losing the keyboardThe 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 pasteThe 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:

  1. MouseEnter → Set hover state true
  2. 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
  3. MouseDown → Set pressed state true, record click point
  4. 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);
}
;
}