Program (MVU)¶
maya's primary architecture is MVU — Model, View, Update — the Elm
Architecture, the same model the C++ framework runs through run<P>. If you
have not read How maya-py works §4 yet, read it first: it places
Program next to App and explains why the two runtimes exist. This page is
the deep dive into Program.
A Program is four functions over an immutable model and a closed set of
messages:
| Function | Signature | Role |
|---|---|---|
init() |
-> model \| (model, Cmd) |
initial state + startup effect |
update(model, msg) |
-> model \| (model, Cmd) |
pure state transition |
view(model) |
-> Element |
pure rendering |
subscribe(model) |
-> Sub |
declarative event sources |
The whole architecture rests on one discipline: update performs no I/O.
Side effects are not done inside update — they are returned as Cmd data.
Input sources are not registered imperatively — they are declared as Sub
data. The runtime owns the impure world: it performs every Cmd, listens to
every Sub, and feeds the results back to update as ordinary messages. Your
code is a pure description; maya is the interpreter.
This is not a Python reimplementation. Cmd and Sub are the native maya value
types (re-exported verbatim from the _maya extension), built by the same smart
constructors the C++ side uses. An effect or subscription you return from Python
is interpreted by maya's real runtime, with the same timer reconciliation and
animation scheduling as run<P>.
1. Why purity matters¶
Because update is (model, msg) -> (model[, Cmd]) with no terminal, no clock,
and no network inside it, it is a plain function you can call from a test:
from maya_py import Cmd
from myapp import update # your update; needs no maya runtime to call
def test_increment():
assert update({"count": 0}, "inc") == {"count": 1}
def test_quit_emits_cmd():
model, cmd = update({"count": 3}, "quit")
assert model == {"count": 3}
assert isinstance(cmd, Cmd) # the effect is *data*, returned not performed
No event loop is started, no alt screen is entered, nothing is drawn. You assert
on the returned model — and, when there is an effect, on the returned Cmd. See
§7 for the full pattern. This testability
is the single biggest reason to reach for Program over App: your
core logic is verifiable in milliseconds, with no terminal and no flakiness.
The flip side of the discipline: never mutate the model in update. Return
a new value ({**m, "count": m["count"] + 1}), never m["count"] += 1. A
mutated model defeats the architecture and breaks the very testability you came
for.
2. Two ways to write a Program¶
Both forms compile to the identical runtime loop; pick the ergonomics you like.
Plain functions + run_program¶
import maya_py as maya
from maya_py import Cmd, Sub, run_program
def init():
return {"count": 0}
def update(model, msg):
if msg == "inc": return {**model, "count": model["count"] + 1}
if msg == "dec": return {**model, "count": model["count"] - 1}
if msg == "quit": return model, Cmd.quit()
return model
def view(model):
return maya.card(maya.text(f"count: {model['count']}"))
def subscribe(model):
return Sub.on_key(lambda ev:
"inc" if maya.key(ev, "+") else
"dec" if maya.key(ev, "-") else
"quit" if maya.key(ev, "q") else None)
run_program(init, update, view, subscribe, title="counter")
A Program subclass¶
Override the four hooks and call .run(). Same semantics, OO ergonomics:
import maya_py as maya
from maya_py import Cmd, Sub, Program
class Counter(Program):
title = "counter"
def init(self): return {"count": 0}
def view(self, m): return maya.card(maya.text(f"count: {m['count']}"))
def subscribe(self, m): return Sub.on_key(lambda ev:
"inc" if maya.key(ev, "+") else "quit" if maya.key(ev, "q") else None)
def update(self, m, msg):
if msg == "inc": return {**m, "count": m["count"] + 1}
if msg == "quit": return m, Cmd.quit()
return m
Counter().run()
The default Program.init returns {}, the default update is the identity,
and the default subscribe returns Sub.none(). view has no default — it
raises NotImplementedError until you override it.
3. The signatures (from source)¶
run_program¶
run_program(
init: Callable[[], Any],
update: Callable[[Model, Msg], Any],
view: Callable[[Model], Any],
subscribe: Optional[Callable[[Model], Any]] = None,
*,
title: str = "",
inline: bool = False,
mouse: bool = False,
fps: int = 0,
) -> None
init()returnsmodelor(model, Cmd)— pair it with aCmdto run a startup effect (set the title, kick off a fetch, start a timer-equivalent).update(model, msg)returnsmodelor(model, Cmd).view(model)returns anElement(use the easy API:card,row,text, …).subscribe(model)returns aSub. It is optional — omit it (or passNone) for a program with no event sources.title— terminal window title.inline=Truerenders into the terminal's own scrollback (no alternate screen), like a rich prompt;inline=False(the default here) takes the full screen. See concepts §5 on inline vs. alternate screen.mouse=Trueenables mouse event reporting (soSub.on_mousefires).fps>0drives continuous rendering at that frame rate;fps=0(default) means "redraw only when the model changes." Setfps>0only for continuous motion — but note that timer-driven motion is usually better expressed withSub.everyorSub.on_animation_frame(see §6).
run_program blocks until Cmd.quit() is returned or the user hits Ctrl-C.
Program¶
class Program:
title: str = ""
inline: bool = False
mouse: bool = False
fps: int = 0
def init(self) -> Any: ... # model | (model, Cmd)
def update(self, model, msg) -> Any: ... # model | (model, Cmd)
def view(self, model) -> Any: ... # -> Element (must override)
def subscribe(self, model) -> Any: ... # -> Sub (default Sub.none())
def run(self, *, title=None, inline=None, mouse=None, fps=None) -> None: ...
The class attributes (title, inline, mouse, fps) supply defaults;
keyword args to .run() override them per launch. Internally .run() just
forwards your bound hooks to run_program.
4. Messages¶
A message is any Python value — a string, a tuple, an int, a dataclass, an
enum. update dispatches on it however you like. Strings read well for simple
intents; tuples (or dataclasses) carry payloads:
def update(model, msg):
match msg:
case "tick": return {**model, "t": model["t"] + 1}
case ("set", value): return {**model, "field": value}
case ("loaded", rows): return {**model, "rows": rows, "loading": False}
return model
Messages arrive from exactly two places: a Cmd the runtime ran (e.g. a
background task calling dispatch(msg), or an after timer firing) and a
Sub the runtime is listening to (a keypress your on_key filter mapped to a
message, an every timer). There is no other way to mutate the model — which is
why the message set is "closed" and update is exhaustively testable.
5. Cmd — effects as data¶
Cmd is a native maya value type. You build one with a constructor and return it
from init or update (as (model, cmd)); the runtime performs the effect and,
where an effect produces a result, dispatches it back as a message. Return a
bare model (no tuple) when there is no effect — that is sugar for Cmd.none().
| Constructor | Real signature | Effect |
|---|---|---|
Cmd.none() |
none() |
do nothing (the no-op effect) |
Cmd.quit() |
quit() |
exit the program; run_program returns |
Cmd.batch(*cmds) |
batch(*args) |
perform several commands together |
Cmd.after(ms, msg) |
after(ms: float, msg: object) |
dispatch msg once after ms milliseconds (one-shot timer) |
Cmd.task(fn) |
task(fn: Callable) |
run fn(dispatch) on a background worker thread |
Cmd.isolated_task(fn) |
isolated_task(fn: Callable) |
like task, but on a dedicated detached thread |
Cmd.set_title(title) |
set_title(title: str) |
set the terminal window title |
Cmd.write_clipboard(text) |
write_clipboard(text: str) |
copy text to the system clipboard (OSC 52) |
Cmd.query_clipboard() |
query_clipboard() |
request the clipboard contents; the reply arrives as a paste event |
Cmd.force_redraw() |
force_redraw() |
force a soft repaint of the live viewport (like Ctrl-L) |
Cmd.reset_inline() |
reset_inline() |
hard inline reset — a wholesale content swap of the inline region |
Cmd.commit_scrollback(rows) |
commit_scrollback(rows: int) |
mark the top rows inline rows as permanent scrollback (they stop being repainted) |
Cmd.commit_scrollback_overflow() |
commit_scrollback_overflow() |
commit every row that has overflowed the inline viewport into scrollback |
Cmd.suspend(fn) |
suspend(fn: Callable[[], object]) |
tear down the TUI, hand the real terminal to fn(), then restore and dispatch the message fn() returns |
The effect-as-data idea in one line: update decides what should happen and
returns it; the runtime decides when and how to do it and turns any result
back into a message. That is what keeps update pure.
Cmd.batch — more than one effect¶
def update(self, m, msg):
if msg == "start":
return {**m, "running": True}, Cmd.batch(
Cmd.set_title("working…"),
Cmd.after(5000, "timeout"),
)
return m
Cmd.after — one-shot timer¶
Cmd.after(ms, msg) dispatches msg exactly once after ms milliseconds.
Re-issue it from update to build a self-restarting timer (an alternative to a
Sub.every subscription when you want the cadence to depend on per-tick logic):
def update(self, m, msg):
if msg == "tick":
return {**m, "t": m["t"] + 1}, Cmd.after(1000, "tick") # schedule the next
return m
Cmd.task / Cmd.isolated_task — async work¶
fn receives a dispatch callable. Do the slow work on the worker, then call
dispatch(msg) to feed the result back into update:
def update(self, m, msg):
if msg == "fetch":
def work(dispatch):
data = slow_http_get() # runs off the main loop
dispatch(("loaded", data)) # re-enters update() as a message
return {**m, "loading": True}, Cmd.task(work)
if isinstance(msg, tuple) and msg[0] == "loaded":
return {**m, "loading": False, "data": msg[1]}
return m
dispatch is safe to call from the worker thread — the runtime marshals the
message back onto the main loop. Use Cmd.isolated_task for work that can wedge
on a blocking syscall (a hung subprocess, a slow network mount): it runs on its
own detached thread, so at worst it leaks that one thread instead of starving the
shared worker pool.
Clipboard¶
Cmd.write_clipboard(text) copies via OSC 52 (works over SSH on capable
terminals). Cmd.query_clipboard() asks for the current contents; because there
is no synchronous return, the reply comes back as a paste event — declare a
Sub.on_paste(...) to receive it.
Both travel in-band over the terminal escape channel, so they need no remote
clipboard tool over SSH. maya picks the read protocol from the host: OSC 52
(text-only) by default, or kitty's OSC 5522 multi-format read when a kitty
host is detected. Only OSC 5522 can carry image bytes — so on kitty,
Cmd.query_clipboard() can deliver a pasted screenshot over SSH, arriving
as a paste event whose payload is the raw image bytes (not text). This is
transparent to your code: the same Sub.on_paste(fn) receives it; sniff the
payload if you need to tell an image from text.
Inline & scrollback commands¶
These matter when inline=True. Cmd.commit_scrollback(rows) freezes the top
rows of the inline region into the terminal's real scrollback so they are never
repainted again (useful for an append-only log where old lines should scroll away
naturally). Cmd.commit_scrollback_overflow() does the same for whatever has
already overflowed the viewport. Cmd.force_redraw() and Cmd.reset_inline()
are escape hatches for repainting the live region — the soft and hard variants
respectively.
Cmd.suspend — hand the terminal to an interactive child¶
Cmd.suspend(fn) is the escape hatch for running an interactive child that
owns the real terminal — a sudo password prompt, $EDITOR, a pager. maya
tears the TUI down to a clean cooked (line-disciplined) tty, runs fn()
synchronously on the UI thread (the user is interacting with the child, so
there is nothing else to do), then restores raw mode + the TUI, re-anchors the
renderer, and dispatches the message fn() returned so update can fold the
child's result back into the model:
import subprocess
def update(self, m, msg):
if msg == "edit":
def run_editor():
rc = subprocess.call(["vim", m["path"]]) # child owns the tty here
return ("edited", rc) # folded back after restore
return m, Cmd.suspend(run_editor)
if isinstance(msg, tuple) and msg[0] == "edited":
return {**m, "exit_code": msg[1]}
return m
Because fn() runs synchronously and must return a message, it closes the loop
the same way Cmd.task's dispatch does — but on the foreground, with the TUI
torn down for the duration. Return a message carrying whatever the child
produced (exit code, captured output).
6. Sub — input sources as data¶
subscribe(model) declares what the program is currently listening to. It is
called after every update, so subscriptions can depend on the model: listen to a
timer only while a request is in flight, stop listening when a modal closes, and
so on. maya reconciles the declared set against the live one every iteration —
when a Sub.every disappears from what you return, its timer is torn down; when
it reappears, it is restarted. You never start or stop a source imperatively.
| Constructor | Real signature | Source |
|---|---|---|
Sub.none() |
none() |
nothing (the default subscribe) |
Sub.batch(*subs) |
batch(*args) |
combine several subscriptions |
Sub.on_key(filter) |
on_key(filter: Callable) |
filter(event) -> msg \| None on each key event |
Sub.on_mouse(filter) |
on_mouse(filter: Callable) |
filter(event) -> msg \| None on each mouse event (needs mouse=True) |
Sub.on_paste(fn) |
on_paste(fn: Callable) |
fn(text) -> msg on bracketed-paste (and clipboard-query replies) |
Sub.on_resize(fn) |
on_resize(fn: Callable) |
fn(width, height) -> msg on terminal resize |
Sub.every(ms, msg) |
every(ms: float, msg: object) |
dispatch msg every ms milliseconds (timer / polling / animation) |
Sub.on_animation_frame(msg) |
on_animation_frame(msg: object) |
dispatch msg once per rendered animation frame |
How subscriptions deliver messages¶
A Sub does not handle anything itself — it maps a raw event to a message
(or to None to ignore it), and the runtime feeds that message to update. The
key and mouse filters receive the opaque Event and classify it with the same
predicates the App handlers use:
def subscribe(self, m):
return Sub.on_key(lambda ev:
"inc" if maya.key(ev, "+") else
"dec" if maya.key(ev, "-") else
"left" if maya.key_special(ev, maya.SpecialKey.Left) else
"quit" if maya.key(ev, "q") or maya.key_special(ev, maya.SpecialKey.Esc) else
None) # None → event ignored
Returning None from a filter means "not for me" — the event falls through.
Sub.on_paste and Sub.on_resize take a plain function of the payload
(text, or (width, height)) rather than an Event.
Combine sources with Sub.batch:
def subscribe(self, m):
return Sub.batch(
Sub.on_key(keymap),
Sub.every(1000, "tick"),
Sub.on_resize(lambda w, h: ("resized", w, h)),
)
Timers vs. fps¶
Sub.every(ms, msg) is the idiomatic way to drive a program forward on a clock —
prefer it over setting fps>0, because it dispatches a message (so the change
flows through update and stays testable) rather than just forcing a blind
repaint. Sub.on_animation_frame(msg) fires once per rendered frame, for
motion that should track the display's actual cadence (games, smooth animation).
7. A complete worked program: a self-driving clock¶
This program needs no keypress to run — a Sub.every timer drives the model, a
startup Cmd sets the title, and q quits. It is runnable as-is with
from maya_py import ....
import time
import maya_py as maya
from maya_py import Cmd, Sub, Program
class Clock(Program):
title = "clock"
def init(self):
# initial model + a startup effect
return {"now": time.strftime("%H:%M:%S"), "ticks": 0}, Cmd.set_title("maya clock")
def update(self, m, msg):
if msg == "tick":
return {**m, "now": time.strftime("%H:%M:%S"), "ticks": m["ticks"] + 1}
if msg == "quit":
return m, Cmd.quit()
return m
def view(self, m):
return maya.box(
maya.text("Clock", maya.bold | maya.fg(120, 180, 255)),
maya.blank(),
maya.text(m["now"], maya.bold),
maya.blank(),
maya.text(f"{m['ticks']} ticks · q to quit", maya.dim),
direction=maya.Column,
border=maya.Round,
padding=2,
)
def subscribe(self, m):
return Sub.batch(
Sub.every(1000, "tick"), # one message per second
Sub.on_key(lambda ev: "quit" if maya.key(ev, "q") else None),
)
if __name__ == "__main__":
Clock().run()
The same program as plain functions and run_program:
def init(): return {"now": time.strftime("%H:%M:%S"), "ticks": 0}
def update(m, msg): ... # identical body
def view(m): ... # identical body
def subscribe(m): return Sub.every(1000, "tick")
run_program(init, update, view, subscribe, title="clock")
For the canonical counter, see
examples/counter_program.py.
8. Testing update without a terminal¶
Because update is pure, the test suite never opens a terminal. Pull update
(and init) into a test and assert on the returned value:
from maya_py import Cmd
from clock import Clock # the module above
def test_init_sets_title():
model, cmd = Clock().init()
assert model["ticks"] == 0
assert isinstance(cmd, Cmd) # startup effect is data
def test_tick_advances():
after = Clock().update({"now": "", "ticks": 4}, "tick")
assert after["ticks"] == 5 # bare model, no Cmd
def test_quit_emits_cmd():
model, cmd = Clock().update({"now": "", "ticks": 1}, "quit")
assert model == {"now": "", "ticks": 1} # model unchanged
assert isinstance(cmd, Cmd) # the quit effect, returned not run
Two patterns to internalize:
- When
updatehas no effect it returns a bare model; when it does, a(model, Cmd)tuple. Assert on the model with==, and on the presence of an effect withisinstance(cmd, Cmd).Cmdis opaque (a native value), so you assert that an effect was returned, not its internals. - For a
Cmd.task, the work runs throughdispatch; in a test you simply feed the resulting message straight intoupdate(e.g.update(m, ("loaded", rows))) to verify the second half of the flow — no thread, no network needed.
This is the payoff of purity from concepts §4: your entire state machine is verifiable in isolation.
9. When to use Program vs. App¶
Both runtimes (App and Program) compile to the identical render
pipeline; they differ only in how you manage state and effects.
Use App for almost everything — it is the 90% path. State is a
mutable bag, handlers mutate it in place (@app.on("+")), and the view
re-renders from it. Less ceremony, faster to write.
Reach for Program when:
- you want testable logic —
updateis a pure function, so you can assert onupdate(model, msg)with no terminal (the reason most people choose it); - effects are complex enough that returning them as data (
Cmd) beats scattering side effects through imperative handlers; - concurrency must be explicit —
Cmd.taskmakes background work and its result message a visible part of the architecture; - you are porting an existing Elm/
run<P>design and want the same shape.
They are not mixed in one application — pick one runtime per program. When in
doubt, start with App; move to Program when the logic grows enough that you
want it under test.
See also¶
- How maya-py works §4 —
Appvs.Program, the mental model. - Apps — the imperative runtime in depth.
examples/counter_program.py— the canonical MVU counter.