Custom Widgets
A widget is a class that derives from Layout (directly or through
TLayout<Derived>) and overrides a set of virtual hooks. Everything
the framework does — layout, styling, animations, input — is driven by
those hooks, so a custom widget only needs to implement the parts it
actually cares about.
This guide walks through building one from scratch, using the built-in widgets as reference.
1. The TLayout CRTP helper
The simplest way to write a widget is to inherit from
TLayout<Derived, Base>:
class MyWidget : public TLayout<MyWidget> {
public:
MyWidget(int arg) : TLayout<MyWidget>(LayoutType::Stack), arg_(arg) {}
protected:
void onBuild() override {
// Called once, after construction, when shared_ptr is valid.
// This is where you add children.
}
private:
int arg_;
};
TLayout<Derived, Base = Layout> provides:
- A
static create(args...)factory that callsmake_shared<Derived>and thenonBuild(). This is the only correct way to construct a widget that has children. - Fluent methods (
cls,id,size,with,passThrough) that returnstd::shared_ptr<Derived>instead ofshared_ptr<Layout>, so chaining preserves the concrete type.
The Base parameter lets you build on top of an existing widget:
class Popup : public TLayout<Popup, Panel> { ... };
class Tooltip: public TLayout<Tooltip, Panel> { ... };
Popup inherits from Panel (which itself is a TLayout<Panel>), and
adds its own behavior. This is how the framework reuses the base
chrome rendering (background, border, radius) without duplicating it.
2. Construction: create() vs make_shared
The constructor of a widget runs before the shared_ptr exists.
Any code that calls shared_from_this() — which includes addChild(),
cls(), size(), capturePointer(), etc. — will throw or return an
invalid pointer inside the constructor.
For this reason, widgets with children must not build their tree in
the constructor. They must use the create() factory:
// ✅ Correct — onBuild runs after the shared_ptr exists
auto t = Toggle::create(false);
// ❌ Wrong — onBuild never runs
auto t = std::make_shared<Toggle>(false);
TLayout::create is defined as:
template <typename... Args>
static std::shared_ptr<Derived> create(Args &&...args) {
auto p = std::make_shared<Derived>(std::forward<Args>(args)...);
static_cast<TLayout<Derived, Base>*>(p.get())->onBuild();
return p;
}
The cast to TLayout<Derived, Base>* is what makes onBuild virtual
across the hierarchy. It's fine because Derived is guaranteed to
inherit from that specialization.
2.1 Safety net
Layout::addChild refuses to run if weak_from_this() is expired. This
catches the mistake of building a tree in the constructor with a
warning instead of a crash:
void addChild(std::shared_ptr<Layout> child) {
if (!child) return;
if (weak_from_this().expired()) {
logWarn("Layout", "", 0, 0,
"addChild called on a node not managed by shared_ptr. "
"Use X::create() and put this logic in onBuild().");
return;
}
// ...
}
The Toggle::onBuild example:
protected:
void onBuild() override {
knob = std::make_shared<Layout>(LayoutType::Stack);
knob->cls("toggle-knob");
knob->setInteractive(false);
knob->setBlocksRaycast(false);
addChild(knob);
}
onBuild runs after construction, so addChild works normally.
2.2 Widgets without children
If your widget has no children (like Slider, ProgressBar,
Checkbox), you can construct it with make_shared or create
indifferently. The framework's convention is still to use create for
consistency:
auto s = Slider::create(0.5f);
auto p = ProgressBar::create(0.3f);
Slider and ProgressBar don't define onBuild — the base no-op runs
and nothing happens. They're "leaf" widgets that render themselves
entirely in renderContent.
3. The virtual hooks
The following virtual methods are the entire extension API. Override only the ones you need.
3.1 onBuild()
virtual void onBuild() {}
Called once, after construction, when the node is managed by a
shared_ptr. This is where you:
- Set the style tag:
setStyleTag("MyWidget"). - Configure the base state:
setInteractive(true),setFocusable(true),setKeyboardActivates(true). - Add default inline styles:
getInlineBase().background = .... - Add children.
Example from Toggle:
void onBuild() override {
setStyleTag("Toggle");
setInteractive(true);
setFocusable(true);
setKeyboardActivates(true);
setChecked(false); // initial state
onClick = [this] {
isChecked = !isChecked;
setChecked(isChecked);
if (onToggle) onToggle(isChecked);
};
knob = std::make_shared<Layout>(LayoutType::Stack);
knob->cls("toggle-knob");
knob->setInteractive(false);
knob->setBlocksRaycast(false);
addChild(knob);
}
3.2 computeIntrinsicSize(availW, availH)
virtual Vec2 computeIntrinsicSize(float availW, float availH) {
return {0, 0};
}
Called during the measure phase, before children are measured. It
should return the widget's "natural" size, given the space it has
available. The default is {0, 0}, meaning "I have no intrinsic size,
just fit my children".
Examples:
// Checkbox: a square based on font-size
Vec2 computeIntrinsicSize(float, float) override {
float fs = style_.currentStyle.fontSize.resolveV(
Metrics::viewport.x, Metrics::viewport.y);
return {fs * 1.4f, fs * 1.4f};
}
// ProgressBar: 120 wide, at least 16 tall
Vec2 computeIntrinsicSize(float, float) override {
float fs = style_.currentStyle.fontSize.resolveV(
Metrics::viewport.x, Metrics::viewport.y);
return {120.0f, std::max(16.0f, fs * 0.8f)};
}
// Text: measured by the renderer
Vec2 computeIntrinsicSize(float availW, float) override {
FontHandle f = resolveFont(style_.currentStyle, defaultFont);
if (wrap_ && availW > 0.0f)
return measureWrapped(availW, f);
return measureSingleLine(f);
}
The returned size is combined with the node's children and the
container's layout rules. For a Stack, it's the max of the two. For
Vertical / Horizontal, it's added to the child sizes along the main
axis.
Important: this method must not call addChild or mutate the
tree. It's called during measure, and the tree is considered stable at
that point.
3.3 renderContent(op, style)
virtual void renderContent(float op, const ComputedStyle& style) {}
Called during the draw phase, after renderChrome (background, border)
and before the children are drawn. This is where the widget draws its
own visual content — text, tracks, marks, cursors, anything that's not
a child node.
void renderContent(float op, const ComputedStyle& style) override {
auto r = UIContext::get().renderer;
// Draw a filled bar
Color fillColor = style.color.withAlpha(op);
Rect fill = {rect.x, rect.y, rect.width * value, rect.height};
r->fillRoundedRect(fill, 4.0f, fillColor);
}
The op parameter is the node's computed opacity multiplied by all
ancestor opacities. Always apply it to any color you draw:
r->fillRect(someRect, myColor.withAlpha(op));
If you forget withAlpha(op), the node's own opacity and any ancestor
opacity will be ignored.
The style parameter is the node's ComputedStyle — the result of the
cascade plus the current CSS animation overlay. Use it to read colors,
sizes, and other properties:
Color c = style.color; // resolved color
float fs = style.fontSize.resolveV(...); // resolved font size in pixels
3.4 renderChrome(op, style)
virtual void renderChrome(float op, const ComputedStyle& style);
Called before renderContent. The base implementation draws:
box-shadow(if any).backgroundcolor.background-texture(withtint).border.
Most widgets don't override this. Only override if you need to
customize how the background is drawn (e.g. a widget with a custom
background texture that shouldn't be affected by the background-texture
property).
Panel and its subclasses use the default renderChrome. Button
uses the default. Slider uses the default — the track and knob are
drawn in renderContent, on top of the background.
3.5 onUpdate(dt)
virtual void onUpdate(float dt) {}
Called once per frame during the update phase, after the state machine has run. Use it for:
- Custom input handling (drag, multi-touch, gestures).
- Local logic that runs every frame.
- Reading input events that aren't covered by the standard callbacks.
Slider uses it for drag:
void onUpdate(float) override {
if (state.focused) {
auto& ev = UIContext::get().inputEvents;
constexpr float STEP = 0.05f;
// ... read keys, update value ...
}
if (state.pressed && !hasPointerCapture())
capturePointer();
if (hasPointerCapture() && rect.width > 0.0f) {
float px = UIContext::get().pointer.pos.x;
float percent = std::clamp((px - rect.x) / rect.width, 0.0f, 1.0f);
if (percent != value) {
value = percent;
if (onValueChanged) onValueChanged(value);
}
}
}
TextInput uses it for keyboard input, cursor blinking, and horizontal
scroll:
void onUpdate(float dt) override {
hasFocusCache = UIContext::get().hasFocus(this);
cursorBlink += dt;
if (cursorBlink > 1.0f) cursorBlink -= 1.0f;
if (!hasFocusCache) { repeatStates.clear(); return; }
// ... process chars, keys, repeat ...
}
When is onUpdate called? The base Layout::update only calls it
if config.enabled || config.updateWhenDisabled. If your widget needs to update
while disabled, call setUpdateWhenDisabled(true).
When is onUpdate NOT called? During a blocksInput animation, or
if the node is disabled and config.updateWhenDisabled is 0.
3.6 onLayout()
virtual void onLayout() {}
Called after arrange has set the node's rect and its children have
been arranged. Use it for things that depend on the final size:
void onLayout() override {
if (!wrap_) return;
float pl = style_.currentStyle.padding.left.resolveH(rect.width, rect.height);
float pr = style_.currentStyle.padding.right.resolveH(rect.width, rect.height);
float newAvailW = std::max(0.0f, rect.width - pl - pr);
if (newAvailW > 0.0f && newAvailW != cachedWrapW) {
wrapText(newAvailW, /* ... */);
cachedWrapW = newAvailW;
}
}
Text uses this to re-wrap when the width changes. Most widgets don't
need it.
Constraint: onLayout must not modify measuredSize. It's called
after measure, and changing the size would invalidate the layout
without triggering a re-measure.
3.7 onEnabledChanged(nowEnabled)
virtual void onEnabledChanged(bool nowEnabled) {}
Called when setEnabled changes the state. Use it for cleanup that
only makes sense while enabled:
void onEnabledChanged(bool nowEnabled) override {
if (!nowEnabled) {
hasFocusCache = false;
repeatStates.clear();
cursorBlink = 0.0f;
}
}
TextInput uses it to clear its keyboard repeat state and cursor blink
when disabled. Dropdown uses it to close the list:
void onEnabledChanged(bool nowEnabled) override {
if (!nowEnabled && isOpen) {
isOpen = false;
applyOpenState();
}
}
3.8 onDescendantFocused(descendant)
virtual void onDescendantFocused(Layout* descendant) {}
Called when a descendant of this node gains focus (via Tab navigation). Useful for containers that want to highlight when any child is focused:
void onDescendantFocused(Layout*) override {
getInlineBase().borderColor = Colors::Yellow;
internal_.pendingTransition = 1;
}
Only Layout::notifyFocusAncestors calls this, and only during Tab
navigation. Clicking to focus a descendant doesn't fire it (the
behavioral difference is a known asymmetry).
4. The CRTP chain: getInlineBase() and cls() inside onBuild
Inside onBuild, you can call anything from Layout:
void onBuild() override {
setStyleTag("MyWidget");
// Inline defaults (can be overridden by CSS)
getInlineDefaults().background = Color{40, 40, 45, 255};
getInlineDefaults().radius = Px(6.0f);
// Inline base (wins over CSS)
getInlineBase().width = Percent(100);
// Fluent (returns shared_ptr<Derived>)
cls("my-widget-class");
// Children
addChild(Label("Hello"));
}
The distinction between inlineDefaults and inlineBase:
inlineDefaults— the "user agent stylesheet" for the widget. It's the lowest-priority source in the cascade, below all CSS rules. Use it for "reasonable defaults the user can override".inlineBase— the "inline style" level. It wins over everything, including:hoverrules. Use it for "the widget's contract" — things that shouldn't be changed by a stylesheet.
Example from Checkbox:
void onBuild() override {
// ...
getInlineBase().background = Colors::DarkGray;
getInlineBase().radius = Px(6.0f);
// ...
}
These are inline base — a user's .zstyle rule for Checkbox { background: ... }
won't override them. The Checkbox::box part can still change the
visual, because renderContent reads the part style, not the base.
Example from Toggle:
void onBuild() override {
setStyleTag("Toggle");
setInteractive(true);
setFocusable(true);
setKeyboardActivates(true);
// No inline base — everything comes from CSS.
}
Toggle has no inline base styles; all its visual comes from the
.zstyle. This is the "flexible" pattern, used when the widget is
meant to be fully styled by the user.
5. Interaction patterns
5.1 Handling clicks
The simplest pattern:
void onBuild() override {
setInteractive(true);
onClick = [this] {
if (onClicked) onClicked();
};
}
std::function<void()> onClicked;
But Layout already has onClick, onPress, onRelease,
onHoverEnter, onHoverExit, onRightClick as public fields. So you
often don't need to do anything — the user sets them directly on the
widget.
Button does exactly this: it inherits the callbacks from Layout and
just sets up the interactive flags:
Button(std::function<void()> cb) {
onClick = std::move(cb);
setInteractive(true);
setFocusable(true);
setKeyboardActivates(true);
setStyleTag("Button");
getInlineBase().itemsH = Align::Center;
getInlineBase().itemsV = Align::Center;
}
5.2 Toggle-style state
For widgets that have a binary state (checked / unchecked), use the
built-in state.checked flag and setChecked:
class MyToggle : public TLayout<MyToggle> {
public:
MyToggle(bool state = false) : TLayout<MyToggle>(LayoutType::Stack) {
setInteractive(true);
setFocusable(true);
setKeyboardActivates(true);
setStyleTag("MyToggle");
setChecked(state);
onClick = [this] {
bool newState = !getChecked();
setChecked(newState);
if (onToggle) onToggle(newState);
};
}
std::function<void(bool)> onToggle;
};
setChecked updates the flag and sets internal_.pendingTransition, which
re-resolves the style on the next update. So a .zstyle rule like
MyToggle:checked { background: green; } will apply automatically.
5.3 Focus-based input
Widgets that handle keyboard input should set setFocusable(true) and
read UIContext::get().inputEvents in onUpdate:
void onUpdate(float dt) override {
if (!UIContext::get().hasFocus(this))
return;
auto& ev = UIContext::get().inputEvents;
for (int c : ev.chars) {
// handle printable char
}
for (int k : ev.keys) {
// handle special key (rising edge)
}
// ev.held contains keys currently down (for repeat behavior)
}
ev.chars contains Unicode codepoints typed this frame.
ev.keys contains special key codes (Key::Enter, Key::Escape,
Key::Left, Key::Right, Key::Home, Key::End, Key::Backspace,
Key::Delete, Key::Tab, Key::Space) pressed this frame.
ev.held contains special keys currently held.
For key repeat, you need to implement it yourself — see TextInput for
a full example using repeatStates.
5.4 Custom mouse handling in onUpdate
Some widgets need to react to clicks that go beyond "something clicked
inside my rect" — a rating's stars, a colour picker's swatches, a
toolbar's buttons drawn in renderContent. In that case, the widget
reads the pointer in onUpdate and decides what to do based on where
the pointer is.
The check that decides whether this widget received the click is
UIContext::get().topmostConsumer == this, not rect.contains(pos).
void onUpdate(float) override {
auto& ctx = UIContext::get();
if (!ctx.pointer.pressed) return;
if (ctx.topmostConsumer != this) return;
// The click is ours. Compute what sub-region it hit.
float localX = ctx.pointer.pos.x - rect.x;
int index = (int)(localX / itemWidth_);
index = std::clamp(index, 0, itemCount_ - 1);
selectIndex(index);
}
topmostConsumer is set by hitTest before update runs, so it's
already up to date when onUpdate is called. It reflects z-order,
portals and blocking rules — every check the framework performs
during hit-testing. A geometric rect.contains() ignores all of that
and can match a node that is not actually on top of the click, or can
match two overlapping nodes in the same frame.
For this to work correctly, the widget must call setBlocksRaycast(true)
in onBuild. Without it, the node is passed through during hit-test
and topmostConsumer will point to an ancestor (or nothing at all),
never to the widget.
void onBuild() override {
setStyleTag("MyWidget");
setInteractive(true);
setBlocksRaycast(true); // required for topmostConsumer to point here
setFocusable(true);
}
5.5 Pointer capture (drag)
For drag-style interactions, use the pointer capture API:
void onUpdate(float) override {
if (state.pressed && !hasPointerCapture())
capturePointer();
if (hasPointerCapture()) {
// The cursor is captured — pointer events are routed to this node
// regardless of position.
Vec2 p = UIContext::get().pointer.pos;
// do something with p
}
}
Capture is released automatically when the mouse button is released.
Call releasePointer() explicitly if you need to end the drag early.
See Slider::onUpdate for a complete example.
5.6 Portal widgets
If your widget floats above the tree (dropdown list, tooltip, popup),
set setPortal(true) in onBuild and compute the rect in onUpdate:
void onBuild() override {
setPortal(true);
setInteractive(false);
setBlocksRaycast(false);
style_.inlineBase.position = Position::Absolute;
style_.inlineBase.opacity = 0.0f;
setEnabled(false);
}
void arrange(Rect) override { /* no-op, we position manually */ }
void onUpdate(float) override {
if (!isOpen) return;
Vec2 s = getMeasuredSize();
rect = {pos_.x, pos_.y, s.x, s.y};
arrangeInto(rect);
}
See Portals for the full pattern.
6. Styling your widget
A widget has a style tag set with setStyleTag("MyWidget"). This is
what MyWidget { ... } selectors in .zstyle match:
MyWidget {
background: #232328;
radius: 8px;
}
If the widget has children with cls("my-part"), they can be targeted
with descendant selectors:
MyWidget .my-part {
color: red;
}
For pseudo-elements drawn in renderContent, use ::part:
void renderContent(float op, const ComputedStyle&) override {
Style trackStyle = partStyle("track");
Style fillStyle = partStyle("fill");
Color trackColor = trackStyle.background.is_set
? trackStyle.background.value
: Color{40, 40, 45, 255};
// draw track
r->fillRect(trackRect, trackColor.withAlpha(op));
// draw fill
Color fillColor = fillStyle.color.is_set
? fillStyle.color.value
: Colors::White;
r->fillRect(fillRect, fillColor.withAlpha(op));
}
Then:
MyWidget::track { background: #1717cf; }
MyWidget::fill { color: #d6ff32; }
partStyle(name) resolves the part's Style from the theme, applying
its own transitions and CSS animations. It's the only correct way to
read a part's properties — do not try to resolve rules manually.
The result of partStyle is a Style with optional fields. Check
is_set before using:
if (style.background.is_set) {
r->fillRect(rect, style.background.value.withAlpha(op));
} else {
r->fillRect(rect, Colors::DarkGray.withAlpha(op));
}
See Slider, Checkbox, ProgressBar, TextInput for examples of
parts in use.
7. Registering with ZMarkup
To use a custom widget in ZMarkup, register a factory:
ZMarkup::Registry reg;
ZMarkup::registerBuiltins(reg); // if you want the defaults too
reg.reg("MyWidget", [](const ZMarkup::Element& e) {
return MyWidget::create(e.attrFloat("value", 0.5f));
});
The factory:
- Receives the parsed
Element. - Returns a
shared_ptr<Layout>(ornullptrto skip). - Doesn't need to handle classes, id, attrs, or children — the registry does that after calling the factory.
For the full registry API, see ZMarkup §6.
Note: the default ZMarkup::build uses a private registry that
already has the built-ins. To add your own tags to that registry, you
need to build the tree yourself using parse + your own registry +
UINode:
auto el = ZMarkup::parse(source);
auto ctx = std::make_shared<ZMarkup::BuildContext>();
ZMarkup::Registry reg;
ZMarkup::registerBuiltins(reg);
reg.reg("MyWidget", ...);
auto root = reg.create(el, *ctx);
ZMarkup::UINode ui(root, ctx);
There's no global way to mutate the private registry used by
ZMarkup::build — this is intentional, to keep the default set stable.
8. Complete example: a Rating widget
Let's build a 5-star rating widget from scratch. It:
- Displays
maxStarsstars. - Shows the current rating (0..maxStars).
- Fills stars up to
ratingwith a "filled" color. - Lets the user click a star to set the rating.
- Exposes
onRatingChanged. - Supports
.zstylethrough a::starpart.
8.1 The header
#pragma once
#include "Common.hpp"
#include "Layout.hpp"
namespace ZenitUI::UI {
class Rating : public TLayout<Rating> {
public:
Rating(int maxStars = 5, int rating = 0)
: TLayout<Rating>(LayoutType::Horizontal),
maxStars_(maxStars),
rating_(std::clamp(rating, 0, maxStars))
{
setStyleTag("Rating");
setInteractive(true);
setFocusable(true);
setKeyboardActivates(false);
getInlineBase().gap = Px(4.0f);
}
int getRating() const { return rating_; }
void setRating(int r) {
int old = rating_;
rating_ = std::clamp(r, 0, maxStars_);
if (rating_ != old) {
internal_.pendingTransition = 1;
if (onRatingChanged) onRatingChanged(rating_);
}
}
std::function<void(int)> onRatingChanged;
protected:
Vec2 computeIntrinsicSize(float, float) override {
float fs = style_.currentStyle.fontSize.resolveV(
Metrics::viewport.x, Metrics::viewport.y);
float starSize = fs * 1.2f;
return {starSize * maxStars_ + 4.0f * (maxStars_ - 1), starSize};
}
void renderContent(float op, const ComputedStyle& style) override {
auto r = UIContext::get().renderer;
if (!r) return;
float fs = style.fontSize.resolveV(Metrics::viewport.x, Metrics::viewport.y);
float starSize = fs * 1.2f;
float gap = 4.0f;
float totalW = starSize * maxStars_ + gap * (maxStars_ - 1);
float startX = rect.x + (rect.width - totalW) * 0.5f;
float centerY = rect.center().y;
Style starStyle = partStyle("star");
Color filledColor = starStyle.color.is_set
? starStyle.color.value
: Colors::Yellow;
Color emptyColor = starStyle.background.is_set
? starStyle.background.value
: Color{80, 80, 90, 255};
for (int i = 0; i < maxStars_; ++i) {
float cx = startX + i * (starSize + gap) + starSize * 0.5f;
Color c = (i < rating_) ? filledColor : emptyColor;
r->fillCircle({cx, centerY}, starSize * 0.5f, c.withAlpha(op));
}
}
void onUpdate(float) override {
// Check for click on a star
if (!UIContext::get().pointer.pressed) return;
if (!rect.contains(UIContext::get().pointer.pos)) return;
float fs = style_.currentStyle.fontSize.resolveV(
Metrics::viewport.x, Metrics::viewport.y);
float starSize = fs * 1.2f;
float gap = 4.0f;
float totalW = starSize * maxStars_ + gap * (maxStars_ - 1);
float startX = rect.x + (rect.width - totalW) * 0.5f;
float localX = UIContext::get().pointer.pos.x - startX;
int index = (int)(localX / (starSize + gap));
index = std::clamp(index, 0, maxStars_ - 1);
setRating(index + 1);
}
private:
int maxStars_;
int rating_;
};
} // namespace ZenitUI::UI
8.2 Usage
auto rating = Rating::create(5, 3);
rating->onRatingChanged = [](int r){
std::printf("rating: %d\n", r);
};
root->addChild(rating);
8.3 Styling
Rating {
font-size: 4vh;
gap: 6px;
}
Rating::star {
color: #FFD700; /* filled star */
background: #505060; /* empty star */
}
Rating:focus::star {
color: #FFAA00; /* focused: warmer gold */
}
8.4 Registering in ZMarkup
reg.reg("Rating", [](const ZMarkup::Element& e) {
return Rating::create(
(int)e.attrFloat("max", 5.0f),
(int)e.attrFloat("value", 0.0f)
);
});
Then:
Rating#stars max=5 value=3
ui.find<Rating>("stars")->onRatingChanged = [](int r){ /* ... */ };
9. Registering with UIComponents.hpp
UIComponents.hpp is the aggregator that pulls in all widget headers:
// Base
#include "components/Layouts.hpp"
#include "components/Text.hpp"
#include "components/Button.hpp"
#include "components/ImageContainer.hpp"
// Controls
#include "components/Toggle.hpp"
#include "components/Checkbox.hpp"
#include "components/Slider.hpp"
#include "components/ProgressBar.hpp"
#include "components/TextInput.hpp"
// Containers
#include "components/ScrollView.hpp"
#include "components/Dropdown.hpp"
#include "components/Canvas.hpp"
#include "components/Modal.hpp"
#include "components/Tooltip.hpp"
#include "components/Popup.hpp"
If your widget lives in components/, add its header here so that
including UI.hpp pulls it in.
If your widget lives in a separate module, you can simply include its
header where needed — the framework doesn't require registration
beyond ZMarkup::Registry (which is separate from C++ includes).
10. Common pitfalls
Calling addChild in the constructor.
Toggle(bool state) {
addChild(knob); // ❌ weak_from_this() is expired
}
Move it to onBuild() and construct with Toggle::create(state).
Using shared_from_this() in the constructor.
Checkbox() {
onClick = [this, sp = shared_from_this()] { ... }; // ❌ throws
}
shared_from_this() is only valid after the shared_ptr exists. Use
[this] and store the callback as a member field instead.
Not applying op to drawn colors.
r->fillRect(rect, myColor); // ❌ ignores node opacity
r->fillRect(rect, myColor.withAlpha(op)); // ✅
Every color you draw must have .withAlpha(op) applied. Otherwise the
node's opacity and all ancestor opacities are silently ignored.
Forgetting setStyleTag.
void onBuild() override {
// no setStyleTag → MyWidget { } rules never match
}
Without a style tag, .zstyle rules that target MyWidget will never
apply. The tag defaults to an empty string, which no rule matches.
Modifying measuredSize in onLayout.
onLayout is called after measure. Changing the size there means the
layout was computed with stale data. If you need the size to depend on
final dimensions (like Text wrapping), cache the result and use it in
the next frame's measure.
Reading part styles outside renderContent.
partStyle(name) triggers a re-tick of the part's animation and
transition. It's designed to be called once per frame per part, inside
renderContent. Calling it from onUpdate or onLayout works but
wastes cycles and can trigger the part's transition in the wrong frame.
Focus and Tab.
If your widget handles keyboard input but isn't focusable, it will
never receive Enter/Space via config.keyboardActivates. Set
setFocusable(true) and, if you want Enter/Space to fire onClick,
setKeyboardActivates(true).
Child widgets should be non-interactive.
knob->setInteractive(false);
knob->setBlocksRaycast(false);
A child used purely for visuals (a knob, a mark, a track) should not
intercept input. Otherwise the parent's click won't reach it when the
child is under the cursor. This is what Toggle::onBuild does for its
knob.
Callbacks that capture this after destruction.
onClick = [this] { ... }; // fine: `this` outlives the node
But:
someExternalNode->onClick = [this] { ... }; // ⚠️ dangling if `this` dies first
If you attach callbacks to external nodes, use a weak_ptr or make
sure the lifetimes are ordered. The Tooltip::attach helper captures a
shared_ptr<Tooltip>, so it's safe. The ContextMenu helper captures
a weak_ptr<Popup> specifically to avoid a cycle. Follow the same
pattern when the target outlives the source.
Widgets that read style_.currentStyle in onBuild.
At onBuild time, style_.currentStyle hasn't been resolved yet — the
first update populates it. If you need the resolved style, override
onLayout or renderContent, or read the inline style you just set.
updateWhenDisabled.
By default, onUpdate is not called when the widget is disabled. If
your widget needs to keep running (e.g. to animate its own disabled
fade-out), call setUpdateWhenDisabled(true).