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.
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:
from xnano import Context, on_keyboard
@on_keyboard("q")
def quit(self, ctx: Context) -> None: # (1)!
ctx.runtime.request_exit() # (2)!
- Annotations are optional for dispatch; they help type checkers.
- Prefer
ctx.runtimefor focus, exit, and host controls.ctx.terminalis 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.
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).
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())
countis state-only data on this grid (state=True— no paint slot).- A hook that takes only
selfgets 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:
Filter by transition with kind=:
@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:
@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:
Mouse movement and buttons¶
Filters by button and optional kind / field region. Defaults to left-button
press when button and kind are omitted.
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=:
@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=.
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())
- Group-scoped: fires when any field with
Field(group="save")is clicked, including across nested grids. - Field-scoped: binds to the
bodyfield 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.
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())
- Interval in milliseconds. Bare
@on_tick(or interval0) fires every frame.
Keyword form is equivalent:
Payload: ctx.tick_event → TickEventData 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".
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())
- Window-level terminal focus (host must report focus-change events).
- Field name on this grid. Prefer
group=when the same label is shared across nested grids — same string asField(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.
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).
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=...)).
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())
- Mutation form: once when
countchanges. - 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).
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())
- Fires once when
totalis assigned a new value. - 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.
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())
- Same as
@on_poll("idle")/@on_poll(when="idle"). - 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.
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())
- Same binding grammar as
@on_keyboard. - Changing
SAVEupdates 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¶
- Context —
ctxattributes and state typing - Actions — named triggers and
perform - State — application state, field state, focus groups
- Components —
handle_keyboardvs grid hooks - Terminal —
mouse_events=, tick interval,run
API
xnano.hooks ·
xnano.events ·
Context ·
Action ·
xnano.requests