Skip to content

Events and Listeners

Listeners registered with on(event, handler) on a Node or a Window. For a walkthrough, see Events and Input.

Node listeners

button.on("click", lambda e: print("clicked", e.target == button))
button.off("click")

node.on(event, handler) registers handler, replacing any earlier listener for the same event on that node. handler receives an Event, or nothing if it takes no parameters. An unknown event name raises ValueError listing the valid ones.

Event Fires when Bubbles
pointer_enter, pointer_leave The pointer enters or leaves the node's subtree — not when it moves between the node and its own descendants, and pointer_leave also fires when the pointer leaves the window no
pointer_down, pointer_move, pointer_up A button is pressed, the pointer moves, a button is released yes
pointer_cancel A press was taken by the OS — to move the window from a drag region, toggle maximize, or show the window menu — so no pointer_up or click follows, and any pointer capture is released (0.5.0) yes
click Primary press and release on the same node, or keyboard activation yes
secondary_click Secondary press and release on the same node yes
wheel A wheel or trackpad scroll yes
file_hover, file_hover_cancel, file_drop Files dragged from the OS over, away from, or dropped on the node under the pointer; paths (0.5.4) yes
link A click (press and release) on a text span that has a link; href is the span's link string. Not fired when the press became a selection (0.5.4) yes
touch_start, touch_move, touch_end, touch_cancel A finger touches the screen, moves, lifts, or is taken away by the system; each carries pointer_id, and a finger's events all go to the node it landed on (0.5.4) yes
tap A quick touch and release in place; count is 2 for a second tap close to the first (0.5.4) yes
long_press A touch held in place for half a second (0.5.4) yes
pan One finger dragging past a small slop; phase is "began", "changed", "ended" or "cancelled" (0.5.4) yes
pinch Two fingers moving together or apart, or a trackpad pinch; carries scale and scale_delta (0.5.4) yes
key_down, key_up A key is pressed or released while the node, or a descendant, has focus (the root gets keys when nothing is focused) yes
input Committed text arrives for the focused text field yes
focus, unfocus A node gains or loses keyboard focus yes
change A text field's text was changed by the user no
scroll A scroll view's offset changed, by any cause: wheel, keys, scroll_into_view, focus, set, or an animation, once a frame (0.4.2) no
animation_end An animation on the node ended: it ran to its end (finished is True), or it was replaced by another animation of the same property, or the property was set or stop_animationped (finished is False); property names it. Delivered in the frame after, and after any on_complete callback (0.5.6) no
submit Enter in a single-line text input (0.5.6) no
compose_start, compose_update, compose_end An IME composition began, changed or ended in a text input; text is the preedit text and preedit_cursor the IME's cursor range. The commit follows as input and change (0.5.6) no
caret_move A text input's caret moved or its text changed by a key, click or drag; caret is its rectangle and inserted the bytes an edit inserted. Not for set (0.5.6) no
layout The node's laid-out size changed, or it was laid out for the first time, by any cause (a set, a parent's resize, a calc size, new text); width/height are the new size and old_value/new_value the (width, height) before (None the first time) and after. Only a node with a listener is watched; once a frame (0.5.6.2) no
dismiss An outside press or Escape asked a layer to close no
a11y_action An assistive technology requested increment, decrement, expand, collapse, scroll_into_view, or set_value (its activate and focus requests arrive as click and focus) yes

Propagation

A bubbling event runs the target's listener, then its parent's, and so on to the root. Any listener can call event.stop() to end it. event.target is where the event happened and event.current is the node whose listener is running. Because focus and unfocus bubble, a container knows focus moved somewhere inside it, and event.target says where.

Focus

focus and unfocus each carry event.related_target: the node on the other side of the move. It's the node losing focus for focus and the node gaining it for unfocus. It's None when focus comes from, or goes to, nowhere in the window. A search bar wrapping a text field and a clear button can use it to tell focus moving between its own children from focus leaving it:

def on_unfocus(e):
    if e.related_target not in (field, clear_button):
        close_suggestions()

bar.on("unfocus", on_unfocus)

focus also carries event.focus_visible, which says whether to draw a focus indicator (Tesserae Engine draws none itself):

  • True when focus arrived by keyboard: Tab, Shift+Tab, or an assistive technology's focus request.
  • False when focus arrived from a pointer press.
  • For node.focus() and other programmatic focus, whatever the last interaction was. Before any pointer press, that's True.

Key presses with Ctrl, Alt, or Meta held are shortcuts, not navigation, so they don't count as keyboard input for this. This is the heuristic browsers use for :focus-visible.

Raw input is delivered before what it caused, so a click delivers pointer_down, then pointer_up, then click.

text nodes are never the target of pointer events: a press on a button's label lands on the button. Set hit_testable=False on an icon (a path) for the same effect.

Pointer capture

def on_down(e):
    thumb.capture_pointer()

thumb.on("pointer_down", on_down)
thumb.on("pointer_move", lambda e: print(e.x))  # keeps firing off the thumb

node.capture_pointer() routes every later pointer event to node, wherever the pointer goes, until a button is released or node.release_pointer() is called. While captured, the capturing node is the target and events still bubble from it.

Window listeners

window.on("resize", lambda e: print(e.width, e.height))
window.on("close_requested", lambda e: e.cancel())  # keep the window open
Event Fires when Payload
resize The window's client area changed size width, height
color_scheme The OS switched between light and dark dark
frame A frame was drawn and presented (0.5.4); fires every frame, so keep the handler cheap stats
reduced_motion The OS's reduce-motion preference changed (0.5.4) reduced_motion
high_contrast The OS's increased-contrast preference changed (0.5.4) high_contrast
scale_factor The window moved to a display with a different scale factor scale_factor
close_requested The user asked to close the window; event.cancel() keeps it open —
closed The window closed — by the user or by reaching max_frames —
dock_target During a docking drag, the pointer moved into another dock zone, or out of every zone side
file_hover Files are being dragged from the OS over the window (0.5.4); paths —
file_hover_cancel The file drag left the window or was abandoned (0.5.4) —
file_drop Files were dropped on the window (0.5.4); paths —
dock_drop A docking drag ended with the primary button's release; the panel has moved to the zone there, if any panel, side
maximized The window was maximized or restored (0.5.0) maximized
active The window gained or lost the OS's focus (0.5.0) active
titlebar_inset The area the OS's window controls take over the content changed — macOS's traffic lights on an undecorated window, 0 in fullscreen (0.5.0) titlebar_inset
gpu_lost The GPU was lost — a driver fault, or a hang the driver's own timeout caught — and the run is ending (0.5.1) reason ("unknown" or "destroyed"), message
gpu_error The GPU reported an error; the draw that caused it was skipped. Once per distinct message (0.5.1) message
gpu_stalled A submitted frame hasn't completed after gpu_watchdog seconds (0.5.1) seconds

A window event has no node: event.target is None.

Window properties

window.set(title="Editor — draft.md")
window.get("scale_factor")  # 1.0 until App.run() opens the window
window.get("dark")          # True, False, or None where the OS can't say
window.get("reduced_motion")  # (0.5.4) True if the user asked for less motion
window.get("high_contrast")   # (0.5.4) True if the user asked for more contrast

window.set(title=...) changes the title, live if the window is open. window.set(partial_redraw=False) redraws the whole window every frame instead of only what changed (Window). window.get(name) reads width, height, title, scale_factor, dark, reduced_motion, high_contrast, partial_redraw, partial_redraw_active, or show_damage, and (0.5.0) decorations, fullscreen, min_width, min_height, maximized, minimized, active, platform, resize_border, system_menu, titlebar_inset, native_controls, and (0.5.1) gpu_watchdog — see Window controls and state, macOS: the overlay title bar, and Title bar and borders. root is the window's root node.

dark is the OS's appearance right now, so an app that follows it can start in the right one and then listen to color_scheme. Linux reads it from the desktop's settings portal, before App.run() too, and the portal's changes arrive as color_scheme; macOS and Windows answer once the window is open, and None before. None also means no portal answered (a headless session).

Reduced motion and increased contrast (0.5.4) are the OS's accessibility preferences, read the same way. window.get("reduced_motion") is True when the user asked the system to cut animation, False when they did not, and None where the platform can't say; get("high_contrast") is the same for "more contrast". An app that honours them reads them at start and listens for reduced_motion and high_contrast:

if window.get("reduced_motion"):
    ANIMATE_MS = 0                      # snap instead of easing
window.on("reduced_motion", lambda e: set_animations(not e.reduced_motion))
window.on("high_contrast", lambda e: use_high_contrast_palette(e.high_contrast))

The engine reports them and changes nothing itself: durations are the app's to shorten, and colours its to swap. Linux reads the settings portal's reduced-motion and contrast (with GNOME's enable-animations and high-contrast for an older portal) and its changes arrive live; Windows reads the client-area animation setting and the High Contrast theme, macOS the "Reduce motion" and "Increase contrast" options. Windows and macOS announce no change, so the event fires when the window regains focus after one. Only the Linux read is checked on real hardware; the Windows and macOS code is compiled for those targets and untested.

Event

Field Present for
type every event
target every node event — where it happened
current node listener events — the node whose listener is running
x, y pointer and wheel events — local to current
window_x, window_y pointer and wheel events — in the window
button pointer events and pointer clicks — "primary", "secondary", "middle", or the mouse's side buttons "back" and "forward" (0.4.1; they make no click)
delta_x, delta_y wheel — pixels, positive scrolling right and down; with Shift held, a wheel with no horizontal part arrives as delta_x (0.4.3)
pointer_id touch_* — which finger
phase pan, pinch — "began", "changed", "ended", "cancelled"
count tap — 1, or 2 for a double tap
scale, scale_delta pinch — the distance between fingers over what it was at the start, and over what it was at the last event
delta_x, delta_y (also) pan, pinch — how far the point (a pan's finger, a pinch's midpoint) moved since the last event
total_x, total_y pan, pinch — how far it has moved in all
velocity_x, velocity_y pan when it ends — the speed the finger lifted at, in pixels a second
key, repeat key_down, key_up
shift, ctrl, alt, meta pointer, wheel, key, and click events
text input
old_value, new_value change — the text; scroll — the offset; layout — the (width, height) size before (None the first time) and after
action, value a11y_action
related_target focus, unfocus — the node on the other side of the move
focus_visible focus — whether focus arrived by keyboard
width, height / dark / scale_factor resize / color_scheme / scale_factor (and layout on a node: its new size)
reduced_motion / high_contrast reduced_motion / high_contrast — the new preference
stats frame — the frame's costs, the same dict as window.frame_stats()["last"]
maximized / active maximized / active — the window's new state
path, paths file_hover, file_drop — the dragged files' paths as strs; path is the first
href link — the clicked span's link string
preedit_cursor compose_start, compose_update — the IME's (start, end) cursor range in the preedit text, or None
caret, inserted caret_move — the caret's (x, y, width, height) in the node's coordinates, and the (start, end) bytes an edit inserted or None
property, finished animation_end — the animated property, and whether it ran to the end (False: it was replaced or stopped)
side dock_target, dock_drop — the dock zone under the pointer, or None
panel dock_drop — the dragged panel

Methods: stop() ends propagation; cancel() prevents close_requested's default (any other event raises ValueError).

Key names are snake_case ("enter", "escape", "arrow_left", "page_up", "f5") for named keys, and the produced character ("a", or "A" with Shift) for character keys.

Node handles compare equal, and hash equal, when they name the same node — event.target == button works, and nodes can key a dict.

Testing without a display

window.simulate(event, node=None, **fields) delivers a synthetic event through the same pipeline as real input, so every listener fires as it would live.

window.simulate("click", node=button)
window.simulate("pointer_down", node=thumb, x=4, y=10, shift=True)
window.simulate("key_down", key="tab")
window.simulate("resize", width=800, height=600)
Event Fields
pointer_down, pointer_up node and/or x, y; button
pointer_move, pointer_enter, click, secondary_click node and/or x, y
wheel node and/or x, y; delta_x, delta_y
pointer_leave — (the pointer leaves the window)
file_hover, file_drop paths (a list of str) or path; node and/or x, y for where they are over
link node and href (a str); delivers the event as a click on a link would
file_hover_cancel —
touch_start, touch_move, touch_end, touch_cancel node and/or x, y; id (default 0) tells fingers apart
trackpad_pinch node and/or x, y; delta (a magnification step, 1 + delta the scale to apply), phase ("started", "moved" — the default — "ended", "cancelled")
key_down, key_up key; repeat
input text
focus, unfocus node
a11y_action node, action; value for set_value
resize width, height
color_scheme dark
reduced_motion, high_contrast value (a bool)
scale_factor scale_factor
close_requested, closed —
maximized / active maximized / active — sets the window's state; the event fires only if it changed, as the live window's does. Before App.run(), maximize()/restore()/minimize() change the state with no event, so simulating the state you're already in fires nothing

Pointer events aim at node's center, at x/y local to node, or at window-space x/y without a node. Pointer, wheel, click, and key events also take shift, ctrl, alt, and meta. A simulated key press edits text and moves focus exactly as a real one does — Ctrl+C/X/V/A included — and a focused terminal takes every key, as it does live. Simulated pointer, touch and key events also reach the parts of text handling that need the text layout (0.5.4): a press, drag and release select text, a press and release on a link span fire link, two quick presses select a word, and Shift+Up and Shift+Down move a selection a line, all as they do live (the text layout comes from a headless renderer, and the clock is the window's, so advance moves the double-click window). An unknown event or field raises ValueError.