The Compile-Time DSL¶
maya's DSL lets you define UI trees as C++26 template expressions. Text content, styles, layout properties, and tree structure are all resolved at compile time. The compiler itself enforces correctness — impossible states don't compile.
#include <maya/maya.hpp>
using namespace maya::dsl;
constexpr auto ui = v(
t<"Hello"> | Bold | Fg<100, 180, 255>,
h(t<"A"> | Dim, t<"B">) | border_<Round> | pad<1>
);
Everything above — the tree shape, the text content, the styles, the border,
the padding — is a compile-time value. .build() converts it to a runtime
Element for rendering.
Node Types¶
Every DSL construct is a Node — a type that satisfies:
template <typename T>
concept Node = requires(const T& n) {
{ n.build() } -> std::convertible_to<Element>;
};
All nodes also have operator Element() for implicit conversion, so you can
pass them directly wherever an Element is expected.
TextNode — Compile-Time Text¶
t<"Hello World"> // TextNode<"Hello World", CTStyle{}>
t<"Error"> | Bold | Fg<255, 80, 80> // TextNode<"Error", {bold, red}>
t<"..."> creates a TextNode parameterized on the string content (as an
NTTP). Pipe style tags to compose styling at compile time — no runtime cost.
The string is a Str<N> — a compile-time fixed string:
BoxNode — Containers (v/h)¶
v(child1, child2, child3) // Vertical stack (column direction)
h(child1, child2, child3) // Horizontal stack (row direction)
v() and h() create BoxNode<Direction, Config, Children...> — a variadic
template that captures the children by type. The config accumulates layout
modifiers applied via |. They accept any DslChild — either a Node or
an ElementRange (like std::vector<Element>).
Children can be any mix of node types — TextNode, other BoxNodes,
DynNode, RuntimeTextNode, SpacerNode, etc. — and runtime collections
like std::vector<Element>:
std::vector<Element> rows;
rows.push_back(text("Row 1") | Bold);
rows.push_back(text("Row 2") | Dim);
auto panel = v(
t<"Header"> | Bold,
rows, // vector<Element> flattened into children
t<"Footer"> | Dim
) | border_<Round> | pad<1>;
Any type satisfying ElementRange (a range whose elements convert to Element)
works — vector<Element>, span<Element>, filtered views, etc.
RuntimeTextNode — Dynamic Text¶
text("hello") // RuntimeTextNode<string_view>
text(std::to_string(n)) // RuntimeTextNode<string>
text(42) // RuntimeTextNode<int>
text(3.14) // RuntimeTextNode<double>
text("status: ok") | Bold | Fg<80,220,120> // Pipeable!
text() returns a RuntimeTextNode<S> — a proper Node that works in the DSL
tree and supports pipe-based styling just like compile-time nodes:
template <typename S>
struct RuntimeTextNode {
S content;
Style style{};
TextWrap wrap{TextWrap::Wrap};
operator Element() const;
Element build() const;
};
Use text() when content is known only at runtime. Use t<"..."> when the
string is a compile-time literal.
DynNode — Runtime Escape Hatch¶
dyn() wraps a lambda that returns an Element (or anything convertible to
Element). The lambda is called during .build() — it's the bridge between
the compile-time tree and runtime state:
auto ui = v(
t<"Static title"> | Bold,
dyn([&] {
// This runs at build time — access any runtime state here
if (loading) return text("Loading...") | Dim;
return text("Ready") | Bold | Fg<80, 220, 120>;
})
);
The constraint is:
template <typename F>
requires std::invocable<F> && std::convertible_to<std::invoke_result_t<F>, Element>
auto dyn(F&& fn);
Since all nodes have operator Element(), your lambda can return any node type.
MapNode — Range Projection¶
std::vector<std::string> items = {"alpha", "beta", "gamma"};
auto list = v(
t<"Items:"> | Bold,
map(items, [](const auto& s) { return text(s) | Dim; })
);
map() projects a range through a function, producing an ElementList:
template <std::ranges::range R, typename Proj>
auto map(R&& range, Proj&& proj) -> MapNode<decay_t<R>, decay_t<Proj>>;
The projection can return any Node — it will be .build()'d automatically.
SpacerNode — Flexible Space¶
h(t<"Left">, space, t<"Right">) // space pushes "Right" to the far edge
h(t<"Left">, spacer(), t<"Right">) // same thing, function form
space (or spacer()) is a zero-content box with grow: 1 — it absorbs all
remaining space in a flex container. Useful for pushing items apart:
SepNode / VSepNode — Separators¶
v(t<"Above">, sep, t<"Below">) // Horizontal line between items
h(t<"Left">, vsep, t<"Right">) // Vertical line between items
sep draws a horizontal border line. vsep draws a vertical one. Both use
BorderStyle::Single with BorderSides::horizontal() or vertical().
BlankNode — Empty Line¶
blank_ is an empty TextElement — a visual spacer that takes one line.
Scroll Pipes — Viewports¶
Wrap any node in a scroll viewport backed by a caller-owned ScrollState
(the host stores it, typically mutable on the Model). The renderer applies
the offset and writes max_x/max_y back after layout, so scroll clamping is
automatic — you never compute bounds yourself.
ScrollState st; // owned by the host
v(long_log) | scroll(st) // both axes, viewport = allotted size
v(long_log) | scrolly(st, 20) // vertical only, 20-row window
h(wide_row) | scrollx(st, 60) // horizontal only, 60-col window
v(grid) | scroll(st, 60, 20) // both axes, fixed 60×20 window
| Pipe | Axes | Viewport |
|---|---|---|
scroll(st) |
both | allocated size |
scroll(st, h) |
vertical | fixed height h |
scroll(st, w, h) |
both | fixed w×h |
scrolly(st, h) |
vertical | fixed height h |
scrollx(st, w) |
horizontal | fixed width w |
See Signals & Scroll for ScrollState and the scrollbar
widget that visualises it.
Text-Wrap Tags — clip / nowrap¶
By default text() word-wraps at the container width. Two tags override that
per node:
text(long_path) | clip // TextWrap::TruncateEnd — hard-truncate at the edge
text(banner) | nowrap // TextWrap::NoWrap — overflow past the edge, no wrap
Style Pipe Operators¶
Styles compose left-to-right with |:
Text Attributes¶
| Tag | Effect |
|---|---|
Bold |
Bold weight |
Dim |
Dimmed/faint |
Italic |
Italic |
Underline |
Underlined |
Strike |
Strikethrough |
Inverse |
Swap fg/bg |
Colors¶
How It Works¶
Each style tag is a StyTag<CTStyle{...}> — a zero-size type carrying a
compile-time style value. The | operator merges the tag's style into the
node's accumulated style. For TextNode, this produces a new TextNode type
with the merged style as a template parameter — zero runtime cost:
template <Str S, CTStyle Sty, CTStyle V>
constexpr auto operator|(TextNode<S, Sty>, StyTag<V>) {
return TextNode<S, Sty.merge(V)>{}; // New type, merged at compile time
}
For RuntimeTextNode, the merge happens at the Style object level:
template <typename S, CTStyle V>
auto operator|(RuntimeTextNode<S> n, StyTag<V>) {
n.style = n.style.merge(V.runtime());
return n;
}
Layout Pipe Operators¶
Layout modifiers apply only to BoxNode (i.e., to v() and h() results):
Padding¶
v(...) | pad<1> // 1 cell on all sides
v(...) | pad<1, 2> // 1 vertical, 2 horizontal
v(...) | pad<1, 2, 3, 4> // top, right, bottom, left
Compile-time validated: negative values won't compile.
Gap¶
Grow¶
h(
v(...) | grow_<3>, // Takes 3/5 of available space
v(...) | grow_<2> // Takes 2/5 of available space
)
Border¶
v(...) | border_<Round> // Round corners: ╭ ╮ ╰ ╯
v(...) | border_<Single> // Single lines: ┌ ┐ └ ┘
v(...) | border_<Double> // Double lines: ╔ ╗ ╚ ╝
v(...) | border_<Thick> // Bold lines: ┏ ┓ ┗ ┛
Border Color (Type-State Safety)¶
v(...) | border_<Round> | bcol<60, 65, 80> // OK: border declared first
v(...) | bcol<60, 65, 80> // COMPILE ERROR: no border!
This is enforced via a requires clause:
template <FlexDirection Dir, BoxCfg Cfg, typename... Cs, uint8_t R, uint8_t G, uint8_t B>
requires (Cfg.has_border) // <-- type-state check
constexpr auto operator|(BoxNode<Dir, Cfg, Cs...> n, BColTag<R, G, B>);
Available BorderStyle Aliases¶
| Alias | BorderStyle | Characters |
|---|---|---|
Round |
BorderStyle::Round |
╭ ─ ╮ │ ╯ ─ ╰ │ |
Single |
BorderStyle::Single |
┌ ─ ┐ │ ┘ ─ └ │ |
Double |
BorderStyle::Double |
╔ ═ ╗ ║ ╝ ═ ╚ ║ |
Thick |
BorderStyle::Bold |
┏ ━ ┓ ┃ ┛ ━ ┗ ┃ |
Constexpr Everything¶
Because all DSL nodes are structural types with NTTP-safe fields, entire UI
trees can be constexpr:
constexpr auto sidebar = v(
t<"Navigation"> | Bold,
t<"Home"> | Fg<100, 180, 255>,
t<"Settings"> | Dim,
t<"About"> | Dim
) | border_<Single> | pad<0, 1>;
The compiler evaluates the template instantiations at compile time. At runtime,
.build() just reads the template parameters and constructs the Element tree
— effectively a direct assignment with no parsing, no branching, and no
allocation beyond the element nodes themselves.
Mixing Static and Dynamic¶
The real power is combining compile-time structure with runtime content:
auto ui = v(
// Compile-time: structure, static text, styles
t<"Dashboard"> | Bold | Fg<100, 180, 255>,
sep,
// Runtime: dynamic data via dyn()
dyn([&] { return text("Users: " + std::to_string(count)); }),
// Runtime: list from data via map()
map(items, [](const auto& item) {
return h(
text(item.name) | Bold,
text(item.value) | Dim
);
}),
// Compile-time: fixed footer
space,
t<"[q] quit"> | Dim
) | border_<Round> | pad<1>;
The tree structure, borders, padding, and static text are fully resolved at
compile time. Only the dyn() and map() nodes execute at runtime.
Runtime Pipes¶
The compile-time DSL pipes (pad<1>, border_<Round>, bcol<50,54,62>) only
work with compile-time values. Runtime pipes fill the gap — same | syntax, but
accepting runtime values (variables, function results, theme colors, etc.).
All runtime pipe factories live in the maya::dsl namespace.
Layout¶
padding(int all) // uniform padding
padding(int v, int h) // vertical, horizontal
padding(int t, int r, int b, int l) // top, right, bottom, left
gap(int g) // gap between children
margin(int all) // uniform margin
margin(int v, int h) // vertical, horizontal
margin(int t, int r, int b, int l) // top, right, bottom, left
grow(float g = 1.0f) // flex grow factor
width(int w) // fixed width
height(int h) // fixed height
Border¶
border(BorderStyle bs) // runtime border style
bcolor(Color c) // runtime border color
btext(string s, // border text label
BorderTextPos pos = Top,
BorderTextAlign align = Start)
Style¶
Alignment¶
align(Align a) // align items (cross-axis)
justify(Justify j) // justify content (main-axis)
overflow(Overflow o) // overflow behavior
Compile-Time vs Runtime Comparison¶
| Compile-time | Runtime | Purpose |
|---|---|---|
pad<1> |
padding(1) |
Padding |
gap_<1> |
gap(1) |
Gap |
border_<Round> |
border(Round) |
Border style |
bcol<50,54,62> |
bcolor(c) |
Border color |
| — | btext("Title") |
Border text |
grow_<1> |
grow(1.0f) |
Flex grow |
w_<40> |
width(40) |
Fixed width |
h_<10> |
height(10) |
Fixed height |
Fg<R,G,B> |
fgc(color) |
Foreground |
Bg<R,G,B> |
bgc(color) |
Background |
| — | margin(1) |
Margin |
| — | align(Center) |
Align items |
| — | justify(End) |
Justify content |
| — | overflow(Hidden) |
Overflow |
Design¶
- Runtime pipes wrap any
Nodein aWrappedNodethat appliesBoxBuildersettings at build time. - Chaining multiple runtime pipes reuses the same
WrappedNode— no extra nesting per pipe. - Compile-time pipes still work after a runtime pipe.
WrappedNodesatisfiesNode— it can be a child ofv()/h().- Both pipe types compose freely in the same expression.
Examples¶
// Runtime values from variables/theme
Color border_color = theme.border;
auto ui = v(
t<"Status"> | Bold,
text(message)
) | border(Round) | bcolor(border_color) | btext("Info") | padding(0, 1);
// Mix compile-time and runtime pipes
auto panel = v(children)
| pad<1> // compile-time padding
| border(user_border_style) // runtime border (from config)
| bcolor(status_color) // runtime color (from state)
| grow(1.0f); // runtime grow
Hex Color Shorthand¶
For compile-time colors, hex literals are more convenient than separate RGB components:
These are compile-time pipe tags just like Fg / Bg, but take a single
24-bit hex value instead of three separate uint8_t parameters.
Gotcha —
fg<>/bg<>are ambiguous underusing namespace maya;. The rootmayanamespace also defines free functionsmaya::fg(Color)/maya::bg(Color)(theStylebuilders). If you pull in bothusing namespace maya;andusing namespace maya::dsl;(the common setup), an unqualifiedfg<0xFFFFFF>fails to compile with “reference to ‘fg’ is ambiguous”. Fixes, any one of:The capitalBold | dsl::fg<0xFFFFFF> // qualify the tag Bold | Fg<0xFF, 0xFF, 0xFF> // use the capital RGB tag (never ambiguous)Fg<R,G,B>/Bg<R,G,B>tags have no free-function twin, so they are always unambiguous. This only affects the lowercase hex tags.
Style Presets¶
Compose style tags into reusable presets with constexpr:
constexpr auto heading = Bold | fg<0xFFFFFF>;
constexpr auto error = Bold | fg<0xFF4444>;
t<"Title"> | heading
text(msg) | error
Because style tags are zero-size types, preset composition is resolved entirely at compile time.
User-Defined Literal¶
The _t literal creates a TextNode from a string literal, equivalent to
t<"..."> but usable inline without angle brackets:
bordered<> — Combined Border + Color¶
bordered<> applies a border style and optional border color in a single pipe,
avoiding the two-step border_<> | bcol<> chain:
v(...) | bordered<Round, 0x323746> // border + color in one pipe
v(...) | bordered<Single> // border only, no color
The color argument is a 24-bit hex value, same format as fg<> / bg<>.
when() — Conditional Rendering¶
when() selects between two elements (or omits content entirely) based on a
runtime boolean:
when(is_loading, spinner, content) // show spinner or content
when(show_debug, debug_panel) // omit else -> blank
When the condition is false and no else branch is provided, when() produces
a blank element that takes no space.
visible() — Runtime Visibility¶
visible() is a runtime pipe that controls whether a node is rendered. Unlike
when(), it keeps the node in the tree — it just suppresses its output:
When debug_mode is false, the node still exists in the element tree but
renders as zero-size.
Using DSL Trees in Applications¶
DSL trees integrate the same way regardless of which application API you use —
the render side always produces an Element via .build().
Simple run()¶
The render closure returns an Element directly. Just build the tree:
maya::run(cfg,
[](const maya::Event& e) { /* handle input */ },
[] {
return v(
t<"Hello"> | Bold,
text("frame " + std::to_string(frame))
) | border_<Round> | pad<1>;
}
);
Program run<P>()¶
The view function receives the model and returns an Element. Same pattern:
struct MyProgram {
// ...
static Element view(const Model& m) {
return v(
t<"Counter"> | Bold,
text(m.count)
) | border_<Round> | pad<1>;
}
};
maya::run<MyProgram>(cfg);
live() and print()¶
live() works like simple run() — the render closure returns an Element.
print() renders once and exits; just call .build() directly:
// live(): continuous rendering
maya::live([] {
return v(t<"Status"> | Bold, text(current_status));
});
// print(): one-shot
maya::print(v(t<"Report"> | Bold, map(rows, row_fn)).build());
In all cases the DSL tree is identical — only the surrounding application structure differs.