Theme — API Reference
The global stylesheet: a list of rules, a root style, and a map of
keyframes. Populated by the .zstyle parser, read by StyleResolver
during style resolution.
- Header:
Theme.hpp - Namespace:
ZenitUI
For the cascade and specificity, see
Style System. For the .zstyle
grammar, see ZStyle.
1. Specificity
struct Specificity {
int ids{0};
int classes{0};
int tags{0};
bool operator<(const Specificity& o) const;
bool operator==(const Specificity& o) const;
bool operator!=(const Specificity& o) const;
};
A triple of counters, compared lexicographically: (ids, classes,
tags). So one id beats any number of classes, one class beats any
number of tags.
computeSpecificity(chain)
inline Specificity computeSpecificity(
const std::vector<SimpleSelector>& chain);
Walks a selector chain and accumulates:
Id→ids++.Class→classes++.Tag→tags++.- Each entry in a selector's
extras(the additional components of a compound selector) contributes the same way: a class adds toclasses, an id adds toids, a tag adds totags. - Any state flag on the selector (
:hover,:pressed,:focus,:disabled,:checked) →classes++.
So Button.btn-primary has specificity (0, 1, 1) — one class from
.btn-primary, one tag from Button. Button.btn-primary#save has
(1, 1, 1). A state on the compound counts once, not once per
component:
computeSpecificity({ tag("Button", {class("btn-primary")},
/*requireHover=*/true) });
// → (0, 2, 1): one class from .btn-primary, one class from :hover,
// one tag from Button
2. ThemeRule
struct ThemeRule {
std::vector<SimpleSelector> chain;
std::string part; // "track", "knob", etc. Empty for regular rules.
Style style;
Specificity specificity;
int order{0};
std::optional<MediaQuery> media;
};
One rule from a .zstyle file.
Fields
| Field | Description |
|---|---|
chain |
Descendant selector, right-to-left. chain.back() matches the node itself. |
part |
Non-empty for ::part rules. Empty for regular rules. |
style |
The declarations from the rule's { ... } block. |
specificity |
Computed from chain when the rule is added to the Theme. |
order |
Declaration order (0-based index in the Theme). Used as tie-break. |
media |
Optional media query. If set, the rule only matches when the query is satisfied. |
Chain semantics
A selector like .card Button.btn-primary produces a chain:
[
{ kind: Class, name: "card" },
{ kind: Tag, name: "Button" },
{ kind: Class, name: "btn-primary" }
]
The rightmost entry (btn-primary) matches the node itself. The
earlier entries match ancestors.
A ::part rule's chain is the selector before the :::
Slider:hover::knob { ... }
produces:
chain = [ { Tag, "Slider", requireHover = true } ]
part = "knob"
The part name is stored separately, and chain refers to the widget
node (not the part).
3. Theme
class Theme {
public:
std::vector<ThemeRule> rules;
Style root;
std::unordered_map<std::string, KeyframeAnimation> keyframes;
static Theme& get();
void addRule(ThemeRule r);
void addKeyframes(const KeyframeAnimation& anim);
void clear();
};
A singleton holding the entire stylesheet.
get()
static Theme& get();
Returns the global instance. Thread-safe initialization via C++11 magic statics.
rules
Ordered list of all rules. The index in this vector is the rule's
order. When two rules have the same specificity, the one with the
larger order wins.
Rules are stored in the order they were declared, across all
loadStyleFile / loadStyleString calls. Loading a new stylesheet
appends to the end.
root
The :root { ... } block. Serves three purposes:
- Base style for non-inherited properties. In
ComputedStyle::from, if a property isn't set in the node's style or inherited from the parent, the root's value is used (if set). - Base custom properties. A node's
customPropsstart from the parent's (or the root's, if no parent) and merge with the node's own. - Typography defaults. Since
font,color,letterSpacing, andfontSizeare inherited, setting them on:rootpropagates to every node unless overridden.
Example:
:root {
--primary: #0079F1;
font: calibri;
font-size: 20px;
color: #FFFFFF;
}
keyframes
Map of keyframe name → KeyframeAnimation. Populated by @keyframes
blocks. The animation: shorthand references names in this map.
Duplicate names: the later definition overwrites the earlier one. If
two stylesheets both define pulse, the second wins.
addRule(ThemeRule r)
void Theme::addRule(ThemeRule r) {
r.specificity = computeSpecificity(r.chain);
r.order = (int)rules.size();
rules.push_back(std::move(r));
}
Fills in specificity and order, then appends. The caller doesn't
need to compute these.
addKeyframes(anim)
void Theme::addKeyframes(const KeyframeAnimation& anim) {
keyframes[anim.name] = anim;
}
Stores (or overwrites) a keyframe animation by name.
clear()
void Theme::clear() {
rules.clear();
keyframes.clear();
root = Style{};
}
Removes all rules, keyframes, and resets the root. The Theme is back
to empty.
Used primarily in tests (Env::reset) and for full reloading of the
stylesheet.
4. MediaQuery and MediaCondition
Defined in Media.hpp, but used by ThemeRule.
MediaCondition
struct MediaCondition {
enum class Kind {
MinWidth,
MaxWidth,
MinHeight,
MaxHeight,
OrientationLandscape,
OrientationPortrait,
MinAspectRatio,
MaxAspectRatio
};
Kind kind;
float value{0.0f}; // not used for orientation
};
MediaQuery
struct MediaQuery {
std::vector<MediaCondition> conditions;
bool empty() const { return conditions.empty(); }
};
A list of conditions joined by and. An empty query is always true.
evaluateMedia(q, viewport)
inline bool evaluateMedia(const MediaQuery& q, Vec2 viewport);
Evaluates all conditions against the given viewport. Returns true if all pass.
| Condition | Passes when |
|---|---|
MinWidth |
viewport.x >= value |
MaxWidth |
viewport.x <= value |
MinHeight |
viewport.y >= value |
MaxHeight |
viewport.y <= value |
OrientationLandscape |
viewport.x >= viewport.y |
OrientationPortrait |
viewport.x < viewport.y |
MinAspectRatio |
viewport.x / viewport.y >= value |
MaxAspectRatio |
viewport.x / viewport.y <= value |
The viewport is Metrics::viewport (logical pixels), not the physical
window size.
Example
@media (min-width: 900px) and (orientation: landscape) {
.card { padding: 2vh 2vw; }
}
Produces:
MediaQuery {
conditions: [
{ MinWidth, 900.0 },
{ OrientationLandscape, 0.0 }
]
}
5. How Theme is populated
The theme is populated by the parser in StyleParser.hpp. The
parser reads a .zstyle file (or string) and calls
applyStyleSheet, which:
- For each
:rootdeclaration,applyStyleDeclaration(theme.root, ...). - For each rule, build a
ThemeRule, apply its declarations, and calltheme.addRule(...). - For each
@keyframes, build aKeyframeAnimation(parsing frames, normalizing), and calltheme.addKeyframes(...).
The public entry points are:
// Parse a string
ZMarkup::loadStyleString(src, theme = Theme::get(), fileName = "<string>");
// Parse a file
ZMarkup::loadStyleFile(path, theme = Theme::get());
Both return bool (true on success). Errors (missing file, parse
failures on individual rules) are logged via Logger but don't abort
the whole parse.
Example
ZMarkup::loadStyleFile("assets/game.zstyle");
Reads the file, parses it, and appends every rule, keyframe, and root
declaration to Theme::get().
To load into a custom Theme (for tests):
Theme t;
ZMarkup::loadStyleString(R"(
Button { background: red; }
)", t);
6. Rule ordering and specificity
The cascade uses three criteria, in order:
- Inline styles (from
getInlineBase(), ZMarkup attributes). These win unconditionally, regardless of specificity. - Specificity (higher wins).
- Declaration order (later wins) — only used as a tie-break when specificity is equal.
So a rule with lower specificity but later declaration does not override a rule with higher specificity. Only same-specificity rules tie-break by order.
Example
Button { color: red; } /* (0,0,1), order 0 */
.btn-primary { color: blue; } /* (0,1,0), order 1 */
Button.btn-primary { color: green; }/* (0,1,1), order 2 */
#save { color: yellow; } /* (1,0,0), order 3 */
Applied to <Button class="btn-primary" id="save">:
- All four match.
- Sorted by specificity:
(0,0,1),(0,1,0),(0,1,1),(1,0,0). - The highest
(1,0,0)wins →yellow.
If #save weren't there, (0,1,1) wins → green.
If only the first two were there, (0,1,0) wins → blue, even though
it's declared after Button.
Same specificity, different order
Button { color: red; }
Button { color: blue; }
Both are (0,0,1). The second has higher order, so it wins → blue.
::part rules
::part rules use the same specificity computation on the chain
(which excludes the part name). But they're stored in Theme::rules
alongside normal rules, distinguished by part != "".
The resolveFor method skips them (if (!r.part.empty()) continue;).
The partFor method filters for r.part == partName.
This means a ::part rule and a normal rule with the same chain and
specificity don't compete — they're applied to different targets.
7. Keyframe storage
Theme::keyframes is a map from name to KeyframeAnimation. The map
is populated by the parser's applyStyleSheet:
for (auto& kf : sheet.keyframes) {
KeyframeAnimation anim;
anim.name = kf.name;
for (auto& frame : kf.frames) {
float t = 0.0f;
if (frame.ts == "from") t = 0.0f;
else if (frame.ts == "to") t = 1.0f;
else if (!frame.ts.empty() && frame.ts.back() == '%')
t = std::stof(frame.ts.substr(0, frame.ts.size() - 1)) / 100.0f;
Keyframe k;
k.t = t;
for (auto& d : frame.decls)
applyStyleDeclaration(k.delta, d.prop, d.value, loc);
anim.keyframes.push_back(std::move(k));
}
std::sort(anim.keyframes.begin(), anim.keyframes.end(),
[](const Keyframe& a, const Keyframe& b) { return a.t < b.t; });
normalizeKeyframes(anim, kfLoc);
theme.addKeyframes(anim);
}
Steps:
- Parse each frame's timestamp (
from,to,0%…100%). - Parse each declaration into the frame's
Style delta. - Sort frames by
t. - Normalize (auto-fill missing properties).
- Store in
theme.keyframes[name].
KeyframeAnimation
struct KeyframeAnimation {
std::string name;
std::vector<Keyframe> keyframes;
};
struct Keyframe {
float t{0.0f};
Style delta;
};
Each frame has a normalized time t ∈ [0, 1] and a Style with the
deltas (not the full style — only the properties that change at
that keyframe).
The evaluator (evaluateKeyframes in AnimPrimitives.hpp) interpolates
between the two keyframes surrounding the current time.
Auto-fill
normalizeKeyframes fills missing properties from the nearest
neighbor (previous frame first, then next). A warning is logged for
each auto-filled property per keyframe.
This is a convenience — the animation works without explicit values in every frame — but the warnings nudge you to be explicit.
8. Common tasks
Clearing the theme between tests
Theme::get().clear();
Resets everything. Any node that has already resolved its style with
the old theme keeps its cached style until the next re-resolve (which
happens when the node's internal_.pendingTransition is set — e.g. by a state
change or by beginTransition()).
In tests, use Env::reset() which calls Theme::clear() and resets
the UIContext.
Loading multiple stylesheets
ZMarkup::loadStyleFile("assets/base.zstyle");
ZMarkup::loadStyleFile("assets/game.zstyle");
ZMarkup::loadStyleFile("assets/settings.zstyle");
Rules accumulate. The second file's rules have higher order than the
first's, so at equal specificity, later files win.
There's no unload — to reload, clear() first.
Inspecting rules
auto& theme = Theme::get();
for (const auto& r : theme.rules) {
std::printf("%s spec=(%d,%d,%d)\n",
selectorToString(r).c_str(),
r.specificity.ids,
r.specificity.classes,
r.specificity.tags);
}
Or use Debug::dumpTheme() (see Debugging).
Adding a rule programmatically
ThemeRule rule;
rule.chain = { /* SimpleSelector for "Button" */ };
rule.style.background = Colors::Red;
Theme::get().addRule(std::move(rule));
Rarely needed — the parser handles the normal case. Programmatic rules are useful for generated styles or tests.
Counting keyframes
size_t n = Theme::get().keyframes.size();
9. Pitfalls
Theme is a singleton.
There's only one Theme::get(). Loading a stylesheet always loads into
the global theme (unless you pass a custom one to loadStyleString).
This means tests can pollute each other if they don't clear().
Rules are never removed.
Once added, a rule stays until clear() is called. There's no
removeRule or unloadStylesheet. To reload, clear and re-parse.
order is not re-assigned after removal.
There's no removal, so order is monotonic: 0, 1, 2, ... as rules are
added.
Media queries are evaluated at rule-match time, every frame.
ruleMatches calls evaluateMedia(*r.media, Metrics::viewport). This
happens every time a node's style is resolved. For a node with many
matching rules and many media queries, this is a small per-node cost.
The viewportGeneration counter ensures the style is re-resolved on
viewport change, but there's no caching of "which rules match given a
viewport" — each resolution recomputes.
addRule overwrites specificity and order.
If you set them manually before calling addRule, they're overwritten.
The addRule function is the authority.
Specificity counts :checked as a class.
A selector like Toggle:checked has specificity (0, 1, 1) — one
class-equivalent for the state, one tag for Toggle. This is different
from pure CSS, where :checked has class specificity. It's the same
effect.
Keyframe names are global.
There's no namespacing. A @keyframes pulse in one stylesheet and
another in a second stylesheet: the second overrides the first. If you
need namespacing, prefix names ("game_pulse", "settings_pulse").
Theme::root is not a ThemeRule.
The root style is stored separately. It has no specificity and no order — it's the lowest-priority source for non-inherited properties, and the base for custom properties.
Because :root rules don't compete with other rules, you can't write
:root { background: red; } and expect it to override
Button { background: blue; } — the root's background is only used
for a node that has no other background source.
Media queries don't support or, not, or comma.
Only and between conditions. To express "wide OR tall", use two rules:
@media (min-width: 900px) {
.card { ... }
}
@media (min-height: 900px) {
.card { ... }
}
The rules will both match if both conditions are true, but the CSS cascade handles the duplicate application (last one wins for the same property).
evaluateMedia uses Metrics::viewport, not the window size.
On HiDPI displays where the logical viewport is smaller than the
physical window, the media queries evaluate against the logical size.
This is usually what you want, but it means a @media (min-width:
1920px) won't trigger on a 1920-physical-pixel window if the DPI
scale is 2.0 (logical width would be 960).
Rules with the same specificity and different states.
Button:hover { background: red; }
Button:pressed { background: blue; }
When a button is pressed, both rules match (since state.hovered is
usually true during a press). Specificity is equal: (0, 1, 1) for
both. The order decides — the later rule wins. So if :pressed is
declared after :hover, blue wins during the press.
This is the standard CSS behavior and matches what users expect.
Custom properties in :root don't override inherited values from a
parent.
Custom props inherit. If a parent has --primary: red (set via a
.card { --primary: red; } rule that matched the parent), a child
inside it inherits --primary: red, even if :root has
--primary: blue. Local wins.
To "reset" a custom prop to the root value, you'd need to re-declare it explicitly:
.reset { --primary: var(--primary-from-root); }
Or use a different name.
Theme::keyframes uses std::unordered_map, so iteration order is
unspecified.
Debug::dumpTheme iterates the map for the keyframe summary. The
order of keyframes in the dump is not the order of declaration. If you
need stable iteration, sort the names.
applyStyleSheet doesn't sort rules.
Rules are added in file order (with multiple selectors from a comma
list being added in order). The sorting happens at match time in
resolveFor, which sorts the matching subset. So the Theme::rules
vector itself is not sorted by specificity.
This means Debug::dumpTheme shows rules in declaration order, not
specificity order. If you're debugging specificity, look at the
spec= line for each rule.
Adding rules after the first frame doesn't re-resolve existing nodes.
A node's style is resolved on demand (when internal_.pendingTransition is 1,
or on state change). Adding a new rule to Theme doesn't
retroactively affect nodes. To force a re-resolve, call
beginTransition() on the node, or trigger a state change.
The common pattern is to load all stylesheets before the first frame. Dynamic style changes are rare.
selectorToString for ::part rules shows the part name.
Debug::selectorToString(rule) appends ::part if rule.part is
non-empty. So a ::part rule is printed with its :: suffix, making
it easy to spot in dumpTheme.
The chain order in dumpTheme output is left-to-right.
A .card Button rule is printed as .card Button, matching the source.
But the internal chain vector stores them in the same left-to-right
order — chain[0] = .card, chain[1] = Button. The matching algorithm
uses chain.back() for the node itself.
10. See also
- Style System — how rules are matched and sorted.
- ZStyle — the
.zstylegrammar. - Style — the
Stylestruct that lives inside rules. - Debugging —
dumpThemefor inspection.