Widgets
All built-in widgets live in namespace ZenitUI::UI. Every one of them is
a subclass of Layout, so they all share the common API (see §2) and can
be styled with .zstyle exactly like a plain Layout.
If you know a widget from CSS or another UI framework, most of the naming will feel familiar. Where it doesn't, this document explains the differences.
1. Overview
| Widget | C++ class | Factory helper | ZMarkup tag | Interactive |
|---|---|---|---|---|
| Text / Label | Text |
Label(text) |
Text, Label |
no |
| Button | Button |
Btn(text, cb) |
Button |
yes |
| Panel | Panel |
Pan() |
Panel |
no (blocks) |
| Image | ImageContainer |
— | — | no |
| Toggle | Toggle |
— | Toggle |
yes |
| Checkbox | Checkbox |
— | Checkbox |
yes |
| Slider | Slider |
— | Slider |
yes |
| Progress bar | ProgressBar |
— | ProgressBar |
no |
| Text input | TextInput |
— | TextInput |
yes |
| Scroll view | ScrollView |
— | ScrollView |
yes |
| Dropdown | Dropdown |
— | Dropdown |
yes |
| Canvas (render target) | CanvasLayout |
— | — | — |
| Modal | Modal |
— | — | yes |
| Tooltip | Tooltip |
Tooltip::attach(...) |
— | no |
| Popup / Context menu | Popup |
ContextMenu(items) |
— | yes |
| VStack / HStack | (helpers) | VStack(), HStack() |
VStack, HStack |
— |
All widgets except helpers are created via their ::create(...) factory,
not via std::make_shared. The factory calls onBuild() after
construction, which is where composite widgets (Toggle, Dropdown, …)
attach their internal children.
auto t = Toggle::create(true); // ✅ right
auto t = std::make_shared<Toggle>(); // ❌ onBuild never runs
2. Common API
Every widget inherits from Layout and therefore has:
Fluent helpers
widget->cls("my-class"); // add a CSS class
widget->id("my-id"); // set the node id
widget->size(VW(10), VH(5)); // set width/height
widget->passThrough(true); // click passes through to parent
Class management
Beyond the fluent cls() for build time, Layout exposes a small set
of methods to manage classes at runtime:
node->addClass("highlight"); // no-op if already present
node->removeClass("highlight"); // no-op if absent
node->hasClass("highlight"); // -> bool
node->clearStyleClasses(); // removes all classes
These are the same operations cls() performs, but usable at any point
in the frame — typically from a callback, an onUpdate, or an external
controller.
removeClass is what makes it possible to start and stop a CSS
animation declared under a class, without rebuilding the node:
.pulse { animation: pulse 1s infinite; }
btn->addClass("pulse"); // starts the animation
// ...
btn->removeClass("pulse"); // stops it immediately
addClass is idempotent: calling it twice with the same name does not
create duplicates. This is different from the raw styleClasses vector
(which is exposed through getStyleClasses() as a const& and cannot
be modified directly), and it matches the behavior you'd expect from
CSS class manipulation.
Inline style
widget->getInlineBase().background = Colors::Blue;
widget->getInlineBase().radius = Px(8);
getInlineBase() returns a mutable Style&. Inline styles have the
highest priority in the cascade (they beat any .zstyle rule).
Tree
parent->addChild(widget);
widget->removeFromParent();
widget->children; // std::vector<std::shared_ptr<Layout>>
State
widget->setEnabled(false);
widget->setInteractive(true);
widget->setFocusable(true);
widget->setKeyboardActivates(true);
widget->setChecked(true); // for Toggle, Checkbox
Callbacks
All widgets support:
| Callback | Signature | Fires when |
|---|---|---|
onClick |
void() |
Press + release on the same node. |
onPress |
void() |
Pointer down on the node. |
onRelease |
void() |
Pointer up on the node (regardless of press). |
onHoverEnter |
void() |
Pointer enters the node. |
onHoverExit |
void() |
Pointer leaves the node. |
onRightClick |
void() |
Right-click on the node. |
See Events for bubbling and pointer-capture details.
ZMarkup attributes
Every ZMarkup element accepts the same inline style attributes:
Button "OK" width="100px" height="40px" background="#FF0000" radius="8px"
For the full attribute list, see ZStyle §"Inline attributes".
3. Text / Label
Plain text with wrapping, alignment, and font selection.
C++
auto t = Label("Hello, world!");
t->cls("normal-text");
auto wrapped = Label("A long paragraph that will wrap on multiple lines.");
wrapped->setWrap(true);
wrapped->size(Percent(100), Auto());
auto rich = Text::create("Replaced later");
rich->setText("New content");
rich->setFont(assets.getFont("mont"));
ZMarkup
Text "Hello, world"
Label.normal-text "Hello, world"
Text wrap "A long paragraph that will wrap..."
In ZMarkup, the string literal after the tag is the text content. wrap
can be a bare flag (defaults to true) or wrap=false / wrap=true.
API
| Method | Description |
|---|---|
setText(std::string) |
Replace the text. |
getText() |
Returns the current text as const std::string&. |
setFont(FontHandle) |
Override the resolved font. |
setWrap(bool) |
Enable / disable wrapping. |
getWrap() |
Query the current setting. |
Styling
Text {
color: #FFFFFF;
font-size: 3vh;
letter-spacing: 2px;
font: calibri;
text-align: center; /* start | center | end */
}
Text is not interactive. It has no pseudo-states.
Notes
- Intrinsic size is the measured text box. With
wrap, it depends on the available width — the parent must supply a bounded width, otherwise the text will never wrap. text-alignonly affects the horizontal position of each line inside the node's rect. To center the node itself, use the parent'sitems-h: center.
4. Button
A clickable box with a text child.
C++
auto b = Btn("Click me", []{
std::printf("clicked\n");
});
b->cls("btn-primary");
// Full control — no auto text child
auto raw = Button::create([]{
std::printf("clicked\n");
});
raw->addChild(Label("Custom")->cls("btn-text"));
ZMarkup
Button.btn-primary#go "Click me"
The ZMarkup factory uses Btn(text), so a Text child with class
btn-text is added automatically.
API
Button inherits all the common Layout API. On top of that:
| Method | Description |
|---|---|
text() |
Returns the current label as std::string. Empty if the button has no Text child (e.g. built via Button::create(cb) and never given one). |
setText(const std::string&) |
Replaces the label. If the button has no Text child yet, creates one with class "btn-text" — same class Btn(...) uses. |
Example:
auto b = Btn("Save", []{ save(); });
b->setText("Saving..."); // updates the existing label
std::printf("%s\n", b->text().c_str()); // "Saving..."
// Works on a bare Button too: creates the label on first call.
auto raw = Button::create([]{}); // no text child
raw->setText("New"); // creates a Text child with class "btn-text"
### Styling
```css
Button {
radius: 8px;
padding: 1.5vh 2vw;
transition: background 0.25s ease-out, scale 0.12s ease-out;
}
Button:hover { background: #3296FF; scale: 1.05; }
Button:pressed { background: #505050; scale: 0.95; }
Button:focus { border-color: #FDF900; border-width: 2px; }
Button:disabled{ opacity: 0.4; }
.btn-primary { background: #0079F1; }
.btn-danger { background: #BE2137; }
Button is the style tag. .btn-primary / .btn-danger are classes,
typically applied together.
5. Panel
A plain box. No content, no interaction, just a rectangle you can style.
C++
auto p = Pan();
p->cls("card");
p->addChild(Label("Inside the panel"));
ZMarkup
Panel.card {
Text "Inside the panel"
}
Panel sets setInteractive(false) and setBlocksRaycast(true) — it
won't receive clicks itself, but it will stop them from reaching things
behind it.
Styling
Panel {
background: #232328FF;
radius: 8px;
}
.card {
padding: 3vh;
gap: 2vh;
border-width: 1px;
border-color: #3A3A40;
}
Panel is a Stack — children are overlaid, not flowed. For a flowed
layout, use VStack / HStack or set display: vertical | horizontal
in .zstyle (there's no display property in ZenitUI — see the note
below).
Note: ZenitUI's
LayoutTypeis set at construction, not through a style property. In.zstyleyou cannot changeStack→Vertical. Use the right container from the start, or useVStack()/HStack()directly.
6. ImageContainer
A node that draws a texture scaled to fill its rect.
C++
auto img = ImageContainer::create(assets.getTexture("bubble"));
img->size(Px(200), Px(200));
API
| Method | Description |
|---|---|
setTexture(TextureHandle) |
Change the texture at runtime. |
Styling
The texture is stretched to fill the rect. tint colors the texture:
ImageContainer {
tint: #FF0000FF; /* multiplies the texture by red */
}
There is no ::part and no interaction.
7. Toggle
An on/off switch. Renders a track (the node itself) and a knob (a child
positioned by .zstyle).
C++
auto t = Toggle::create(false);
t->onToggle = [](bool checked){
std::printf("toggle: %d\n", checked);
};
t->setChecked(true); // programmatic
ZMarkup
Toggle#t checked=true
ui.onToggle("t", [](bool v){ /* ... */ });
API
| Method / field | Description |
|---|---|
isChecked |
Public bool, the current state. |
onToggle |
std::function<void(bool)>, fires on user click. |
setChecked(bool) |
Programmatic state change (from Layout). |
getChecked() |
Current state (from Layout). |
Toggle::create(state) sets the initial isChecked.
Internal structure
Toggle::onBuild() creates one child:
- a
Layoutwith classtoggle-knob.
Its geometry (position, size, radius) is set entirely through .zstyle.
The .zstyle must set position: absolute on the knob and use
Toggle:checked .toggle-knob to move it.
Toggle {
width: 10vh;
height: 5vh;
radius: 50ph;
background: #505050;
transition: background 0.15s linear;
}
Toggle:checked { background: #00E430; }
Toggle .toggle-knob {
position: absolute;
width: 60ph;
height: 60ph;
top: 20ph;
left: 20ph;
radius: 50%;
background: #FFFFFF;
transition: left 0.28s ease-out-cubic;
}
Toggle:checked .toggle-knob {
left: calc(100pw - 80ph);
}
The :checked state is a real CSS state — see Events §"State flags".
8. Checkbox
A small box with a checkmark drawn directly (no child nodes).
C++
auto c = Checkbox::create(false);
c->onToggle = [](bool v){ /* ... */ };
ZMarkup
Checkbox#c checked=true
API
Same as Toggle: isChecked, onToggle, setChecked.
Intrinsic size
font-size × 1.4. Override with width / height if you want a
different size.
Styling
Checkbox draws its box and mark using the ::part mechanism:
Checkbox {
background: #505050; /* the box background */
radius: 6px;
transition: background 0.15s linear;
}
Checkbox:hover { background: #606060; }
Checkbox:checked { background: #1574b0; }
Checkbox::box { background: #1574b0; radius: 6px; }
Checkbox::mark { color: #FFFFFF; }
Checkbox:disabled::box { background: #3A3A40; }
Checkbox:disabled::mark { color: #808080; }
::box is the outer rounded rect. ::mark is the small centered square
that appears when checked. Both are painted by renderContent, not by
children.
9. Slider
A horizontal track with a draggable knob. Value is a float in [0, 1].
C++
auto s = std::make_shared<Slider>(0.5f);
s->onValueChanged = [](float v){
std::printf("value = %.2f\n", v);
};
Note: Slider is constructed directly (std::make_shared<Slider>(...)),
not via ::create. There's no onBuild to run.
Actually, you can also use Slider::create(0.5f) if you prefer
consistency — it works because TLayout generates both.
ZMarkup
Slider#s value=0.65 width="55%"
ui.onValueChanged("s", [](float v){ /* ... */ });
API
| Method / field | Description |
|---|---|
getValue() |
Current value in [0, 1]. |
setValue(float v, bool notify = true) |
Sets the value (clamped to [0, 1]). Fires onValueChanged if notify is true and the value actually changed. |
onValueChanged |
std::function<void(float)>. Fires on user input and on setValue when notify is true. |
Example — programmatic value with no callback:
auto s = Slider::create(0.5f);
// Sync from external state without triggering the reaction.
s->setValue(savedVolume, /*notify=*/false);
// Set and react, as if the user had dragged the slider.
s->setValue(0.8f);
setValue does nothing if the new value equals the current one, so calling setValue(s->getValue()) in a loop is a safe no-op. This is what makes it convenient for syncing with an external source of truth without the risk of infinite feedback loops.
Interaction
- Drag: click anywhere on the track and drag. The knob follows the pointer, and pointer capture keeps events flowing even if the cursor leaves the node.
- Keyboard:
Left/Rightmove by0.05. WithShiftheld, the step becomes0.01.
Styling
Slider {
color: #3296FF; /* used by fill and knob if no override */
}
Slider::track { background: #1717cf; radius: 4px; }
Slider::fill { color: #d6ff32; }
Slider::knob { color: #d6ff32; }
Slider:hover::knob { animation: knobGlow 0.6s ease-in-out infinite; }
Slider:pressed::knob { animation: knobPop 0.2s ease-out; }
::trackis the background bar.::fillis the portion from the left to the current value.::knobis the circular handle.
All three are painted by renderContent. ::part rules support state
pseudo-classes (:hover, :pressed, :checked, :focus, :disabled)
— see ZStyle §"::part".
10. ProgressBar
A non-interactive bar showing a value in [0, 1].
C++
auto pb = std::make_shared<ProgressBar>(0.3f);
pb->setValue(0.7f);
ZMarkup
ProgressBar#pb value=0.3 width="55%"
API
| Method | Description |
|---|---|
setValue(float) |
Change the value (clamped to [0, 1]). |
getValue() |
Current value. |
Intrinsic size
120 × max(16, font-size × 0.8).
Styling
ProgressBar {
background: #2A2A32;
color: #69ff32; /* fill color */
radius: 6px;
}
ProgressBar::track { background: #cc1c9d; }
ProgressBar::fill { color: #69FF32; }
::track is the background bar. ::fill is the filled portion.
11. TextInput
A single-line editable text field with a cursor, horizontal scrolling, and keyboard repeat.
C++
auto input = std::make_shared<TextInput>("Initial text");
input->onTextChanged = [](const std::string& s){
std::printf("now: %s\n", s.c_str());
};
input->onSubmit = [](const std::string& s){
std::printf("submitted: %s\n", s.c_str());
};
ZMarkup
TextInput#name value="Player1" width="55%"
ui.onClick("name", ...); // not needed — click focuses automatically
The value attribute sets the initial text.
API
| Method / field | Description |
|---|---|
setText(std::string) |
Replace the content. |
getText() |
Current content. |
clear() |
Empty the input. |
getCursorPos() |
Cursor index in [0, text.size()]. |
setCursorPos(int) |
Sets the cursor. Clamped to [0, text.size()]. |
setFont(FontHandle) |
Override the resolved font. |
onTextChanged |
std::function<void(const std::string&)>, fires on any edit. |
onSubmit |
std::function<void(const std::string&)>, fires on Enter. |
Cursor position
The cursor is an index into the string:
0→ before the first character.text.size()→ after the last character.
setCursorPos(v) clamps v to [0, text.size()], so out-of-range
values are safe. Calling setText with a shorter string also clamps
the current position.
auto input = TextInput::create("Hello world");
input->setCursorPos(0); // before 'H'
input->setCursorPos(100); // clamped to 11 (after 'd')
int p = input->getCursorPos(); // 11
Clicking anywhere in the widget sets the cursor to the character
boundary nearest to the click's x coordinate. The computation is done
by measuring the prefix string, so it accounts for kerning and
letter-spacing correctly. You don't need to call setCursorPos
manually for click-to-position.
Interaction
- Click → focuses the node.
- Typing → inserts characters at the cursor. Only printable ASCII
(
0x20–0x7E) is accepted by default. Backspace/Delete— delete around the cursor, with key repeat.Left/Right— move the cursor, with key repeat.Home/End— jump to start / end.Enter— firesonSubmit, then releases focus.Escape— releases focus without submitting.
Key repeat uses a 0.40s initial delay and 0.04s interval.
Styling
TextInput {
background: #1A1A20;
color: #FFFFFF;
radius: 6px;
padding: 1.2vh 1.5vw;
border-color: #505060;
border-width: 2px;
font-size: 3vh;
transition: border-color 0.2s ease-out;
}
TextInput:focus { border-color: #FDF900; }
TextInput::cursor {
color: #FF6600;
border-width: 3px;
}
TextInput:hover::cursor {
color: #FDF900;
border-width: 10px;
}
::cursor is the blinking caret. Its border-width becomes the caret
thickness; its color becomes the caret color. Blink period is 1s
(half-visible, half-hidden).
12. ScrollView
A Layout with overflow: scroll on both axes by default. Its actual
behavior (clip, scrollbar, inertia, wheel) lives in Layout itself; the
ScrollView class is just a convenience.
C++
auto sv = ScrollView::create(LayoutType::Vertical);
sv->addChild(Label("..."));
sv->addChild(Label("..."));
// many more children...
ZMarkup
ScrollView#sv {
Text "..."
Text "..."
// ...
}
API
ScrollView adds no new methods beyond Layout. All scroll APIs are on
Layout:
| Method | Description |
|---|---|
scrollTo(x, y) |
Absolute scroll position. |
scrollToTop() / scrollToBottom() |
Vertical. |
scrollToLeft() / scrollToRight() |
Horizontal. |
getScrollX() / getScrollY() |
Current offset. |
getMaxScrollX() / getMaxScrollY() |
Maximum offset. |
isScrollContainer() |
True if overflow is not visible on either axis. |
Styling
ScrollView {
overflow: scroll;
background: #1A1A20;
radius: 8px;
border-color: #505060;
border-width: 2px;
padding: 8px;
}
Set overflow-x: hidden / overflow-y: auto to disable one axis or make
it appear only when needed. See Layout §8 Overflow.
Notes
- The container's intrinsic size is
{0, 0}— you must give it an explicit size (or agrow/shrinkcontext) so it can actually scroll. AScrollViewwithheight: autowill never need to scroll. - Children with
position: absoluteinside aScrollVieware not scrolled (they're removed from flow before the scroll offset is applied). Use a normal child if you want it to move with the content.
13. Dropdown
A button that opens a scrollable list of options. The list is a portal — it renders above everything else, ignoring clipping and z-index of the surrounding tree.
C++
auto dd = Dropdown::create(
std::vector<std::string>{"Easy", "Normal", "Hard"},
1 // default selection index
);
dd->onChange = [](int idx, const std::string& name){
std::printf("picked %d: %s\n", idx, name.c_str());
};
ZMarkup
Dropdown#diff options="Facile,Normale,Difficile"
ui.find<Dropdown>("diff")->onChange = [](int i, const std::string& n){ /* ... */ };
API
| Method / field | Description |
|---|---|
getSelected() |
Current index. |
setSelected(int) |
Programmatically change selection. Fires onChange. |
getOpen() / setOpen(bool) |
Control the open state. |
onChange |
std::function<void(int, const std::string&)>. |
Internal structure
Dropdown::onBuild() creates:
- A
Buttonwith classdropdown-trigger— the closed-state face. - A
ScrollViewwithposition: absoluteandsetPortal(true)— the list of options. Each option is aLabelwithpaddingand anonClickthat sets the selection.
The list's position is computed every frame in arrange():
- Default: below the trigger, offset by
4px. - If there isn't enough space below, it flips above.
- If it doesn't fit on either side, it's shrunk to the largest available space.
Closing on outside click is handled in onUpdate. Clicking anywhere
that isn't the trigger or the list closes the dropdown.
Styling
The trigger's visual properties live in inlineDefaults, so a
.zstyle rule can override them freely:
.dropdown-trigger {
height: 48px; /* default: 3vh */
background: #232328;
radius: 6px;
padding: 8px 14px;
border-width: 1px;
border-color: #505060;
font-size: 16px;
}
.dropdown-trigger .btn-text {
font-size: 16px;
}
Dropdown { background: #232328; } /* affects the trigger face */
Only width: 100% is baked into the trigger's inlineBase — it's
what makes the trigger fill its logical parent. To constrain the
dropdown's overall width, wrap it in a fixed-size container:
auto wrapper = std::make_shared<Layout>(LayoutType::Stack);
wrapper->size(Px(180), Auto());
wrapper->addChild(dropdown);
parent->addChild(wrapper);
This is the recommended pattern when placing a dropdown inside an
HStack or any other container that would otherwise let the trigger
stretch to the full row width.
Individual options inherit whatever styling you apply to Label inside
the list. The list itself (ScrollView) can be targeted via
Dropdown ScrollView { ... }.
Notes
- The list is a portal, so it is not clipped by ancestor
overflow: hidden. This is the entire reason portals exist. setOpen(false)when the dropdown is disabled — the implementation closes it automatically inonEnabledChanged.- The trigger's
height,background,radius,border-*,items-h, anditems-vare all overridable via.zstyle. Onlywidth: 100%is fixed. - If the trigger's text is invisible, check the trigger's padding
against its height. If
padding-top + padding-bottomis larger than the trigger's content box, the text gets clipped to zero height.
14. Canvas (render target)
A CanvasLayout renders its subtree into an offscreen render target,
then draws that target as a texture. Useful for effects that need to be
applied to a subtree as a whole, or for caching complex subtrees.
C++
auto canvas = std::make_shared<CanvasLayout>();
canvas->size(Px(400), Px(300));
canvas->addChild(someHeavySubtree);
// Optional: apply a shader to the whole canvas
canvas->setShader(assets.getEffect("hueShift"));
Notes
- The render target is recreated whenever the node's
rectsize changes. - The target is destroyed in the destructor.
supports(Feature::Effects)is required for the shader to be applied; the Raylib backend supports it.CanvasLayoutisStacktype.
There is no ZMarkup tag for Canvas by default. You can register one
yourself via ZMarkup::Registry::reg("Canvas", ...).
15. Modal
A full-screen overlay with a centered content panel and an intro animation.
C++
auto modal = Modal::create();
// modal->children[0] is the content VStack with class "modal-content".
auto content = modal->children[0];
content->addChild(Label("Are you sure?"));
content->addChild(Btn("OK", [modal]{ modal->hide(); }));
// The modal lives at the root, so it covers the whole viewport.
root->addChild(modal);
modal->show();
Note: place the modal as a direct child of the root, not inside
a Vertical / Horizontal container. The modal is width: 100%;
height: 100% and it's designed to overlay the whole viewport, not to
flow inside a layout column.
// ✅ correct
root->addChild(page); // the page content
root->addChild(modal); // overlay, drawn on top of the page
// ❌ wrong — the modal will be pushed by the container's flow
page->addChild(modal);
API
| Method | Description |
|---|---|
show() |
Set opacity to 1, enable input, play "Intro" forward. |
hide() |
Set opacity to 0, disable input, play "Intro" in reverse. |
Structure
Modal::onBuild() creates:
- A full-screen
Stackwith tag"Modal", black background at 0.6 opacity,setFocusScope(true). - One child: a
VStackwith classmodal-content. This is where you put your content.
The "Intro" animation translates the content from VH(100) (bottom)
to 0 using ease-out-back, over 0.4s.
Input handling
The modal is disabled while hidden (setEnabled(false) in
onBuild), and enabled again by show(). This is what makes the
overlay safe:
- While hidden: the modal doesn't participate in hit-testing, so clicks on the page behind it reach their targets normally.
- While shown: the modal intercepts input, as a modal should.
Without this, the full-screen invisible modal would swallow clicks meant for the page underneath.
Styling
Modal { background: #000000CC; }
.modal-content {
width: 60vw;
padding: 4vh;
gap: 2vh;
background: #1E1E24;
radius: 12px;
}
Modal is a focus scope — pressing Tab inside it cycles through its
focusable descendants only, not the whole tree. See
Events §"Focus scopes".
16. Tooltip
A small floating box shown when hovering its owner, after a delay.
C++ — attach to an owner
auto btn = Btn("Save", []{});
Tooltip::attach(btn, "Save your changes", 0.4f);
attach(owner, text, showDelay):
- Creates the tooltip.
- Sets
owneras its anchor. - Hooks into
owner->onHoverEnter/onHoverExitto start / stop the show timer. - Adds the tooltip as a child of owner (for lifetime) but renders it as a portal.
Manual control
auto tip = Tooltip::create("Hello");
tip->setAnchor(someNode);
tip->setShowDelay(0.5f);
someNode->addChild(tip);
tip->show(); // starts the delay timer
tip->hide(); // hides immediately
API
| Method | Description |
|---|---|
attach(owner, text, delay) |
Static. Convenience. |
setAnchor(Layout*) |
Node the tooltip is positioned relative to. |
setShowDelay(float) |
Seconds before the tooltip appears. |
show() / hide() |
Manual control. |
Positioning
The tooltip is centered above its anchor, offset by 6px vertically. If
the anchor has not been measured yet, the tooltip is not positioned.
Styling
Tooltip {
background: #000000E6;
color: #FFFFFF;
padding: 0.6vh 0.8vw;
radius: 4px;
border-color: #606070;
border-width: 1px;
transition: opacity 0.2s ease-out;
}
Tooltips are Panel subclasses with setPortal(true) and
setBlocksRaycast(false). They don't capture input.
17. Popup / ContextMenu
A floating panel that can be opened below, above, or at an arbitrary
screen position. Used for context menus, dropdown menus, and tooltips
that need more interactivity than Tooltip.
C++ — as a menu
auto menu = ContextMenu({
{ "Copy", []{ /* ... */ } },
{ "Paste", []{ /* ... */ } },
{ "Delete",[]{ /* ... */ } },
});
someNode->addChild(menu); // lifetime
someNode->onClick = [menu, someNode]{ menu->openBelow(someNode); };
someNode->onRightClick = [menu, someNode]{ menu->openBelow(someNode); };
ContextMenu(items) returns a Popup with a VStack content and one
Button per item, each with class popup-item. Clicking an item fires
its callback and closes the popup automatically.
C++ — custom content
auto popup = Popup::create();
auto content = VStack();
content->addChild(Label("Custom popup"));
content->addChild(Btn("Close", [popup]{ popup->close(); }));
popup->setContent(content);
parent->addChild(popup);
popup->openAt({100, 100});
API
| Method | Description |
|---|---|
setContent(shared_ptr<Layout>) |
Set (and add) the content node. |
openBelow(anchor) |
Open below a node. |
openAbove(anchor) |
Open above a node. |
openAt(Vec2 screenPos) |
Open at an absolute screen position. |
close() |
Hide the popup. |
isOpen() |
Current state. |
closeOnClickOutside |
bool, default true. |
closeOnEscape |
bool, default true. |
offsetBelow |
float, default 4.0f. |
onClose |
std::function<void()>. |
Positioning and behavior
On every frame while open, the popup:
- Computes its size from the content's measured size + its own padding.
- Computes its
x/ybased on the anchor orpos_. - Flips above if it would overflow the viewport.
- Clamps to the viewport edges.
- Calls
arrangeInto()with the final rect.
It closes on:
- Left click outside (if
closeOnClickOutside). - Right click outside (same).
Escape(ifcloseOnEscape).- An item click (if built via
ContextMenu).
Styling
Popup {
background: #1E1E24F2;
border-color: #505060;
border-width: 1px;
radius: 6px;
padding: 6px;
transition: opacity 0.12s ease-out;
}
.popup-item {
background: transparent;
color: #FFFFFF;
radius: 4px;
padding: 1vh 1.2vw;
transition: background 0.1s linear;
}
.popup-item:hover { background: #3296FF60; }
.popup-item:pressed { background: #3296FFB0; }
Popup is a Panel subclass with setPortal(true). Its opacity is
animated by a state transition; the content can have its own animations.
18. VStack / HStack
Thin helpers that create a Layout of the right type and set the style
tag. They're not classes.
C++
auto col = VStack({ Label("a"), Label("b"), Label("c") });
auto row = HStack({ Btn("A"), Btn("B") });
// Empty, fill later
auto col = VStack();
col->addChild(...);
ZMarkup
VStack {
Text "a"
Text "b"
}
HStack gap="8px" {
Text "x"
Text "y"
}
Styling
VStack and HStack set the style tag to "VStack" / "HStack", so
you can target them:
VStack { gap: 2vh; }
HStack { gap: 1vw; items-v: center; }
The underlying class is Layout; the tag is only for styling and
debugging.
19. Widget composition
The factories Btn, Label, VStack, HStack return the widget
directly. For composite widgets (Toggle, Dropdown, Modal,
Tooltip, Popup), use ::create() so that onBuild() runs.
If you write a custom widget with children, follow the same pattern:
class MyWidget : public TLayout<MyWidget> {
public:
MyWidget(int arg) : TLayout<MyWidget>(LayoutType::Vertical), arg(arg) {}
protected:
void onBuild() override {
addChild(Label("built"));
}
int arg;
};
auto w = MyWidget::create(42); // ✅ onBuild called
See Custom Widgets for a full walkthrough.