Apps¶
App is the high-level, imperative way to build an interactive terminal
program in maya-py. It owns the event loop, your state, and the render
schedule; you just declare what's in state, how keys change it, and what
the screen looks like. This is the 90% path — reach for the Elm-style
Program (MVU) only when you need pure, testable update logic
or explicitly-managed effects.
Before reading on, internalise the mental model in concepts.md §4 (the two runtimes), §5 (the frame lifecycle) and §6 (the event & input model). This page is the depth-first tour of everything those sections summarise.
1. The state → view loop¶
An App is built on one idea (see
concepts.md §1):
State is a mutable bag. Handlers mutate it. The view is a pure function from that state to an element tree, re-evaluated after every handled event.
You never imperatively touch the screen. You don't "redraw line 3" or "clear
the field." You mutate state, and maya re-runs your view(state), lays it out,
paints it, diffs it against the previous frame, and writes only the changed
cells.
from maya_py import App, card, b
app = App("counter", n=0, quit_keys=("q", "esc")) # 1. construct + seed state
@app.on("+", "=") # 2. bind keys → mutate state
def inc(s):
s.n += 1
@app.view # 3. state → element tree
def view(s):
return card(b(f"Count: {s.n}"))
app.run() # 4. block until stop()
Every runnable snippet on this page imports only from maya_py.
The frame lifecycle, concretely¶
When you press a key (full detail in concepts.md §5):
- Input is decoded into an
Event. - The runtime routes it (in this exact order — see §6 below): Ctrl-C
guard → mouse handlers → paste → resize → focused widget →
@app.on_keycatch-alls → the first matching@app.on(...)binding. @app.on_framehandlers (if any) run, then your view is called for a fresh tree.- The tree is laid out, painted, diffed, and the changed cells are written.
- The loop waits — for the next input, or, if a frame rate is set, until the next frame deadline.
Handlers return nothing meaningful. You don't return a new view — the view is re-evaluated from state every frame, so just mutate
sand let the loop do the rest.
2. Construction¶
App(title="", *, inline=True, mouse=False, fps=0,
quit_on_ctrl_c=True, quit_keys=(), model=None, keys=None, **state)
| Argument | Default | Meaning |
|---|---|---|
title |
"" |
Terminal/window title while running. |
inline |
True |
Render in place in scrollback vs. take over the alternate screen. |
mouse |
False |
Enable mouse reporting. Auto-enabled if you register any mouse handler. |
fps |
0 |
0 = redraw only on change; >0 = continuous redraw at that rate. |
quit_on_ctrl_c |
True |
Ctrl-C always quits, even from a frozen handler — unless you bind ctrl+c yourself, or set this False. |
quit_keys |
() |
Keys auto-bound to stop() (e.g. ("q", "esc")). |
model |
None |
Use your own object as state instead of the attribute bag. |
keys |
None |
Declarative {key: fn(state)} map, an alternative to @app.on. |
**state |
— | Seed the attribute bag: App("c", n=0, name="x"). Ignored when model= is given. |
The three state styles¶
You pick exactly one of these per app. They're mutually exclusive in practice:
**state kwargs are ignored when you pass a model.
(a) kwargs — the attribute bag. Fast to write. Each kwarg becomes an
attribute on app.s:
app = App("counter", n=0, name="world")
@app.on("+")
def inc(s):
s.n += 1 # s is the bag; attributes are your kwargs
(b) model= — your own object. State is an instance of your class;
handlers mutate it and call its methods. This keeps logic with data and is the
cleanest style for anything non-trivial:
class Counter:
def __init__(self):
self.n = 0
def bump(self, d):
self.n += d
app = App("counter", model=Counter())
@app.on("+")
def inc(s):
s.bump(+1) # s is your Counter instance
(c) keys= — declarative bindings. A {key_name: fn(state)} map, evaluated
at construction. Pairs beautifully with model= so the whole app is data:
app = App(
"counter", model=Counter(),
keys={
"+": lambda s: s.bump(+1),
"-": lambda s: s.bump(-1),
},
)
keys= and @app.on can coexist; both append to the same binding list.
inline vs. alternate screen¶
inline=True (the default) renders in place in your scrollback, like a rich
prompt — output above the app is preserved, and the app cleans up to a few
stable rows on exit. This is what makes maya feel native in a normal shell
session. inline=False takes over the whole terminal (alternate screen),
which suits full-screen dashboards and games. See
concepts.md §5.
Two classmethod constructors read as intent instead of a boolean flag:
app = App.inline("counter", fps=30) # == App("counter", inline=True, fps=30)
app = App.fullscreen("boids", mouse=True) # == App("boids", inline=False, mouse=True)
Fullscreen pixel canvas. A fullscreen half-block app used to hand-roll a
shutil.get_terminal_size() fallback, because a grow=1 component under the
alternate screen receives an unbounded height sentinel, not the real cell
height. fullscreen_pixels(draw) does that for you — it hands draw(field,
pw, ph) a PixelField already sized to the visible terminal (2 pixels tall
per cell) and renders it as half-blocks:
from maya_py import App, fullscreen_pixels, hsv, wrap
app = App.fullscreen("plasma", fps=30, t=0.0)
@app.on_frame
def tick(s, dt): s.t += dt
@app.view
def view(s):
def draw(f, pw, ph):
for y in range(ph):
for x in range(pw):
f.set(x, y, hsv(wrap(x / pw + s.t * 0.1, 1.0)))
return fullscreen_pixels(draw, bg=(4, 4, 10))
Need raw cell dimensions yourself? term_size() -> (cols, rows),
term_cols(), term_rows() wrap the size lookup with a sane fallback.
fps=0 vs. fps>0¶
fps=0(default): event-driven. The loop sleeps until input arrives, then redraws. Zero CPU when idle. Correct for almost every UI.fps>0: continuous redraw at that rate — for clocks, spinners, games, progress bars. Registering an@app.on_framehandler automatically bumps a0fps up to30. Don't setfps>0for a static UI; it burns CPU re-rendering identical frames.
Quit handling¶
quit_on_ctrl_c=True(default) — Ctrl-C arrives as a key event (raw mode disables tty signals) and always stops the app, so a frozen handler can still be killed. Bindingctrl+cyourself (@app.on("ctrl+c")) takes over and disables this guard.quit_keys=("q", "esc")— auto-binds those keys tostop(), so you don't hand-write a quit handler in every app.
3. State access¶
app.state(**kw) -> state # seed/update fields; returns the state object
app.s # the live state object (read/write)
app.state(n=0) is the imperative twin of the n=0 constructor kwarg — useful
when you want to construct the app, attach handlers, then seed state:
app = App("counter")
app.state(n=0, history=[])
app.s.n += 1 # direct mutation works anywhere you hold `app`
When you pass model=, app.s is your object and app.state(**kw) writes
attributes onto it (so prefer constructing your model with its own fields).
4. Handlers¶
All handlers are registered with decorators on the app. The complete set is
exactly: on, on_key, on_frame, on_paste, on_resize, on_click,
on_scroll, on_mouse.
@app.on(*keys) — fn(state)¶
Bind one or more key names (see §5) to a handler that receives state. The first matching binding for an event runs, then routing stops:
@app.on("+", "=") # several keys → one handler
def inc(s):
s.n += 1
@app.on("r")
def reset(s):
s.n = 0
@app.on_key — fn(state, event) (catch-all)¶
Runs for every key event, before the @app.on bindings, and receives the
raw Event. Use the low-level predicates to inspect it
(key, key_special, ctrl, alt, event_char, any_key — see
low-level.md). Multiple on_key handlers all run.
from maya_py import App, event_char, card
app = App("typer", buf="")
@app.on_key
def feed(s, ev):
ch = event_char(ev) # the typed character, or None
if ch is not None:
s.buf += ch
@app.view
def view(s):
return card(s.buf or "(type something)")
@app.on_frame — fn(state, dt) (animation)¶
Called once per frame before the view renders, with dt = seconds since
the previous frame. Put your simulation/animation step here so view(state)
stays a pure function of state. Registering a frame handler turns on continuous
redraw: if fps was 0, it becomes 30.
from maya_py import App, card, b
app = App("clock", t=0.0)
@app.on_frame
def tick(s, dt):
s.t += dt
@app.view
def view(s):
return card(b(f"{s.t:5.1f}s elapsed"))
app.run()
@app.on_paste — fn(state, text)¶
Bracketed-paste text arrives in one shot — never reconstruct it keystroke-by-keystroke. If a focused text widget exists it also receives the paste automatically.
@app.on_resize — fn(state, cols, rows)¶
Fires on terminal resize with the new dimensions.
Mouse handlers (need mouse=True)¶
Registering any of these auto-sets mouse=True, so you don't strictly have
to pass it — but being explicit is fine.
| Decorator | Signature | Fires on |
|---|---|---|
@app.on_click(button="left") |
fn(state, col, row) |
a button press; button is "left"/"right"/"middle"/"any". col/row are 1-based. |
@app.on_scroll |
fn(state, direction) |
wheel; direction is -1 (up) or +1 (down). |
@app.on_mouse |
fn(state, event) |
every mouse event (press, release, move, scroll); inspect with mouse_* predicates. |
from maya_py import App, card
app = App("pointer", last=None)
@app.on_click("left")
def click(s, col, row):
s.last = (col, row)
@app.on_scroll
def scroll(s, direction):
s.last = f"scroll {direction:+d}"
@app.view
def view(s):
return card(f"last: {s.last}")
app.run()
@app.on_click is a decorator factory (note the call: @app.on_click("left")
or @app.on_click()), whereas @app.on_scroll / @app.on_mouse are bare
decorators.
Coordinates are frame-relative — even inline¶
mouse_pos(ev) and the col/row passed to on_click are relative to your
UI's top-left, not the terminal's. In inline=True mode your app is drawn
partway down the terminal, but maya translates the raw (absolute) mouse position
into frame-relative coordinates for you (it learns the frame's top row via a
one-time cursor-position query at startup). So a click on the first cell of your
view reports (1, 1) whether the app is at the top of the screen or 20 rows
down — exactly like fullscreen mode.
Clicks and scrolls that land outside your frame (in the surrounding scrollback) are dropped — your handlers only fire for events on the rendered UI.
Mouse capture vs. native terminal scroll: set_mouse()¶
There's one unavoidable trade-off: while mouse reporting is on, the terminal hands the scroll wheel to your app instead of scrolling its own scrollback. That's how terminal mouse protocols work (the wheel is reported as a mouse button) — it's not specific to maya, and it can't be worked around while capture is on. So in inline mode, an app with mouse enabled means the user can't scroll the terminal's history until the app exits (capture is always released on exit).
When that matters, toggle capture at runtime:
| Member | Role |
|---|---|
app.set_mouse(on) |
Turn mouse capture on/off while running (call from a handler). Off → the wheel goes back to the terminal (native scrollback works); on → clicks/drag/wheel are captured again. |
app.mouse_active |
bool — the current capture state. |
from maya_py import App, card, col, b
app = App("toggle", mouse=True, quit_keys=("q",))
@app.on("m")
def toggle(s):
app.set_mouse(not app.mouse_active) # flip between clicks and terminal scroll
@app.view
def view(s):
state = "ON (clicks)" if app.mouse_active else "OFF (terminal scroll)"
return col(b(f"mouse: {state}"), card("m = toggle · q = quit"))
app.run()
If your app simply doesn't need the mouse, don't enable it (the default is
mouse=False) and native terminal scroll just works. set_mouse() is the
runtime escape hatch for apps that want both, on demand. Full example:
examples/mouse.py.
5. Key name syntax¶
@app.on(...), quit_keys=, and keys= all use the same key-name vocabulary:
- Characters — the literal char, case-sensitive enough that
"+","=","G","g"all work:@app.on("q", "+", "G"). - Special keys (case-insensitive names):
"up","down","left","right","enter"(alias"return"),"esc"(alias"escape"),"tab","backtab","space","backspace","delete","home","end","pageup","pagedown". - Combos —
"ctrl+<char>"and"alt+<char>", e.g."ctrl+s","alt+x". (These match a single modifier + single character.)
@app.on("up", "k") def up(s): s.cursor -= 1
@app.on("down", "j") def down(s): s.cursor += 1
@app.on("space", "enter") def go(s): s.toggle()
@app.on("ctrl+s") def save(s): s.save()
@app.on("pageup") def pg(s): s.cursor -= 10
For anything the name vocabulary can't express, drop to @app.on_key and the
low-level predicates (low-level.md).
6. Event routing order¶
When an event arrives, App routes it in this fixed order — understanding it
explains why, e.g., a focused text field "eats" your j/k bindings:
- Ctrl-C guard — if
quit_on_ctrl_cand you didn't bindctrl+c, stop. - Mouse events →
on_mouse, thenon_scrolloron_click. Mouse events never reach key bindings. - Paste → focused widget (if any), then
on_pastehandlers. - Resize →
on_resizehandlers. - Focused widget (if
app.focus(...)was called) — Tab/Shift-Tab cycle focus; the focused widget consumes the key first. Keys it doesn't consume fall through to: @app.on_keycatch-alls (all of them run), then- The first matching
@app.on(...)binding (then routing stops).
Scroll states get their refusal inside the run loop before your handlers — see concepts.md §6.
7. Hosting interactive widgets: text_input & textarea¶
maya hosts real interactive input widgets in Python — cursor movement, UTF-8 editing, history, password masking — that you drop straight into a view.
text_input(placeholder="", *, password=False, multiline=False, value="", bind=None)
textarea(placeholder="") # == text_input(placeholder, multiline=True)
value seeds the field's initial text. bind=(obj, "attr") two-way-binds the
field to an attribute (edits write through to obj.attr, and the initial
obj.attr seeds the field).
Both return a widget object with this surface:
| Member | Meaning |
|---|---|
.value |
the current text (read and write: w.value = "x"). |
.clear() |
empty the field. |
.on_submit(fn) |
register fn(text) for Enter (single-line) / Ctrl/Shift-Enter (multiline). Returns fn, so it works as a decorator. |
.on_change(fn) |
register fn(text) called on every edit. Returns fn. |
To make a widget receive keystrokes, register it with app.focus(...):
The first widget is focused initially; Tab / Shift-Tab cycle focus
among them; the focused widget consumes keys it understands and lets the rest
fall through to your @app.on bindings. Place the widget directly in the view
(it coerces into the tree automatically) and read .value:
from maya_py import App, text_input, col
app = App("name")
name = text_input("your name…")
app.focus(name)
@app.view
def view(s):
return col("Name:", name, f"hello {name.value}")
app.run()
on_submit / on_change are commonly used as decorators:
pw = text_input("password", password=True)
app.focus(user, pw)
@pw.on_submit
def submit(text):
app.s.submitted = user.value
textarea("notes…") is the multi-line variant: Enter inserts a newline,
Ctrl/Shift-Enter submits.
8. The view, run, and stop¶
@app.view # register fn(state) -> node (str / T / Element / widget)
def view(s): ...
app.run() # start the loop; blocks until stop() / Ctrl-C
app.stop() # request exit (call from any handler)
@app.viewregisters the one view function. Its return value is coerced to an element — a bare string, aT(...), a layout container, or an input widget all work as the root.app.run()blocks. It can't be nested (calling it re-entrantly raises).app.stop()sets a flag the loop checks each cycle; the app exits cleanly after the current frame.quit_keysis sugar for@app.on(...key): app.stop().
9. Automatic scrolling¶
This is a maya superpower (full detail in
concepts.md §6 and
widgets.md): a scroll_state() placed inside a viewport(...)
and/or scrollbar(...) auto-dispatches — the run loop forwards arrows,
PgUp/PgDn, Home/End and (with mouse=True) the wheel and scrollbar drag to
every on-screen scroll state with no handler code of yours.
from maya_py import App, card, col, row, viewport, scrollbar, scroll_state
LINES = [f"line {i:03d}" for i in range(200)]
app = App("log", mouse=True)
s = scroll_state() # auto-dispatch is on
app.state(s=s, vh=14)
CONTENT = col(*LINES) # built once
@app.view
def view(st):
# viewport + scrollbar sit side by side, so wrap them in a `row`.
# `card` is a column — putting them straight into it stacks the
# scrollbar *below* the content instead of beside it.
return card(
row(
viewport(CONTENT, st.s, height=st.vh, grow=1),
scrollbar(st.s, st.vh, thumb_color="sky"),
),
title="log",
)
app.run()
No @app.on("up"), no wheel handler — navigation comes for free. You only call
scroll_handle(state, ev) manually if you're driving the low-level
run loop yourself. The ScrollState exposes helpers like
scroll_to_top(), scroll_to_bottom(), .y, .max_y for your own keys
(e.g. binding g/G to jump). Full scrolling example:
scroll.py.
10. Conditional styling: T.opt(**flags)¶
When a view styles by state, branching with if gets noisy. The T builder's
.opt(...) applies attributes conditionally — only truthy flags take
effect — and .fg(None) is a no-op, so a dynamic label reads as one chain:
from maya_py import T
# a todo line: highlight when focused, dim+strike when done
T(text).fg("sky" if focused else None).opt(dim=done, strike=done)
Pair it with when(cond, then, else_) for conditional
content. Together they keep state-driven views declarative instead of
imperative if ladders.
11. Performance in a live app¶
The view is re-run every frame, so building the tree in Python is the cost floor (see concepts.md §7). Two reflexes:
memostable sub-trees, keyed by small comparable summaries — never the whole state object:header(s.title, len(s.items)), notheader(s).- Tuple cells (
(text, fg)) instead ofTin hot per-frame loops.
And don't render to a string and print it in a loop — that throws away maya's diff.
12. A complete worked app: focused login form¶
This ties together model=, app.focus, text_input (incl. password=),
on_submit as a decorator, conditional styling, and quit_keys. Runnable as-is.
from maya_py import App, text_input, card, col, row, b, dim_text, T
app = App("login", submitted=None, quit_keys=("esc",))
user = text_input("username")
pw = text_input("password", password=True)
app.focus(user, pw) # user focused first; Tab → pw
@pw.on_submit # Enter in the password field submits
def submit(_text):
app.s.submitted = user.value
@app.view
def view(s):
if s.submitted is not None:
return card(b(f"Welcome, {s.submitted}!").fg("green"), title="login")
return card(
col(
row(T("user ").fg("slate"), user),
row(T("pass ").fg("slate"), pw),
dim_text("Tab to switch · Enter to submit · Esc to quit"),
gap=1,
),
title="login",
)
app.run()
What's happening:
- State is the attribute bag with one field,
submitted.quit_keys=("esc",)auto-binds Esc tostop(). - Two widgets are registered with
app.focus; Tab cycles between them. The focused field gets every key first, so there are no@app.onbindings to conflict with typing. @pw.on_submitwires Enter-in-password to copy the username into state.- The view branches on
s.submitted: the same pure function renders either the form or the welcome card. The widgets coerce into the tree directly; their live.valueis read implicitly by maya as it builds them.
For a model= + declarative keys= + memo + .opt example, see the todo
app: todo.py.
More: counter.py,
login.py,
scroll.py.
Testing apps headlessly (app.test() → Pilot)¶
You can drive an app with no terminal at all. app.test() returns a Pilot
that feeds synthetic events through the same handler + view path the live
loop uses, and renders frames to a plain string you can assert on. No PTY, no
threads, fully deterministic — ideal for unit tests and CI.
from maya_py import App, card, b
def build():
app = App("counter", n=0, quit_keys=("q",))
app.on("+")(lambda s: setattr(s, "n", s.n + 1))
app.view(lambda s: card(b(f"Count: {s.n}"), title="counter"))
return app
def test_counter():
app = build()
p = app.test(width=40)
p.press("+", "+", "+") # three increments
assert app.s.n == 3
assert "Count: 3" in p.render() # the actual rendered frame
p.press("q")
assert not p.running # quit key took effect
The Pilot surface mirrors everything a real user can do — each method returns
the pilot, so calls chain:
| Method | What it does |
|---|---|
press(*keys, ctrl=, alt=, shift=) |
Press named ("up", "enter", "esc", "tab", "space") or single-char keys |
type(text) |
Type a string one char-event at a time (for text_input) |
click(col, row, button="left") |
Mouse press+release at a 1-based cell |
scroll("up"\|"down", col, row) |
Wheel scroll |
paste(text) |
Bracketed paste |
resize(cols, rows) |
Terminal resize (also sets render width) |
tick(dt=1/30) |
Advance @app.on_frame handlers and maya's native animation clock by dt seconds, deterministically |
send(ev) |
Feed a raw event from a maya.make_* factory |
render(width=None) |
The current view as a string — the thing to assert on |
.running / .state |
Quit flag / the app's state object |
Under the hood these use synthetic-event factories you can also call directly
— maya.make_key("a", ctrl=True), make_mouse, make_scroll, make_paste,
make_resize — which produce the exact same Event the live loop delivers, so
every key() / mouse_*() predicate treats them as real input.
Deterministic animation: the skewable clock¶
Every time-driven native widget (the streaming-markdown reveal, the
activity bar's tape, any Motion / pulse) reads one animation clock —
maya.anim_now_ms(), a monotone steady_clock plus a test-only skew. Because
Pilot.tick(dt) advances that skew (not just your @app.on_frame handlers),
you can render a native widget's animation frame by frame with zero wall
time and assert on the stepped result:
from maya_py import anim_now_ms
p = app.test()
t0 = anim_now_ms()
p.tick(0.5) # +500 ms of ANIMATION time, no sleep
assert anim_now_ms() - t0 >= 500
frame = p.render() # native widgets show their 500-ms-later phase
The skew is additive and monotone — a negative tick/advance is a no-op,
so latched "first time X happened" timestamps stay consistent. Advance it
directly with maya.advance_anim_clock_ms(ms) when you're not going through a
Pilot. ProgramPilot (from Program.test()) has the same .tick(dt).
Color-only animation and render()
render() returns plain text (no ANSI), so a widget that animates via
colour rather than glyphs (e.g. the activity bar's sweep) won't show a
difference in the rendered string even though its phase advanced. Assert on
anim_now_ms() or on glyph-changing widgets for those.
Where to go next¶
- Program (MVU) — the pure, testable runtime; when state and effects should be data.
- Widgets — the native renderer catalog (incl.
viewport/scrollbar/ScrollState). - Layout —
row/col/card/grow/gap/justify. - Text & Style —
T,when, palettes, tuple cells. - Low-Level API —
run, the rawEventand its predicates. - Concepts — the mental model behind all of it.