Style System
The style system is StyleResolver plus the data structures it operates
on: Style, ComputedStyle, Theme, ThemeRule, SimpleSelector,
and the Theme::keyframes map. This document describes how a node's
final style is computed every frame, how transitions are ticked, how
inheritance propagates, and how ::part styles are resolved
independently.
1. The data model
1.1 Style — the "declared" style
Style is a bag of optional properties. Every field is an
Opt<T> — a T plus a bool is_set. The property list is generated
by the macro BUBBLE_STYLE_PROPS(X):
#define BUBBLE_STYLE_PROPS(X) \
X(Value, width, Value::autoSize()) \
X(Value, height, Value::autoSize()) \
X(float, grow, 0.0f) \
X(Color, background, Colors::Blank) \
X(float, opacity, 1.0f) \
/* ... ~45 properties total ... */ \
X(std::string, font, "")
Style also has:
Opt<std::vector<TransitionSpec>> transitions— the parsedtransition:shorthand.Opt<std::vector<AnimationRef>> animations— the parsedanimation:shorthand.std::unordered_map<std::string, std::string> customProps— custom properties (--name).std::unordered_map<std::string, std::string> unresolvedProps— raw properties that containvar()and couldn't be resolved at parse time.
The macro is used by Style::overlay, stylesDiffer, forEachSetStyleProp,
copyStyleProp, hasStyleProp, propTable, clearStyleProp, and
Debug::styleToString. Adding a property means adding one line to the
macro and everything else picks it up.
overlay(o) copies every property from o that is set:
Style& overlay(const Style& o) {
#define X(T, name, def) if (o.name.is_set) name = o.name;
BUBBLE_STYLE_PROPS(X)
#undef X
if (o.transitions.is_set) transitions = o.transitions;
if (o.animations.is_set) animations = o.animations;
// merge customProps, unresolvedProps
return *this;
}
The macro-based overlay is why the entire cascade is a sequence of
overlay calls — see §3.
1.2 ComputedStyle — the "resolved" style
Same property list, but each field is a concrete T, not Opt<T>. No
is_set flag — everything has a value (the default from
BUBBLE_STYLE_PROPS).
ComputedStyle::from(s, parent, root) converts a Style to a
ComputedStyle using the priority:
- If
s.name.is_set, use it. - Else if the property is inherited and
parentexists, useparent->name. - Else if
roothas the property set, useroot->name. - Else use the default.
Plus the overflow computed-value rule:
if (c.overflowX == Overflow::Visible && c.overflowY != Overflow::Visible)
c.overflowX = Overflow::Auto;
else if (c.overflowY == Overflow::Visible && c.overflowX != Overflow::Visible)
c.overflowY = Overflow::Auto;
And the merge of transitions, animations, and customProps (custom
props inherit from parent by default).
ComputedStyle has operator== and operator!=, both generated from
the macro. They compare every field, which is what drives the
styleChanged check in recomputeDirty.
1.3 Theme — the global stylesheet
Theme is a singleton:
class Theme {
public:
std::vector<ThemeRule> rules;
Style root;
std::unordered_map<std::string, KeyframeAnimation> keyframes;
static Theme& get();
void addRule(ThemeRule r); // computes specificity + order
void addKeyframes(const KeyframeAnimation& anim);
void clear();
};
rules is an ordered list — order of declaration. root is the
:root block, used as a fallback for non-inherited properties and as
the base for custom properties.
1.4 ThemeRule — a single rule
struct ThemeRule {
std::vector<SimpleSelector> chain; // descendant chain
std::string part; // "track", "knob", or empty
Style style;
Specificity specificity;
int order; // declaration order
std::optional<MediaQuery> media; // @media condition
};
chain is right-to-left: chain.back() matches the node itself, and
chain[i] for i < back matches ancestors.
part is non-empty only for ::part rules. Those rules are stored in
the same rules vector, but skipped by the normal cascade and picked
up by partFor.
1.5 SimpleSelector — one component of a selector
struct SimpleSelector {
enum class Kind { Tag, Class, Id };
std::string name;
Kind kind;
bool requireHover{false};
bool requirePressed{false};
bool requireFocus{false};
bool requireDisabled{false};
bool requireChecked{false};
};
A compound selector like Button.btn-primary:hover:pressed becomes one
SimpleSelector with kind = Tag, name = "Button", requireHover =
true, requirePressed = true.
The class check is actually a "contains class" — the node's
styleClasses vector is searched for name.
1.6 Specificity
struct Specificity {
int ids{0}, classes{0}, tags{0};
bool operator<(const Specificity& o) const; // lexicographic
};
Computed from the chain:
Specificity computeSpecificity(const std::vector<SimpleSelector>& chain) {
Specificity s;
for (const auto& sel : chain) {
if (sel.kind == Id) s.ids++;
else if (sel.kind == Class) s.classes++;
else s.tags++;
if (sel.requireHover || sel.requirePressed || sel.requireFocus ||
sel.requireDisabled || sel.requireChecked)
s.classes++;
}
return s;
}
States count as classes. A rule like Toggle:checked .toggle-knob has
specificity (0, 2, 1) — one class (.toggle-knob), one state
(:checked), one tag (Toggle).
2. Selector matching
2.1 nodeMatchesSimple(node, sel)
inline bool nodeMatchesSimple(const Layout* node, const SimpleSelector& ss) {
if (!node) return false;
if (ss.requireHover && !node->isHoveredState()) return false;
if (ss.requirePressed && !node->isPressedState()) return false;
if (ss.requireFocus && !node->isFocusedState()) return false;
if (ss.requireDisabled && !node->isDisabledState()) return false;
if (ss.requireChecked && !node->isCheckedState()) return false;
if (ss.kind == Tag) return node->getStyleTag() == ss.name;
if (ss.kind == Id) return node->nodeId == ss.name;
for (const auto& c : node->getStyleClasses())
if (c == ss.name) return true;
return false;
}
Order matters: state checks come first (cheap), then tag/id/class.
2.2 ruleMatches(rule, node)
inline bool ruleMatches(const ThemeRule& r, const Layout* node) {
if (r.chain.empty() || !node) return false;
if (r.media.has_value() && !evaluateMedia(*r.media, Metrics::viewport))
return false;
if (!nodeMatchesSimple(node, r.chain.back()))
return false;
int i = (int)r.chain.size() - 2;
const Layout* cur = node->getParent().get();
while (i >= 0 && cur) {
if (nodeMatchesSimple(cur, r.chain[i]))
--i;
cur = cur->getParent().get();
}
return i < 0;
}
The algorithm:
- Evaluate the media query, if any. If false, no match.
- Check the rightmost selector against the node.
- Walk up the ancestor chain, trying to match each remaining selector in reverse order. If all are matched, the rule matches.
The ancestor walk is greedy but not backtracking: if a candidate
ancestor matches selector i, we advance i. If it doesn't, we keep
going up. This is the standard CSS descendant combinator semantics (a
descendant is "some ancestor", not "the immediate parent").
There's no backtracking, so a selector like .a .a .b will match
.a .b even with only one .a ancestor, because the first candidate
.a matches the second .a and there's no way to distinguish. This
is the same as CSS: .a .a matches a single .a ancestor.
2.3 Media queries
if (r.media.has_value() && !evaluateMedia(*r.media, Metrics::viewport))
return false;
Metrics::viewport is updated every beginFrame. So a resize changes
the viewport, which changes media evaluation, which changes which rules
match. The viewportGeneration counter ensures nodes re-resolve their
style on viewport change.
3. StyleResolver::resolveFor(node)
This is the main cascade. It builds a ComputedStyle for a node from
scratch.
3.1 Collect matching rules
std::vector<Match> matches;
for (const auto& r : theme.rules) {
if (!r.part.empty()) continue; // skip ::part rules
if (!ruleMatches(r, &node)) continue;
matches.push_back({&r});
}
Skips ::part rules — those are handled by partFor.
3.2 Sort by specificity then order
std::stable_sort(matches.begin(), matches.end(),
[](const Match& a, const Match& b) {
if (a.rule->specificity != b.rule->specificity)
return a.rule->specificity < b.rule->specificity;
return a.rule->order < b.rule->order;
});
Ascending specificity, and within the same specificity, ascending declaration order. This means later rules win — the standard CSS tie-break.
3.3 Build the cascade
Style finalStyle;
finalStyle.overlay(inlineDefaults);
for (const auto& m : matches) finalStyle.overlay(m.rule->style);
finalStyle.overlay(inlineBase);
Four priority levels, each overlaying the previous:
inlineDefaults— the widget's "user agent" defaults.- CSS rules — in specificity order.
inlineBase— the widget's contract, wins over CSS.- State-specific inline styles (see §3.5).
3.4 Custom properties
std::unordered_map<std::string, std::string> allCustomProps;
if (auto p = node.getParent())
allCustomProps = p->getStyle().customProps;
else
allCustomProps = theme.root.customProps;
for (const auto& [k, v] : finalStyle.customProps)
allCustomProps[k] = v;
Custom props inherit from the parent, then merge with the node's own
(winning on conflict). Root has theme.root.customProps as its base.
3.5 Resolve var() references
if (!finalStyle.unresolvedProps.empty()) {
Style resolved = resolveUnresolvedProps(finalStyle.unresolvedProps,
allCustomProps);
finalStyle.overlay(resolved);
finalStyle.unresolvedProps.clear();
}
resolveUnresolvedProps substitutes var(--x) with the value from
allCustomProps, then applies each property via applyStyleAttr. If
the substituted value parses, the property is set; otherwise it's left
unset (with a warning logged by the parser).
The substitution is iterative (up to 10 passes) to handle chained
var() references (--a: var(--b)).
3.6 State inline styles
if (!node.getEnabled())
finalStyle.overlay(inlineDisabled);
else {
if (node.isHoveredState() || node.isPressedState())
finalStyle.overlay(inlineHover);
if (node.isPressedState())
finalStyle.overlay(inlinePressed);
if (node.isFocusedState())
finalStyle.overlay(inlineFocus);
if (node.isCheckedState())
finalStyle.overlay(inlineChecked);
}
The state-specific inline styles are the widget's way of saying "when
in this state, my base style is X". They win over the CSS rules, but
not over the base inlineBase.
Order: Disabled is exclusive (a disabled node doesn't get hover or
pressed). Otherwise Hover (or Pressed, which implies hovered),
then Pressed, then Focus, then Checked.
3.7 Produce the ComputedStyle
const ComputedStyle* parentStyle = nullptr;
if (auto p = node.getParent())
parentStyle = &p->getStyle();
ComputedStyle result = ComputedStyle::from(finalStyle, parentStyle, &theme.root);
result.customProps = std::move(allCustomProps);
return result;
ComputedStyle::from applies the inheritance rules, the overflow
computed-value rule, and the defaults. The result is a fully-resolved
style ready to use.
3.8 When resolveFor runs
Three places:
initStyleIfNeeded— on the first frame, to initializecurrentStyle = targetStyle = transitionStartStyle.beginStateTransition— when the state machine changes state.resolvePendingTransition— wheninternal_.pendingTransitionwas set (some property was changed viagetInlineBase()or similar).measure— ifinternal_.pendingTransitionis still set, to get the target size.
The result is cached in targetStyle. currentStyle only changes when
a transition completes (or immediately, if there's no transition
duration).
4. Transitions
4.1 beginStateTransition
void StyleResolver::beginStateTransition(Layout& node, UIState newState) {
currentState = newState;
transitionTimer = 0.0f;
transitionStartStyle = currentStyle;
targetStyle = resolveFor(node);
node.internal_.pendingTransition = 0;
}
Snapshot the current style, resolve the new target, reset the timer. The
state change is now in progress; tick will interpolate from
transitionStartStyle to targetStyle over the duration.
4.2 resolvePendingTransition
void StyleResolver::resolvePendingTransition(Layout& node) {
ComputedStyle newTarget = resolveFor(node);
node.internal_.pendingTransition = 0;
if (newTarget != targetStyle) {
if (node.anim_.hasActiveImperative()) {
// An imperative animation rewrites the inline style every
// frame, so the target changes every frame too. A CSS
// transition would reset its timer on each of those
// changes and never make progress. The animation itself
// is the interpolation, so we snap currentStyle to the
// target and let the animation drive the visual output.
currentStyle = newTarget;
targetStyle = std::move(newTarget);
transitionStartStyle = currentStyle;
transitionTimer = 1.0f;
} else {
transitionStartStyle = currentStyle;
transitionTimer = 0.0f;
targetStyle = std::move(newTarget);
}
} else {
targetStyle = std::move(newTarget);
if (transitionTimer >= 1.0f) {
currentStyle = targetStyle;
transitionStartStyle = currentStyle;
}
}
}
Similar to beginStateTransition, but only restarts the transition if
the new target differs from the current one. If the target is
unchanged, it just refreshes targetStyle and, if the transition was
already done, snaps currentStyle to it.
When an imperative animation is running
(node.anim_.hasActiveImperative() is true), the resolver skips the
transition entirely and sets currentStyle = newTarget immediately.
This is what allows imperative animations to produce visible motion:
the animation's track setters write into inlineBase, the resolver
re-resolves the target every frame, and currentStyle follows it
directly. Without this branch, every frame would restart the
transition and currentStyle would never reach the target. See
Animation System §6.
4.3 tick
void StyleResolver::tick(Layout&, float dt) {
if (transitionTimer >= 1.0f) return;
float maxDur = targetStyle.transitionTime;
for (const auto& spec : targetStyle.transitions)
maxDur = std::max(maxDur, spec.duration);
if (maxDur <= 0.001f) {
transitionTimer = 1.0f;
currentStyle = targetStyle;
} else {
float elapsed = transitionTimer * maxDur;
transitionTimer += dt / maxDur;
if (transitionTimer >= 1.0f) {
transitionTimer = 1.0f;
currentStyle = targetStyle;
} else {
currentStyle = lerpStyleTimed(transitionStartStyle, targetStyle, elapsed);
}
}
}
The transition runs over the maximum duration of all declared
transitions. Each property interpolates with its own duration / easing
via lerpStyleTimed:
ComputedStyle lerpStyleTimed(const ComputedStyle& a, const ComputedStyle& b,
float realElapsed)
{
ComputedStyle r;
auto timingFor = [&](const char* propName)
-> std::pair<float, TransitionFunction>
{
for (const auto& spec : b.transitions)
if (spec.prop == propName || spec.prop == "all")
return {spec.duration, spec.ease};
return {b.transitionTime, b.ease};
};
#define X(T, name, def) \
{ \
auto [dur, ease] = timingFor(#name); \
float tp = (dur > 0.0f) ? clamp(realElapsed / dur, 0.0f, 1.0f) : 1.0f; \
r.name = lerpProp(a.name, b.name, getRatio(tp, ease)); \
}
BUBBLE_STYLE_PROPS(X)
#undef X
r.transitions = b.transitions;
r.customProps = b.customProps;
return r;
}
Each property is interpolated using its own timing, sampled at
realElapsed (the global elapsed since the transition started). So a
property with duration 0.1s finishes at 0.1s, while another with 0.5s
continues until 0.5s. They all complete when transitionTimer reaches
1.0, which is when realElapsed = maxDur.
4.4 lerpProp variants
Interpolation depends on type:
float—a + (b - a) * t.Color— per-channel.Value— term-by-term on the union of units.autoon either side causes a snap.Spacing— element-wise.- Enums,
ZIndex, strings,TextureRef,FilterReflist,BoxShadow— snap tot > 0 ? b : a.
The snap rule means a property that can't be smoothly interpolated
switches at the midpoint of the transition (since t > 0 becomes
true immediately after realElapsed > 0). Actually, t > 0.0f ? b : a
returns b as soon as t > 0, which is the first tick after the
transition starts. So a snap happens on the first frame of the
transition. This is intentional — better than a late switch.
4.5 Transition and internal_.pendingTransition
The internal_.pendingTransition flag is set by:
getInlineBase()(returns a mutable reference, so the caller will probably modify it).setInlineBase(style).addClass,setId,setSize,setEnabled,setChecked,setPortal,addChild,beginTransition,markInheritanceDirty.Text::setText,TextInput::setText,Slider(viainternal_.pendingTransitiondirectly).
When internal_.pendingTransition is 1 at the start of update, the resolver
re-resolves the target style. If it differs from the current target,
the transition restarts.
The flag is cleared inside resolvePendingTransition and
beginStateTransition.
5. Inheritance
5.1 Inherited properties
Only five properties are inherited:
inline bool isInheritedProp(std::string_view name) {
return name == "font" ||
name == "fontSize" ||
name == "color" ||
name == "letterSpacing" ||
name == "textAlign";
}
ComputedStyle::from consults this function to decide whether to pull
the value from the parent.
5.2 propagateInheritance
void StyleResolver::propagateInheritance(Layout& node) {
bool changed =
lastInherited.hovered != node.isHoveredState() ||
lastInherited.pressed != node.isPressedState() ||
lastInherited.focused != node.isFocusedState() ||
lastInherited.enabled != node.getEnabled() ||
lastInherited.checked != node.isCheckedState() ||
lastInherited.font != currentStyle.font ||
!(lastInherited.fontSize == currentStyle.fontSize) ||
!(lastInherited.color == currentStyle.color) ||
!(lastInherited.letterSpacing == currentStyle.letterSpacing) ||
!(lastInherited.textAlign == currentStyle.textAlign);
if (!changed) return;
for (auto& c : node.children)
c->markInheritanceDirty();
// update lastInherited snapshot
// ...
}
This runs after style_.tick. If any inherited property or state flag
changed since the last frame, each child is marked inheritance-dirty
via markInheritanceDirty:
void markInheritanceDirty() {
internal_.pendingTransition = 1;
internal_.subtreeDirty = 1;
for (auto& c : children)
c->markInheritanceDirty();
}
The child's internal_.pendingTransition flag is set, so its own update will
re-resolve on the next frame (or the same frame, if it hasn't been
processed yet).
5.3 State flag propagation
State flags (hovered, pressed, focused, enabled, checked) are
part of the snapshot, because a change in a parent's state can affect
which rules match a descendant. For example:
.card:hover .card-title { color: green; }
The .card-title node depends on its ancestor's :hover state. When
the card becomes hovered, the title's matching rules change, so its
style must be re-resolved. propagateInheritance triggers this by
marking the subtree dirty.
This is why the snapshot includes state flags — they're not really "inherited" in the CSS sense, but they participate in descendant selectors, so they need the same propagation mechanism.
5.4 Custom properties and inheritance
Custom properties also inherit (they're merged in ComputedStyle::from
via parent->customProps). But the propagation mechanism doesn't
explicitly track them — the change is detected indirectly because any
style change sets internal_.pendingTransition on the node, which re-resolves
its style and, if the custom props changed, produces a different
ComputedStyle, which then triggers propagation on the next frame.
This can cause a one-frame delay in custom property propagation in some cases. In practice it's not observable.
6. ::part styles
6.1 partFor(node, partName)
Style StyleResolver::partFor(Layout& node, const std::string& partName) {
auto& theme = Theme::get();
std::vector<const ThemeRule*> matches;
for (const auto& r : theme.rules) {
if (r.part != partName) continue;
if (!ruleMatches(r, &node)) continue;
matches.push_back(&r);
}
std::stable_sort(matches.begin(), matches.end(),
[](const ThemeRule* a, const ThemeRule* b) {
if (a->specificity != b->specificity)
return a->specificity < b->specificity;
return a->order < b->order;
});
Style target;
for (const auto* r : matches) target.overlay(r->style);
// transition + animation overlay (see below)
return result;
}
Filters rules by part name, matches them against the node (using the
same ruleMatches as the main cascade), and overlays in specificity
order. The result is a Style (not a ComputedStyle) — parts are not
inherited and don't interact with the parent's cascade.
6.2 Part transitions
Each part has its own transition state:
struct PartTransition {
bool initialized{false};
Style target;
Style current;
double startTime{0.0};
float duration{0.0f};
TransitionFunction ease{TransitionFunction::Linear};
bool active{false};
};
std::unordered_map<std::string, PartTransition> partTransitions;
Keyed by part name, so a widget with track, fill, and knob parts
has three independent transition states.
When partFor is called:
- If the part has no state, initialize it (
target = current = target). - If the target differs from last frame's target, start a transition.
- Tick the transition based on
now - startTime. - Interpolate
current → targetusing the transition timer.
The part uses the absolute time from UIContext::get().time
(double), not the incremental dt. This is why parts use tickAbsolute
in Anim — the animation elapsed is now - startTime.
6.3 Part animations
After the transition, Anim::applyCssToPart overlays any CSS keyframe
animation:
node.anim_.applyCssToPart(node, partName, result);
That method:
- Reads
out.animations(the part's declared animations). - Syncs the active animation list against them.
- Ticks each active animation with the absolute time.
- Overlays the current keyframe values onto
result.
So a part style is: static rules + transition interpolation + keyframe overlay.
6.4 When partFor is called
Only from renderContent (or renderChrome) of a widget, via
partStyle(partName):
protected:
Style partStyle(const std::string& partName) {
return style_.partFor(*this, partName);
}
Each call re-resolves the part's style, ticks its transition, and
overlays its animations. This means the part's state advances every
time partStyle is called, which for a well-behaved widget is once
per frame per part.
If a widget calls partStyle("track") twice in the same frame, the
second call sees the same state as the first (the transition tick is
idempotent within a frame — it reads now from UIContext::time,
which doesn't advance during a frame).
If a widget doesn't call partStyle on a frame, its parts don't
tick. A widget that hides a part (e.g. Checkbox::mark when unchecked)
skips the call, so the part's transition doesn't advance — but since
it's not drawn either, nobody notices.
6.5 Parts are not nodes
A ::part is not a Layout. There's no hit-testing, no layout, no
children. It's a styling surface that a widget paints. renderContent
reads the part style and paints accordingly.
This is why Slider::knob doesn't have a rect — its geometry is
computed by renderContent from the node's own rect and the current
value. The part only contributes style (color, radius, animations).
7. stylesDiffer and style comparison
stylesDiffer(a, b) compares two Styles (not ComputedStyles):
inline bool stylesDiffer(const Style& a, const Style& b) {
#define X(T, name, def) \
if (a.name.is_set != b.name.is_set) return true; \
if (a.name.is_set && !(a.name.value == b.name.value)) return true;
BUBBLE_STYLE_PROPS(X)
#undef X
// animations comparison (size + each field)
// ...
return false;
}
Used by partFor to decide whether to start a part transition. It's
stricter than ComputedStyle::operator== because it distinguishes
"unset" from "set to the default" — important for part inheritance,
where "unset" means "fall back to the previous part's value".
ComputedStyle::operator== is used by recomputeDirty to detect
whether the resolved style changed. It compares all fields (which are
always set in ComputedStyle), so it detects value changes but not
"setness" changes — but by that point, setness has been resolved to
concrete values anyway.
8. The internal_.pendingTransition flag
internal_.pendingTransition is a per-node flag that means "something about my
style changed, re-resolve on the next update". It's set by every setter
that could affect the resolved style:
getInlineBase()— returns a mutable reference, so any modification will require a re-resolve.getInlineDefaults()— same.setInlineBase(style)— merges aStyleintoinlineBase.addClass,setId,setStyleTag— changes selectors that match.setSize,setEnabled,setChecked,setPortal— changes state or inline values.addChild,removeFromParent(via the child's flag) — affects the layout.beginTransition— explicit "start a transition from the current style".markInheritanceDirty— propagates from a parent's change.Text::setText,TextInput::setText,Slider::setValue,ProgressBar::setValue— content changes.Dropdown::setSelected— updates the button's text.
The flag is cleared in beginStateTransition and
resolvePendingTransition, both of which run inside update.
The subtlety: getInlineBase() is used by animation track setters:
anim->addTrack<float>(0.0f, 1.0f,
[](Layout* l, float v) { l->getInlineBase().opacity = v; });
Every frame that the animation ticks, the setter calls
getInlineBase(), which sets internal_.pendingTransition = 1. This is
intentional: the animation changes the inline style, so the node needs
to re-resolve. The overhead is small (one re-resolve per frame per
animated node).
tickImperative runs before the style check in update, so within
the same frame the internal_.pendingTransition set by the animation is picked
up by the else if (internal_.pendingTransition) branch:
if (stateChanged) {
style_.beginStateTransition(*this, nextState);
anim_.syncCss(*this);
} else if (internal_.pendingTransition) {
style_.resolvePendingTransition(*this);
anim_.syncCss(*this);
}
resolvePendingTransition sees that an imperative animation is
active and snaps currentStyle to the new target instead of starting
a transition (see §4.2). The result is that the animated value is
visible in the same frame it was written: the animation's setter
runs, internal_.pendingTransition is set, the target is re-resolved, and
currentStyle is updated to match. renderStyle at draw time then
contains the interpolated value, since it's a copy of currentStyle
with the CSS keyframe overlay applied on top.
8.1 Flag lifecycle
Without an imperative animation running:
Frame N, update:
├── tickImperative: no playing animation → nothing happens
├── ...
├── internal_.pendingTransition might be set by a setter called earlier in update
├── resolvePendingTransition runs, if internal_.pendingTransition is 1
│ ├── target differs → start a transition (timer = 0)
│ ├── target same → refresh target, snap if transition done
│ └── clears internal_.pendingTransition
├── style.tick advances the transition (if any)
└── draw uses currentStyle + css overlay
With an imperative animation running:
Frame N, update:
├── tickImperative: writes to inlineBase → sets internal_.pendingTransition
├── ...
├── resolvePendingTransition runs
│ ├── sees hasActiveImperative() == true
│ ├── sets currentStyle = newTarget (snap, no transition)
│ └── clears internal_.pendingTransition
├── style.tick: transitionTimer already 1.0 → no-op
└── draw uses currentStyle (already at the animated value)
The animation continuously sets internal_.pendingTransition and the resolver
continuously clears it, snapping currentStyle to the animated value
on each frame. Once the animation finishes (hasActiveImperative()
becomes false), the next style change goes through the normal
transition path again.
9. Common tasks
9.1 Adding a new property
- Add one line to
BUBBLE_STYLE_PROPS(X):cpp X(float, myProp, 0.0f) - If the property should be interpolated, add a
lerpPropoverload for its type if it doesn't exist.float,Color,Value,Spacingalready have overloads. Enums snap. - If the property should be inherited, add it to
isInheritedProp. - Add the parser case in
applyStyleAttr(StyleAttr.hpp). - If needed, add a
Debug::toStringoverload for the type.
Everything else (overlay, comparison, dump, prop table) updates automatically.
9.2 Adding a new selector feature
- Add a field to
SimpleSelector(e.g.requireActive). - Update
nodeMatchesSimpleto check it. - Update
computeSpecificityif the new feature contributes to specificity. - Update the parser in
StyleParser.hppto recognize it. - Update
Debug::selectorToStringto print it.
The matching and sorting code is feature-agnostic, so no changes there.
9.3 Adding a new state
- Add the flag to
NodeState(likefocused). - Update
InputController::updateFlagsto set it. - Add
requireXtoSimpleSelectorandnodeMatchesSimple. - Update the parser to recognize the pseudo-class.
- Add an
inlineXtoStyleResolverif there's a base style for the state. - Update
resolveForto overlayinlineXwhen the state is active.
10. Pitfalls
Styles are re-resolved on every internal_.pendingTransition.
The resolver does the full cascade (matching all rules, sorting,
overlaying, resolving var()) on every update where the node's
internal_.pendingTransition is 1. For a typical node, this is once at
initialization and then only when the state changes or a property is
set. For an animated node, it's once per frame. Theme::rules is a
linear list — a stylesheet with thousands of rules will make resolution
slow for nodes that change state frequently.
There's no rule indexing by tag or class. If you need to optimize, the
first step is to bucket Theme::rules by tag so resolveFor only
checks the relevant subset.
Specificity is a struct, not a single number.
(ids, classes, tags) is compared lexicographically. This means
(1, 0, 0) beats (0, 999, 999). Correct, matches CSS.
:hover doesn't propagate to ::part by default.
A part has its own state check against the node. So
Slider:hover::knob works because the rule's chain is Slider with
requireHover, and the rule's part is "knob". But
Slider:hover .knob (no ::) would be a descendant selector — a
different thing entirely. Parts use ::, not descendant.
var() in a shorthand.
transition: var(--my-transition);
This isn't supported. transition and animation are parsed as
shorthands directly in the parser, before var() substitution happens.
unresolvedProps handling only applies to applyStyleAttr-style
properties, not the special-cased shorthands. If you need
parameterized transitions, define the whole thing in a rule.
ComputedStyle::from always picks a parent value for inherited props.
If a parent exists, the parent's computed value wins over the root
style and the default. This means you can't "un-inherit" a property —
once the parent has a value, children inherit it unless they set it
themselves. This is CSS behavior.
inlineDefaults is not the same as "the default".
The default is what ComputedStyle::from uses when nothing else is
set. inlineDefaults is an explicit override that the widget applies
via getInlineDefaults(), and it wins over nothing — it's the lowest
priority in the cascade. To use it correctly, put "reasonable defaults
the user should be able to override" there. To use it wrong, put
"contract" values there and expect CSS to override them (it won't, if
the CSS rule has lower specificity).
propagateInheritance doesn't compare all properties.
Only the inherited ones plus the state flags. Custom properties and
transitions / animations are not compared. A change to a custom
property on a parent will propagate via the normal internal_.pendingTransition
mechanism (the parent's style changes, and ComputedStyle::from for
the child reads the parent's customProps), but not via
propagateInheritance. In practice, this is fine because the child
re-resolves anyway when its parent does.
partFor matches against the node, not the part.
A rule like Slider:hover::knob { ... } matches against the Slider
node with :hover required. The part name is just a label. So you
can't have a rule that matches "the knob when the knob is hovered" —
the part has no independent state.
Snap interpolation has no easing.
lerpProp(enum, enum, t) returns t > 0 ? b : a. So an enum
transition switches on the first tick, ignoring the easing. If you want
a "delayed" snap, you'd need a custom lerpProp that samples the
easing function.
Value interpolation with different units can produce weird
intermediates.
lerpProp(Value, Value, t) merges units term-by-term. Px(0) and
Percent(100) interpolated at t = 0.5 becomes {0.5*0 px + 0.5*100 %},
which resolves to 50% at draw time. If the parent width is 200, that's
100px — visually halfway between 0px and 100% at 200px parent, which
is 100px. So it works. But if the parent width changes during the
transition, the intermediate value changes too, which can look jittery.
For clean interpolation, keep both endpoints in the same unit..