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, the ideal installation uses your system package manager:
# windows (choco source = the azul.rs nuget v3 feed)
choco install libazul --source https://azul.rs/nuget/index.json
# linux - Debian / Ubuntu (apt repo: https://azul.rs/apt)
apt install libazul
# linux - Fedora / RHEL (dnf/yum repo: https://azul.rs/rpm)
dnf install libazul
# linux - openSUSE (run once: zypper ar https://azul.rs/rpm azul)
zypper install libazul
# linux - Arch / Manjaro (add [azul] Server=https://azul.rs/arch/$arch to pacman.conf)
pacman -S libazul
# linux - Alpine (add https://azul.rs/alpine/x86_64 to /etc/apk/repositories)
apk add libazul
# macos (run once: brew tap fschutt/azul https://azul.rs/homebrew-azul.git)
brew install libazul
This installs libazul.{so,dylib,dll} plus the family of azul<NN>.hpp wrappers (and the underlying C azul.h) into the standard system locations, so a plain g++ hello-world.cpp -lazul will pick everything up. Alternatively, download the wrapper(s) and the DLL manually from the /releases page (or in your CI):
# wrapper for the C++ standard you target
curl -L -O https://azul.rs/release/0.2.0/azul17.hpp
# also: azul03.hpp, azul11.hpp, azul14.hpp, azul20.hpp, azul23.hpp
# C++20+ users also get a sibling azul.cppm for import azul; support.
# windows
curl.exe -L -O https://azul.rs/release/0.2.0/azul.dll
# linux
curl -L -O https://azul.rs/release/0.2.0/libazul.so
# macos
curl -L -O https://azul.rs/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;- 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. 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.- 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<AzUrl> last_url;
};
// Callback signatures take the raw C types because the framework
// dispatches through C function pointers. Inside the body we use
// `azul::downcast_ref<T>` and `azul::downcast_mut<T>` directly on the
// parameter, so there's no need to wrap.
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) {
// Free-function downcast: works directly on the AzRefAny parameter.
// 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 = downcast_ref<MyDataModel>(data);
if (!d) return Dom::create_body();
// To pass the data to the button's click handler we clone the
// underlying ref. AzRefAny_clone bumps the refcount; the clone is
// owned by whoever consumes it (the button's RefAny parameter).
AzRefAny on_click_data = AzRefAny_clone(&data);
// 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_p_with_text(String(std::to_string(d->counter).c_str()))
.with_css("font-size: 50px;"sv))
.with_child(Button::create("Increase counter"sv)
.with_button_type(AzButtonType_Primary)
.with_on_click(RefAny(on_click_data), on_click)
.dom())
.with_component_css(Css::empty());
}
// 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) {
// downcast_mut is the mutable counterpart. Borrow tracking is
// identical. nullptr means either the type doesn't match or the
// RefAny is already borrowed elsewhere.
auto* d = downcast_mut<MyDataModel>(data);
if (!d) return AzUpdate_DoNothing;
d->counter += 1;
// RefreshDom queues a new layout() invocation:
// dom build -> cascade -> relayout -> display list -> render
return AzUpdate_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, so the callback signature has to spell outAzRefAnyandAzCallbackInfo. Inside the body you can use the C++ surface freely. Simple enums (AzUpdate,AzMouseCursorType, …) are aliased viausing Update = AzUpdate;so you can drop the prefix on those. azul::downcast_ref<T>/azul::downcast_mut<T>work directly onAzRefAny— free function templates that take the C struct by reference and returnconst T*/T*(or nullptr on type mismatch). Same generic identity scheme asRefAny::create<T>. Use these inside callback bodies so you don't have to wrap the parameter just to downcast it.- The
RefAnywrapper class is still useful — when you need RAII auto-cleanup (the framework hands the callback an owned reference; the destructor decrements the refcount) or when you want the chainableRefAny::create<T>(model)factory anddata.clone()builder. 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: 50px;"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<AzUrl, AzUrlParseError>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 your system package manager, 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 with chocolatey / vcpkg, the equivalent is
cl /std:c++17 /EHsc hello-world.cpp azul.lib once azul.lib is on
the linker search path.)
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.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 theAzUpdate_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
AzUpdate_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, which goes through theAZ_REFLECT(StructName)macro and the rawAz*types directly.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]