Hello World [C]
Introduction
C is the „native“ surface of Azul. Every other language binding (Rust, C++, Python, ...)
ultimately calls into the same azul.h symbols described here, so anything that works in
C works everywhere.
The good news is that you do not have to build Azul „from scratch“ as a C user. The library ships as a prebuilt DLL and a single header file, both of which you can drop into an existing project and link like any other system library.
Installation
Pre-built DLL (recommended)
On Debian/Ubuntu and Fedora/RHEL you can install the prebuilt package straight from the GitHub release:
# linux - Debian / Ubuntu (installs /usr/include/azul.h + /usr/lib/libazul.so)
curl -L -O https://github.com/fschutt/azul/releases/download/0.2.0/azul_${VERSION}_amd64.deb
sudo apt install ./azul_${VERSION}_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
ARM64 and other architectures are on the
GitHub releases page.
The .deb / .rpm installs libazul.so plus the azul.h header into the
standard system locations, so a plain cc hello-world.c -lazul will pick
everything up.
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 - no external registry involved) installs libazul.dylib plus
azul.h:
brew tap fschutt/azul https://azul.rs/ui/homebrew-azul.git
brew install fschutt/azul/azul
Experimental Chocolatey / Arch / Alpine mirrors are published best-effort under
azul.rs/ui/{nuget,arch,alpine}. On
Windows (and for CI on any platform), download the
header and the library directly from the
release page:
# header (same file on every platform)
curl -L -O https://azul.rs/ui/release/0.2.0/azul.h
# windows (plus azul.lib 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.
Simple „Counter“ Example
The C version of the counter is about ~60 lines (without comments):
#include "azul.h"
#include <stdio.h>
#include <string.h>
// Tiny helper: turn a compile-time string literal into an AzString.
// AzString_fromConstStr is a designated-initializer macro - in expression
// context it needs a compound-literal cast to `(AzString){...}`. The
// resulting AzString just points at the literal (which lives in .rodata)
// with a NoDestructor - zero allocation. For runtime-built strings, see
// AzString_copyFromBytes further down.
#define AZ_CONST_STR(s) ((AzString)AzString_fromConstStr(s))
// Data model: Plain old struct - the "single source of truth" for app state.
typedef struct { uint32_t counter; } MyDataModel;
// Called once when the framework drops the last RefAny pointing at
// our struct. No heap allocation here, so the body is empty.
void MyDataModel_destructor(void* m) { }
// AZ_REFLECT expands into a small set of helper functions:
//
// MyDataModel_upcast(struct) -> AzRefAny
// MyDataModel_downcastRef(&refany,&MyDataModelRef) -> bool (const access)
// MyDataModel_downcastMut(&refany,&MyDataModelRefMut) -> bool (mutable access)
//
// It stores a compiler-generated tag in the RefAny, so that the
// framework can verify the type-safety casts at runtime.
//
// Alternatively, use AZ_REFLECT_JSON if you want to plug into the
// state-hot-reload machinery (requires you to supply toJson /
// fromJson callbacks).
AZ_REFLECT(MyDataModel, MyDataModel_destructor);
// AZ_REFLECT_JSON(MyDataModel, destructor_fn, toJson, fromJson)
// Forward-declare on_click so layout() can pass it to the button.
// All UI callbacks share this same signature.
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) {
// We downcast non-mutably from the RefAny to our MyDataModelRef
// (generated by AZ_REFLECT macro). We only want to read the counter
// so we don't use the "RefMut" here.
MyDataModelRef d = MyDataModelRef_create(&data);
if (!MyDataModel_downcastRef(&data, &d)) {
// _delete is safe even though the downcast failed: it only
// releases what _create allocated and skips the borrow-count
// decrease when .ptr was never set.
MyDataModelRef_delete(&d);
return AzDom_createBody();
}
// Format the counter value into a string and immediately
// release the const borrow - we don't need it further.
char buffer[20];
int written = snprintf(buffer, 20, "%d", d.ptr->counter);
MyDataModelRef_delete(&d);
// copy the bytes to an owned AzString, since char[20] will
// go out of scope at the end of this function
AzString label_text = AzString_copyFromBytes(
(const uint8_t*)buffer,
0 /* start */,
written /* num_bytes to copy */
);
// AzDom_create_text would just creates the raw inline text node
// ("p::text" in CSS) - but we have to wrap it in a <p> block here
AzDom label_dom = AzDom_createPWithText(label_text);
AzDom_setCss(&label_dom, AZ_CONST_STR(
"font-size: 50px;"
));
// Button widget - has its own helper API on top of Dom
AzButton button = AzButton_create(AZ_CONST_STR("Increase counter"));
AzButton_setButtonType(&button, AzButtonType_Primary);
// RefAny_clone bumps the refcount - clone ownership
// is moved into the AzButton so the AzButton
// can hand it back to on_click later.
AzRefAny data_clone = AzRefAny_clone(&data);
AzButton_setOnClick(&button, data_clone, on_click);
// Convert the button widget to a plain Dom node so we
// can append it like any other.
AzDom button_dom = AzButton_dom(button);
// Final wrapup and return
AzDom body = AzDom_createBody();
AzDom_addChild(&body, label_dom);
AzDom_addChild(&body, button_dom);
return body;
}
// Click callback, framework invokes this function pointer
// when the button's hit-test matches a MouseUp event.
AzUpdate on_click(AzRefAny data, AzCallbackInfo info) {
// Now we use the RefMut variant (generated by AZ_REFLECT),
// to perform the runtime borrow check. Failure means the RefAny
// is already borrowed elsewhere or holds a different type.
MyDataModelRefMut d = MyDataModelRefMut_create(&data);
if (!MyDataModel_downcastMut(&data, &d)) {
// You could debug here. _delete is safe on this failure
// path too - see the note in layout() above.
MyDataModelRefMut_delete(&d);
return AzUpdate_DoNothing;
}
// Actually increase the counter behind the MyDataModelRefMut
// (thread safe, no other thread has access to this at this point)
d.ptr->counter += 1;
// ALWAYS pair _create with _delete before returning - on every
// path, including failed downcasts. Forgetting it after a
// successful downcast leaves the RefAny borrowed and the next
// downcast will fail.
MyDataModelRefMut_delete(&d);
// RefreshDom now queues a new layout() invocation:
// dom build -> cascade -> relayout -> display list -> render
return AzUpdate_RefreshDom;
}
int main() {
// Initialize the data model
MyDataModel model = { .counter = 5 };
// Move ownership of the model into a RefAny.
AzRefAny data = MyDataModel_upcast(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.
AzWindowCreateOptions window = AzWindowCreateOptions_create(layout);
// Play with the window options
window.window_state.title = AZ_CONST_STR("Hello World!");
window.window_state.size.dimensions.width = 400.0;
window.window_state.size.dimensions.height = 300.0;
// OS draws close/min/max buttons; framework auto-injects a
// draggable titlebar above our content.
window.window_state.flags.decorations =
AzWindowDecorations_NoTitleAutoInject;
window.window_state.flags.background_material =
AzWindowBackgroundMaterial_Sidebar;
// AppConfig discovers system-native styling, monitor layout, etc.
AzApp app = AzApp_create(data, AzAppConfig_create());
// Blocks until the last window closes (on Win32 this never
// returns; on other systems it depends on window_config flags).
AzApp_run(&app, window);
AzApp_delete(&app);
return 0;
}
Five things to notice.
AZ_REFLECT(...)— generates the upcast / downcast helpers and a runtime type tag the framework uses to verify casts. The destructor parameter is a function pointer the framework calls when the lastRefAnyreferencing your struct is dropped; if your struct owns heap data, free it here. The longer formAZ_REFLECT_JSON(struct, destructor, toJson, fromJson)additionally plugs into the state-hot-reload machinery — not needed for hello-world.FooRef_create/FooRef_delete— every_createmust be paired with a_deletebefore the function returns, on the success and the failure path._deleteis failure-safe: if the downcast never succeeded (.ptris still0), it only releases the bookkeeping that_createallocated and does not touch the borrow count. After a successful downcast it is the C version of Rust'sRefMutgoing out of scope: it releases the runtime borrow on this RefAny instance.AzRefAny_clone— bumps the reference count, does not deep-copy your struct. The secondRefAnyis moved into the button so the click handler can downcast it later. Cloning is thread-safe (refcount is atomic).AzString_fromConstStrvsAzString_copyFromBytes— strings cross the FFI as length-prefixed UTF-8 buffers, notconst char*. For compile-time string literals,AzString_fromConstStr(wrapped in ourAZ_CONST_STRmacro) is a zero-allocation designated initializer: the resultingAzStringjust points at.rodataand carries aNoDestructorso the framework will not try to free it. For runtime-built strings (e.g. the countersnprintf'd into a stack buffer above), useAzString_copyFromBytesinstead — it copies the bytes into a refcounted heap buffer so the framework can outlive your stack frame. For NUL-terminated runtime strings,azul.halso ships the shorthandAZ_STR(s), which expands toAzString_copyFromBytes((const uint8_t*)(s), 0, strlen(s)).AzDom_setCss("...")— accepts a CSS string, the C analogue of Rust'sset_css. Multi-property strings are valid:"font-size: 50px; color: white;". You can also embed:hover { },:focus { },@media ... { },@os(macos >= sonoma) { }dynamic queries directly inline — in difference to regular CSS. The string is parsed once on the first cascade and cached, so this is not a per-frame cost. (For programmatic, type-safe access without a CSS string round-trip, useAzDom_addCssPropertywith theAzCssProperty_*constructors instead.)
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.AzWindowCreateOptions— title, size, decorations, transparency, monitor pinning. Covered in windowing.
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:
cc hello-world.c -lazul -o hello-world
./hello-world
(On Windows the equivalent is cl hello-world.c 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 header 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
cc hello-world.c \
-I/path/to/azul-headers \
-L/path/to/azul-lib \
-lazul -ldl -lpthread -lm \
-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
cc hello-world.c \
-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 hello-world.c /I path\to\azul-headers ^
/link /LIBPATH:path\to\azul-lib azul.dll.lib
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.
AzApp_runopened 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
- Linker reports unresolved
Az*symbols — the dynamic library is not on the linker path. Check-Land-l(orLIBPATHon MSVC). - Runtime: „library not found“ — the loader cannot find
libazul.{so,dylib,dll}. On Linux exportLD_LIBRARY_PATH; on macOS use-Wl,-rpath,@executable_path/.; on Windows placeazul.dllnext to the.exe. downcastMut/downcastRefreturnsfalse— usually a missing_deletesomewhere is leaving theRefAnyborrowed. Less commonly, it holds a different type than you think. ReturnAzUpdate_DoNothing(orAzDom_createBody()) and audit your borrow scopes.- Counter does not update on click — the click callback returned
AzUpdate_DoNothing, or the downcast failed silently. Add aprintfto the failure branch to verify. - The window opens blank — the layout callback returned
AzDom_createBody()with no children, or the_addChildcalls were applied to the wrong node.
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)
cargo run -p azul-doc --release -- codegen all
# build the actual DLL with the now-generated .rs C-API bindings
cargo build -p azul-dll --release --features build-dll
Notice the required --features build-dll, as this is a flag to „build the DLL, don't link to it“. The DLL lands at target/release/libazul.{so,dylib} (or azul.dll). The header is previously generated by azul-doc codegen all and ends up at target/codegen/azul.h. Copy both somewhere your C compiler can find.
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 [C++]