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 — 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;
    • 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. 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.
    • 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<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 like AzCallbackType are fixed to the raw C structs by the framework, so the callback signature has to spell out AzRefAny and AzCallbackInfo. Inside the body you can use the C++ surface freely. Simple enums (AzUpdate, AzMouseCursorType, …) are aliased via using Update = AzUpdate; so you can drop the prefix on those.
  • azul::downcast_ref<T> / azul::downcast_mut<T> work directly on AzRefAny — free function templates that take the C struct by reference and return const T* / T* (or nullptr on type mismatch). Same generic identity scheme as RefAny::create<T>. Use these inside callback bodies so you don't have to wrap the parameter just to downcast it.
  • The RefAny wrapper 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 chainable RefAny::create<T>(model) factory and data.clone() builder. 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: 50px;"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<AzUrl, AzUrlParseError> 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 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.

  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 AzUpdate_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 AzUpdate_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, which goes through the AZ_REFLECT(StructName) macro and the raw Az* types directly.
  • 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

Back to guide index