ZMarkup
ZMarkup is a small declarative DSL for building UI trees without writing
C++ boilerplate. It's parsed at runtime, compiled to Layout nodes, and
returns a UINode handle you can use to bind callbacks and query
widgets by id.
It is not a replacement for C++ — it's a convenience for describing structure. Logic, callbacks, and integration with the rest of your code still happen in C++.
1. Why two languages?
You could write everything in ZMarkup:
VStack.card {
Text.card-title "Settings"
HStack.setting-row {
Text.setting-label "Volume"
Slider#vol value=0.8
}
}
Or everything in C++:
auto card = VStack()->cls("card");
card->addChild(Label("Settings")->cls("card-title"));
auto row = HStack()->cls("setting-row");
row->addChild(Label("Volume")->cls("setting-label"));
row->addChild(Slider::create(0.8f)->id("vol"));
card->addChild(row);
Both produce the same tree. ZMarkup shines when:
- The structure is deep but the logic is shallow.
- You want designers / non-programmers to edit layout.
- You want to prototype quickly.
- You want to separate "what the UI looks like" from "what the UI does".
ZMarkup is weaker when:
- You need computed attributes (build the string dynamically).
- You need loops / conditionals (build in C++ instead).
- You need tight control over every node.
You can mix the two freely — a ZMarkup tree can be added as a child of a C++ tree, and vice versa.
2. Syntax
Tag.class1.class2#id attr1="value1" attr2=value2 flag "text content" {
Child
Child
}
Everything except the tag name is optional. The parts are described in the sections below.
2.1 Tag
The first identifier is the tag. It maps to a factory registered with the
ZMarkup::Registry. Built-in tags:
| Tag | Widget | Notes |
|---|---|---|
Stack |
Layout(Stack) |
Style tag Stack |
VStack |
Layout(Vertical) |
Style tag VStack |
HStack |
Layout(Horizontal) |
Style tag HStack |
Spacer |
Layout(Stack) |
Style tag Spacer, typically grow: 1 |
Text / Label |
Text |
Text widget |
Button |
Button |
Uses Btn(text) factory |
Panel |
Panel |
Plain box |
Toggle |
Toggle |
|
Checkbox |
Checkbox |
|
Slider |
Slider |
|
ProgressBar |
ProgressBar |
|
TextInput |
TextInput |
|
Dropdown |
Dropdown |
|
ScrollView |
ScrollView |
|
Canvas |
CanvasLayout |
Renders subtree into an offscreen target. |
Modal |
Modal |
Full-screen overlay with intro animation. Content goes into contentTarget() (see below). |
Popup |
Popup |
Floating panel; requires C++ to call openBelow/openAt. |
Tags are case-sensitive. Button and button are different — only
Button is registered by default.
2.2 Classes
A .name adds a CSS class. You can chain as many as you want, in any
order relative to #id:
Button.btn.btn-primary "OK"
Button#submit.btn-primary "OK"
The classes are added via Layout::cls() and are visible to .zstyle
selectors and to getStyleClasses() at runtime.
2.3 Id
A #name sets the node id. It is used:
- by
UINode::find<T>(id)to look up the node, - by
#nameselectors in.zstyle, - by
onClick(id, ...)and friends on theUINodehandle.
There is no requirement that ids are unique, but find returns the
first match in the order nodes were created, so use unique ids if
you plan to query them.
2.4 Attributes
key=value pairs, separated by whitespace. Values can be:
- Quoted strings:
value="Player 1"(supports\n,\t,\"). - Bare tokens:
value=0.65— anything up to the next whitespace,{,}, or". - Flags:
checkedwith no=is shorthand forchecked="true".
Attributes are applied after the widget is created, so they can override defaults set by the factory.
The following attributes have special meaning:
| Attribute | Effect |
|---|---|
passthrough |
Sets Layout::setPassThrough(...). |
value |
Passed to TextInput, Slider, ProgressBar at construction. |
checked |
Passed to Toggle, Checkbox at construction. |
options |
Comma-separated list for Dropdown. |
wrap |
Enables wrapping on Text. |
Everything else is applied as an inline style via
applyStyleAttr(getInlineBase(), key, value). So any property you
could set in .zstyle can also be set inline here — see the full list
in ZStyle §"Inline attributes".
Button "OK" width="120px" height="40px" background="#0079F1" radius="8px"
2.5 Text content
A quoted string after the attributes is the widget's text. It is passed to the tag's factory, which decides what to do with it:
Text/Label→setText(text).Button→Btn(text), adds aTextchild.TextInput→ initial value.- Others → ignored.
Text "Hello"
Button "Click me"
2.6 Children
A { ... } block contains child elements, each on its own line (or
separated by whitespace). Nesting is unlimited.
VStack {
Text "First"
Text "Second"
HStack {
Button "A"
Button "B"
}
}
Children are added to the parent's content target. For most widgets
the content target is the widget itself, so { ... } children end up
directly inside the widget, exactly as addChild() would do.
For composite widgets that have an internal content wrapper — currently
only Modal — the content target is the wrapper. This means:
Modal#dialog {
VStack.modal-box {
Label "Are you sure?"
}
}
The VStack.modal-box ends up inside the modal's content wrapper,
not as a sibling of it. Since the wrapper owns the "Intro" animation
that slides the modal content into view, markup children participate
in that animation automatically. No C++ injection needed:
auto ui = ZMarkup::loadMarkupFile("assets/ui/menu.zmarkup");
auto modal = ui.find<Modal>("dialog");
modal->show(); // the modal-box slides in, animated
For other composite widgets (Toggle, Dropdown), the content target
is the widget itself — same as a plain Layout. Adding children to a
Toggle via markup stacks them on top of the knob in the same Stack
layer, which is usually not what you want. Use Toggle without markup
children, and configure its look through .zstyle instead.
For the full contract, see Layout::contentTarget() in the Layout API
2.7 Comments
// starts a line comment. Everything from // to the end of the line
is ignored.
// This is a comment
VStack {
Text "Hello" // trailing comment
}
Block comments /* ... */ are not supported in ZMarkup (they are
supported in .zstyle).
3. Building a UI
ZMarkup::build(source) parses the source, builds the tree, and returns
a UINode handle.
#include "ZMarkup.hpp"
using namespace ZenitUI;
auto ui = ZMarkup::build(R"(
VStack.card#card {
Text.card-title "Settings"
HStack.setting-row {
Text.setting-label "Volume"
Slider#vol value=0.8 width="55%"
}
Button.btn-primary#save "Save"
}
)");
auto root = ui.root();
parent->addChild(root);
The UINode handle
| Method | Description |
|---|---|
root() |
The root Layout of the built tree. |
context() |
The BuildContext used to build the tree. Escape hatch for looking up nodes by id outside of find(). See §6. |
find(id) |
Find a node by id, returns shared_ptr<Layout>. |
find<T>(id) |
Typed lookup — ui.find<Slider>("vol"). |
onClick(id, cb) |
Bind onClick. |
onPress(id, cb) |
Bind onPress. |
onRelease(id, cb) |
Bind onRelease. |
onHoverEnter(id, cb) |
Bind onHoverEnter. |
onHoverExit(id, cb) |
Bind onHoverExit. |
onValueChanged(id, cb) |
Bind Slider::onValueChanged. |
onToggle(id, cb) |
Bind Toggle::onToggle or Checkbox::onToggle. |
All the on* methods return UINode& for chaining:
ui.onClick("save", []{ save(); })
.onClick("cancel", []{ cancel(); })
.onToggle("mute", [](bool v){ setMuted(v); });
If the id is not found, the binding is silently ignored (no exception, no log). This is intentional — it makes partial UIs easier to iterate on.
Manual lookup with type
if (auto slider = ui.find<Slider>("vol")) {
slider->onValueChanged = [](float v){
std::printf("volume: %.2f\n", v);
};
}
if (auto toggle = ui.find<Toggle>("mute")) {
toggle->onToggle = [](bool v){ /* ... */ };
}
find<T> uses std::static_pointer_cast, so you must be sure of the
type — otherwise you'll get undefined behavior when the cast is used.
Prefer the typed on* helpers when possible.
Loading from a file
auto ui = ZMarkup::loadMarkupFile("assets/ui/main_menu.zmarkup");
if (!ui.root()) {
// file not found or empty; see the log for details
return;
}
root->addChild(ui.root());
Symmetric to ZMarkup::loadStyleFile for .zstyle sources.
Missing file → null root. The error is logged via Logger, so a
custom logger will see it. The UINode is always usable: context()
returns a valid (empty) context even when the root is null.
4. Common patterns
4.1 Settings row
VStack.settings-section {
Text.settings-section-title "Audio"
HStack.settings-row {
Text.setting-label "Music"
Slider#music value=0.55 width="55%"
Text.setting-value#music-value "55%"
}
}
auto ui = ZMarkup::build(markup);
auto slider = ui.find<Slider>("music");
auto label = ui.find<Text>("music-value");
slider->onValueChanged = [label](float v){
label->setText(std::to_string((int)(v * 100)) + "%");
};
4.2 Button row with mixed actions
HStack.button-row {
Button.btn-primary#save "Save"
Button.btn-secondary#cancel "Cancel"
Button.btn-danger#delete "Delete"
}
ui.onClick("save", []{ save(); })
.onClick("cancel", []{ cancel(); })
.onClick("delete", []{ confirmDelete(); });
4.3 Nested layouts
VStack.card {
Text.card-title "Two columns"
HStack gap="16px" {
VStack.side-panel {
Text "Left"
Button "L1"
}
VStack.side-panel {
Text "Right"
Button "R1"
}
}
}
4.4 Form with a dropdown and a progress bar
VStack.card {
Text.card-title "Download"
HStack.setting-row {
Text.setting-label "Quality"
Dropdown#quality options="Low,Medium,High" width="40%"
}
HStack.setting-row {
Text.setting-label "Progress"
ProgressBar#progress value=0.3 width="55%"
}
}
auto dd = ui.find<Dropdown>("quality");
auto pb = ui.find<ProgressBar>("progress");
dd->onChange = [](int idx, const std::string& name){
std::printf("quality: %s\n", name.c_str());
};
ui.onClick("start", [pb]{
pb->setValue(0.0f);
// kick off your download...
});
5. Building fragments
You can build a fragment (a tree with a non-Stack root, or a single
widget) and use it as part of a C++ tree:
auto ui = ZMarkup::build(R"(Button.btn-primary "Click me")");
auto button = ui.root();
parent->addChild(button);
Or add a ZMarkup subtree to an existing widget:
auto card = VStack()->cls("card");
auto ui = ZMarkup::build(R"(
HStack.setting-row {
Text.setting-label "Volume"
Slider#vol value=0.5
}
)");
card->addChild(ui.root());
// Bind after adding
ui.find<Slider>("vol")->onValueChanged = [](float v){ /* ... */ };
6. Extending the registry
ZMarkup resolves tags through a registry. You can register your own factories to expose custom widgets to the DSL.
ZMarkup::Registry& reg = /* ... */;
The built-in registry is a static instance inside ZMarkup::build, so
you can't easily get a reference to it. If you need custom tags, build
your own registry:
ZMarkup::Registry reg;
ZMarkup::registerBuiltins(reg); // start from the defaults
reg.reg("MyWidget", [](const ZMarkup::Element& e){
auto w = MyWidget::create(e.attrFloat("value", 0.f));
return std::static_pointer_cast<Layout>(w);
});
auto el = ZMarkup::parse(source);
auto ctx = std::make_shared<ZMarkup::BuildContext>();
auto root = reg.create(el, *ctx);
ZMarkup::UINode ui(root, ctx);
A factory receives the Element and returns a shared_ptr<Layout> (or
nullptr to skip). The registry then:
- Applies
passthroughif present. - Adds the classes.
- Sets the id.
- Applies inline style attributes.
- Recursively creates and adds children.
This means a factory only needs to construct the widget — everything else (classes, id, attrs, children) is handled by the registry.
The Element struct
struct Element {
std::string tag;
std::vector<std::string> classes;
std::string id;
std::string text;
std::vector<std::pair<std::string, std::string>> attrs;
std::vector<Element> children;
std::string attr(std::string_view k, std::string_view d = "") const;
bool has(std::string_view k) const;
float attrFloat(std::string_view k, float d = 0.f) const;
bool attrBool(std::string_view k, bool d = false) const;
};
attrFloat / attrBool do a best-effort parse and return the default
on failure. attr returns an empty string (or the given default) if the
key isn't present.
Accessing the BuildContext
The default ZMarkup::build and loadMarkupFile create a
BuildContext internally and store it in the returned UINode.
context() exposes it:
auto ui = ZMarkup::build(source);
auto ctx = ui.context();
// ctx->byId["save"] is the node with id "save", or absent
auto it = ctx->byId.find("save");
if (it != ctx->byId.end()) {
auto save = it->second;
// ...
}
Most of the time find() is enough and more ergonomic. context()
is the escape hatch for two cases:
-
Bulk inspection. Iterating every id in the tree, checking for duplicates, or building a map from id → node for external use.
find()is per-id;context()gives the whole map. -
Extending a tree at runtime. If you built the tree with a custom Registry (see above), you can use the same registry and the same BuildContext to add more nodes later, and they'll be registered in the same id map:
auto ui = /* built with custom registry + context */;
auto ctx = ui.context();
auto el = ZMarkup::parse(R"(Button#new "Added later")");
auto node = myRegistry.create(el, *ctx);
ui.root()->addChild(node);
// ctx->byId now contains "new" as well
The BuildContext is a plain struct holding a single
unordered_map<string, shared_ptr<Layout>>. It's safe to keep the
shared pointer around as long as you need it. Trees built by
ZMarkup::build share nothing with each other — each call produces
its own context.
7. Interaction with .zstyle
Attributes in ZMarkup are inline styles. They have the highest
priority in the cascade — they beat any .zstyle rule, including
state rules like :hover.
// Inline: wins over any .zstyle rule
Button "OK" background="#FF0000"
/* Ignored — the inline style above always wins */
Button { background: #0000FF; }
Best practice: use ZMarkup attributes only for structural things
(id, class, value, checked, options, wrap), and put visual
properties in .zstyle.
Button.btn-primary#save "Save"
.btn-primary {
background: #0079F1;
radius: 8px;
padding: 1.5vh 2vw;
transition: background 0.25s ease-out;
}
.btn-primary:hover { background: #3296FF; }
This keeps your markup readable and your styles centralized.
8. Loading styles separately
ZMarkup also handles the .zstyle language. Two entry points:
ZMarkup::loadStyleString(R"(
Button { radius: 8px; }
Button:hover { background: #3296FF; }
)");
ZMarkup::loadStyleFile("assets/game.zstyle");
Both append rules to Theme::get(). Rules accumulate — you can call
loadStyleFile multiple times and they'll all apply.
To reset:
Theme::get().clear();
See ZStyle for the full language reference.
9. Limitations
- No conditionals, no loops. ZMarkup is a static description. Use C++ to generate strings or to build the tree directly.
- No expressions in attributes.
width=calc(...)works becausecalc()is parsed by the value parser, but you can't reference runtime variables. Build the string in C++ if you need dynamic values. - No
textattribute. Use the quoted string form (Text "Hello") rather thanText text="Hello"— the latter is not handled by the factories. - No escaping of
"inside attributes. You can use\"in the quoted form (the parser handles it), but it's easier to avoid. - Comments cannot be nested.
//is line-based; there is no block comment. - Tags must be registered. Unknown tags cause
nullptrto be returned by the factory, and the registry skips the element silently (including its children).
10. Pitfalls
Forgetting .create().
Inside ZMarkup, this is not an issue — the factories call ::create()
correctly. But if you write a custom factory and call
std::make_shared<MyWidget>(), onBuild() won't run. Always use
MyWidget::create(...).
Missing id on bind.
ui.onClick("save", ...) silently does nothing if #save isn't
present. Double-check the id string.
Duplicate ids.
find(id) returns the first match in creation order, which is
depth-first. If you have two #save, the first one built wins.
Setting width on a VStack in ZMarkup, then also in .zstyle.
The inline attribute wins. Move it to .zstyle if you want state rules
to be able to override it.
wrap on Text needs a bounded width.
/* Fine — Text has width, so wrap works */
.card Text { width: 100%; }
/* Broken — Text gets infinite width, wrap never kicks in */
VStack {
Text wrap "long text..."
}
Wrap the Text in a fixed-width parent, or set an inline width.
Number vs string attributes.
value=0.65 is a bare token — the parser stores "0.65" and the
factory uses attrFloat to convert. value="0.65" is equivalent. But
value="Hello, world" with a comma is parsed as-is; the Dropdown
factory splits on commas, so options="A,B,C" creates three options.
options with quotes inside options.
Not supported. If you need options containing commas or quotes, build
the Dropdown in C++ and add it as a child.