How maya-py works (and how to think in it)¶
This page is the mental model. Once these ideas click, every other page in the manual — and every design decision you'll make in your own apps — follows from them. Read it once end-to-end; come back to it when something surprises you.
1. The one-sentence model¶
You write a pure function from state to an element tree; maya lays it out, paints it to a cell grid, diffs that grid against what's already on the terminal, and writes only the bytes that changed.
Everything else — the easy API, the App class, memo, tuple cells, the MVU
Program — is in service of making that loop fast, correct, and pleasant to
write. Hold onto two consequences:
- Your view is a description, not a sequence of commands. You never "move the cursor" or "clear the screen." You return what the screen should look like now, and maya computes the minimal way to get there.
- The expensive part is building the tree in Python; the cheap part is maya's native render. Almost every performance technique is about building less (see §7).
2. The render pipeline¶
A single frame flows through five stages. Three live in Python, two in C++:
your view(state) build (Python + pybind11 boundary)
│ returns
▼
Element tree ─┐
│ │
▼ │ maya, native C++
Flexbox layout (Yoga) │ ~flat cost in tree size
│ → x/y/w/h per node │
▼ │
Paint → Canvas (cell grid) │ each cell = char + 64-bit packed style id
│ │
▼ │
Diff vs previous frame (SIMD) ─┘ → only changed cells
│
▼
Bytes on the wire (ANSI/SGR escapes) → your terminal
- Element tree — immutable nodes (
Element) describing boxes, text, and widgets. Built bycard(...),row(...),T(...).fg(...), the widget functions, etc. Each call crosses into C++ once. - Layout — maya runs a flexbox solver (Yoga) over the tree: it resolves
every node's position and size from your
width/grow/gap/align/… constraints. This is why arow(...)with agrow(...)child "just fills." - Paint — the laid-out tree is rasterized into a Canvas: a flat grid of cells. Each cell packs its glyph plus a 16-bit style id into 64 bits, so two cells are equal iff their 64-bit words are equal (this makes the diff fast).
- Diff — the new Canvas is compared against the previous frame's grid with SIMD. Only cells that actually changed produce output.
- Wire — changed cells are serialized to the minimal ANSI escape sequence and written to the terminal.
You can observe the boundaries directly: to_string(node) runs build + layout +
paint + serialize once (no diff, no previous frame); show(node) does the same
straight to stdout; the interactive loops add the diff + wire stages and repeat.
3. Elements are values; strings are elements¶
The easy API leans on one trick: anywhere a child is expected, a bare str
(or a styled T, or a tuple) is accepted and coerced into an element. So
card("hello") and row("a", "b") work without wrapping. This is why the docs
say "strings are UI."
Three things flow into a layout container as children:
| You pass | Becomes |
|---|---|
"text" |
a text leaf |
T("text").fg("sky").bold |
a styled text leaf (one boundary crossing when built) |
("text", "sky") / ("text", fg, bg, attrs) |
a styled leaf built from raw scalars — no T object |
card(...), row(...), a widget |
a nested element subtree |
T is a fluent builder: T("x").bold.fg("sky") accumulates style in pure
Python and makes a single crossing into C++ when the element is finally built
(then caches it on the T). The tuple form skips even the T allocation — it's
the fastest way to emit many styled cells (tables, dashboards, per-frame redraws).
Expert habit: reach for
T/markup for one-off UI (it reads well), and for tuple cells in hot per-frame loops. Both produce byte-identical output.
4. Two runtimes: App and Program¶
maya-py gives you two ways to run an interactive loop. They compile down to the same render pipeline; they differ in how you manage state and side effects.
App — imperative state, the 90% path¶
from maya_py import App, card, b
app = App("counter", n=0, quit_keys=("q", "esc"))
@app.on("+", "=")
def inc(s): s.n += 1 # mutate state in place
@app.view
def view(s): # pure: state -> element tree
return card(b(f"Count: {s.n}"))
app.run()
The model: state is a mutable bag (app.s), handlers mutate it, and the
view re-renders from it after every handled event. The full surface:
| Member | Role |
|---|---|
App(title="", *, inline=True, mouse=False, fps=0, quit_on_ctrl_c=True, quit_keys=(), model=None, keys=None, **state) |
construct; seed state as kwargs, or pass your own model= object, or bind keys declaratively with keys={...} |
app.state(**kw) / app.s |
seed / access the live state bag |
@app.on(*keys) |
run fn(state) on those keys |
@app.on_key |
catch-all fn(state, event) |
@app.on_frame |
per-frame tick fn(state, dt) — enables continuous animation |
@app.on_paste / @app.on_resize |
fn(state, text) / fn(state, cols, rows) |
@app.on_click / @app.on_scroll / @app.on_mouse |
mouse handlers (needs mouse=True) |
app.focus(*widgets) |
register interactive widgets; the focused one gets keys, Tab cycles |
@app.view |
register the state -> node view |
app.run() / app.stop() |
start (blocks) / request exit |
Program — the Elm Architecture (MVU), the control-freak path¶
from maya_py import run_program, Cmd, Sub, card
def init(): return {"n": 0}
def update(model, msg):
if msg == "inc": return {"n": model["n"] + 1}, Cmd.none()
return model, Cmd.none()
def view(model): return card(f"Count: {model['n']}")
run_program(init, update, view)
Here state is immutable: update is a pure function (model, msg) ->
(new_model, Cmd). Side effects never happen inline — you return them as a
Cmd, and the runtime performs them and feeds results back as messages. Inputs
arrive through Subscriptions.
Cmd(effects to run):none,quit,batch,after,task,isolated_task,set_title,write_clipboard,query_clipboard,force_redraw,reset_inline,commit_scrollback,commit_scrollback_overflow,suspend.Sub(input sources):none,batch,on_key,on_mouse,on_paste,on_resize,every(timer),on_animation_frame.
When to use which. Use App for almost everything — it's less ceremony.
Reach for Program when you want testable update logic (it's a pure function,
so you can assert on update(model, msg) with no terminal), when effects are
complex enough that returning them as data beats scattering them through
handlers, or when concurrency (Cmd.task) needs to be explicit. They're not
mixed in one app; pick one per program.
5. The frame lifecycle¶
What actually happens between "you press a key" and "the screen updates":
- Input arrives (key, mouse, paste, resize) and is decoded into an
Event. - The runtime routes it: scroll states get first refusal (see §6), then
your handlers (
@app.on(...),on_key, …) run and mutate/transform state. - If anything changed (or
on_frame/an animation tick fired), the runtime calls your view to get a fresh element tree. - The tree is laid out, painted, diffed against the previous frame, and the changed cells are written.
- The loop waits — until the next input, or, if a frame rate is set, until the next frame deadline.
Two details that trip people up:
fps=0means "redraw only when something changes." That's the efficient default for most apps — no input, no work. Setfps>0(or useon_frame/Sub.every/animate) only when you need continuous motion (clocks, spinners, games, progress).- Inline vs alternate screen.
App(inline=True)(the default) renders in place in your scrollback, like a rich prompt; the diff preserves stable rows so output above is untouched. This is what makes maya feel native in a normal terminal session rather than taking over the whole screen.
6. The event & input model¶
Keys¶
In App, bind by name: @app.on("q", "enter", "ctrl+c", "up", "+"). Names cover
chars ("q", "+"), specials ("up", "enter", "esc", "tab",
"backspace", "home", "pageup", …) and combos ("ctrl+c", "alt+x"). For a
catch-all, @app.on_key gives you the raw Event, which you inspect with the
low-level predicates: key(ev, "c"), key_special(ev, SpecialKey.Up),
ctrl(ev, "c"), alt(ev, "x"), event_char(ev) (the typed character or
None), any_key(ev).
Mouse¶
Set mouse=True, then use @app.on_click / @app.on_scroll / @app.on_mouse,
or the predicates: is_mouse(ev), mouse_clicked/released/moved(ev),
mouse_button(ev), mouse_pos(ev) (1-based (col, row)), scrolled_up/down(ev).
Coordinates are frame-relative: even in inline mode (where the app sits
partway down the terminal) a click on your top-left cell reports (1, 1), and
clicks/scrolls outside the frame are dropped. The trade-off is structural:
while mouse capture is on, the terminal gives the scroll wheel to your app, so
its own scrollback stops scrolling until the app exits — that's the terminal
mouse protocol, not maya. app.set_mouse(on) toggles capture at runtime so you
can hand the wheel back to the terminal on demand (see Apps); if an
app doesn't need the mouse, leave mouse=False and native scroll just works.
Paste & resize¶
@app.on_paste delivers bracketed-paste text in one shot (don't reconstruct it
keystroke-by-keystroke); @app.on_resize gives new (cols, rows). Low-level:
pasted(ev), resized(ev), resize_size(ev).
Scrolling is (mostly) automatic¶
This is a maya superpower worth internalizing: a scroll_state() you put in a
viewport(...)/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 without any handler code. You only call
scroll_handle(state, ev) manually if you're driving the low-level run loop
yourself. Build a scrolling log or list by composing viewport + scrollbar;
the navigation comes for free.
Focus¶
app.focus(w1, w2, ...) registers interactive widgets (e.g. text_input,
textarea). The focused widget receives keys; Tab cycles focus. This is how you
host real input fields inside an otherwise-declarative view.
7. The performance model (this is what separates experts)¶
Re-read §1: build is the floor, render is cheap. Concretely, for a 30-row dashboard, building the tree in Python costs ~100 µs and maya's native render ~24 µs — so ~80% of a frame is the Python boundary, not maya. You make apps fast by not rebuilding what didn't change:
memo — skip Python tree construction¶
from maya_py import memo, card, b
@memo
def header(title, count): # rebuilt only when title/count change
return card(b(title), f"{count} items")
memo caches a built sub-tree keyed by its positional args. If the args are
unchanged since last frame, it returns the same Element object — zero Python
work, and maya's diff sees an unchanged subtree. Pass comparable summaries
(header(s.title, len(s.items))), never the whole mutable state bag
(header(s) defeats it). This is the single biggest lever for a live app: a
steady-state frame can do almost no Python at all.
Tuple cells — skip T allocations¶
In a hot redraw of many styled cells (a table, a chart, a per-frame grid), the
throwaway T per cell dominates build cost. Pass (text, fg[, bg[, attrs]])
tuples to row/col instead — same output, far fewer allocations.
The diff is the real win — don't fight it¶
Because maya emits only changed cells, a live redraw where one line changes writes ~11× fewer bytes than re-printing the frame. On a real terminal — and especially over SSH — bytes on the wire are the bottleneck, and this is a structural advantage no string-concatenating renderer matches. The corollary: don't render to a string in a hot loop and print it yourself — you'd throw the diff away and re-emit everything every frame.
Two subtleties from the engine room¶
- Colors are interned, for the life of the run. maya keeps a
StylePoolof every distinct style it has seen and never evicts it (id space is 16-bit). An animation that emits a fresh shade per cell per frame (e.g. an uncached continuous gradient) keeps growing that pool. Quantize continuous colors to a fixed palette (precompute N steps and index into them) so the pool stays bounded. Static and few-color UIs never notice this. - Output is non-blocking and back-pressured. On a slow terminal the writer buffers what the wire can't yet take and the loop defers the next frame rather than piling up — so a heavy full-screen animation degrades to a lower effective frame rate instead of growing memory. You get this for free; just know that "my 60fps plasma runs at 30 on this terminal" is the system working as intended.
8. Common pitfalls (and the expert's reflex)¶
| Pitfall | Why it bites | Do this instead |
|---|---|---|
| Rebuilding everything every frame | Python build cost dominates | memo the stable sub-trees; pass comparable args |
memo-ing on the whole state object |
identity/compare never matches → never cached | pass small comparable summaries |
| Rendering to a string and printing it in a loop | throws away the diff; re-emits the whole frame | use App/animate/live so the diff runs |
fps>0 for a static UI |
burns CPU redrawing identical frames | leave fps=0; redraw only on change |
| Fresh color per cell per frame | unbounded StylePool growth |
quantize to a fixed palette |
| Reconstructing pasted text keystroke-by-keystroke | bracketed paste arrives whole | handle it in @app.on_paste |
| Hand-rolling scroll key handling | scroll states auto-dispatch | compose viewport + scrollbar and let the loop route |
Mutating state inside Program.update |
update must be pure |
return a new model + Cmd |
A size-aware component/pixel_canvas in to_string |
it needs the live renderer's measure pass | render it via animate/run, or use halfblock with a grid for static output |
9. A reading order from here¶
- Getting Started — install and your first UI + app.
- Text & Style and Layout — the building blocks (§3 in depth).
- Apps and Program (MVU) — the two runtimes (§4 in depth).
- Widgets — the native renderer catalog.
- Rendering —
show/to_string/animate/run(the pipeline entry points, §2). - Performance — the benchmarks and techniques behind §7.
- Low-Level API and API Reference — the primitives and the complete symbol list.