Getting Started¶
This is the tutorial. By the end you will have built four working apps — a static panel, an interactive counter, the same counter rebuilt in the testable Program architecture, and a small but complete to-do app — and you will understand every line of each. Nothing here is hand-waved: every type, every operator, every function call is explained where it first appears.
Work through it top to bottom with a terminal open. The whole thing takes about 30 minutes and leaves you able to read any maya example and write your own.
1. Install¶
What you need¶
- A C++26 compiler — GCC 15+ or Clang 19+ (maya uses concepts, structural
NTTPs, and
consteval, so an older compiler will not build it). - CMake 3.28+.
- A Unix-like OS — Linux or macOS. maya talks to the terminal through raw
mode,
ioctl(TIOCGWINSZ), and POSIX signals. - Nothing else. maya has zero external dependencies — no ncurses, no Boost, no package manager step. The whole library is in this one repository.
Build the library and examples¶
git clone <repo-url> maya
cd maya
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
-B build puts all generated files in a build/ directory (out-of-source, so
your tree stays clean). --build build -j$(nproc) compiles using one job per
CPU core. When it finishes you have build/libmaya.a (the static library you
link against) and every example binary.
Run one to confirm it works:
./build/maya_counter # a minimal interactive counter
./build/maya_snake # Snake, drawn on the canvas API
./build/maya_dashboard # a multi-panel system dashboard
Press q to quit any of them.
Build options¶
You only need these later, but here they are in one place:
| Option | Default | What it does |
|---|---|---|
MAYA_BUILD_EXAMPLES |
ON |
Build the example programs. |
MAYA_BUILD_TESTS |
OFF |
Build + register the test suite (ctest). |
MAYA_FAST_TESTS |
ON |
When tests are on, link them against a non-LTO -O1 library for fast iteration. Production maya is unaffected. |
MAYA_NATIVE_TUNING |
ON |
Bake the host CPU in (-march=native). Turn OFF for portable release builds — a native binary will SIGILL on a chip missing the build host's instructions. |
Use maya in your own project¶
The simplest path — vendor maya as a subdirectory:
add_subdirectory(vendor/maya) # builds libmaya.a as part of your build
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE maya) # the `maya` target carries all include paths + flags
Linking the maya target is all you need — it propagates the include
directories and the -std=c++2c requirement to your target automatically.
Or install it system-wide and find it:
2. The two namespaces¶
Before any code: maya lives in two namespaces, and almost every program opens both.
using namespace maya; // the runtime: run(), Cmd, Sub, Signal, Event, quit()…
using namespace maya::dsl; // the UI vocabulary: t<>, v(), h(), Bold, Fg<>, border_<>…
maya::dslis everything you use to describe a UI — text nodes, layout containers, style tags, and the modifier operators that compose them.mayais everything that runs a UI — the entry-point functions, the message/effect types, reactive signals, and event helpers.
The split is deliberate: the dsl half is pure compile-time machinery that
produces data; the maya half is the engine that turns that data into pixels
and feeds it events. You will use both in the same file constantly.
3. Your first app — a static panel¶
Create main.cpp:
#include <maya/maya.hpp> // (1) the single umbrella header
using namespace maya; // (2)
using namespace maya::dsl; // (3)
int main() {
print( // (4)
(v( // (5)
t<"Hello, maya!"> | Bold | Fg<100, 180, 255>, // (6)
t<"A C++26 TUI framework."> | Dim // (7)
) | border_<Round> | pad<1>).build() // (8)
);
}
Line by line:
#include <maya/maya.hpp>— the umbrella header. It pulls in the DSL, the layout engine, the renderer, and the runtime. You never need to include anything else for normal app code.using namespace maya;— bringsprint(and laterrun,Signal, …) into scope.using namespace maya::dsl;— bringst<>,v,Bold,Fg,border_,padinto scope.print(...)— the simplest of maya's four entry points. It renders anElementonce to stdout and returns. No event loop, no raw mode — the output becomes ordinary scrollback text, exactly likeecho. Perfect for CLI output that happens to be styled.v(...)— a vertical stack (a column). Its arguments are its children, stacked top to bottom. (h(...)is the horizontal sibling.)t<"Hello, maya!">— a compile-time text node. The string lives in the template parameter, so it is baked into the binary with zero runtime cost.| Boldmakes it bold;| Fg<100, 180, 255>sets its foreground to RGB(100,180,255). The|operator is how you attach style/layout modifiers to any node — read it as "with".t<"…"> | Dim— a second text node, dimmed. Because it is the second argument tov(...), it renders on the row below the first.| border_<Round> | pad<1>then.build()— modifiers applied to the whole stack:border_<Round>wraps it in a rounded box;pad<1>adds one cell of padding on every side (between the border and the text)..build()converts the compile-time DSL tree into a runtimeElement— which is whatprintactually consumes. This.build()call is the bridge from the compile-time world to the runtime world, and you will see it at the end of nearly every render expression.
Compile and run:
(Or wire it through CMake as shown in §1 — far easier once you have more than one file.)
You get a rounded box with two lines of styled text, printed inline. There is
no "press q to quit" because print does not loop — it draws once and exits.
The .build() rule
DSL expressions are compile-time nodes. The runtime wants elements. So
a render expression almost always ends in .build(). If the compiler
complains that it wanted an Element but got some long template type, you
forgot .build().
4. Making it interactive — the simple run()¶
print draws once. To handle keystrokes and redraw, use run() — maya's
simplest interactive entry point. It takes two lambdas: an event handler
and a render function, and loops between them until you quit.
Here is a counter you can increment with + and quit with q:
#include <maya/maya.hpp>
using namespace maya;
using namespace maya::dsl;
int main() {
int count = 0; // (1) app state — a plain local
run(
[&](const Event& ev) { // (2) event handler
on(ev, '+', '=', [&] { ++count; }); // (3) + or = → increment
return !key(ev, 'q'); // (4) keep running until 'q'
},
[&] { // (5) render function
return (v(
text("Count: " + std::to_string(count)) | Bold, // (6) dynamic text
t<"[+] increment [q] quit"> | Dim // (7) static hint
) | border_<Round> | pad<1>).build();
}
);
}
int count = 0;— your application state. In the simplerun()style, state is just ordinary variables captured by reference in the lambdas. (The Program architecture in §5 makes this state explicit and testable; for small apps, a local is fine.)- The event handler
[&](const Event& ev) -> bool. maya calls it once for every input event (a keypress, a mouse action, a resize). The[&]capture gives it access tocount. Its return value controls the loop:truemeans keep running,falsemeans quit. on(ev, '+', '=', [&] { ++count; })— theon()helper fires its callback if the event matches any of the listed keys. Here both+and=(the unshifted+key) increment the counter.on()returns whether it matched, which you can chain or ignore. This is the readable way to bind keys without writingif (key(ev, ...))by hand.return !key(ev, 'q');—key(ev, 'q')istruewhen the event is theqkey. Negated, the handler returnsfalseonq(→ quit) andtrueotherwise (→ keep going).- The render function
[&] -> Element. maya calls it after every event to redraw. It returns the current frame as anElement. text("Count: " + std::to_string(count))— note this istext(...)(lowercase, runtime) nott<...>(compile-time). Usetext()whenever the string is computed at runtime — here it embeds the livecount.| Boldstyles it.t<"[+] increment [q] quit">— a static hint line; the string never changes, so the compile-timet<>is ideal.| Dimde-emphasises it.
Build and run it; press + a few times and watch the count climb, then q to
quit. The terminal redraws in place — only the changed cells are written to the
wire (maya diffs every frame), so it is smooth even at speed.
t<> vs text() — the one distinction that trips everyone up¶
| When the string is… | Cost | |
|---|---|---|
t<"literal"> |
known at compile time | baked into the binary, zero runtime work |
text(runtime_string) |
computed at runtime | constructs a node each frame |
Reach for t<> for fixed labels and hints; reach for text() for anything
interpolated. Mixing them in one v(...) is normal and expected (line 6 vs 7
above).
5. The Program architecture — structure that scales¶
The simple run() is great for a screenful of state. Once your app grows —
multiple kinds of state, effects like timers or network calls, logic you want
to unit-test — reach for the Program architecture. It is maya's take on the
Elm pattern: your app is four pure functions over an explicit Model and a
Msg (message) type.
Here is the same counter, rebuilt as a Program:
#include <maya/maya.hpp>
using namespace maya;
using namespace maya::dsl;
struct Counter {
// ── 1. Model: ALL of the app's state, in one struct ──────────────
struct Model { int n = 0; };
// ── 2. Msg: every event that can change the model ────────────────
struct Inc {};
struct Quit {};
using Msg = std::variant<Inc, Quit>;
// ── 3. init: the starting model ──────────────────────────────────
static Model init() { return {}; }
// ── 4. update: (model, msg) -> (new model, effect) ───────────────
static std::pair<Model, Cmd<Msg>> update(Model m, Msg msg) {
return std::visit(overload{
[&](Inc) { return std::pair{Model{m.n + 1}, Cmd<Msg>::none()}; },
[](Quit) { return std::pair{Model{}, Cmd<Msg>::quit()}; },
}, msg);
}
// ── 5. view: model -> UI (a pure function, no side effects) ──────
static Element view(const Model& m) {
return (v(
text("Count: " + std::to_string(m.n)) | Bold,
t<"[+] increment [q] quit"> | Dim
) | border_<Round> | pad<1>).build();
}
// ── 6. subscribe: model -> which events produce which Msgs ───────
static Sub<Msg> subscribe(const Model&) {
return key_map<Msg>({ {'+', Inc{}}, {'q', Quit{}} });
}
};
int main() {
run<Counter>({ .title = "counter" }); // (7) launch the Program
}
This looks longer than the simple version, but every piece earns its place:
Model— all state in one struct. There are no stray variables; if it is not in theModel, it is not state. This is what makes a Program testable:updateis a pure function ofModelandMsg.Msg— astd::variantof every distinct thing that can happen.Inc{}andQuit{}are empty "tag" structs; a message that carries data would have fields (e.g.struct SetName { std::string name; };). The variant is a closed set — the compiler knows every message type, so astd::visitover it is exhaustively checked.init()— returns the firstModel.return {};value-initialises it, sonstarts at 0.update(Model m, Msg msg)— the heart of the app. Given the current model and a message, it returns the next model plus an optional effect (Cmd<Msg>). It is pure: same inputs → same outputs, no I/O. Westd::visitthe message with theoverload{...}helper, which bundles one lambda per message type:Inc→ a new model withn + 1, andCmd<Msg>::none()(no effect).Quit→Cmd<Msg>::quit(), the effect that stops the runtime. The returned model does not matter here since we are quitting.Cmd<Msg>is "effects as data": instead of callingexit(), you return a value describing the effect, and the runtime performs it. Other commands includeCmd<Msg>::after(duration, msg)(deliver a message later),Cmd<Msg>::set_title(...), andCmd<Msg>::batch(...)(several at once). Because effects are data,updatestays pure and unit-testable.
view(const Model& m)— turns a model into anElement. Identical body to the simple version, but it now reads fromm(the explicit model) rather than a captured local.viewis pure — it must not mutate anything; it just maps state → UI. maya calls it whenever the model changes.subscribe(const Model&)— declares which inputs become which messages.key_map<Msg>({...})builds a keyboard subscription from a table: pressing+dispatchesInc{}; pressingqdispatchesQuit{}. The runtime reads this, watches the keyboard, and routes matching keys intoupdateas messages. (Subscriptions can also be timers —every(16ms, Tick{})— or mouse, focus, and resize streams.) It takes the model in case the active subscriptions depend on state (e.g. only listen for arrow keys while a menu is open).run<Counter>({ .title = "counter" })— launches the Program.run<P>()wiresinit→view→ render, feeds events throughsubscribe→update, performs returnedCmds, and repeats. The config struct sets the window title; it also carries.mode,.fps,.mouse, and more (see §7).
Why bother with all four functions?¶
Because the payoff compounds as the app grows:
updateis unit-testable with no terminal:auto [m2, cmd] = update(Model{5}, Inc{}); assert(m2.n == 6);.- State is centralised — there is exactly one source of truth (
Model), not a scatter of captured variables. - Effects are explicit and inspectable — a test can assert that
QuityieldsCmd::quit()without anything actually quitting. - The data flow is one direction: event →
Msg→update→ newModel→view→ screen. No callback ever reaches back and mutates the UI directly.
For a counter it is overkill. For a chat client, a deploy dashboard, or anything with timers and async work, it is the difference between a maintainable app and a tangle of callbacks.
6. A complete small app — a to-do list¶
Let us put it all together: a Program with a model that holds a list, messages that carry data, text input, and a list view. This is a real (if tiny) app.
#include <maya/maya.hpp>
#include <string>
#include <vector>
using namespace maya;
using namespace maya::dsl;
struct Todos {
struct Item { std::string text; bool done = false; };
// The whole app state: the items, and what the user is currently typing.
struct Model {
std::vector<Item> items;
std::string draft;
int cursor = 0; // which item the selection is on
};
// Messages — some carry data (Type), some are bare tags.
struct Type { char c; }; // a character was typed into the draft
struct Back {}; // backspace in the draft
struct Add {}; // commit the draft as a new item
struct Toggle {}; // flip done/undone on the selected item
struct Up {};
struct Down {};
struct Quit {};
using Msg = std::variant<Type, Back, Add, Toggle, Up, Down, Quit>;
static Model init() { return {}; }
static std::pair<Model, Cmd<Msg>> update(Model m, Msg msg) {
std::visit(overload{
[&](Type ty) { m.draft.push_back(ty.c); },
[&](Back) { if (!m.draft.empty()) m.draft.pop_back(); },
[&](Add) {
if (!m.draft.empty()) {
m.items.push_back({ m.draft, false });
m.draft.clear();
}
},
[&](Toggle) {
if (!m.items.empty())
m.items[m.cursor].done = !m.items[m.cursor].done;
},
[&](Up) { if (m.cursor > 0) --m.cursor; },
[&](Down) { if (m.cursor + 1 < (int)m.items.size()) ++m.cursor; },
[](Quit) {}, // handled by returning quit() below
}, msg);
if (std::holds_alternative<Quit>(msg))
return { std::move(m), Cmd<Msg>::quit() };
return { std::move(m), Cmd<Msg>::none() };
}
static Element view(const Model& m) {
// Build one row per item. We accumulate runtime elements in a vector
// and feed them to vec() — the runtime sibling of v().
std::vector<Element> rows;
for (int i = 0; i < (int)m.items.size(); ++i) {
const auto& it = m.items[i];
std::string mark = it.done ? "[x] " : "[ ] ";
// Selected row is highlighted (inverse video); done rows are dimmed.
Style s{};
if (i == m.cursor) s = s.with_inverse();
if (it.done) s = s.with_dim();
rows.push_back(text(mark + it.text, s));
}
if (rows.empty())
rows.push_back(text("(no items yet — type one and press Enter)",
Style{}.with_dim()));
return (v(
t<"maya to-do"> | Bold | Fg<120, 200, 255>,
// The draft line, with a caret so you can see where you're typing.
text("> " + m.draft + "_"),
t<"────────────────────────────"> | Dim, // a simple rule
// v() also accepts a std::vector<Element> directly — the runtime
// list of rows we built in the loop above.
std::move(rows),
t<"[Enter] add [Space] toggle [up/down] move [q] quit"> | Dim
) | border_<Round> | pad<1>).build();
}
static Sub<Msg> subscribe(const Model&) {
// Combine several subscriptions with Sub::batch. They run together;
// the FIRST one that produces a Msg for a given event wins.
return Sub<Msg>::batch(
// A raw key handler: inspect the KeyEvent and map printable
// characters to Type{c} (or q → Quit). KeyEvent::key is a
// variant<CharKey, SpecialKey>; CharKey carries a char32_t
// codepoint. We only forward plain ASCII into the draft.
Sub<Msg>::on_key([](const KeyEvent& k) -> std::optional<Msg> {
if (auto* ck = std::get_if<CharKey>(&k.key)) {
char32_t cp = ck->codepoint;
if (cp == U'q') return Quit{};
if (cp >= U' ' && cp < 0x7f) return Type{ (char)cp };
}
return std::nullopt;
}),
// A declarative key table for the non-printable controls.
key_map<Msg>({
{ SpecialKey::Enter, Add{} },
{ SpecialKey::Backspace, Back{} },
{ ' ', Toggle{} },
{ SpecialKey::Up, Up{} },
{ SpecialKey::Down, Down{} },
})
);
}
};
int main() {
run<Todos>({ .title = "todos", .mode = Mode::Fullscreen });
}
What is new here, and why it matters:
- Messages with payloads.
struct Type { char c; };carries the typed character.updatereadst.c. This is how events become data: the subscription decides what happened,updatedecides what it means. std::move(m)in the return.updatetakes the model by value (a fresh copy it owns) and returns it moved — no heap churn, the model flows through the function. Mutating the localmand returning it is the normal pattern.std::move(rows)as av(...)child.v(...)accepts not only fixed child nodes but also astd::vector<Element>built at runtime — it splices the vector's elements in as siblings. So you build your dynamic rows in a loop, then drop the whole vector into the stack alongside the static header/footer lines. (If you have only a vector and no static siblings,map(range, projection)builds the list in one expression — see Runtime Content.)Style{}.with_inverse()/.with_dim()— the runtime styling API. Thet<"…"> | Boldpipe is compile-time; when you compute a style at runtime (here, depending on selection and done-state) you build aStylevalue with thewith_*chain and pass it totext(string, style). Both reach the same renderer. (with_inverseswaps fg/bg — the classic "selected row" look.)- The rule line is just a
t<>of box-drawing characters dimmed — the simplest possible divider. maya also ships richer separators and dozens of other widgets (see the Widget Reference). Sub<Msg>::batch(a, b, …)— combines multiple subscriptions into one. Here a raw key handler and a declarative key table run together; the first to produce aMsgfor a given event wins.Sub<Msg>::on_key([](const KeyEvent& k){ … })— the general key subscription: you get the rawKeyEventand returnstd::optional<Msg>.k.keyis astd::variant<CharKey, SpecialKey>;std::get_if<CharKey>pulls out a printable character (as achar32_t codepoint). Returningstd::nulloptignores the key.key_map(used below it) is just a convenience wrapper around this for the common key→message table case.SpecialKey::Enter/Backspace/Up/Down— non-printable keys are matched by enum, not bychar..mode = Mode::Fullscreen— this app takes over the whole screen (alt buffer). The counter used the default inline mode. The next section explains the difference.
Build it, type a few items, Enter to add each, Space to tick one off,
arrows to move the selection, q to quit. That is a complete maya app in ~90
lines, and you can read every one of them.
7. Choosing a rendering mode¶
maya has four entry points. You have now used three (print, run(event,
render), run<P>). Here is when to reach for each:
print(element)— draw once to stdout and return. Styled CLI output, no interactivity. Becomes normal scrollback text.run(event_fn, render_fn)— the simple interactive loop with captured state. Best for small apps where a Program is overkill.run<Program>(cfg)— the structured architecture from §5–6. Best for anything that grows.canvas_run(...)— imperative cell-by-cell painting for games, visualisations, and animations (Snake, Game of Life, the Mandelbrot example). You get aCanvasand set cells directly; see the Canvas API.
Both run forms accept a config with a mode:
Mode::Inline(the default) — renders inline in the terminal without the alternate screen. Scrollback above your app is preserved; completed content scrolls into the terminal's native history. This is how a chat or agent UI behaves — the app lives in the normal terminal flow.Mode::Fullscreen— switches to the alternate screen buffer; your app owns the entire viewport and the terminal has no scrollback while it runs (it is restored on exit). Best for dashboards, editors, games — anything full-screen and self-contained.
The full mode comparison, pipelines, and scrollback semantics are in Rendering Modes. For now: inline for flow-style apps, fullscreen for take-over apps.
8. Where to go next¶
You now know the four entry points, both styling APIs, the compile-time vs runtime split, and the Program architecture. The rest of the guide goes deep on each axis:
- The Compile-Time DSL — every node and pipe:
t<>,v/h,dyn,map, and how the type-state validation rejects impossible UIs at compile time. - Styling — colours, attributes, themes, and the compile-time style tags.
- Layout — the flexbox model: direction, grow/shrink, gap, alignment, wrapping, and borders.
- Runtime Content —
dyn(),text(),vec(),map(), and mixing static and dynamic subtrees. - Event Handling — keys, mouse, paste, focus, resize, and
the full
on()/key_map/sub_batchtoolkit. - Signals & Reactivity —
Signal<T>,Computed<T>,Effect,Batchfor fine-grained reactive state inside the simplerun(). - Animation — the motion framework: self-driving
Motion<T>, springs, timelines, and the streaming typewriter. - Widget Reference — the 50+ built-in widgets.
- Examples Walkthrough — annotated tours of every example binary you ran in §1.
Read them in any order — each is self-contained. But you already have what you
need to start building. Open examples/ and you will recognise the shape of
every program there.