Rendering Modes¶
maya provides four rendering modes, each suited to different use cases. Choose the one that matches your application's needs.
Overview¶
| Mode | Function | Screen | Event Loop | Use Case |
|---|---|---|---|---|
| Fullscreen | run<P>({.mode = Mode::Fullscreen}) |
Alt screen | Yes | Interactive TUIs, dashboards |
| Inline | run<P>({.mode = Mode::Inline}) |
Scrollback | Yes | Claude Code-style apps |
| Live | live() |
Scrollback | Timer-based | Progress bars, streaming output |
| Canvas | canvas_run() |
Alt screen | Yes | Games, animations, visualizations |
| One-shot | print() |
Scrollback | No | CLI output, reports, status cards |
run() — Interactive Apps¶
The primary API for interactive terminal applications. Two forms:
Simple: run(config, event_fn, render_fn)¶
For quick prototypes and simple tools — closures, no boilerplate:
Signal<int> count{0};
run(
{.title = "counter"},
[&](const Event& ev) {
on(ev, '+', '=', [&] { count.update([](int& n) { ++n; }); });
on(ev, '-', '_', [&] { count.update([](int& n) { --n; }); });
return !key(ev, 'q');
},
[&] {
return (v(
dyn([&] { return text("Count: " + std::to_string(count.get()),
Style{}.with_bold()); }),
t<"[+/-] change [q] quit"> | Dim
) | pad<1>).build();
}
);
Event function returns bool (false = quit) or void (call maya::quit()).
Render function returns Element, optionally taking const Ctx& for terminal size/theme.
Program: run\<P>(config)¶
For complex apps — pure functions, effects as data, testable logic:
A type P satisfies Program if it provides:
struct P {
using Model = /* your state type */;
using Msg = /* std::variant of message types */;
static auto init() -> Model; // or pair<Model, Cmd<Msg>>
static auto update(Model m, Msg msg) -> std::pair<Model, Cmd<Msg>>;
static auto view(const Model&) -> Element;
static auto subscribe(const Model&) -> Sub<Msg>; // optional
};
RunConfig¶
struct RunConfig {
std::string_view title = ""; // Terminal window title
int fps = 0; // 0 = event-driven, >0 = continuous
bool mouse = false; // Enable mouse reporting
Mode mode = Mode::Fullscreen;// Rendering mode
Theme theme = theme::dark; // Color theme
};
Event-Driven vs Continuous¶
-
fps = 0(default): Only re-renders when an event arrives (key press, mouse move, resize). Minimal CPU usage. Best for static or user-driven UIs. -
fps = 30(or any positive value): Re-renders at the given frame rate regardless of input. Required for animations, timers, live data.
Inline Mode¶
Set mode = Mode::Inline to render in the scrollback instead of the alt screen:
This gives you event handling with inline rendering — useful for TUIs that should stay in the terminal history.
live() — Timer-Based Inline Rendering¶
For animations and progress displays that render inline (in the terminal's scrollback) without taking over the screen. No event handling — just a render loop with a timer.
Signature¶
LiveConfig¶
struct LiveConfig {
int fps = 30; // Target frames per second
int max_width = 0; // 0 = auto-detect terminal width
bool cursor = false; // Show cursor during rendering
};
Render Function¶
Two signatures:
// With delta time (seconds since last frame)
[&](float dt) -> Element { ... }
// Without delta time
[&]() -> Element { ... }
Stopping the Loop¶
Call maya::quit() from inside the render function:
live({.fps = 30}, [&](float dt) {
elapsed += dt;
if (elapsed > 5.0f) quit(); // Stop after 5 seconds
return text("Time: " + std::to_string(elapsed));
});
Example: Progress Bar¶
float progress = 0;
live({.fps = 30}, [&](float dt) {
progress += dt * 0.2f;
if (progress >= 1.0f) quit();
int filled = static_cast<int>(progress * 40);
std::string bar(filled, '#');
bar += std::string(40 - filled, '.');
return (v(
text("Installing...") | Bold,
text("[" + bar + "] " + std::to_string(int(progress * 100)) + "%")
) | pad<0, 1>).build();
});
How It Works¶
live() renders each frame by:
1. Building the element tree from your render function
2. Laying out and painting to a canvas
3. Serializing to ANSI escape sequences
4. Moving the cursor up to overwrite the previous frame
5. Writing the new frame
6. Sleeping until the next frame time
The cursor is hidden during rendering and restored on exit. Output stays in the terminal scrollback — it doesn't use the alt screen.
canvas_run() — Imperative Canvas Painting¶
For maximum control: direct cell-level painting on a double-buffered canvas. Best for games, particle systems, complex visualizations, and anything that needs per-cell control.
Signature¶
Status canvas_run(
CanvasConfig cfg,
std::function<void(StylePool&, int w, int h)> on_resize,
std::function<bool(const Event&)> on_event,
std::function<void(Canvas&, int w, int h)> on_paint
);
CanvasConfig¶
struct CanvasConfig {
int fps = 60; // Target frame rate
bool mouse = false; // Enable mouse reporting
Mode mode = Mode::Fullscreen;// Rendering mode
bool auto_clear = true; // Clear the canvas before each on_paint
std::string title; // Terminal window title
};
Callbacks¶
on_resize(StylePool& pool, int w, int h) Called at startup and after each terminal resize. The style pool is cleared before the call — re-intern all your styles here:
[&](StylePool& pool, int W, int H) {
// Pre-intern styles (compact uint16_t IDs for canvas cells)
style_bold = pool.intern(Style{}.with_bold().with_fg(Color::green()));
style_dim = pool.intern(Style{}.with_dim().with_fg(Color::gray()));
// Rebuild size-dependent state
particles.resize(W * H);
}
on_event(const Event& ev) -> bool
Same as run() — return false to quit:
[&](const Event& ev) -> bool {
if (key(ev, 'q')) return false;
on(ev, 'p', [&] { paused = !paused; });
return true;
}
on_paint(Canvas& canvas, int w, int h) Called every frame. The canvas is pre-cleared. Paint your frame:
[&](Canvas& canvas, int W, int H) {
for (auto& particle : particles) {
canvas.set(particle.x, particle.y, particle.glyph, particle.style_id);
}
canvas.write_text(0, H - 1, "status bar", bar_style);
}
Return Value¶
canvas_run() returns a Status (Result<void>). Check for errors:
auto result = canvas_run(config, on_resize, on_event, on_paint);
if (!result) {
std::println(std::cerr, "maya: {}", result.error().message);
return 1;
}
Example: Starfield¶
struct Star { float x, y, speed; };
std::vector<Star> stars;
uint16_t star_style;
auto result = canvas_run(
{.fps = 60, .title = "starfield"},
[&](StylePool& pool, int W, int H) {
star_style = pool.intern(Style{}.with_bold().with_fg(Color::white()));
stars.clear();
for (int i = 0; i < 200; ++i)
stars.push_back({randf(0, W), randf(0, H), randf(0.5f, 3.0f)});
},
[&](const Event& ev) { return !key(ev, 'q'); },
[&](Canvas& canvas, int W, int H) {
for (auto& s : stars) {
s.x -= s.speed;
if (s.x < 0) { s.x = W; s.y = randf(0, H); }
canvas.set(int(s.x), int(s.y), U'*', star_style);
}
}
);
print() — One-Shot Output¶
Render an element tree to stdout and return. No event loop, no terminal control. Perfect for CLI tools that want styled output:
void print(const Element& root); // Auto-detect terminal width
void print(const Element& root, int width); // Explicit width
Example¶
constexpr auto card = v(
t<"Build Status"> | Bold | Fg<100, 180, 255>,
t<"">,
h(t<"Tests:"> | Dim, t<" 142 passed"> | Fg<80, 220, 120>),
h(t<"Lint:"> | Dim, t<" 0 warnings"> | Fg<80, 220, 120>),
h(t<"Bundle:"> | Dim, t<" 2.4 MB"> | Fg<240, 200, 60>)
) | border_<Round> | bcol<60, 65, 80> | pad<1>;
print(card.build());
Output (with ANSI colors in a real terminal):
╭──────────────────────────╮
│ Build Status │
│ │
│ Tests: 142 passed │
│ Lint: 0 warnings │
│ Bundle: 2.4 MB │
╰──────────────────────────╯
Inline Scrollback Preservation¶
When building inline (non-fullscreen) UIs — particularly AI agent sessions, multi-step build pipelines, or any workflow where components complete and new ones appear — preserving the terminal scrollback is critical. The user should be able to scroll up and see the full history of what happened: expanded diffs, tool output, test results, etc.
The Problem: Content Shrinkage Destroys Scrollback¶
The inline renderer works by overwriting its output in place each frame: it moves the cursor up to the top of the previous frame, writes the new frame, and erases any leftover lines below.
This works perfectly when content height stays the same or grows. But when
content shrinks — e.g. a tool card collapses from 20 rows (showing a full
diff) to 2 rows (just a status line) — the old expanded content at those
terminal rows is overwritten with the shorter content, and the leftover
lines are erased with \x1b[2K. The old diff is gone from the terminal
buffer entirely. The user cannot scroll up to see it.
Frame N (tool running — 20 rows):
┌─────────────────────────────────────┐
│ ▸ Edit src/middleware/auth.ts │ ← header
│ src/middleware/auth.ts │ ← breadcrumb
│ -import session from 'express-… │ ← diff line 1
│ +import jwt from 'jsonwebtoken'; │ ← diff line 2
│ …16 more diff lines… │
│ ◐ running │ ← spinner
└─────────────────────────────────────┘
Frame N+1 (tool done — 2 rows):
┌─────────────────────────────────────┐
│ ✓ Edit src/middleware/auth.ts +18 -14 │ ← collapsed
│ │ ← next tool starts…
└─────────────────────────────────────┘
↑ Cursor moved up 19 rows, overwrote everything.
Rows 3–20 erased. Diff is gone from scrollback.
The Solution: Two-Part Fix¶
Maya solves this at both the framework and application levels.
1. Framework: Row-Hash Committed Scrollback¶
The inline renderer computes a fast hash (FNV-1a over packed 64-bit cells) for every canvas row each frame. It compares these hashes against the previous frame to find the stable prefix — the longest run of rows from the top that are identical between frames.
Stable rows are committed to scrollback: the cursor is never moved above them and they are never overwritten. Only the "live" region below the committed area is re-rendered each frame.
Canvas row 0: [User message] ← stable, committed (never touched)
Canvas row 1: [Context pills] ← stable, committed
Canvas row 2: [Thinking block] ← stable, committed
Canvas row 3: [Tool card header] ← stable, committed
Canvas row 4: [ diff line 1] ← stable, committed
…
Canvas row 18: [ diff line 15] ← stable, committed
Canvas row 19: [Spinner / status] ← CHANGING → live region starts here
Canvas row 20: [Status bar] ← CHANGING → live
Once committed, a row stays committed for the entire inline session. Even if the canvas content at that position later changes (e.g. the tool card header switches from a spinner to a checkmark), the committed row in the terminal retains its original content — which is exactly what scrollback preservation means.
The live region (everything below the committed boundary) is managed normally: overwritten in place each frame, with leftover lines erased when it shrinks.
Key implementation details:
committed_height_is monotonically increasing — rows are never un-committed- Row hashes use FNV-1a for fast comparison with extremely low collision risk
- The
serialize()call is passedlive_startto skip committed rows entirely prev_livetracks the live area height for correct cursor movement
2. Application: Content Should Only Grow¶
The framework's row-hash comparison works best when content grows monotonically — each new component adds rows below existing ones, and completed components keep their content visible.
This mirrors how Claude Code (built on Ink) works:
- A completed Read card keeps its file preview visible
- A completed Edit card keeps its diff visible with a ✓ header
- A completed Bash card keeps its output visible with exit code
- Only the header styling changes (spinner → checkmark)
Do this:
// Tool status changes but content stays visible
if (phase_timer > 2.0f) {
edit_status = TaskStatus::Completed; // header shows ✓
// DiffView stays in the tree — height doesn't change
}
Don't do this:
// ❌ Dramatic collapse — destroys scrollback content
if (phase_timer > 2.0f) {
edit_status = TaskStatus::Completed;
tool_collapsed = true; // Hides DiffView, height drops 15+ rows
}
If you need user-toggleable collapse, use key bindings:
This way, the user controls when to collapse — the framework doesn't do it automatically during the session flow.
How It All Fits Together¶
Session start:
committed = 0, live = all rows
└─ Every row is overwritten each frame (normal)
After 5 stable frames:
committed = 12, live = rows 12+
└─ Rows 0–11 (user msg, context, thinking) are locked in scrollback
Tool card runs for 2 seconds:
committed = 12, live = rows 12+ (tool header + diff change due to spinner)
└─ When spinner stops → tool body rows become stable → committed grows to 30
New tool starts:
committed = 30, live = rows 30+ (new tool header + body)
└─ All previous tool output (rows 0–29) locked in scrollback forever
User scrolls up in terminal:
└─ Sees full diffs, file contents, test output — all preserved
Limitations¶
-
Hash collisions: The FNV-1a row hash has a theoretical collision risk. In practice, terminal content collisions are astronomically unlikely (one in ~2^64 per row pair per frame). A false match would cause one row to be skipped for one frame — self-correcting on the next frame when the hash changes.
-
Content above committed boundary can't update: If you change content at a row that's already committed (e.g. updating an old tool card header), the terminal won't reflect the change. The committed row retains what was originally rendered. This is by design — it's the scrollback preservation guarantee.
-
Very tall content: When content exceeds the terminal height, the top rows are cropped via
skip_rows. Rows that were visible and committed but get cropped remain in the terminal's scrollback from when they were written.
Cmd-Level Scrollback Control¶
Most apps get correct scrollback for free from the row-hash mechanism above.
Hosts that manage their own sealed history (agent sessions that trim old turns)
drive it explicitly through Cmds from update():
| Cmd | Use |
|---|---|
Cmd::commit_scrollback(ScrollbackDebt) |
Commit trimmed rows using a maya-measured token. Obtain the debt from ScrollbackLedger::harvest() — the row count comes from maya's own paint pass and structurally cannot drift from the wire. |
Cmd::commit_scrollback_overflow() |
Commit every row of the last frame that has provably overflowed the viewport. A "trigger" — maya derives the safe row count itself. |
Cmd::force_redraw() |
Schedule a soft viewport repaint next frame. |
Cmd::reset_inline() |
Hard inline reset (destructive scrollback wipe) — wholesale model swaps only. |
Prefer the typed
ScrollbackLedgerpath over the deprecated raw-intcommit_scrollback(int): every historical trim-corruption bug was drift between a host's guessed row count and what maya painted. Hold your sealed prefix in aScrollbackLedger, render it vialedger_ref/Conversation::Config::ledger, and passledger.harvest()to the Cmd. See internals/witness-chain.md.
Handing Off the Terminal: Cmd::suspend()¶
When an inline app needs to run an interactive child that owns the real tty
(a sudo prompt, $EDITOR, a pager), return Cmd::suspend(run). Maya tears
the TUI down to a clean cooked tty, runs the child synchronously, then
restores raw mode, re-anchors below the child's output, and dispatches the
Msg the callable returned so update() can fold the result back in. See
API Reference → Cmd::suspend().
Choosing the Right Mode¶
Need interactivity?
├── Yes: Need per-cell control?
│ ├── Yes → canvas_run() (games, animations, visualizations)
│ └── No → run() or run<P>() (dashboards, forms, menus)
│ └── Want scrollback output? → run({.mode = Mode::Inline}) or run<P>({.mode = Mode::Inline})
└── No: Need animation?
├── Yes → live() (progress bars, streaming output)
└── No → print() (CLI reports, status cards)
Quitting¶
Simple run(): return false or call quit()¶
In simple run() apps, quit either by returning false from the event function
or by calling maya::quit() if the event function returns void:
// Event function returning bool — return false to quit
run({}, [&](const Event& ev) {
return !key(ev, 'q'); // false = quit
}, render_fn);
// Event function returning void — call quit() to quit
run({}, [&](const Event& ev) {
if (key(ev, 'q')) quit();
}, render_fn);
Program apps: Cmd\<Msg>::quit()¶
In run<P>() apps, quit by returning Cmd<Msg>::quit() from update():
static auto update(Model model, Msg msg) -> std::pair<Model, Cmd<Msg>> {
return std::visit(overload{
[&](Quit) { return std::pair{model, Cmd<Msg>::quit()}; },
// ...
}, msg);
}
canvas_run() / live(): maya::quit()¶
Call from anywhere — event handlers, render functions, signal effects — to schedule a clean exit after the current frame. Thread-local, safe to call from any context.