Hello World [C++]
Introduction
The C++ binding is a thin, header-only wrapper over the C ABI: same DLL, same azul.h underneath,
but on top you get RAII types, builder methods, integration with std::string / std::optional /
std::expected / std::span, and template-based reflection. The wrapper is generated separately for
each C++ standard, so it scales from -std=c++03 (Colvin-Gibbons move emulation) all the way to
-std=c++23 (deducing this, std::expected).
There is one wrapper header per standard rather than a single „C++ header“, because C++ has shifted
significantly between standards (move semantics, auto, structured bindings, concepts, modules, …).
Pick the one that matches the standard you compile with. This guide is written for C++17, which is
representative of what most projects use today; C++20/23 add the same features as C++17 plus the ones
called out below.
Installation
Pre-built DLL (recommended)
Same as the C installation: on Debian/Ubuntu and
Fedora/RHEL, install the prebuilt package from the GitHub release
(it installs libazul.so and azul.h into standard system locations):
# linux - Debian / Ubuntu
curl -L -O https://github.com/fschutt/azul/releases/download/0.2.0/azul_0.2.0_amd64.deb
sudo apt install ./azul_0.2.0_amd64.deb
# linux - Fedora / RHEL
curl -L -O https://github.com/fschutt/azul/releases/download/0.2.0/azul-0.2.0-1.x86_64.rpm
sudo dnf install ./azul-0.2.0-1.x86_64.rpm
Instead of downloading the .deb manually, Debian/Ubuntu users can also
subscribe to the self-hosted apt repository served from azul.rs (it is
unsigned, hence the explicit [trusted=yes]):
echo "deb [trusted=yes] https://azul.rs/ui/apt stable main" | sudo tee /etc/apt/sources.list.d/azul.list
sudo apt update
sudo apt install azul
On macOS, a self-hosted Homebrew tap (a real git repository served from
azul.rs) installs libazul.dylib plus the C header azul.h (you still
need to download the C++ wrapper header below):
brew tap fschutt/azul https://azul.rs/ui/homebrew-azul.git
brew install fschutt/azul/azul
There is currently no Chocolatey package, AUR or Alpine repository. On Windows (and for CI on any platform), download the C++ wrapper header and the library directly from the release page:
# wrapper for the C++ standard you target
curl -L -O https://azul.rs/ui/release/0.2.0/azul17.hpp
# also: azul03.hpp, azul11.hpp, azul14.hpp, azul20.hpp, azul23.hpp
# the C header - every azul<NN>.hpp does #include "azul.h"
curl -L -O https://azul.rs/ui/release/0.2.0/azul.h
# windows (plus azul.dll.lib, the MSVC import library, if you link with MSVC)
curl.exe -L -O https://azul.rs/ui/release/0.2.0/azul.dll
# linux
curl -L -O https://azul.rs/ui/release/0.2.0/libazul.so
# macos (Apple Silicon; Intel: libazul.x86_64.dylib)
curl -L -O https://azul.rs/ui/release/0.2.0/libazul.dylib
You then either install both into a system path or pass -I and -L to the compiler.
Pick a language standard
Each header wraps the same C ABI; the deltas are real, not cosmetic. What you actually get per standard, in code:
azul03.hpp— nonoexcept, no move semantics. Uses the Colvin-Gibbons trick to return non-copyable RAII objects. Reflection goes through theAZ_REFLECT(StructName)macro, which emitsStructName_upcast/_downcast_ref/_downcast_mut. No template metaprogramming on the user side.azul11.hpp—noexcepteverywhere, real move semantics, lambdas.AZ_REFLECTis replaced by template members onRefAnyitself:RefAny::create<T>(model)(factory; T is deduced from the argument),refany.downcast_ref<T>(),refany.downcast_mut<T>(),RefAny::type_id<T>(). No per-type macro line — anyTyou hand toRefAny::createregisters itself the first time it is instantiated.azul14.hpp— same as C++11 plusRefAny::type_id_v<T>(variable- template shorthand forRefAny::type_id<T>()) andauto-return functions.azul17.hpp— adds:std::string_viewsibling overloads on everyString-taking method, so"foo"svflows in without aString(...)wrapping step;[[nodiscard]]on factory and constructor methods;Option<T>::toStdOptional() -> std::optional<Inner>plus the matching implicit conversion. When the payload has a wrapper class,InnerIS that wrapper (std::optional<String>, notstd::optional<AzString>); for non-copy payloads the conversion is&&-qualified (consuming) so ownership moves into thestd::optionalexactly once;- structured bindings on every
ResultXxxwrapper:auto [ok, err] = std::move(result);works without per-class hooks.
azul20.hpp— adds:- the
azul::ReflectableModelconcept; theRefAny::create/downcast_ref/downcast_mut/type_idtemplate members are constrained by it, so feeding a non-reflectable type produces a readable requires-clause error rather than a wall of template- instantiation noise; Vec<T>::toSpan() -> std::span<T>for zero-copy access;- a sibling
azul.cppmmodule partition file (generated byazul-doc codegen all, not part of the release downloads). With a modules-aware toolchain you canimport azul;instead of#include "azul20.hpp".
- the
azul23.hpp— adds:Result<Ok, Err>::toStdExpected() && -> std::expected<Ok, Err>and the matching implicit conversion. Methods returning aResultXxxwrapper can be assigned straight into astd::expected<Ok, Err>, then chained monadically with.and_then/.or_else.Ok/Errare the wrapper classes when one exists (std::expected<Url, UrlParseError>, notstd::expected<AzUrl, AzUrlParseError>) — the expected owns them.- Deducing-
thisbuilder methods: everywith_*is emitted as atemplate<class Self> auto with_xxx(this Self&& self, …)so the same method body works on l-values and r-values without separateconst&/&&overloads.
The example below is C++17 — representative of what most projects write.
The full set of C++ examples lives under examples/cpp/cpp<NN>/ in the
repository; each standard's hello-world.cpp exercises that standard's
own features.
Simple „Counter“ Example
The C++17 version of the counter is about ~50 lines (without comments).
The wrapper types own their Az* handle and free it on destruction, so
unlike C you do not have to pair every _create with a _delete — RAII
does that for you:
#include "azul17.hpp"
#include <optional>
#include <string>
#include <string_view>
// Brings in RefAny, Dom, App, String, Css, Button, WindowCreateOptions, ...
// Raw C types remain Az*-prefixed; wrapper types have no prefix.
using namespace azul;
using namespace std::string_view_literals;
// Data model: a plain struct - the "single source of truth" for app state.
// No AZ_REFLECT macro line in C++11+: reflection is template-based.
struct MyDataModel {
uint32_t counter;
// OptionXxx wrappers convert implicitly to std::optional<Inner>, so a
// model field that nullably caches a parsed URL keeps its source-of-
// truth shape while the rest of the app reads it as std::optional.
std::optional<Url> last_url;
};
// Callback signatures take the raw C types because the framework
// dispatches through C function pointers. The framework hands every
// callback an OWNED AzRefAny (a refcount+1 clone), so the first thing
// the body does is adopt it into the RAII `RefAny` wrapper - the
// wrapper's destructor releases that reference when the callback
// returns. (Calling the free `azul::downcast_ref<T>(data)` on the raw
// parameter without adopting it would leak one strong reference per
// invocation.)
AzUpdate on_click(AzRefAny data, AzCallbackInfo info);
// f(DataModel) -> Dom. Runs once on startup and again after every
// callback that returns Update::RefreshDom.
AzDom layout(AzRefAny data, AzLayoutCallbackInfo info) {
// Adopt the owned reference; ~RefAny drops it on return.
RefAny data_wrapper(data);
// Member downcast: returns const T* (or nullptr on type mismatch).
// Per-type identity is derived from the address of a template-
// instantiated static, so the compiler stamps a unique tag per T at
// link time. No AZ_REFLECT macro, no per-type registration.
auto* d = data_wrapper.downcast_ref<MyDataModel>();
if (!d) return Dom::create_body();
// To pass the data to the button's click handler we clone the
// wrapper. clone() bumps the refcount; the clone is owned by the
// button and released when the button's DOM node is destroyed.
//
// String-taking methods gained std::string_view overloads in C++17,
// so "..."sv literals flow straight in. .with_* methods consume
// *this and return a new value, so chain them inline. The Dom's
// r-value `operator AzDom()` does the C-ABI conversion implicitly
// on return, so no `.release()` is needed.
return Dom::create_body()
.with_child(Dom::create_div()
.with_css("font-size: 32px;"sv)
.with_child(Dom::create_text(String(std::to_string(d->counter).c_str()))))
.with_child(Button::create("Increase counter"sv)
.with_button_type(ButtonType::Primary) // scoped enum constants
.with_on_click(data_wrapper.clone(), on_click)
.dom());
}
// Definition of the click callback forward-declared above. The framework
// invokes this through a C function pointer when the button's hit-test
// matches a MouseUp event.
AzUpdate on_click(AzRefAny data, AzCallbackInfo info) {
// Adopt the owned reference here too, then use downcast_mut - the
// mutable counterpart. nullptr means the type tag doesn't match.
RefAny data_wrapper(data);
auto* d = data_wrapper.downcast_mut<MyDataModel>();
if (!d) return Update::DoNothing;
d->counter += 1;
// Update::RefreshDom queues a new layout() invocation:
// dom build -> cascade -> relayout -> display list -> render
// (the constant's type is the raw AzUpdate, so it flows straight
// through the C fn-ptr return; AzUpdate_RefreshDom works too)
return Update::RefreshDom;
}
// Every ResultXxx wrapper destructures into (std::optional<Ok>, std::optional<Err>)
// via the codegen's tuple_size / tuple_element specializations. No per-class
// helper - just structured bindings.
static void demo_structured_bindings() {
auto [ok, err] = std::move(Url::parse("https://example.com/"sv));
if (ok) {
// *ok is an AzUrl; the Url wrapper would adopt it via Url(*ok).
} else if (err) {
// *err is an AzUrlParseError.
}
}
int main() {
// Initialize the data model. std::nullopt as a model field is fine -
// it'll convert to AzOptionUrl when the codegen needs it.
MyDataModel model = { 5, std::nullopt };
(void)demo_structured_bindings;
// Move ownership of the model into a RefAny via RefAny::create<T>(model).
// T is deduced from the argument; the spelling
// RefAny::create<MyDataModel>(std::move(model))
// also works if you want it explicit. No AZ_REFLECT line was needed -
// RefAny::create registers T's identity the first time it's instantiated.
RefAny data = RefAny::create(std::move(model));
// Configure the window(s) to spawn on startup. layout() is the
// "/" default route; SPA-style routing is done later by swapping
// the layout callback on a window.
WindowCreateOptions window = WindowCreateOptions::create(layout);
// 'default' is a C++ keyword - the wrapper exposes it as
// default_() with a trailing underscore.
//
// AppConfig discovers system-native styling, monitor layout, etc.
App app = App::create(std::move(data), AppConfig::default_());
// Blocks until the last window closes; the destructor cleans
// up the framework instance on scope exit.
app.run(std::move(window));
return 0;
}
Seven things to notice.
- Why
Az*prefixes appear at all — most types are exposed as wrapper classes (RefAny,Dom,App) for RAII. Function-pointer typedefs likeAzCallbackTypeare fixed to the raw C structs by the framework (a function pointer must match the C signature exactly), so the callback definition has to spell outAzRefAny,AzCallbackInfoand theAzUpdatereturn type. Inside the body you can use the C++ surface freely. For every simple enum the header emits a namespace of value constants with the Rust spelling —Update::RefreshDom,ButtonType::Primary— whose type is the raw C enum, so they flow through method arguments and C fn-ptr returns without casts. The C enumerator spellings (AzUpdate_RefreshDom,AzButtonType_Primary) keep working; in signatures the enum type is spelledAzUpdate(the non-prefixed name is the constants namespace). The callback fn-ptr typedefs also get non-prefixed aliases (azul::CallbackType,azul::LayoutCallbackType, …) for storing callbacks in your own code. - Adopt the callback's
AzRefAnyparameter intoRefAny— the framework hands every callback an owned reference (a refcount+1 clone), and the wrapper's destructor is what releases it when the callback returns. The freeazul::downcast_ref<T>/azul::downcast_mut<T>function templates also work directly on the C struct (same generic identity scheme asRefAny::create<T>, nullptr on type mismatch), but they only read — using them on the raw parameter without adopting it (or manually callingAzRefAny_delete(&data)before returning) leaks one strong reference per invocation. Reserve them forAzRefAnyvalues that something else owns. - The
RefAnywrapper class is the ownership tool — RAII auto-cleanup (the framework hands the callback an owned reference; the destructor decrements the refcount), the chainableRefAny::create<T>(model)factory, and thedata_wrapper.clone()builder for handing a new strong reference to a widget. Construct it from a rawAzRefAnyonly when you actually want to take ownership. - No
AZ_REFLECTline for C++11+ —RefAny::create<T>/refany.downcast_ref<T>()/refany.downcast_mut<T>()are template members onRefAny. The compiler stamps a unique runtime tag perTvia the address of a template-instantiatedstatic, so identity is stable across translation units without per-type registration. TheAZ_REFLECT(StructName)macro is still emitted inazul03.hppfor C++03 compatibility (no template member functions there). - RAII over manual
_delete— there's no explicit pairing with_deletelike in C, which removes a whole class of bugs. - C-ABI return is implicit — every wrapper class has an r-value
operator AzInner()that yields the underlying C struct and zeros the wrapper. Soreturn Dom::create_body();from a callback whose signature isAzDom layout(...)works without an explicit.release(). The conversion only fires on r-values (return values,std::move'd locals); l-values still need an explicit.release()if you want to hand the C struct to a function pointer outside the return statement. Inside C++, transfer ownership withstd::moveas usual:App::create(std::move(data), ...),app.run(std::move(window)). .with_*builder methods consume*this— they return a new value rather than mutating in place. Chain them inline; they do not allocate beyond what the underlyingAz*_set*would. The correspondingset_*methods (e.g.Button::set_on_click) mutate in place if you prefer that style.std::movefor ownership transfer —App::run(std::move(window)),App::create(std::move(data), ...). A copy would leave you with two handles competing to free the same memory; if there's a debug build you'll get a double-free at exit. Modern compilers warn when a value is implicitly copied where a move was wanted.std::string_viewflows in —Button::create("Increase counter"sv)andwith_css("font-size: 32px;"sv)use the C++17 sv-literal directly. The codegen emits sibling(std::string_view)overloads on every method whose original signature took aString, so there is noString("...")wrapping step.
Things we did not use that you may want to explore next.
AzLayoutCallbackInfo— read-only access to the system font cache, image cache, GL context, current window size, routing, and localization dictionaries.AzCallbackInfo— many functions for navigating the DOM, mutating CSS without rebuilding the tree, querying computed layout / styles, etc.WindowCreateOptions— title, size, decorations, transparency, monitor pinning. Same fields as in C; covered in windowing.
What changes for older / newer standards
examples/cpp/cpp03/hello-world.cppkeeps the explicitAZ_REFLECT(MyDataModel)line and usesMyDataModel_upcast/MyDataModel_downcast_ref/MyDataModel_downcast_mutdirectly. No move semantics, no string-view, nostd::optional.examples/cpp/cpp14/hello-world.cppaddsauto-return onlayoutand a runtime sanity check onRefAny::type_id_v<MyDataModel>(the address-of-static trick that backs it isn't a constant expression, so it can't bestatic_assert-ed).examples/cpp/cpp20/hello-world.cppstatic_asserts onazul::ReflectableModel<MyDataModel>(the concept itself isconstexpr-friendly, the value oftype_id_visn't), and feeds aU8Vecstraight into a function takingstd::span<const uint8_t>via the implicittoSpan()conversion.examples/cpp/cpp23/hello-world.cppreturns astd::expected<Url, UrlParseError>(the RAII wrapper classes, which the expected then owns) directly from a function whose body just doesreturn Url::parse("…"sv);. The implicitoperator std::expected<Ok, Err>() &&on theResultwrapper does the conversion. The example also exercises the deducing-thisbuilders by chaining.with_*on a mix of l-value and r-valueDoms in the same expression.
Build and run
If you installed libazul through the .deb / .rpm package, the
header and the shared library live in standard locations and the
compiler will find them on its own — one line is enough:
g++ -std=c++17 hello-world.cpp -lazul -o hello-world
./hello-world
(On Windows the equivalent is
cl /std:c++17 /EHsc hello-world.cpp azul.dll.lib once the downloaded
MSVC import library azul.dll.lib is on the linker search path, with
azul.dll next to the resulting .exe at run time.)
If you downloaded the wrappers and DLL manually (or built from source),
you have to point the compiler at them explicitly. -I / -L add
include and link search paths; -Wl,-rpath tells the dynamic loader
where to find libazul.{so,dylib} at runtime so you do not have to
set LD_LIBRARY_PATH (Linux) or DYLD_LIBRARY_PATH (macOS) every
time you run the binary.
# Linux
g++ -std=c++17 hello-world.cpp \
-I/path/to/azul-headers \
-L/path/to/azul-lib -lazul \
-Wl,-rpath,/path/to/azul-lib \
-o hello-world
# macOS — @executable_path resolves relative to the binary, so you can
# ship the .dylib next to the .bin and the loader will pick it up
g++ -std=c++17 hello-world.cpp \
-I/path/to/azul-headers \
-L/path/to/azul-lib -lazul \
-Wl,-rpath,@executable_path/. \
-o hello-world
# Windows (MSVC) — drop azul.dll next to the .exe at run time
cl /std:c++17 /EHsc hello-world.cpp /I path\to\azul-headers ^
/link /LIBPATH:path\to\azul-lib azul.dll.lib
# C++03 - same DLL, different wrapper
g++ -std=c++03 hello-world.cpp -I/path/to/azul-headers \
-L/path/to/azul-lib -lazul -o hello-world
# C++20+ with modules: precompile the sibling azul.cppm once, then
# replace the #include with import azul; in your source files.
clang++ -std=c++20 -fmodules -c azul.cppm
clang++ -std=c++20 -fmodules hello-world.cpp -lazul -o hello-world
You should see the window pictured on the hello-world landing page. Click the button: the counter increments, the layout callback re-runs, and the new value renders.
app.run(std::move(window))opened a native window and ranlayout()once with yourRefAnyon startup.- The returned
AzDomwas styled, laid out, and rendered (default: CPU-rendered; can be GPU-rendered if needed). - On click, the button's event filter matched a
MouseUpinside its hit-test bounds. The framework borrowed theRefAnymutably, ranon_click, observed theUpdate::RefreshDomreturn, and re-invokedlayout(). - The new
AzDomwas diffed against the previous one; only the changed text node was repainted.
Common errors
- Double-free at exit — you copied a wrapper that should have been moved. Inside C++, use
std::movefor everyRefAny/WindowCreateOptions/Button/Domyou hand off. The layout callback's return is handled implicitly by the wrapper's r-valueoperator AzInner(), so there's nothing manual there. - Linker error:
undefined reference to AzApp_create— the dynamic library is not linked. Add-lazuland confirm the rpath (-Wl,-rpath,/path/to/azul-libon Linux,@executable_path/.on macOS, placeazul.dllnext to the.exeon Windows). - Counter does not update on click — the click callback returned
Update::DoNothing, or the downcast silently returnednullptr. Verify with anassert(d != nullptr)or a print before the increment. - The window opens blank — the layout callback returned an empty body, or you forgot a
.with_child(...)somewhere in the chain. error: 'auto' not allowed— you are compiling with-std=c++03. Either upgrade toc++11, or use theazul03.hppexample template: the same wrapper classes (Dom,Button,App, …) work there too — only the template-based reflection is missing, so reflection goes through theAZ_REFLECT(StructName)macro instead.no member named 'create_p_with_text' in 'azul::Dom'— you copied an old example that usedDom::porDom::body, or a pre-rename one that usedDom::create_p_with_text. The actual codegen surface uses the api.json names verbatim:Dom::create_body()/Dom::create_p_with_text(...)/Dom::create_h1_with_text(...)/Dom::with_css(...).
Building from source
Only needed if you want to track master or patch the library locally:
# git clone https://github.com/fschutt/azul
# cd myfolder/azul
# generate the bindings from api.json (required - emits azul.h plus
# every azul<NN>.hpp wrapper and the azul.cppm module partition under
# target/codegen/)
cargo run -p azul-doc --release -- codegen all
# build the actual DLL
cargo build -p azul-dll --release --features build-dll
The DLL lands at target/release/libazul.{so,dylib} (or azul.dll). The wrappers live at target/codegen/azul<NN>.hpp, plus target/codegen/azul.cppm for the C++20+ module partition. Copy the wrapper for your standard plus the DLL somewhere your C++ compiler can find them.
Coming Up Next
- Application Architecture — Explains the concepts of architecting a larger Azul application
- Document Object Model — The Dom tree - node types, hierarchy, and CSS
- Hello World [Rust]