Skip to content

Events & Hooks

A method wrapped in an @on_* decorator is a hook. It runs when something matching its filter happens on the live host: a key, a click, a tick, and so on.

Hooks live on the grid they affect. There is no separate event bus to register with — declare the method on the BaseGrid subclass and the host dispatches into it.

Decorators are imported from xnano (or xnano.hooks). Event payload types live in xnano.events (KeyboardEventData, MouseEventData, …).

from xnano import on_keyboard, on_tick, on_click
# or: from xnano.hooks import on_keyboard, on_tick, on_click

All Events

Unfiltered: fires on every event the host delivers. You inspect ctx.event yourself.

All events
from xnano import BaseGrid, Field, Terminal, on_event

class Debug(BaseGrid):
    last: str = Field(default="—", height=1)

    @on_event
    def observe(self, ctx) -> None:
        self.last = ctx.event.type  # "keyboard", "mouse", "tick", …

Terminal().run(Debug())

Reach for specialized decorators when a filter exists — @on_event is for logging, instrumentation, or multi-kind handlers that branch on ctx.has_keyboard_event() / ctx.has_mouse_event() / …

Context

Add a second parameter and xnano passes a Context:

Context on a hook
from xnano import Context, on_keyboard

@on_keyboard("q")
def quit(self, ctx: Context) -> None: # (1)!
    ctx.runtime.request_exit() # (2)!
  1. Annotations are optional for dispatch; they help type checkers.
  2. Prefer ctx.runtime for focus, exit, and host controls. ctx.terminal is the same runtime object under an older name.

Typed payloads when the hook was triggered by that kind of event:

Attribute Payload
ctx.keyboard_event KeyboardEventData or None
ctx.mouse_event MouseEventData or None
ctx.tick_event TickEventData or None

Deprecated aliases still work: ctx.keyboard, ctx.mouse, ctx.tick. Prefer the *_event names in new code.

A hook that takes only self gets no extra arguments. ctx is optional on every decorator form.

Grid hooks vs component input

App policy belongs on the grid: @on_keyboard, @on_click, @on_action, and friends. Components may implement handle_keyboard / handle_paste for widget-local consumption (editors, lists) while focused — return True only when the event is handled.

Grid owns activation; component may consume keys
from xnano import BaseGrid, Field, on_click, on_keyboard
from xnano.components.button import Button

class Form(BaseGrid):
    submit: Button = Field(
        default_factory=lambda: Button(label="Save"),
        group="submit",
    )

    @on_click(group="submit")
    @on_keyboard("enter")
    def save(self, ctx) -> None:
        ...

Button leaves activation keys free so those hooks fire. Inputs can declare passthrough / submit_keys for the same reason. See Components.

Keyboard events

Runs when a matching binding is pressed (or released / repeated, if you filter by kind).

Keyboard bindings
from xnano import BaseGrid, Field, Terminal, on_keyboard

class Counter(BaseGrid, direction="vertical", gap=1):
    label: str = Field(default="Count: 0", height=1)
    hint: str = Field(
        default="↑ / ↓ to count · q to quit",
        height=1,
        foreground="slate-500",
    )

    count: int = Field(default=0, state=True) # (1)!

    @on_keyboard("up")
    def inc(self) -> None: # (2)!
        self.count += 1
        self.label = f"Count: {self.count}"

    @on_keyboard("down")
    def dec(self) -> None:
        self.count -= 1
        self.label = f"Count: {self.count}"

    @on_keyboard("q")
    def quit(self, ctx) -> None:
        ctx.runtime.request_exit()

Terminal().run(Counter())
  1. count is state-only data on this grid (state=True — no paint slot).
  2. A hook that takes only self gets no extra arguments.

Binding grammar

Bindings are primary keys, optionally with +-joined modifiers:

Example Meaning
"enter", "escape", "tab", "space" Named keys
"up", "down", "left", "right" Arrows
"a", "1", "f1" Character / function keys
"ctrl+s", "alt+enter", "shift+tab" Modifier + key
"ctrl+shift+z" Multiple modifiers

Aliases: "esc""escape", "return""enter". Modifier names are ctrl, alt, and shift (also accepted as bare bindings for the physical modifier key press).

Pass several bindings to one handler:

Multiple bindings
@on_keyboard("j", "down")
def move_down(self) -> None:
    ...

Filter by transition with kind=:

kind= on keyboard
@on_keyboard("space", kind="press")   # default filter is any kind if omitted
def jump(self) -> None:
    ...

@on_keyboard("ctrl+c", kind="release")
def on_release(self) -> None:
    ...

kind values: "press", "release", "repeat".

When you need the payload:

Reading keyboard_event
@on_keyboard("ctrl+s")
def save(self, ctx: Context) -> None:
    event = ctx.keyboard_event
    if event is None:
        return
    # event.binding, event.key, event.kind, event.modifiers, event.character
    self.status = f"saved via {event.binding}"

Mouse and click events

Pointer input is off by default. Enable it on the host:

Terminal(mouse_events=True).run(App())

Mouse movement and buttons

Filters by button and optional kind / field region. Defaults to left-button press when button and kind are omitted.

Mouse button filter
from xnano import BaseGrid, Field, Terminal, on_mouse

class Menu(BaseGrid, direction="vertical"):
    status: str = Field(default="right-click for menu", height=1)

    @on_mouse("right", kind="press")
    def open_menu(self) -> None:
        self.status = "context menu"

    @on_mouse(kind="scroll_up")
    def scroll_up(self) -> None:
        ...

Terminal(mouse_events=True).run(Menu())

kind values: "press", "release", "drag", "move", "scroll_up", "scroll_down", "scroll_left", "scroll_right".

Buttons: "left", "right", "middle". Move and scroll kinds report no button — a button filter would never match them.

Scope to a field's painted region with field=:

Mouse on a field region
@on_mouse("left", field="sidebar", kind="press")
def select_sidebar(self, ctx) -> None:
    ...

Clicks

Convenience for a press on a field or a group. Requires either a field name or group=.

Click by field or group
from xnano import BaseGrid, Field, Terminal, on_click
from xnano.components.button import Button

class Toolbar(BaseGrid, direction="horizontal", gap=1):
    save: Button = Field(
        default_factory=lambda: Button(label="Save"),
        group="save",
        height=1,
    )
    body: str = Field(default="Click Save or this body", border="rounded")

    @on_click(group="save") # (1)!
    def do_save(self) -> None:
        self.body = "saved"

    @on_click("body") # (2)!
    def highlight_body(self) -> None:
        self.body = "body clicked"

Terminal(mouse_events=True).run(Toolbar())
  1. Group-scoped: fires when any field with Field(group="save") is clicked, including across nested grids.
  2. Field-scoped: binds to the body field on this grid class.

Optional kwargs on @on_click: button= (default "left"), kind= (default "press").

@on_click(group="rows", button="left", kind="press")
def select_row(self, ctx) -> None:
    mouse = ctx.mouse_event
    ...

Timers and frame ticks

Clock / frame timing. Pass an interval in milliseconds, or bare @on_tick for every frame.

Interval tick
import time

from xnano import BaseGrid, Field, Terminal, on_tick

class Clock(BaseGrid, direction="vertical"):
    display: str = Field(default="", height=3, border="rounded", title=" Time ")

    @on_tick(1000) # (1)!
    def update(self) -> None:
        self.display = time.strftime("  %H:%M:%S")

Terminal().run(Clock())
  1. Interval in milliseconds. Bare @on_tick (or interval 0) fires every frame.

Keyword form is equivalent:

@on_tick(interval_milliseconds=250)
def pulse(self) -> None:
    ...

Payload: ctx.tick_eventTickEventData with elapsed_ms.

Focus changes

Two scopes:

Form Fires on
Bare @on_focus OS-level window focus gained / lost
@on_focus("field") / field= Application field focus
@on_focus(group=...) Any field sharing that Field(group=...)

Filter with kind="gained" or kind="lost".

Window and field focus
from xnano import BaseGrid, Context, Field, Terminal, on_focus
from xnano.components.input import Input

class Search(BaseGrid, direction="vertical", gap=1):
    status: str = Field(default="unfocused", height=1, foreground="slate-500")
    query: Input = Field(default_factory=Input, group="search", height=1)

    @on_focus(kind="gained") # (1)!
    def window_in(self) -> None:
        self.status = "window focused"

    @on_focus("query", kind="gained") # (2)!
    def search_in(self, ctx: Context) -> None:
        self.status = "search focused"
        ctx.cursor.visible = False

    @on_focus(group="search", kind="lost")
    def search_out(self) -> None:
        self.status = "search blurred"

Terminal().run(Search())
  1. Window-level terminal focus (host must report focus-change events).
  2. Field name on this grid. Prefer group= when the same label is shared across nested grids — same string as Field(group=...).

ctx.focus("search") / ctx.blur() move field focus from any hook. See State for focus groups.

Window resize

Fires when the host viewport changes size.

Resize
from xnano import BaseGrid, Field, Terminal, on_resize

class Layout(BaseGrid):
    info: str = Field(default="", height=1)

    @on_resize
    def on_size(self, ctx) -> None:
        w, h = ctx.device.size.width, ctx.device.size.height
        self.info = f"{w} × {h}"

Terminal().run(Layout())

Resize payload is on ctx.event.resize_event (width, height in cells). ctx.device.size tracks the same viewport.

Clipboard paste

Fires on paste / clipboard input (bracketed paste when the host supports it).

Paste
from xnano import BaseGrid, Field, Terminal, on_clipboard

class PasteTarget(BaseGrid):
    preview: str = Field(default="paste something", border="rounded")

    @on_clipboard
    def accept(self, ctx) -> None:
        event = ctx.event.clipboard_event
        text = event.text if event is not None else ""
        self.preview = (text or "")[:80]

Terminal().run(PasteTarget())

To write the system clipboard from a hook, use ctx.device.copy_to_clipboard(text) — see Device.

State and field conditions

Watch shared application state or this grid's own fields. Two expression shapes:

Expression Behavior
Bare name ("count", "user.name") Fire once per mutation of that value
Predicate ("count > 0") Fire every frame the expression is truthy

Application state

Evaluated against ctx.state (the object passed to Terminal(state=...) / Web(state=...)).

Watch application state
import dataclasses

from xnano import BaseGrid, Context, Field, Terminal, on_keyboard, on_state

@dataclasses.dataclass
class AppState:
    count: int = 0

class Counter(BaseGrid, direction="vertical", gap=1):
    label: str = Field(default="0", height=1)
    banner: str = Field(default="", height=1, foreground="emerald-400")

    @on_keyboard("up")
    def inc(self, ctx: Context[AppState]) -> None:
        ctx.state.count += 1
        self.label = str(ctx.state.count)

    @on_state("count") # (1)!
    def count_changed(self, ctx: Context[AppState]) -> None:
        self.banner = f"changed → {ctx.state.count}"

    @on_state("count > 5") # (2)!
    def high(self, ctx: Context[AppState]) -> None:
        self.banner = "high"

Terminal(state=AppState()).run(Counter())
  1. Mutation form: once when count changes.
  2. Expression form: every frame while truthy.

Grid field values

Same rules against this grid's fields (self.count, nested attributes / indexing as the expression language allows).

Watch grid fields
from xnano import BaseGrid, Field, Terminal, on_field, on_keyboard

class Cart(BaseGrid, direction="vertical"):
    total: int = Field(default=0, state=True)
    status: str = Field(default="empty", height=1)

    @on_keyboard("a")
    def add(self) -> None:
        self.total += 1

    @on_field("total") # (1)!
    def total_changed(self) -> None:
        self.status = f"total={self.total}"

    @on_field("total > 0") # (2)!
    def ready(self) -> None:
        self.status = "checkout ready"

Terminal().run(Cart())
  1. Fires once when total is assigned a new value.
  2. Fires every frame while total > 0.

Prefer bare names when you only need to react to changes; prefer expressions when a continuous “while true” condition is intentional. See State.

Background polling

Background / idle work. Default is when="idle" (once per idle event wait). Pass when="frame" to run every frame.

Idle poll
from xnano import BaseGrid, Field, Terminal, on_poll

class Worker(BaseGrid):
    status: str = Field(default="idle", height=1)
    _n: int = 0

    @on_poll # (1)!
    def pump(self) -> None:
        self._n += 1
        if self._n % 30 == 0:
            self.status = f"idle pumps: {self._n}"

    @on_poll("frame") # (2)!
    def every_frame(self) -> None:
        ...

Terminal().run(Worker())
  1. Same as @on_poll("idle") / @on_poll(when="idle").
  2. Keyword form: @on_poll(when="frame").

Use idle for cheap housekeeping that should not thrash every paint. Use frame when the work must stay locked to the render cycle (prefer @on_tick when you need a fixed millisecond interval).

Named actions

Bind a named Action instead of repeating a binding string. Events answer what happened; actions name what your app cares about and can also be performed as synthetic input.

Named action
from xnano import Action, BaseGrid, Field, Terminal, on_action

SAVE = Action.keyboard("ctrl+s") # (1)!

class Editor(BaseGrid):
    status: str = Field(default="unsaved", height=1)

    @on_action(SAVE) # (2)!
    def save(self) -> None:
        self.status = "saved"

Terminal().run(Editor())
  1. Same binding grammar as @on_keyboard.
  2. Changing SAVE updates every hook that references it.

@on_action accepts keyboard, mouse/click, focus, clipboard, resize, and tick actions. Full perform/match API: Actions.

HTTP requests

HTTP is a separate surface: @on_get_request, @on_post_request, and related decorators live in xnano.requests, not xnano.hooks. They run when a grid is served under Web or a request server — a plain Terminal session does not open ports.

See HTTP Requests.

Decorator index

Decorator Role
@on_keyboard Key bindings (kind=, multiple bindings)
@on_mouse / @on_click Pointer (Terminal(..., mouse_events=True))
@on_tick Clock interval (ms) or every frame
@on_focus Window or field focus (kind=, group=)
@on_resize Host resize
@on_clipboard Paste
@on_state / @on_field Expression / mutation watch
@on_poll Idle or every frame
@on_event All events (no filter)
@on_action(...) Named Action
@on_get_request / … HTTP — Requests

Live event loops need a real terminal (or web host). Static render(...) / Terminal().render(...) examples paint one frame and do not drive hooks that require input or time.

Next

  • Contextctx attributes and state typing
  • Actions — named triggers and perform
  • State — application state, field state, focus groups
  • Componentshandle_keyboard vs grid hooks
  • Terminalmouse_events=, tick interval, run
API

xnano.hooks · xnano.events · Context · Action · xnano.requests