Skip to content

Apps

← Manual index

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):

  1. Input is decoded into an Event.
  2. The runtime routes it (in this exact order — see §6 below): Ctrl-C guard → mouse handlers → paste → resize → focused widget → @app.on_key catch-alls → the first matching @app.on(...) binding.
  3. @app.on_frame handlers (if any) run, then your view is called for a fresh tree.
  4. The tree is laid out, painted, diffed, and the changed cells are written.
  5. 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 s and 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_frame handler automatically bumps a 0 fps up to 30. Don't set fps>0 for 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. Binding ctrl+c yourself (@app.on("ctrl+c")) takes over and disables this guard.
  • quit_keys=("q", "esc") — auto-binds those keys to stop(), 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_keyfn(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_framefn(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_pastefn(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_paste
def on_paste(s, text):
    s.buf += text

@app.on_resizefn(state, cols, rows)

Fires on terminal resize with the new dimensions.

@app.on_resize
def on_resize(s, cols, rows):
    s.size = (cols, rows)

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:

  1. Ctrl-C guard — if quit_on_ctrl_c and you didn't bind ctrl+c, stop.
  2. Mouse eventson_mouse, then on_scroll or on_click. Mouse events never reach key bindings.
  3. Paste → focused widget (if any), then on_paste handlers.
  4. Resizeon_resize handlers.
  5. 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:
  6. @app.on_key catch-alls (all of them run), then
  7. 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(...):

app.focus(*widgets) -> first_widget

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.view registers the one view function. Its return value is coerced to an element — a bare string, a T(...), 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_keys is 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:

T.opt(*, bold=False, dim=False, italic=False,
      underline=False, strike=False, inverse=False) -> T
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:

  • memo stable sub-trees, keyed by small comparable summaries — never the whole state object: header(s.title, len(s.items)), not header(s).
  • Tuple cells ((text, fg)) instead of T in 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 to stop().
  • Two widgets are registered with app.focus; Tab cycles between them. The focused field gets every key first, so there are no @app.on bindings to conflict with typing.
  • @pw.on_submit wires 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 .value is 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).
  • Layoutrow / col / card / grow / gap / justify.
  • Text & StyleT, when, palettes, tuple cells.
  • Low-Level APIrun, the raw Event and its predicates.
  • Concepts — the mental model behind all of it.