Events and Rust

A template describes how an interface responds to its state. An event handler gives the user a way to change that state: advance a task, edit a title, choose a destination, or move something across the screen.

This chapter continues the Field notes panel from Properties. We will look closely at its button handler, add a title editor and a keyboard shortcut, then explore lifecycle and custom interactions. The examples use the same Notes component and its title and progress properties; the complete starting declaration and template are in Properties. Keep use pax_kit::*; in the Rust file.

Space Game combines keyboard input with a running simulation. Use w, a, s, and d to move and Space to fire. In an embed, first click the game area so keyboard input reaches its document. After game-over, choose Play Again below the score to start a new round. Its source combines key state with tick-driven updates; the frame-based movement is specific to this example.

Connect an action to Rust

The panel's button names the method to call when it is activated:

<Button x=24px y=76px width=160px height=28px
    label="Advance" @button_click=self.advance
/>

The corresponding method lives in impl Notes:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn advance(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        let next = (self.progress.get() + 0.25).min(1.0);
        self.progress.set(next);
    }
}
}

The binding @button_click=self.advance connects the Button's activation event to a public Rust method. The method receives three things:

  • &mut self gives it mutable access to the Notes instance that owns this template.
  • &NodeContext describes the node handling the event and provides runtime information and operations, such as its bounds and navigation.
  • Event<ButtonClick> carries the event. This particular payload has no fields; the activation itself is enough to advance progress.

The leading underscores in _ctx and _event are Rust's convention for arguments that this method does not use. Other handlers can name them ctx and event and read their contents.

The handler calculates a new value and writes it to the property. The label and progress bar already read that property, so their bindings pick up the change. See Properties for the state operations and PAXEL for the formulas that consume them.

Buttons and custom activation

Use @button_click with the native Button control. For custom content, such as a card or drawing, @click and @tap provide general activation handlers. Both receive Event<Click>, which includes position, button, and modifier information through event.mouse.

Either binding on its own receives mouse clicks and single-touch taps. If both are present on the same node, mouse input selects @click and touch input selects @tap. This lets a custom surface share an action across input types, while still allowing separate actions where needed.

A touch activation occurs at release, after TouchEnd, when the gesture qualifies as a tap. For immediate pressed feedback or dragging, use the lower-level mouse and touch events described under Events and coordinates.

Use event data

An event's type tells you what data the handler receives. These are common starting points:

BindingHandler argumentUseful data
@button_clickEvent<ButtonClick>Activation of a native Button
@click, @tapEvent<Click>event.mouse.x, .y, .button, .modifiers
@mouse_moveEvent<MouseMove>Pointer position through event.mouse
@touch_start, @touch_move, @touch_end, @touch_cancelEvent<TouchStart>, Event<TouchMove>, Event<TouchEnd>, Event<TouchCancel>event.touches, including identifiers and positions
@key_down, @key_upEvent<KeyDown>, Event<KeyUp>event.keyboard.key, .modifiers, .is_repeat
@textbox_input, @textbox_changeEvent<TextboxInput>, Event<TextboxChange>event.text
@checkbox_changeEvent<CheckboxChange>event.checked
@slider_changeEvent<SliderChange>event.value

Event<T> exposes its payload fields directly, so event.text is a convenient way to read event.args.text. The event API reference lists the complete payloads, including wheel, drop, and other input events. Sensor availability and native control behavior also depend on the target.

To edit the panel's title, add this Textbox after the panel's outer Group in lib.pax:

<Textbox x=24px y=260px width=320px height=32px
    text={self.title} @textbox_input=self.rename
/>

Add its handler to the Rust file:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn rename(&mut self, _ctx: &NodeContext, event: Event<TextboxInput>) {
        self.title.set(event.text.clone());
    }
}
}

As the user types, the handler copies the event's text into title, and the panel's heading follows. The template supplies the current title to the Textbox; the handler makes the application's response to edits explicit.

On web, @textbox_input follows the browser's input event, while @textbox_change follows its change event, used for committed edits. Choose input for live feedback; choose change when the action should wait for a commit. Exact commit timing belongs to the platform control, so test it on the targets you ship. Forms, two-way bindings, and focus are covered in Native Controls.

Choose the binding's scope

An inline binding belongs to the element where it is written. In the Advance example, the Button is the receiving node, and self is the containing Notes component. Consequently, ctx.bounds_self describes the Button's bounds, even though self.progress is a field on Notes.

A binding in the component's @settings block runs at component scope. For example, to handle button activations that reach Notes, move the binding off the Button and into the template's settings:

@settings {
    @button_click: advance
}

Here both self and the receiving context refer to Notes. Activations from its buttons can reach this handler through event propagation. If you try this alternative, remove @button_click=self.advance from the Button: keeping both bindings calls advance at both places and increments twice.

The same distinction applies when you use a child component. A handler in the child's own settings acts on that child; an inline binding on the child's invocation acts on the containing component. See Components and Composition for the component boundary and Event delivery for propagation.

Do application work

Handlers are ordinary Rust methods. They can validate input, call helpers, update several properties, and invoke application services. Keep reusable work in a helper when more than one interaction needs it.

For example, let the right arrow key advance the panel as well as the button. Replace the original impl Notes containing advance with:

#![allow(unused)]
fn main() {
impl Notes {
    fn advance_progress(&mut self) {
        let next = (self.progress.get() + 0.25).min(1.0);
        self.progress.set(next);
    }

    pub fn advance(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        self.advance_progress();
    }

    pub fn key_down(&mut self, _ctx: &NodeContext, event: Event<KeyDown>) {
        if event.keyboard.key == "ArrowRight" && !event.keyboard.is_repeat {
            self.advance_progress();
            event.prevent_default();
        }
    }
}
}

Keep rename and any other methods in their existing impl blocks. Add the keyboard binding to the template's @settings block, or create that block if there is none:

@settings {
    @key_down: key_down
}

The is_repeat check limits this action to the initial key press. On web, try it while focus is outside the Textbox and other native controls. The keyboard dispatch and default-prevention boundaries are explained below.

Pax calls event handlers synchronously. Keep their work short enough for the interface to remain responsive. For network requests or lengthy work, use an integration appropriate to your target, then publish the result into application state on the runtime's thread. Property handles belong to a thread-local reactive graph; they are not a cross-thread messaging API. Declaring a handler async fn does not give Pax an executor to run it.

A loading interface can expose a loading flag, a result, and an error as properties. Its template can then describe those states while the application layer manages the request. A complete asynchronous integration is beyond this chapter. For other common actions, read Routing for navigation and Motion for easing and timeline control.

Work with component lifecycle

Some work follows the lifetime of a component rather than an input event. Pax recognizes public lifecycle methods on its Rust implementation:

MethodWhen it runsTypical use
on_mountWhen the component mountsInstall computations or subscriptions that need established bindings or context
on_tickOn runtime ticks while the component is activeAdvance an application simulation or other time-driven logic
on_pre_renderAfter tick handlers, in the pre-render phaseSynchronize work that must follow that tick's updates
on_unmountWhen the component is removed from the mounted treeRelease application-owned registrations or finish mounted work

Lifecycle methods take &mut self and &NodeContext, without an Event argument. For a small way to observe the panel's lifetime, add:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn on_mount(&mut self, _ctx: &NodeContext) {
        log::info!("Notes mounted");
    }

    pub fn on_unmount(&mut self, _ctx: &NodeContext) {
        log::info!("Notes unmounted");
    }
}
}

On web, open the app with ?pax_log=info to see these messages in the browser console. The unmount message appears when Pax removes the component from the tree, for example through a template conditional. It is not a guarantee of cleanup on browser or process termination.

If Notes already has an on_mount, add the new work to that method. The computed-property example uses mount to establish a derived label. Ordinary initial values can live in Default; mount is useful for setup that needs the mounted context or property connections. Do not assume that all children have finished mounting or measuring when the parent's mount handler runs.

The short names mount, tick, pre_render, and unmount are also recognized. When both forms exist, the on_* name is selected. To use a different method name, bind it explicitly, for example @mount: initialize in @settings, with a public initialize(&mut self, ctx: &NodeContext) method. An explicit binding replaces automatic selection for that lifecycle event.

on_tick is driven by the runtime, with no fixed frame-rate guarantee. Use elapsed time for behavior that should progress at a stable real-time rate; ctx.elapsed_frames and ctx.elapsed_millis expose the runtime's clocks. For ordinary visual transitions, start with the facilities in Motion.

Subscriptions registered through NodeContext::subscribe are automatically cleared when their node unmounts. Other resources owned by your application still need the cleanup appropriate to that integration. See Subscriptions and effects for callback scheduling and lifetime details.

Dispatch custom events

A reusable control can report an application-level action such as “advance,” “save,” or “dismiss.” Its caller chooses how to respond. Use NodeContext::dispatch_event to connect that named action to a handler on the caller.

Consider an Advance control whose internal Button should ask Notes to update progress. Add this declaration to lib.rs, keeping the existing use pax_kit::*; import:

#![allow(unused)]
fn main() {
#[pax]
#[inlined(
    <Button width=100% height=100% label="Advance"
        @button_click=self.activate
    />
)]
pub struct AdvanceButton {}

impl AdvanceButton {
    pub fn activate(&mut self, ctx: &NodeContext, _event: Event<ButtonClick>) {
        ctx.dispatch_event("advance")
            .expect("AdvanceButton requires an @advance binding");
    }
}
}

#[inlined(...)] associates this short template with AdvanceButton. The native Button calls activate on that component. Its handler then dispatches the custom name "advance". The name is a string literal; the method accepts an &'static str. Choose a name for the action your component offers, keeping it distinct from built-in input event names.

Replace the panel's original Button in lib.pax with:

<AdvanceButton x=24px y=76px width=160px height=28px
    @advance=self.advance_from_control
/>

Add the receiver to Notes:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn advance_from_control(&mut self, _ctx: &NodeContext) {
        self.progress.set((self.progress.get() + 0.25).min(1.0));
    }
}
}

The @advance binding on the component invocation registers that receiver. Pressing the Button calls AdvanceButton::activate, which queues advance; Pax then calls Notes::advance_from_control. The two handlers belong to different component instances. The control needs no access to Notes' fields, and the caller can reuse the control with another response elsewhere.

Context and receiver

The context passed to dispatch_event identifies the emitting component. Here ctx is the internal Button's context, whose containing component is AdvanceButton. Pax looks for advance on that component's invocation. In the receiver, self is Notes while the context describes the AdvanceButton invocation, following the inline-binding scope described above.

This mechanism connects a component to its caller. It does not search the tree for a matching name or broadcast the action to every ancestor. Use the context supplied to the emitting handler and connect the action explicitly at the call site. A root component has no outer caller to receive such an action.

Bind custom event names inline, as @advance=self.advance_from_control. Custom names are not currently supported in an @settings handler declaration. That restriction is separate from the built-in bindings, such as @button_click, that settings blocks do support.

Delivery and data

dispatch_event returns Result<(), String>. It checks that the named receiver is registered before adding the event to the runtime's queue. Ok(()) means the event was queued; the receiving handler has not run when the call returns. Delivery happens in the custom-event phase at the end of a runtime tick. Avoid reading state immediately after dispatch under the assumption that the receiver has already updated it.

The example uses expect because the control requires an @advance binding. If the action is optional, handle the returned error according to that component's contract. A missing receiver does not create a new event binding.

Custom callbacks have two arguments, &mut self and &NodeContext, with no Event<T> payload. This API sends a name only: it has no argument for custom data, no return value from the receiver, and no input event to cancel. Use properties or shared state for data the receiver needs, and remember that a queued receiver observes that state at delivery time. If several queued actions each need distinct data, the application must preserve those values explicitly rather than overwrite a single shared field.

The original Button event still follows its ordinary delivery rules. Dispatching advance neither cancels that input nor stops its propagation. If the same application action is also bound to a bubbling @button_click, both paths can run it. Use the custom action as the control's outward interface and keep the native activation handler inside the control.

For split-file components, value inputs, bind:, and scoped stores, continue with Components and Composition.

Event delivery

The remaining sections are useful when interactions span several elements, share shortcuts, or depend on pointer geometry.

Targeting and propagation

Pointer input begins with hit testing: Pax finds the topmost eligible node under the pointer. Remember that earlier siblings in a Pax template are drawn above later siblings; see Templates. Native controls can also send events directly to their corresponding node, as a Button does for @button_click.

Common pointer and control events then travel from the receiving node through its template parents. This is event bubbling. Each visited node runs its applicable handlers with its own context, which is why an inline handler and a component-level handler can both observe one action.

The route follows template ancestry. Projection and component composition can make that ancestry different from the visual container arrangement. Do not infer the entire event path from which rectangles overlap on screen.

Not every event family bubbles. For example, @mouse_over and @mouse_out dispatch locally, while keyboard events use the global delivery described below. The click/tap choice described earlier is made at each node visited during activation propagation.

Default actions

event.prevent_default() requests that the chassis suppress the platform's default response. The arrow-key handler uses it to request that the browser not perform its normal arrow-key action for that handled press.

Default prevention leaves Pax's handler delivery intact: other handlers and template parents still receive the event. The current Event API has no stop_propagation method.

Whether the request can suppress an action depends on the chassis and event. On web, it also depends on the browser listener's cancellation rules; the current touch-start, touch-end, and touch-cancel listeners are passive. Do not rely on default prevention as a portable way to take ownership of a gesture or prevent a native control from changing its value.

Keyboard delivery

Keyboard events currently dispatch across the mounted Pax tree, so multiple components with keyboard handlers may receive the same key. If a shortcut belongs to one active view or mode, check that application state in the handler before acting on it.

On web, the chassis forwards these global keyboard events only while the document body is the active element. When a Textbox or another native DOM element has focus, its keyboard input stays with that control. A component's @key_down binding does not by itself give that component keyboard focus. Read Native Controls for the control-specific focus and accessibility model.

Events and coordinates

Mouse and touch positions use Pax's window coordinate space. An interaction inside a translated, scaled, rotated, or scrolling node needs local coordinates. Use ctx.local_point to convert through the node handling the event.

For example, add a pad below the title editor:

<Rectangle x=24px y=320px width=320px height=100px
    fill=rgb(220, 216, 206) corner_radius=12
    @mouse_move=self.track_mouse
/>

Import Point2 alongside the existing pax_kit import and add this handler:

#![allow(unused)]
fn main() {
use pax_kit::math::Point2;

impl Notes {
    pub fn track_mouse(&mut self, ctx: &NodeContext, event: Event<MouseMove>) {
        let local = ctx.local_point(Point2::new(event.mouse.x, event.mouse.y));
        self.progress.set(local.x.clamp(0.0, 1.0));
    }
}
}

Moving across the pad maps its left edge to zero progress and its right edge to one. local_point returns normalized coordinates: (0, 0) is the node's local origin and (1, 1) is its opposite corner. To work in local pixel-like units, read (width, height) from ctx.bounds_self.get() and multiply local.x * width and local.y * height. These are layout units, not a promise of physical display pixels.

The conversion includes the node's transform and ancestor Scroller presentation offsets. Convert inside the handler using its current context; subtracting only the element's x and y misses rotation, scale, and moving scroll content. Read more about Layout and Scrolling.

Touch sequences and capture

For a custom touch interaction, use @touch_start, @touch_move, @touch_end, and @touch_cancel. Each payload contains touches with a position, movement deltas, and an identifier. Retain the identifier your interaction accepts so subsequent events can be matched to it.

The primary touch captures the topmost hit node at TouchStart. While that capture remains valid, subsequent move, end, and cancel events target it even when the finger leaves its bounds; the usual template-parent delivery still applies. This is useful for keeping a drag attached to its original control. It does not establish equivalent mouse capture or a general multi-touch gesture recognizer.

Clear pressed or dragging state in both end and cancel handlers. TouchEnd finishes a normally released sequence. TouchCancel handles an interrupted sequence, such as one aborted by the platform. A cancelled sequence does not synthesize a click or tap. Use the activation event for the final action and the lower-level events for transient feedback when those responsibilities need to stay separate.

Touches inside a Scroller

On iOS and iPadOS, the native Scroller's touch observation lets custom child content receive the start immediately and continue receiving moves while scrolling. Normal release produces TouchEnd; recognition of a scroll disqualifies tap activation without requiring cancellation of that lower-level stream. This allows a child to light up on contact, follow the touch, and clear its feedback when the finger lifts.

Because local conversion includes the moving content transform, a contact that moves with the scroll stays approximately fixed on its original child in that direction. Native controls and platform gestures can impose further input behavior, so test the particular control and target together. On web, tap qualification also uses a movement tolerance; a moved touch sequence need not produce activation on release.

Read further

Continue with Components and Composition for reusable component interfaces, shared state, and custom event contracts.