Guide

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 — no noexcept, no move semantics. Uses the Colvin-Gibbons trick to return non-copyable RAII objects. Reflection goes through the AZ_REFLECT(StructName) macro, which emits StructName_upcast / _downcast_ref / _downcast_mut. No template metaprogramming on the user side.
  • azul11.hppnoexcept everywhere, real move semantics, lambdas. AZ_REFLECT is replaced by template members on RefAny itself: 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 — any T you hand to RefAny::create registers itself the first time it is instantiated.
  • azul14.hpp — same as C++11 plus RefAny::type_id_v<T> (variable- template shorthand for RefAny::type_id<T>()) and auto-return functions.
  • azul17.hpp — adds:
    • std::string_view sibling overloads on every String-taking method, so "foo"sv flows in without a String(...) 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, Inner IS that wrapper (std::optional<String>, not std::optional<AzString>); for non-copy payloads the conversion is &&-qualified (consuming) so ownership moves into the std::optional exactly once;
    • structured bindings on every ResultXxx wrapper: auto [ok, err] = std::move(result); works without per-class hooks.
  • azul20.hpp — adds:
    • the azul::ReflectableModel concept; the RefAny::create / downcast_ref / downcast_mut / type_id template 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.cppm module partition file (generated by azul-doc codegen all, not part of the release downloads). With a modules-aware toolchain you can import azul; instead of #include "azul20.hpp".
  • azul23.hpp — adds:
    • Result<Ok, Err>::toStdExpected() && -> std::expected<Ok, Err> and the matching implicit conversion. Methods returning a ResultXxx wrapper can be assigned straight into a std::expected<Ok, Err>, then chained monadically with .and_then / .or_else. Ok / Err are the wrapper classes when one exists (std::expected<Url, UrlParseError>, not std::expected<AzUrl, AzUrlParseError>) — the expected owns them.
    • Deducing-this builder methods: every with_* is emitted as a template<class Self> auto with_xxx(this Self&& self, …) so the same method body works on l-values and r-values without separate const& / && 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 like AzCallbackType are 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 out AzRefAny, AzCallbackInfo and the AzUpdate return 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 spelled AzUpdate (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 AzRefAny parameter into RefAny — 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 free azul::downcast_ref<T> / azul::downcast_mut<T> function templates also work directly on the C struct (same generic identity scheme as RefAny::create<T>, nullptr on type mismatch), but they only read — using them on the raw parameter without adopting it (or manually calling AzRefAny_delete(&data) before returning) leaks one strong reference per invocation. Reserve them for AzRefAny values that something else owns.
  • The RefAny wrapper class is the ownership tool — RAII auto-cleanup (the framework hands the callback an owned reference; the destructor decrements the refcount), the chainable RefAny::create<T>(model) factory, and the data_wrapper.clone() builder for handing a new strong reference to a widget. Construct it from a raw AzRefAny only when you actually want to take ownership.
  • No AZ_REFLECT line for C++11+RefAny::create<T> / refany.downcast_ref<T>() / refany.downcast_mut<T>() are template members on RefAny. The compiler stamps a unique runtime tag per T via the address of a template-instantiated static, so identity is stable across translation units without per-type registration. The AZ_REFLECT(StructName) macro is still emitted in azul03.hpp for C++03 compatibility (no template member functions there).
  • RAII over manual _delete — there's no explicit pairing with _delete like 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. So return Dom::create_body(); from a callback whose signature is AzDom 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 with std::move as 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 underlying Az*_set* would. The corresponding set_* methods (e.g. Button::set_on_click) mutate in place if you prefer that style.
  • std::move for ownership transferApp::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_view flows inButton::create("Increase counter"sv) and with_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 a String, so there is no String("...") 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.cpp keeps the explicit AZ_REFLECT(MyDataModel) line and uses MyDataModel_upcast / MyDataModel_downcast_ref / MyDataModel_downcast_mut directly. No move semantics, no string-view, no std::optional.
  • examples/cpp/cpp14/hello-world.cpp adds auto-return on layout and a runtime sanity check on RefAny::type_id_v<MyDataModel> (the address-of-static trick that backs it isn't a constant expression, so it can't be static_assert-ed).
  • examples/cpp/cpp20/hello-world.cpp static_asserts on azul::ReflectableModel<MyDataModel> (the concept itself is constexpr-friendly, the value of type_id_v isn't), and feeds a U8Vec straight into a function taking std::span<const uint8_t> via the implicit toSpan() conversion.
  • examples/cpp/cpp23/hello-world.cpp returns a std::expected<Url, UrlParseError> (the RAII wrapper classes, which the expected then owns) directly from a function whose body just does return Url::parse("…"sv);. The implicit operator std::expected<Ok, Err>() && on the Result wrapper does the conversion. The example also exercises the deducing-this builders by chaining .with_* on a mix of l-value and r-value Doms 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.

  1. app.run(std::move(window)) opened a native window and ran layout() once with your RefAny on startup.
  2. The returned AzDom was styled, laid out, and rendered (default: CPU-rendered; can be GPU-rendered if needed).
  3. On click, the button's event filter matched a MouseUp inside its hit-test bounds. The framework borrowed the RefAny mutably, ran on_click, observed the Update::RefreshDom return, and re-invoked layout().
  4. The new AzDom was 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::move for every RefAny / WindowCreateOptions / Button / Dom you hand off. The layout callback's return is handled implicitly by the wrapper's r-value operator AzInner(), so there's nothing manual there.
  • Linker error: undefined reference to AzApp_create — the dynamic library is not linked. Add -lazul and confirm the rpath (-Wl,-rpath,/path/to/azul-lib on Linux, @executable_path/. on macOS, place azul.dll next to the .exe on Windows).
  • Counter does not update on click — the click callback returned Update::DoNothing, or the downcast silently returned nullptr. Verify with an assert(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 to c++11, or use the azul03.hpp example template: the same wrapper classes (Dom, Button, App, …) work there too — only the template-based reflection is missing, so reflection goes through the AZ_REFLECT(StructName) macro instead.
  • no member named 'create_p_with_text' in 'azul::Dom' — you copied an old example that used Dom::p or Dom::body, or a pre-rename one that used Dom::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