Events & handlers¶
Every interactive tempest-core widget has two sides. The handler prop you
pass in (on_click, on_change, on_submit…) is the outside; on the inside,
each widget declares which typed Event that handler will receive. This page
documents that other side: the events, the enums they carry, the
handler aliases that type them, and the Semantics that describes the
node to screen readers. 🚀
What you'll learn here
- How a raw event becomes a typed
Eventinparse_event, and howhandler_accepts_eventdecides the calling convention. - The full table of event types with each one's real fields.
- The enums events carry (
SwipeDirection,SensorType,ConnectivityState,AppState). - The handler aliases (
EventHandler,TapHandler, …) and where they show up. - The
Semanticsclass and how widgets expose it for accessibility.
How events flow¶
Without a WebView there is no JS↔Python frontier: the typed contract lives at the
Python↔Kotlin boundary. When the native side reports a tap or a text change,
it sends a raw payload (a Mapping[str, Any]) — and that payload must be
validated before it enters a Python handler, exactly like FastAPI validates a
request body. Three pieces assemble this flow:
event_schemas— aClassVaron everyWidgetthat maps a handler prop name to theEventtype its payload becomes. It's how introspection publishes each widget's event contract. AButtondeclares{"on_click": TapEvent}.parse_event— the validation gate: it takes the expectedevent_typeand therawpayload, and returns a typedEvent(or raisesEventValidationErrorwith the structured field errors).handler_accepts_event— it inspects the handler's signature: a handler that accepts one positional argument receives the typed event; a zero-argument handler is called bare. Both the bridge registry and the Qt renderer use it to agree on the calling convention.
The handler itself may be sync or async — the runtime schedules awaitables
on the event loop. Here's the full path of a Button with on_click:
from tempest_core import Button, TapEvent, parse_event, handler_accepts_event
def on_tap(event: TapEvent) -> None:
"""Handle a tap by reading the tap position off the typed event."""
print("tapped at", event.x, event.y)
button = Button(label="Save", on_click=on_tap)
# 1. The widget publishes its contract: which prop becomes which Event.
assert button.event_schemas == {"on_click": TapEvent}
# 2. The native side sends a raw payload; parse_event validates at the boundary.
raw: dict[str, float] = {"x": 12.0, "y": 34.0}
event: TapEvent = parse_event(TapEvent, raw)
# 3. handler_accepts_event decides the convention: with-arg vs. bare.
if handler_accepts_event(on_tap):
on_tap(event) # receives the validated TapEvent
else:
on_tap() # a zero-argument handler is called bare
parse_event is the single trust gate
Native code sends an untyped map; only a valid payload becomes an Event
the handler can trust. If the payload doesn't match the event_type,
parse_event raises EventValidationError carrying the event_type and the
Pydantic errors list (JSON-serializable) — no half-validated data ever slips
into the handler.
Zero arguments when the value doesn't matter
Not every handler needs the event. Button(on_click=lambda: counter.incr())
is perfectly valid: because the lambda declares no positional argument,
handler_accepts_event returns False and the runtime calls it bare. You only
declare the parameter when you're going to read a field (event.value,
event.x…).
Event types¶
Every event inherits from Event (a frozen BaseModel, so the reconciler
diffs it by value). The table lists each type with its real fields and defaults.
Fields with no default are required in the payload.
Event |
Emitted by / when | Real fields |
|---|---|---|
TapEvent |
a tap/click on a widget | x: float \| None = None, y: float \| None = None |
TextChangeEvent |
a text input's value changed | value: str, valid: bool \| None = None |
ToggleEvent |
a checkbox/switch toggled | checked: bool |
SlideEvent |
a slider's value changed | value: float (within the widget's [min, max]) |
DateChangeEvent |
a date picker's value changed | value: str (ISO yyyy-mm-dd, empty when cleared) |
FileSelectEvent |
a file was selected from a file picker | uri: str, name: str \| None = None |
LongPressEvent |
a press held past the long-press threshold | x: float \| None = None, y: float \| None = None |
SwipeEvent |
a directional swipe past the distance threshold | direction: SwipeDirection, dx: float = 0.0, dy: float = 0.0 |
RouteChangeEvent |
the active route changed (push/pop/replace) | name: str, params: dict[str, Any] = {} |
ScrollEvent |
a scrollable container scrolled | offset: float, direction: str ("vertical"/"horizontal") |
RefreshEvent |
a pull-to-refresh completed | (no payload — the gesture is the signal) |
EndReachedEvent |
the list scrolled past its end threshold | (no payload — triggers pagination) |
DismissEvent |
an overlay was dismissed (scrim/swipe/back) | overlay_id: str \| None = None |
MenuSelectEvent |
an item selected from a menu/action sheet | value: str, label: str |
PanEvent |
a pan/drag reported per-frame and on release | dx: float = 0.0, dy: float = 0.0, vx: float = 0.0, vy: float = 0.0 |
ScaleEvent |
a pinch (scale + rotation) with a focal point | scale: float = 1.0, focus_x: float = 0.0, focus_y: float = 0.0, rotation: float = 0.0 |
DragEvent |
a drag-and-drop (picked up and, maybe, dropped) | data: str = "", x: float \| None = None, y: float \| None = None |
ReorderEvent |
a list item dragged to another position | from_index: int, to_index: int |
SelectEvent |
an option selected from a dropdown/select | value: str, index: int |
TimeChangeEvent |
a time picker's value changed | value: str (24h "HH:MM", "" when cleared) |
RangeChangeEvent |
a range slider's bounds changed | low: float, high: float |
SubmitEvent |
a form (or completable input) was submitted | values: dict[str, str] = {} |
ValidationEvent |
a form field was validated | field: str, value: str, error: str \| None = None |
PageChangeEvent |
a PageView's active page changed |
page: int, previous: int = 0 |
QrScanEvent |
a QR/barcode was decoded | data: str, format: str = "QR_CODE" |
CameraFrameEvent |
one RGB frame from a live camera preview | width: int, height: int, data: str (base64 of the H×W×3 buffer), rotation: int = 0 |
LifecycleEvent |
the app moved between lifecycle states | state: AppState |
SensorEvent |
one sample from a device sensor stream | sensor: SensorType, values: list[float] = [], timestamp_ms: int = 0 |
ConnectivityEvent |
the device's network connectivity changed | state: ConnectivityState |
DeepLinkEvent |
the app was opened/resumed via a deep link | url: str, params: dict[str, str] = {} |
ThemeChangeEvent |
the active theme mode changed (dark/light) | mode: ThemeMode |
LocaleChangeEvent |
the locale / layout direction changed | language: str, region: str \| None = None, rtl: bool = False |
Payloads are always JSON-serializable by construction
Notice that no event carries tuples or nested models. A ScaleEvent reports
the focal point as two top-level floats (focus_x/focus_y), not a tuple;
RangeChangeEvent sends low/high separately; SensorEvent sends a flat
list of floats. This keeps every payload crossable over the bridge with no
custom serialization.
Events that don't come from a widget handler
LifecycleEvent, SensorEvent, ConnectivityEvent, ThemeChangeEvent, and
LocaleChangeEvent are not emitted by a widget on_*. The host fires them
and the bridge routes them over reserved tokens — "__sensor__:<type>",
"__connectivity__:<state>", "__theme__", "__locale__" — to App methods
(set_theme, set_locale, …). They're platform events, not touch events.
Event enums¶
Some events carry an enum instead of a free-form string, so the field has a closed
domain. All are StrEnum, so they serialize as the string value itself.
SwipeDirection¶
The cardinal direction of a swipe gesture (carried by SwipeEvent.direction).
| Member | Value | Meaning |
|---|---|---|
LEFT |
"left" |
The pointer travelled predominantly toward the left edge (decreasing x). |
RIGHT |
"right" |
The pointer travelled toward the right edge (increasing x). |
UP |
"up" |
The pointer travelled toward the top of the screen (decreasing y). |
DOWN |
"down" |
The pointer travelled toward the bottom of the screen (increasing y). |
SensorType¶
The hardware sensor a continuous stream can be opened on (carried by
SensorEvent.sensor).
| Member | Value | Meaning |
|---|---|---|
ACCELEROMETER |
"accelerometer" |
Linear acceleration along x/y/z (incl. gravity), in m/s². |
GYROSCOPE |
"gyroscope" |
Angular velocity (rate of rotation) about x/y/z, in rad/s. |
MAGNETOMETER |
"magnetometer" |
Geomagnetic field along x/y/z, in microtesla — the compass basis. |
PRESSURE |
"pressure" |
Ambient atmospheric (barometric) pressure, in hectopascals. |
LIGHT |
"light" |
Ambient illuminance at the screen, in lux. |
PROXIMITY |
"proximity" |
Nearness of an object to the front of the device (e.g. an ear), in cm. |
STEP_COUNTER |
"step_counter" |
Cumulative steps since last boot, from the hardware pedometer. |
ConnectivityState¶
The device's network connectivity state (carried by ConnectivityEvent.state).
| Member | Value | Meaning |
|---|---|---|
CONNECTED |
"connected" |
An active link of a generic/undistinguished transport. |
DISCONNECTED |
"disconnected" |
No active link — requests fail until connectivity returns. |
WIFI |
"wifi" |
Connected over Wi-Fi — typically unmetered, larger transfers OK. |
MOBILE |
"mobile" |
Connected over cellular (mobile data) — typically metered. |
AppState¶
The lifecycle state of the app process (carried by LifecycleEvent.state).
| Member | Value | Meaning |
|---|---|---|
FOREGROUND |
"foreground" |
Visible and receiving input — the active task in front of the user. |
BACKGROUND |
"background" |
No longer visible; should pause UI work and release scarce resources. |
INACTIVE |
"inactive" |
Transitional/partially-obscured — visible but not receiving input. |
Handler aliases¶
Each on_* prop is typed by a handler TypeAlias. They aren't classes —
they're just typed callables that document which Event the handler receives
and let introspection emit a schema (a raw Callable has no JSON-schema
representation, so each alias carries a WithJsonSchema annotation).
Every event-bearing alias accepts three shapes for the same prop:
Callable[[YourEvent], Any]— a sync handler that reads the event;Callable[[YourEvent], Awaitable[Any]]— itsasyncversion;- a zero-argument handler (sync or
async) for when the value doesn't matter.
The runtime only passes the event when the handler accepts a positional argument
(exactly what handler_accepts_event decides).
from tempest_core import Slider, SlideHandler, SlideEvent
# All three shapes below are valid SlideHandlers.
def sync_handler(event: SlideEvent) -> None:
"""Read the new value synchronously."""
print(event.value)
async def async_handler(event: SlideEvent) -> None:
"""Persist the new value asynchronously."""
await store.save(event.value)
def bare_handler() -> None:
"""React without needing the value."""
mark_dirty()
slider = Slider(on_change=sync_handler) # (1)!
Slider.on_changeis typed asSlideHandler, so any of the three shapes is accepted. The widget declares{"on_change": SlideEvent}inevent_schemas— that's how the bridge knows to validate the payload into aSlideEvent.
| Alias | Event it delivers | Shows up on (examples) |
|---|---|---|
EventHandler |
(zero arguments) | Button.on_click, IconButton.on_click |
TapHandler |
TapEvent |
tap detectors, on_double_tap |
TextChangeHandler |
TextChangeEvent |
Input.on_change, TextArea.on_change |
ToggleHandler |
ToggleEvent |
Checkbox.on_change, Switch.on_change |
SlideHandler |
SlideEvent |
Slider.on_change |
DateChangeHandler |
DateChangeEvent |
DatePicker.on_change |
FileSelectHandler |
FileSelectEvent |
FilePicker.on_select |
LongPressHandler |
LongPressEvent |
long-press gestures |
SwipeHandler |
SwipeEvent |
swipe gestures |
RouteChangeHandler |
RouteChangeEvent |
Navigator.on_change, TabBar.on_change |
ScrollHandler |
ScrollEvent |
virtualized lists |
RefreshHandler |
RefreshEvent |
RefreshControl |
EndReachedHandler |
EndReachedEvent |
paginated lists |
DismissHandler |
DismissEvent |
Dialog, BottomSheet, Dismissible |
MenuSelectHandler |
MenuSelectEvent |
Menu.on_select, ActionSheet.on_select |
PanHandler |
PanEvent |
pan gesture (the same-named widget — see note) |
ScaleHandler |
ScaleEvent |
pinch gesture (the same-named widget — see note) |
DragHandler |
DragEvent |
Draggable.on_drag, DragTarget.on_drop |
ReorderHandler |
ReorderEvent |
ReorderableList.on_reorder |
SelectHandler |
SelectEvent |
Dropdown.on_change |
TimeChangeHandler |
TimeChangeEvent |
TimePicker.on_change |
RangeChangeHandler |
RangeChangeEvent |
RangeSlider.on_change |
SubmitHandler |
SubmitEvent |
Form.on_submit |
ValidationHandler |
ValidationEvent |
FormField.on_validate |
PageChangeHandler |
PageChangeEvent |
PageView.on_change |
PanHandler and ScaleHandler are widgets at the package level
The PanHandler/ScaleHandler aliases live in widgets.base, but at the
package level (from tempest_core import PanHandler) those names are the
same-named advanced-gesture widgets, which shadow the aliases. The
aliases stay private to base. If you want the callback type, it's PanEvent
/ ScaleEvent it receives; if you import PanHandler, you get the widget.
Semantics & accessibility¶
Any node in the tree carries accessibility metadata via Widget.semantics, a
Semantics instance (a frozen BaseModel, so the reconciler diffs it by
value). The leaf renderers map these fields to the platform's accessibility
surface — QAccessible name/description on Qt, and
Modifier.semantics { contentDescription; role } on Compose — so TalkBack and Qt
AT can describe the node.
| Field | Type | What it carries |
|---|---|---|
label |
str \| None |
The accessible label (contentDescription / accessible name). |
role |
str \| None |
Accessible role hint (e.g. "button", "image", "heading"); the renderer maps it to its native role enum. |
hint |
str \| None |
An accessibility hint / tooltip describing what the node does. |
from tempest_core import Container, Semantics
card = Container(
semantics=Semantics(
label="Product card",
role="button",
hint="Double-tap to open details",
),
)
Icon-only widgets depend on Semantics to not go mute
A button with no visible text has no derivable accessible label. That's why
IconButton routes its label into the accessibility surface — without it,
the node is mute to screen readers. Whenever a widget has no visible text, fill
semantics (or the label the widget dedicates to a11y).
focusable and focus_order travel with Semantics
Beyond semantics, every Widget exposes focusable: bool | None (accepts
focus; None keeps the widget's natural focusability) and focus_order: int |
None (explicit tab order; None uses the natural traversal order). Together
with Semantics, they form the accessibility surface both renderers consume.
A component carries the name you gave it¶
A component is not a node: build replaces it with the tree its render
returns, before any renderer sees the tree. So a prop that describes the node —
semantics, focusable, focus_order, tag, attrs — has to cross that
boundary, or it dies there.
from tempest_core import Card, Semantics, Text, build
tree = build(
Card(
key="totals",
semantics=Semantics(label="Budget totals"), # (1)!
children=[Text(content="$1,234.56")],
)
)
print(tree.type) # "Container"
print(tree.props["semantics"].label) # "Budget totals"
- The prop lives on every
Widget's base, so naming any component is just this — one line, with noContainerwrapped around it to hang the name on.
Until 0.17.0 this did nothing
The prop was declared and dropped: naming a Card compiled, type-checked
and reached no node at all. Measured over the 54 public components:
semantics was dropped by 50 of them, and focusable, focus_order,
tag and attrs by all 54. If you wrapped a component in a Container
just to be able to name it, that wrapper can go.
Who wins, and why style stays out
The render owns what it touched. If the tree the component returns already
sets the prop on any node, yours stays out — which is what keeps a field
correct: it puts the accessible name on the <input> a screen reader stops at,
and a second copy on the wrapper would announce the same control twice.
style is never carried: a component is documented as reading
self.style and folding it into the tree it returns, and several merge it into
an inner node, so carrying it too would apply it twice. The canonical list is
CARRIED_PROPS, exported from the package root.
Recap¶
- Two sides: the
on_*prop is the outside;event_schemasmaps each prop to the typedEventit delivers. parse_eventis the boundary gate: it validates a raw payload into a typedEventor raisesEventValidationErrorwith the structured errors.handler_accepts_eventdecides the convention: a one-argument handler receives the event; a zero-argument handler is called bare. Handlers may be sync orasync.- ~32 event types, all inheriting from
Event(frozen), with payloads always JSON-serializable (top-level floats, flat lists — never tuples). - Closed-domain enums:
SwipeDirection,SensorType,ConnectivityState,AppState. - Handler aliases type each prop and accept sync/
async/zero-arg; watch out that package-levelPanHandler/ScaleHandlerare the widgets. - A component carries them:
semantics,focusable,focus_order,tagandattrs(CARRIED_PROPS) cross the component boundary onto the root it renders — unless its ownrenderalready touched the prop;stylenever does, because the component folds it in. Semantics(label/role/hint) plusfocusable/focus_orderform the accessibility surface Qt and Compose consume.
For the full signatures, see the API reference.