Introduction
SableUI is a high-performance C++ UI framework that brings React's component model and Tailwind's styling approach to native applications - without the overhead of web technologies.
class Counter : public BaseComponent
{
public:
void Layout() override
{
const Theme& t = GetTheme();
Div(bg(t.surface0), p(30), centerXY, rounded(10))
{
Text(SableString::Format("Count: %d", count.get()), fontSize(28), mb(20), textWrap(false));
Div(left_right)
{
Button("Increment", [this]() { count.set(count.get() + 1); }, mr(4));
Button("Decrement", [this]() { count.set(count.get() - 1); });
}
}
}
private:
State<int> count{ this, 0 };
};
What is SableUI?
The driving force behind SableUI is to solve a problem with modern UI development. When building an application, web technologies are often preferable, but they come with heavy performance costs and annoying abstraction layers. SableUI brings modern UI development to a lower level with zero runtime overhead.
OUT OF DATE
Core Philosophy
Traditional C++ UI frameworks require verbose code that's often hard to read. Web frameworks like react solved this with declarative components, but at the cost of performance and introduces an abstraction layer between UI and application logic. SableUI attempts to bridge this gap.
Traditional C++ Pseudo-code
auto* button = new Button();
button->setText("Click me");
button->setPosition(10, 10);
button->onClick([&]() { count++; updateLabel(); });
layout->addWidget(button);
// OR
ElementInfo info{};
info.label = "Click me";
info.position = vec2(10, 10);
info.onClick = [&]() { count++; updateLabel(); };
AddButton(info);
These solutions are not reactive, meaning manual re-renders will have to be scripted to update the labels, and with more complex heirachies it becomes impossible to manage.
SableUI:
Div(onClick([this]() { setCount(count + 1); })) {
Text("Click me");
}
Concise and reactive (comparable to react), setCount() marks the element as dirty, and will be automatically rerendered next frame.
Key Features
React-Inspired components
Components describe what should be rendered, not requireing definitions on how to update existing UI. State changes trigger automatic efficient rerenders through reconcilliation with a virtual DOM.
class TodoList : public SableUI::BaseComponent {
void Layout() override {
for (const auto& todo : todos) {
Div(bg(45, 45, 45) p(10) mb(5)) {
Text(todo.text);
}
}
}
private:
// When "todos" changes via "setTodos()", this component will be automatically re-rendered
useState(todos, setTodos, std::vector<Todo>, {});
/* ^^ This syntax is equivilant to reacts:
* const [todos, setTodos] = useState<std::vector<Todo>>({});
* but due to limitations within c++, useState is a macro that defines
* member variables and setters to the component */
};
Learn more about components here and useState() and reactivity here.
Tailwind-Inspired Styling
Chainable modifiers make styling fact and readable, macros ensure expand in the pre-processor, ensuring no runtime-performance loss
Div(
w(200) h(100) // Fixed width and height
bg(45, 45, 45) // Background colour
p(10) m(5) // Padding & margin
rounded(8) // Border radius
centerXY // Center content on both axis
)
Flexibile Panel System
Declare layouts directly in source per-window that the user can or cannot modify with resizable splitters, inspired by foobar2000's ColumnsUI plugin.
HSplitter() {
Panel("Sidebar");
VSplitter() {
Panel("Editor");
Panel("Console");
}
}
Advanced Text Rendering
Full Unicode support including CJK and greyscale emojis, multiple font styles, LCD subpixel rendering, and cached glyph atlases for performance.
TextU32(U"Hello 世界",
fontSize(16)
textColour(255, 255, 255)
justify_center);
Current Status
SableUI is approaching v1.0. and the core features are stable, but no limited component library as of current so use cases are slim
When v1.0. is done, the following features will be fully implmented:
- Vulkan/Metal backends
- Tested linux & macOS support
- Component library (scrollviews, input fields, tab stacks, sliders, etc)
- Expanded documentation
- Shader transpilation across multiple graphics backends
Platform Support
...
Graphics Backends
- OpenGL 3.3+: 98% (small bug)
- Vulkan and Metal: coming soon
Next steps:
Getting Started
Get SableUI runnning in under 5 minutes.
Building from source
NOTE: Building from source is required until v1.0 release
Prerequisites
- C++20 compiler
- CMake 3.15+
- Git
Platform-specific requirements:
- Linux: - Development libraries for OpenGL
- macOS -Development libraries for OpenGL & Xcode Command Line Tools
Installation
Add to Existing CMake Project
Git Submodules only works if
your-projectis initialised with git, if not, you can rungit initbefore continuing or go with option 2. Add SableUI as a submodule to your project:
cd your-project
git submodule add https://github.com/oliwilliams1/SableUI vendor/SableUI
git submodule update --init --recursive
Update your CMakeLists.txt:
# Add SableUI
add_subdirectory(vendor/SableUI)
# Link to your executable
add_executable(MyApp main.cpp)
target_link_libraries(MyApp PRIVATE SableUI)
Example CMakeLists.txt:
cmake_minimum_required(VERSION 3.15)
project(MyApp)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# Add SableUI
add_subdirectory(vendor/SableUI)
add_executable(MyApp main.cpp)
target_link_libraries(MyApp PRIVATE SableUI)
Now you have configured SableUI, you can now create your first application!
Building a Counter
This walks through one small, complete SableUI application, built up piece by piece. By the end it covers application setup, components, layout, styling, reactive state, interaction, composing components, and reacting to time.
Setup
Create main.cpp and include the main SableUI header, along with the (optional) style namespace:
#include <SableUI/SableUI.h>
using namespace SableUI;
using namespace SableUI::Style;
The application lifecycle
Every SableUI application follows the same shape in main():
int main()
{
InitialisePrimaryWindow();
while (WaitEvents())
Render();
Shutdown();
return 0;
}
InitialisePrimaryWindow() creates the window and sets up the renderer. WaitEvents() handles input and returns false once the window should close, so it doubles as the loop condition. Render() draws the current frame. Shutdown() cleans everything up. This shape doesn't change regardless of what the application actually does — application logic lives elsewhere, not in this loop.
Note:
WaitEvents()can be switched out withWaitEventsTimeout(double timeout)orPollEvents()based on application type.WaitEvents()is typically used for general applications that do not require updates to match the refresh rate of a display,PollEvents()loops instantanously, useful for games, andWaitEventsTimeout(double timeout)is a balance between the two, which waits for events or the specified time runs out.
Your first component
A component is a class deriving from BaseComponent that overrides Layout():
class Counter : public BaseComponent
{
public:
void Layout() override
{
Text("Count: 0");
}
};
Layout() is called during rendering and is where the component's contents are declared — including any conditional logic or loops, since it's ordinary C++. Text(...) adds a text element to the layout.
For example, you can have the following code which lays out exactly as it reads, which is a luxery some solutions don't have, and is built right into the core of SableUI.
class Counter : public BaseComponent
{
public:
void Layout() override
{
for (int i = 0; i < 10; i++)
{
if (i % 2 == 0)
{
Text(SableString::Format("Element number: %d", i));
}
}
}
};
But for the sake of this tutorial, we will revert back to the earlier snippet.
Displaying it
Before the main loop, register the component under a name, then add a panel that uses it:
int main()
{
InitialisePrimaryWindow();
RegisterComponent<Counter>("Counter");
Panel("Counter");
while (WaitEvents())
Render();
Shutdown();
return 0;
}
RegisterComponent<T>(string key) makes Counter creatable by string elsewhere in the framework. Panel("Counter") adds a panel to the window's panel tree and attaches an instance of the registered component to it.
Layout and styling
Div(...) { ... } groups elements together and applies styling to the group. It's a scoped construct — the braces aren't decorative, they define which elements are children of that div:
void Layout() override
{
const Theme& t = GetTheme();
Div(bg(t.surface0), p(30), centerXY, rounded(10))
{
Text("Count: 0", fontSize(28), mb(20), textWrap(false));
}
}
Each argument to Div(...) or Text(...) — bg(...), p(30), centerXY, rounded(10), fontSize(28), mb(20) — is a small style value. They can be freely mixed and chained in any order. GetTheme() returns the current theme, so colours can be pulled from it (t.surface0) rather than hardcoded, which keeps components reusable across different themes.
Making the count reactive
To make Count: 0 into a real counter, add a State<int> member:
class Counter : public BaseComponent
{
public:
void Layout() override
{
const Theme& t = GetTheme();
Div(bg(t.surface0), p(30), centerXY, rounded(10))
{
Text(SableString::Format("Count: %d", count.get()), fontSize(28), mb(20), textWrap(false));
}
}
private:
State<int> count{ this, 0 };
};
State<T> takes the owning component (this) and a default value. Unlike a plain member variable, a State<T> survives across rerenders and reconciliation, and calling .set(...) on it automatically marks the component dirty and schedules a rerender — there's no separate step to tell the framework something changed. .get() reads the current value. SableString::Format(...) works like sprintf.
Adding interactivity
Button(label, callback, ...) wires a click to a callback, and takes the same style arguments as Div or Text:
Div(left_right, mb(8))
{
Button("Increment", [this]() { count.set(count.get() + 1); }, mr(4));
Button("Decrement", [this]() { count.set(count.get() - 1); });
}
left_right lays the div's children out horizontally instead of the default vertical stacking. The callback is a lambda capturing this, so it can reach back into the component's own state — clicking the button calls count.set(...), which triggers the same rerender as any other state change.
Composing components
A component can nest another component inside its Layout(). Here's a second component that just displays a string it's given:
class IntervalDisplay : public BaseComponent
{
public:
void Layout() override
{
Text("Child component");
Text(formattedTime, textWrap(false));
}
void SetFormattedTime(const SableString& str)
{
formattedTime = str;
}
private:
SableString formattedTime = ""; // Not state!
};
formattedTime is a plain member, not a State<T>. It doesn't need to be state because it's recomputed and handed down fresh by the parent every time Layout() runs. If it were a state, and improper state would be preserved, as it will initialise the internal state after the component scope in the parent ends.
To use it inside Counter, register it and nest it with ComponentScoped:
ComponentScoped(intervalDisplay, IntervalDisplay, this, bg(t.surface1), rounded(8), p(8))
{
intervalDisplay->SetFormattedTime(
SableString::Format("Seconds since application start: %d", time.get())
);
}
ComponentScoped(name, Type, owner, <optional> styleArgs) { ... } creates a child component of the given type, gives back a pointer (intervalDisplay) usable inside the braces, and attaches it to owner once the block ends. Data flows one way here: Counter calls a setter on IntervalDisplay, and IntervalDisplay has no way to reach back into Counter's state. The optional field: styleArgs is used for applying styling to the childs containing element.
Reacting to time
OnUpdate(const UIUpdateContext&) is a separate override from Layout(), called every frame, for logic that reacts to time or input rather than declaring what's on screen. Combined with an Interval, it can drive state changes on a schedule:
class Counter : public BaseComponent
{
public:
Counter()
{
interval.Start(1000);
}
void OnUpdate(const UIUpdateContext& ctx) override
{
if (interval.IsFired(ctx))
{
time.set(time.get() + 1);
}
}
private:
Interval interval{ this };
State<int> time{ this, 0 };
};
interval.Start(1000) schedules the interval to fire every 1000ms, starting in the constructor. interval.IsFired(ctx) checks whether it fired this frame. Because time.set(...) marks the component dirty the same way count.set(...) did earlier, updating time from OnUpdate triggers a rerender through the exact same path a button click does — there's no separate mechanism to learn for time-driven versus user-driven updates.
Putting it together
#include <SableUI/SableUI.h>
using namespace SableUI;
using namespace SableUI::Style;
class IntervalDisplay : public BaseComponent
{
public:
void Layout() override
{
Text("Child component");
Text(formattedTime, textWrap(false));
}
void SetFormattedTime(const SableString& str)
{
formattedTime = str;
}
private:
SableString formattedTime = ""; // Not state!
};
class Counter : public BaseComponent
{
public:
Counter()
{
interval.Start(1000);
}
void Layout() override
{
const Theme& t = GetTheme();
Div(bg(t.surface0), p(30), centerXY, rounded(10))
{
Text(SableString::Format("Count: %d", count.get()), fontSize(28), mb(20), textWrap(false));
Div(left_right, mb(8))
{
Button("Increment", [this]() { count.set(count.get() + 1); }, mr(4));
Button("Decrement", [this]() { count.set(count.get() - 1); });
}
ComponentScoped(intervalDisplay, IntervalDisplay, this, bg(t.surface1), rounded(8), p(8))
{
intervalDisplay->SetFormattedTime(
SableString::Format("Seconds since application start: %d", time.get())
);
}
}
}
void OnUpdate(const UIUpdateContext& ctx) override
{
if (interval.IsFired(ctx))
{
time.set(time.get() + 1);
}
}
private:
State<int> count{ this, 0 };
Interval interval{ this };
State<int> time{ this, 0 };
};
int main()
{
InitialisePrimaryWindow();
RegisterComponent<Counter>("Counter");
RegisterComponent<IntervalDisplay>("IntervalDisplay");
Panel("Counter");
while (WaitEvents())
Render();
Shutdown();
return 0;
}
Styling Guide
SableUI uses a Tailwind-inspired styling system with chainable modifiers. Styles are applied inline where elements are declared, eliminating the need for verbose styling or separate stylesheet files.
Philosophy
Traditional UI frameworks separate structure and style, but SableUI brings styles directly to your component definition, just like the powerful Tailwind + React stack, without the overhead.
Example:
Div(bg(90, 160, 255), p(12), rounded(8), w(120))
{
Text("Click me", textColour(255, 255, 255), fontSize(16));
}
Core Concepts
Div(...), Text(...), and the other element macros are just function calls, so every modifier inside them is a comma-separated argument, not whitespace-separated syntax — Div(w(200), h(100)) is ordinary C++, not a special parser.
Each modifier (like w(200)) is a small, constexpr property object holding a value and a function pointer that writes it into the right field of an ElementInfo:
Div(w(200), h(100), bg(45, 45, 45), p(10), m(5), rounded(8))
w(200)writes200intoElementInfo::layout.widthbg(45, 45, 45)writesColour(45, 45, 45)intoElementInfo::appearance.bg
PackStyles folds however many of these are passed into a single ElementInfo at compile time via variadic templates, which is why modifiers can be given in any order and there's no runtime cost to the chaining.
You can find a list of the modifiers and definitions in SableUI/styles/styles.h, or in this table here.
Spacing Units
All spacing values are in pixels. There are no relative units (%) currently and isn't planned anytime soon.
Div(w(300), h(200)) // 300px wide, 200px tall
Layout Properties
Width & Height
// Fixed dimensions
Div(w(200), h(100))
// Fill available space (shared evenly across other 'fill' siblings)
Div(w_fill, h_fill)
// Fit content (default for most elements)
Div(w_fit, h_fit)
// Constraints
Div(minW(100), maxW(500), minH(50), maxH(300))
Width/Height Types:
w(value)/h(value)- Fixed size in pixelsw_fill/h_fill- Fill available spacew_fit/h_fit- Fit to content sizeminW/maxW/minH/maxH- Size constraints in pixels
Example:
Div(w_fill, h_fit, minH(200), maxH(600))
{
// Takes full width, height fits content
// but constrained between 200-600px
}
Margin & Padding
// All sides
Div(m(10), p(20))
// Horizontal/Vertical
Div(mx(15), my(10), px(20), py(15))
// Individual sides
Div(mt(5), mr(10), mb(5), ml(10))
Div(pt(5), pr(10), pb(5), pl(10))
Example:
Div(bg(200, 200, 200), m(20), p(15))
{
// 20px margin (transparent space outside)
// 15px padding (gray space inside, before content)
Text("Content");
}
Borders
Border width is separate from corner radius (see Border Radius below) — a bordered element needs both a width and a colour to actually show anything.
// All sides
Div(b(2), borderColour(80, 80, 80))
// Horizontal/Vertical
Div(bx(2), by(1), borderColour(80, 80, 80))
// Individual sides
Div(bt(1), bb(1), borderColour(80, 80, 80))
Example:
Div(b(1), borderColour(60, 60, 60), rounded(8), bg(35, 35, 35))
{
Text("Bordered panel");
}
Layout Direction
Controls how child elements flow
// Vertical (default)
Div(up_down)
{ // Top to bottom
Text("First");
Text("Second");
}
Div(down_up)
{ // Bottom to top
Text("First");
Text("Second");
}
// Horizontal
Div(left_right)
{ // Left to right
Text("First");
Text("Second");
}
Div(right_left)
{ // Right to left
Text("First");
Text("Second");
}
Default: up_down (top to bottom)
Centering
// Center horizontally
Div(centerX)
{
Text("Centered");
}
// Center vertically
Div(centerY)
{
Text("Centered");
}
// Center both axes
Div(centerXY)
{
Text("Centered");
}
Note: Centering applies to the element within its parent, not to the element's children.
Example
Div(w(500), h(500), bg(255, 0, 0))
{
// Rect is centered within its parent
RectElement(w(50), h(50), centerXY, bg(0, 255, 0))
}
Overflow
By default, children that are larger than their parent will overflow it visibly. clipChildren (alias overflow_hidden) clips them to the parent's bounds instead:
Div(w(200), h(100), clipChildren)
{
// Anything drawn outside this 200x100 box gets clipped
}
Colours
The bg (background colour) modifier can use RGB or RGBA values:
Colour Format: bg(r, g, b, a) where:
r,g,b- Red, Green, Blue (0-255)a- Alpha/opacity (0-255, default 255 (opaque))
// Background color
Div(bg(45, 45, 45)) // RGB
Div(bg(45, 45, 45, 200)) // RGBA (with alpha)
The same rules go with the textColour property:
// Text color
Text("Hello", textColour(255, 255, 255))
Text("Faded", textColour(200, 200, 200, 128))
And you can use the rgb/rgba modifier for added flexibility.
Example with inline conditional colours:
// Using rgb/rgba helpers
Div(bg(value == true ? rgb(255, 45, 45) : rgb(45, 255, 45)))
Div(bg(rgba(45, 45, 45, 200)))
bg colours are opaque by default; use inheritBg(true) if you'd rather an element take on its parent's background than set its own. See Theming for how GetTheme() colours like t.surface0 fit into all of this — most real components pull from the active theme rather than hardcoding RGB values.
Border Radius
// All corners
Div(rounded(8))
// Sharp corners
Div(rounded(0))
// Pill shape
Div(w(100), h(40), rounded(20))
Individual corners can be set separately with roundedTL, roundedTR, roundedBL, roundedBR, or a whole edge at once with roundedTop, roundedBottom, roundedLeft, roundedRight:
// Rounded only on top, sharp on bottom — e.g. a dropdown panel
Div(roundedTop(8), roundedBottom(0))
Text Properties
Font Size
Text("Small", fontSize(10))
Text("Normal", fontSize(14))
Text("Large", fontSize(24))
Text("Huge", fontSize(48))
Default: 11px
Line Height
Controls spacing between lines of wrapped text:
Text("Multi-line text that will wrap...",
fontSize(14),
lineHeight(1.5) // 1.5x the font size
)
Default: 1.15 (15% taller than font size)
Text Justification
Text("Left aligned", justify_left)
Text("Centered", justify_center)
Text("Right aligned", justify_right)
Default: justify_left
Text Wrapping
// Wrap text (default)
Text("Long text that will wrap...", maxW(200), textWrap(true))
// No wrapping (minimum size is constrained to one line)
Text("Long text that won't wrap...", maxW(200), textWrap(false))
Text Styling
Use string methods for bold, italic, etc.
Learn more about how SableString functions and how why/how .bold() is implmented/required here.
Example with formatting:
SableString message =
SableString("This is ") +
SableString("super cool").bold() +
SableString(" formatting").italic();
TextU32(message);
Output: This is super cool formatting
This will probably be improved with macros
Absolute Positioning
Div(absolutePos(100, 50))
{
Text("At x:100, y:50");
}
[!WARNING] Absolute positioning can mess up the layout tree significantly. For most use cases that require absolute positioning, it is recomended you use them with a
CustomTargetQueuewhich is seperate to the default element tree. You can find out more about it in Advanced Topics and can view documented implmentations of specific use cases like that require absolute positioning and custom render targets like modals.
Identification
Any element or component macro accepts id(...), which tags it for lookup later with GetElementById:
Div(id("SubmitButton"), bg(t.primary))
{
Text("Submit");
}
This is mostly useful from inside a component's own OnUpdate/OnUpdatePostLayout, for hit-testing against an element it just laid out — see Event Handling and Components for examples.
Component Sizing
size_sm, size_md, size_lg, and size_none don't affect plain elements — they're read by SableUI's built-in components (Button, Checkbox, etc.) to scale their own internal padding and font size. disabled(bool) similarly only has an effect on components that check info.appearance.disabled themselves.
Button("Small button", onClick, size_sm)
Button("Icon button", onClick, size_none, w(24), h(24)) // fully custom sizing
See Components for what each component does with these.
State Management
Every SableUI component can rebuild its Layout() at any point, so any value that needs to survive that rebuild — and any value that should cause a rebuild when it changes — has to be declared as one of SableUI's state types rather than a plain member variable. There are four of them: State<T>, Ref<T>, Timer, and Interval.
All four are declared as members and constructed with the owning component as their first argument, which registers them so the framework can carry their value across reconciliation:
class MyComponent : public BaseComponent
{
private:
State<int> count{ this, 0 };
};
A plain member variable (no state wrapper) is still completely valid — it's the right choice for anything recomputed fresh every Layout() call, such as a value handed down from a parent. See the formattedTime member in Your First Application for that pattern. State types exist for the values that don't fit that description.
State<T>
State<T> is the one you'll reach for by default — a value the component owns, that persists across rerenders, and that triggers a rerender when it changes.
State<int> count{ this, 0 };
Read it with .get(), and change it with .set(...):
Text(SableString::Format("Count: %d", count.get()));
// ...
count.set(count.get() + 1);
.set(...) checks the new value against the current one first — if they're equal, nothing happens; if they differ, the value is updated and the owning component is marked dirty for a rerender. This means calling .set() with an unchanged value is cheap and safe to do unconditionally, and it's also why T has to support operator== — State<T> won't compile for a type that can't be compared.
State<T> also supports assignment and implicit conversion, so count = count + 1; and int c = count; both work, though .get()/.set() are the clearer choice in most code.
Ref<T>
Ref<T> looks almost identical to State<T> — same constructor shape, same registration with the owner — but .set(...) does not mark the component dirty. It exists for values that need to survive across rerenders (so they can't just be a plain member) but shouldn't themselves cause a rerender when they change.
The clearest real example is a stored callback. Checkbox keeps the caller's onChange handler in a Ref, not a State:
Ref<std::function<void(bool)>> onChangeCallback{ this, nullptr };
Wrapping that in State<T> instead would be actively wrong here — std::function doesn't have operator==, so it wouldn't compile, and even if it did, reassigning a callback is not something that should trigger a visual rerender on its own.
As a rule: if changing the value should update what's on screen, use State<T>. If it just needs to persist and doesn't affect layout directly, use Ref<T>.
Timer and Interval
Both Timer and Interval hook into the background event scheduler rather than driving a value directly — they're for scheduling when something happens, and you still update a State<T> yourself once it does.
Timer fires once, after a delay:
Timer saveDelay{ this };
// ...
saveDelay.Start(2000); // fires once, 2000ms from now
Interval fires repeatedly, on a fixed period:
Interval tick{ this };
// ...
tick.Start(1000); // fires every 1000ms
Both are checked from OnUpdate(). Interval has a convenience method for this:
void OnUpdate(const UIUpdateContext& ctx) override
{
if (tick.IsFired(ctx))
{
seconds.set(seconds.get() + 1);
}
}
Timer doesn't expose its own IsFired, so check it against the input context directly using its handle — this form also works for Interval, and is exactly what Interval::IsFired(ctx) does internally:
void OnUpdate(const UIUpdateContext& ctx) override
{
if (ctx.input.IsFired(saveDelay.GetHandle()))
{
DoSave();
}
}
Both also expose .Stop() and .Reset() — Reset() restarts the countdown/period from now without needing to call .Start() again with the same duration, which is how TextFieldComponent keeps its cursor blinking on a steady rhythm restarted every time a key is pressed, rather than drifting.
Event Handling
Add interactivity with onClick, OnUpdate, and input state
Components
Button, Checkbox, TextField, DatePicker, and Calendar
Examples
See state management in real applications
Style Modifiers Reference
Sizing
| Macro | Description | Example |
|---|---|---|
w(n) | Fixed width | w(200) |
h(n) | Fixed height | h(100) |
w_fill | Fill parent width | w_fill |
h_fill | Fill parent height | h_fill |
w_fit | Fit content width | w_fit |
h_fit | Fit content height | h_fit |
minW(n) | Minimum width | minW(100) |
maxW(n) | Maximum width | maxW(500) |
minH(n) | Minimum height | minH(50) |
maxH(n) | Maximum height | maxH(300) |
Further reference for sizing styling
Spacing
| Macro | Description | Example |
|---|---|---|
m(n) | Margin (all sides) | m(10) |
mx(n) | Margin horizontal | mx(15) |
my(n) | Margin vertical | my(10) |
mt/mr/mb/ml(n) | Individual margins | mt(5) |
p(n) | Padding (all sides) | p(20) |
px(n) | Padding horizontal | px(15) |
py(n) | Padding vertical | py(10) |
pt/pr/pb/pl(n) | Individual padding | pt(5) |
Further reference for margin and padding styling
Borders
| Macro | Description | Example |
|---|---|---|
b(n) | Border width (all sides) | b(2) |
bx(n) | Border width horizontal | bx(2) |
by(n) | Border width vertical | by(2) |
bt/bb/bl/br(n) | Individual border widths | bt(1) |
borderColour(r,g,b) | Border colour | borderColour(80,80,80) |
borderColour(r,g,b,a) | Border colour with alpha | borderColour(80,80,80,180) |
This is border width — for corner radius, see Visual below.
Further reference for border styling
Colors
| Macro | Description | Example |
|---|---|---|
bg(r,g,b) | Background color | bg(45,45,45) |
bg(r,g,b,a) | Background with alpha | bg(45,45,45,200) |
textColour(r,g,b) | Text color | textColour(255,255,255) |
rgb(r,g,b) | Color helper | bg(rgb(45,45,45)) |
rgba(r,g,b,a) | RGBA helper | bg(rgba(45,45,45,200)) |
inheritBg(bool) | Inherit background from parent instead of the theme default | inheritBg(false) |
Further reference for colour styling, and Theming for where these colours can come from
Layout
| Macro | Description | Example |
|---|---|---|
up_down | Children top→bottom | up_down |
down_up | Children bottom→top | down_up |
left_right | Children left→right | left_right |
right_left | Children right→left | right_left |
centerX | Center horizontally | centerX |
centerY | Center vertically | centerY |
centerXY | Center both axes | centerXY |
clipChildren | Clip children that overflow this element (alias: overflow_hidden) | clipChildren |
Further reference for layout directions and centering
Text
| Macro | Description | Example |
|---|---|---|
fontSize(n) | Font size in pixels | fontSize(16) |
lineHeight(n) | Line height multiplier | lineHeight(1.5) |
justify_left | Align text left | justify_left |
justify_center | Center text | justify_center |
justify_right | Align text right | justify_right |
textWrap(bool) | Enable/disable wrapping | textWrap(false) |
Further reference for text-based properties
Visual
| Macro | Description | Example |
|---|---|---|
rounded(n) | Border radius, all corners | rounded(8) |
roundedTL/TR/BL/BR(n) | Radius on a single corner | roundedTL(8) |
roundedTop/Bottom/Left/Right(n) | Radius on both corners of one edge | roundedTop(8) |
absolutePos(x,y) | Absolute position | absolutePos(100,50) |
[!WARNING] Absolute positioning can mess up the layout tree significantly. For most use cases that require absolute positioning, it is recomended you use them with a
CustomTargetQueuewhich is seperate to the default element tree. You can find out more about it in Advanced Topics and can view documented implmentations of specific use cases like that require absolute positioning and custom render targets like modals.
Identification
| Macro | Description | Example |
|---|---|---|
id(str) | Assign an id to an element, for lookup later via GetElementById | id("Submit") |
Component Sizing
These only affect SableUI's built-in components (Button, Checkbox, etc.) — they have no effect on a plain Div, Text, or Rect.
| Macro | Description | Example |
|---|---|---|
size_sm | Small component sizing | size_sm |
size_md | Medium component sizing (default) | size_md |
size_lg | Large component sizing | size_lg |
size_none | Disable automatic sizing/padding entirely | size_none |
disabled(bool) | Disable a component | disabled(true) |
Further reference for how each built-in component uses these
Event Guide
SableUI provides a safe event system that allows you to create interactive components with mouse, keyboard, and scroll input. Simple element events can be attached directly in the Layout() phase using inline callbacks; anything more involved — held keys, drag state, hit-testing — goes through the OnUpdate() method instead.
Inline Events
SableUI provides a small set of callbacks that attach directly to an element, for the common cases where you just need "something happened to this specific element."
Everything else — scrolling, all keyboard events, held/dragged state, and hover — is read from the
UIUpdateContextinOnUpdate(), covered below.
onClick
Triggered when the left mouse button is clicked on an element.
Div(onClick([this]() {
count.set(count.get() + 1);
SableUI_Log("Clicked! Count: %d", count.get());
}))
{
Text(SableString::Format("Click me, num clicks: %d", count.get()),
textColour(255, 255, 255));
}
onSecondaryClick
Triggered when the right mouse button is clicked on an element.
Div(onSecondaryClick([this]() {
SableUI_Info("Right clicked");
}))
{
Text("Right-click me", textColour(200, 200, 200));
}
Commonly paired with a context menu shown as a floating panel positioned at the click location.
onDoubleClick
Triggered when an element is clicked twice within a short window.
Note: The double-click timing window is 300ms, and clicks must be within 5 pixels of each other to register as a double-click. These thresholds are constants on the
Windowclass inwindow.h— they aren't currently exposed as something an application can configure per-instance.
[!WARNING] State lambdas can be dangerous and cause problems if used incorrectly — reference-capturing lambdas (
[&]) can be unstable if the referenced variable goes out of scope before the callback fires. The best practice is to capturethisand other arguments by value, for example:onClick([this, otherVar1, otherVar2]() {});.
Hover
There's no onHover/onHoverExit inline callback yet — ElementInfo doesn't carry hover callbacks the way it does onClickFunc. Until that lands, hover has to be computed manually in OnUpdate() by hit-testing the mouse position against an element's rect, the same way ButtonComponent tracks its own pressed state internally:
void OnUpdate(const UIUpdateContext& ctx) override
{
Element* root = GetRootElement();
if (!root) return;
bool hovered = RectBoundingBox(root->rect, ctx.input.mousePos, ctx.input.obscurers, ctx.zIndex);
isHovered.set(hovered);
}
Keyboard Input
Keyboard events are global rather than element-specific, so there's no onKeyPress(...) element callback — instead, override OnUpdate() and read key state off the input context directly. If a keyboard shortcut should only fire while the component is hovered, pair it with the same RectBoundingBox hit-test shown above.
Accessing the Event Context
Override OnUpdate() in your component to reach keyboard, mouse, and timer state:
class MyComponent : public SableUI::BaseComponent {
public:
void Layout() override {
// Your UI layout here
}
void OnUpdate(const UIUpdateContext& ctx) override {
// ctx.input is the UIInputState for this frame
// ctx.zIndex is this component's current z-index
}
};
Key Constants
SableUI provides constexpr constants for all keyboard keys, borrowed from GLFW (the window manager) for easy translation. These constants follow the pattern SABLE_KEY_*:
A list of these keys can be grabbed from
events.h
Key State Queries
ctx.input provides three ways to query key states, each a std::bitset<SABLE_MAX_KEYS>:
isKeyDown
true every frame while the key is held down.
void OnUpdate(const UIUpdateContext& ctx) override {
if (ctx.input.isKeyDown.test(SABLE_KEY_W))
{
// Move forward continuously
posY.set(posY.get() - speed * ctx.input.deltaTime);
}
if (ctx.input.isKeyDown.test(SABLE_KEY_S))
{
// Move backward continuously
posY.set(posY.get() + speed * ctx.input.deltaTime);
}
}
keyPressedEvent
true only on the frame a key is pressed. Use for single actions.
void OnUpdate(const UIUpdateContext& ctx) override {
if (ctx.input.keyPressedEvent.test(SABLE_KEY_SPACE))
{
// Toggle state once per press
isPaused.set(!isPaused.get());
}
}
keyReleasedEvent
true only on the frame a key is released.
void OnUpdate(const UIUpdateContext& ctx) override {
if (ctx.input.keyReleasedEvent.test(SABLE_KEY_LEFT_SHIFT))
{
// Stop running when shift is released
isRunning.set(false);
}
}
Modifier Keys
These tests can be combined to build key combination events.
void OnUpdate(const UIUpdateContext& ctx) override {
bool ctrlPressed = ctx.input.isKeyDown.test(SABLE_KEY_LEFT_CONTROL) ||
ctx.input.isKeyDown.test(SABLE_KEY_RIGHT_CONTROL);
// Ctrl+S for save
if (ctrlPressed && ctx.input.keyPressedEvent.test(SABLE_KEY_S))
{
Save();
}
}
Text input itself — actual typed characters, as opposed to individual key presses — comes through ctx.input.typedCharBuffer, a std::vector<unsigned int> of codepoints typed this frame. See TextFieldComponent in Components for a full example handling typed input, selection, and clipboard together.
Mouse Position and Scrolling
ctx.input also carries mouse position and scroll information:
Mouse Position
void OnUpdate(const UIUpdateContext& ctx) override {
int mouseX = ctx.input.mousePos.x;
int mouseY = ctx.input.mousePos.y;
// Mouse delta since last frame
int deltaX = ctx.input.mouseDelta.x;
int deltaY = ctx.input.mouseDelta.y;
SableUI_Log("Mouse pos: %dx%d, mouse delta: %dx%d",
mouseX, mouseY, deltaX, deltaY);
}
Scrolling
void OnUpdate(const UIUpdateContext& ctx) override {
float scrollX = ctx.input.scrollDelta.x;
float scrollY = ctx.input.scrollDelta.y;
if (scrollY != 0.0f)
{
// Zoom in/out based on scroll
zoomLevel.set(zoomLevel.get() + scrollY * 0.1f);
}
}
Mouse Button State
Mouse buttons are queried the same way as keyboard keys, against SABLE_MOUSE_BUTTON_* constants:
void OnUpdate(const UIUpdateContext& ctx) override {
// Check if left mouse button is held down
if (ctx.input.mouseDown.test(SABLE_MOUSE_BUTTON_LEFT))
{
// Drag operation
dragX.set(dragX.get() + ctx.input.mouseDelta.x);
dragY.set(dragY.get() + ctx.input.mouseDelta.y);
}
// Check for mouse button press
if (ctx.input.mousePressed.test(SABLE_MOUSE_BUTTON_LEFT))
{
isDragging.set(true);
}
// Check for mouse button release
if (ctx.input.mouseReleased.test(SABLE_MOUSE_BUTTON_LEFT))
{
isDragging.set(false);
}
}
There's also mouseDoubleClicked, tested the same way, if onDoubleClick on a specific element isn't granular enough for what you need.
Delta Time
ctx.input.deltaTime gives frame-independent timing for animation and movement:
void OnUpdate(const UIUpdateContext& ctx) override {
if (ctx.input.isKeyDown.test(SABLE_KEY_RIGHT))
{
// Move at constant speed regardless of frame rate
posX.set(posX.get() + speed * ctx.input.deltaTime);
}
}
deltaTime is in seconds, so if speed = 100.0f, the object moves at 100 pixels per second.
Timers
For anything on a schedule rather than tied to a specific input — a blinking cursor, a polling interval, a delayed action — use Timer or Interval instead of checking deltaTime by hand. See State Management for how those work and how they interact with OnUpdate.
State Management
State<T>, Ref<T>, Timer, and Interval for reactive components
Components
Button, Checkbox, TextField, DatePicker, and Calendar
Examples
See event handling in real applications
Theming
Most of SableUI's built-in components, and most of the styling examples elsewhere in these docs, pull their colours from the active theme rather than hardcoding RGB values:
const Theme& t = GetTheme();
Div(bg(t.surface0), rounded(10))
{
Text("Themed panel", textColour(t.text));
}
GetTheme() is a free function that returns the currently active Theme — a plain struct of Colour fields, so t.surface0 is just a colour value like any you'd pass to bg(...) directly.
Theme Structure
A Theme is organised into a few groups:
Background layers — base, mantle, crust — for the window background and progressively darker/recessed panels.
Surfaces — surface0, surface1, surface2 — for elements that sit visually above the background, each more "elevated" than the last.
Overlays — overlay0, overlay1, overlay2 — for borders, dividers, and other low-emphasis chrome, darkest to lightest.
Text — subtext0 and subtext1 for muted/secondary text, text for primary text — plus subtext0Contrast, subtext1Contrast, and textContrast, the same three but chosen to stay legible on top of an accent colour rather than the theme's background.
Accent palette — rosewater, flamingo, pink, mauve, red, maroon, peach, yellow, green, teal, sky, sapphire, blue, lavender. If those names look familiar, they're the same set used by the Catppuccin colour scheme.
Semantic colours — primary, secondary, error, warning, success, info, checkColour — the ones most component code actually reads (ButtonComponent uses t.primary for its default background, for instance). If a theme doesn't set these explicitly, they fall back to a colour from the accent palette (primary→blue, error→red, warning→yellow, success→green, info→sky, checkColour→lavender, secondary→rosewater) the first time InitialiseSemantics() runs. Both built-in themes override most of these with their own values anyway, so treat the fallback as a safety net for custom themes rather than something to rely on.
Built-in Themes
SableUI registers two themes automatically: "sableui_dark" (the default active theme) and "sableui_light". Switch between them with:
ThemeManager::GetInstance().SetActiveTheme("sableui_light");
SetActiveTheme returns false and logs an error if the name isn't registered, rather than silently doing nothing.
Registering a Custom Theme
A full theme is just a Theme struct populated field by field, registered under a name:
Theme myTheme;
myTheme.name = "my_theme";
myTheme.base = Colour{ 18, 18, 20, 255 };
// ... fill in the rest ...
myTheme.InitialiseSemantics(); // fill any unset semantic colours from the palette
ThemeManager::GetInstance().RegisterTheme("my_theme", myTheme);
Theme Variants
If you only want to tweak a few colours from an existing theme rather than define a whole new one, ThemeOverride lets you register a variant instead. Every field is std::optional, and only the ones you set are applied on top of the base theme:
ThemeOverride highContrast;
highContrast.text = Colour{ 255, 255, 255, 255 };
highContrast.overlay1 = Colour{ 140, 140, 140, 255 };
ThemeManager::GetInstance().RegisterThemeVariant(
"sableui_dark_high_contrast", "sableui_dark", highContrast
);
This resolves to a full Theme at registration time (ThemeOverride::Apply), so switching to the variant afterwards is exactly as cheap as switching to any other theme.
Styling Guide
bg, textColour, and the rest of the style macros
Components
Where t.primary, t.surface0, and friends actually get used
Examples
See custom themes in real applications
Custom Render Targets
Components
SableUI ships a small component library on top of the core framework — enough to build the MVP without hand-rolling every button and text field. Each one is a normal BaseComponent under the hood, added via a macro that handles registering it as a scoped child and calling its Init(...), the same pattern Button(...) uses in Your First Application.
All of them read from the active theme (see Theming) and respond to Component Sizing (size_sm/size_md/size_lg/size_none, disabled(...)) unless noted otherwise.
Button
Button(label, callback, ...)
Button("Save", [this]() { Save(); }, size_lg);
label accepts a SableString, so Unicode and emoji labels work directly — useful for icon-only buttons:
Button(U"\U0001F4C5", [this]() { ToggleCalendar(); },
w(16), h(16), fontSize(8), size_none, bg(rgba(0, 0, 0, 0)), rounded(999));
By default a button's background is t.primary, its corners are rounded 4px unless overridden, and its padding scales with size_sm/size_md/size_lg (or comes from your own p/px/py if you set one). disabled(true) both dims the button to t.subtext0 and stops the callback from firing on click, so it's safe to leave a click handler wired up on a button that's conditionally disabled.
The button tracks its own pressed state internally (a State<bool> set from hit-testing in OnUpdate) purely to darken its background slightly while held — that state isn't exposed, so if you need to know whether a button is currently pressed from outside it, you'll need to track that yourself in the callback.
Checkbox
Two macros, depending on who owns the boolean:
// Externally-owned State<bool> — auto-syncs both ways
CheckboxState(label, checkedState, ...)
// Internally-owned bool with a callback
Checkbox(label, checked, onChange, ...)
State<bool> agreed{ this, false };
// ...
CheckboxState("I agree to the terms", agreed);
Checkbox("Enable notifications", notificationsEnabled,
[this](bool v) { notificationsEnabled = v; });
Box size and label font size follow the same small/medium/large scale as Button (12px/15px/18px box, 10pt/11pt/13pt label). disabled(...) dims the checked colour rather than blocking the click outright — the click handler still checks info.appearance.disabled itself and no-ops, so a disabled checkbox won't toggle even though it's visually similar to an inactive button.
TextField / InputField
InputField(state, ...) // single line
TextField(state, ...) // multiline
Both take a State<InputFieldData>&, where InputFieldData holds the field's content, placeholder, focus state, and optional onChange/onSubmit callbacks:
State<InputFieldData> name{ this, { .placeholder = "Your name" } };
// ...
InputField(name);
Typing, backspace/delete, arrow-key cursor movement with shift-to-select, and clipboard cut/copy/paste are all implemented already, and onSubmit fires on Enter for single-line fields (multiline inserts a newline instead). Clicking outside the field unfocuses it; Escape clears an active selection first, then unfocuses on a second press.
[!WARNING] The visible text cursor and selection highlight aren't drawn yet — the underlying state (
cursorPos,cursorVisible, a blinkingInterval) is all tracked correctly, but the actual draw call inOnUpdatePostLayoutis still commented out pending a custom render target. Typing and selecting both work; you just can't currently see the caret while doing it.
If you're building a component around a text field that needs extra content alongside it — an icon, a button, a suffix — override ContentLeft()/ContentRight() rather than reimplementing the field. This is how DatePickerComponent adds its calendar button without touching any of the text-editing logic.
DatePicker
DateField(state, ...)
DatePickerComponent is a TextFieldComponent subclass — it displays a formatted date and syncs it from an internal CalendarContext, and adds a calendar-icon button via ContentRight():
State<InputFieldData> deadline{ this, {} };
// ...
DateField(deadline);
[!WARNING] The calendar icon button toggles the underlying "open" state correctly, but the floating panel that's meant to actually show the popup calendar (
CalendarHelperPostLayout) is currently commented out, so clicking it doesn't yet show anything on screen. It also doesn't currently restrict typed input the way a finished date picker would — since it inherits full text editing fromTextFieldComponent, typing directly into the field is still possible even though the intended flow is picking a date from the calendar.
If you need a working calendar today rather than the popup integration, use Calendar directly (below) — it's the piece that's actually finished, the popup wiring around it is what's still in progress.
Calendar
Calendar can be embedded directly as an inline date picker, independent of DatePicker:
State<CalendarContext> ctx{ this, {} };
// ...
ComponentScoped(calendar, Calendar, this)
{
calendar->Init(ctx);
}
It initialises itself to today's date the first time it's laid out if ctx hasn't been set yet, renders a month header with prev/next navigation, and highlights the selected day. Reading the selection back out is just ctx.get().selectedDay / .selectedMonth / .selectedYear.
A few free functions handle the parts that don't need a live Calendar instance on screen — useful if you're driving the same CalendarContext from elsewhere in your UI:
InitCalendarToToday(ctx);
InitCalendarToDate(ctx, 2026, 5, 1); // month is 0-indexed
State Management
State<T>, Ref<T>, Timer, and Interval
Theming
Where t.primary, t.surface0, and friends come from
Event Handling
onClick, OnUpdate, and reading input state
Roadmap to 1.0
Components
- Scroll view
- Tab stack with component exposure/initialisation callback
- Button
- Sliders
- Input field
- Large text field
- Input field click for cursor
- Input field multi-line highlighting
- Input field copy/paste support
- Modal
- Checkbox
- Progress
- Dropdown
- Listbox
- Keyboard chip
- Context menu
- Link?
- Popover
- Radio
- Spinner?
- Switch
- Table (contents grid with sortable columns for complex)
- Toast
- Tooltip
- List
- Splitter element (horizontal & vertical)
- Text splitter element
- Menu bar
Bug fixes
- Draw window border
- Fix OpenGL context problems
- Triple check refresh things?
- Better/dynamic frame limiting - check "Event processing" in glfw website
- Cannot access root element directly in TabWithInitialiser (root=nullptr in Layout())
- Overdraw with scrollview
- Child component state losses
- Scroll bar doesn't update on init / resize
- Floating panel with transparent backgrounds blitted multiple times
Features
- Remove element tree from custom layout targets
- Make another api for floating components
-
Add Prop
Graphics API
- Abstract shaders
- Abstract uniforms
- Abstract drawables
- Shaderc transpilation
- Vulkan & Metal support
Events
- Test keyboard events
- Test scroll events
Async stuff
- Timeouts
- Timers
- Animations
Styling
- Themes
- Text wrap property
- Inline text colour
- Replace macro styling
- Inline hover styling
- Size and disabled style
- Gap property
- Change texts from subpixel to greyscale for no fringing
Panels
- Panel builder mode for user building layouts
- Panel editor for locking / unlocking panels at runtime
- Saving panel state across runs
QOL
- Expose titlebar content api
- Expose icon setting + program to embed api
- Allow custom fonts
- Image lazy-loading
- Image load-by-buffer
- Props
- Click and drag scroll bar thumb
Beyond 1.0
- Docking panels
- Web support?
- Mobile support?
- Touch support
- Expose graphics & shader api further for cross-platform custom shaders and objects
- Video player
- Simple 3d renderer
- Plugin support
- External layout script + basic language