Hello World [Zig]
Introduction
Zig talks to Azul through @cImport: the generated azul.zig includes the
C header (pub const C = @cImport(@cInclude("azul.h"))) and every
AzString / AzDom / AzApp_run symbol comes out fully typed, with no
FFI shim in between. Because a Zig function declared with callconv(.c)
is a real C function pointer, callbacks are passed to Azul directly —
Zig is one of the few bindings that needs neither a host-invoker
trampoline nor a wrapper-struct dance for callbacks.
The counter example below uses the raw azul.C.* layer exclusively.
azul.zig also contains an idiomatic wrapper layer on top of the same
import; the raw layer is the path that is exercised end-to-end by the
test suite, so it is what this guide documents.
You need Zig 0.14 or newer (the example is tested against 0.16;
the shipped build.zig uses the Module build API introduced in 0.13,
and lowercase callconv(.c) requires 0.14+).
Installation
There is no package-manager story for Zig yet — you download the header,
the binding, the native library, and a minimal build.zig into one
directory and run zig build run there:
# linux
curl -O https://azul.rs/ui/release/0.2.0/libazul.so
curl -O https://azul.rs/ui/release/0.2.0/azul.h
curl -O https://azul.rs/ui/release/0.2.0/azul.zig
curl -O https://azul.rs/ui/release/0.2.0/build.zig
curl -O https://azul.rs/ui/release/0.2.0/hello-world.zig
LD_LIBRARY_PATH=. zig build run
# macos
curl -O https://azul.rs/ui/release/0.2.0/libazul.dylib
curl -O https://azul.rs/ui/release/0.2.0/azul.h
curl -O https://azul.rs/ui/release/0.2.0/azul.zig
curl -O https://azul.rs/ui/release/0.2.0/build.zig
curl -O https://azul.rs/ui/release/0.2.0/hello-world.zig
DYLD_LIBRARY_PATH=. zig build run
# windows
curl -O https://azul.rs/ui/release/0.2.0/azul.dll
curl -O https://azul.rs/ui/release/0.2.0/azul.h
curl -O https://azul.rs/ui/release/0.2.0/azul.zig
curl -O https://azul.rs/ui/release/0.2.0/build.zig
curl -O https://azul.rs/ui/release/0.2.0/hello-world.zig
zig build run
The LD_LIBRARY_PATH=. / DYLD_LIBRARY_PATH=. prefix is required
because the downloaded build.zig links libazul from the current
directory but does not embed an rpath — the dynamic loader has to be
told where the library lives at run time.
Simple „Counter“ Example
This is the exact hello-world.zig shipped in the release (the same
file the end-to-end test builds and clicks through):
// zig build run
const std = @import("std");
const azul = @import("azul.zig");
const C = azul.C;
// ── Data model ────────────────────────────────────────────────────────
//
// Mirrors the C macro `AZ_REFLECT_JSON(MyDataModel, ...)`:
//
// 1. A compile-time-unique type id (the address of a `var` we'll
// never read or write).
// 2. An `upcast` that wraps the struct in an `AzRefAny`.
// 3. A `downcast` that recovers a typed pointer back from the
// refany.
//
// Plain old data → empty destructor.
const MyDataModel = struct {
counter: u32,
};
var MY_DATA_TYPE_TOKEN: u8 = 0;
fn myDataTypeId() u64 {
return @intFromPtr(&MY_DATA_TYPE_TOKEN);
}
fn myDataDestructor(_: ?*anyopaque) callconv(.c) void {}
fn myDataUpcast(model: MyDataModel) C.AzRefAny {
// `AzRefAny_newC` copies the bytes into its own heap allocation,
// so handing it a stack pointer is fine. `run_destructor=false`
// means libazul won't try to free the caller's pointer when it
// copies — only the heap copy is freed (via myDataDestructor +
// libazul's internal free) when the last clone drops.
var local = model;
const type_name_bytes = "MyDataModel";
const type_name = C.AzString_fromUtf8(type_name_bytes.ptr, type_name_bytes.len);
return C.AzRefAny_newC(
.{ .ptr = @ptrCast(&local), .run_destructor = false },
@sizeOf(MyDataModel),
@alignOf(MyDataModel),
myDataTypeId(),
type_name,
myDataDestructor,
0, // no serialize_fn
0, // no deserialize_fn
);
}
fn myDataDowncast(refany: *const C.AzRefAny) ?*MyDataModel {
if (!C.AzRefAny_isType(refany, myDataTypeId())) return null;
const ptr = C.AzRefAny_getDataPtr(refany) orelse return null;
return @constCast(@as(*const MyDataModel, @ptrCast(@alignCast(ptr))));
}
// ── Callback: button click ────────────────────────────────────────────
fn onClick(data: C.AzRefAny, _: C.AzCallbackInfo) callconv(.c) C.AzUpdate {
var d = data;
const m = myDataDowncast(&d) orelse return C.AzUpdate_DoNothing;
m.counter += 1;
return C.AzUpdate_RefreshDom;
}
// ── Layout callback ───────────────────────────────────────────────────
fn layout(data: C.AzRefAny, _: C.AzLayoutCallbackInfo) callconv(.c) C.AzDom {
var d = data;
const m = myDataDowncast(&d) orelse return C.AzDom_createBody();
// Counter label (wrapped in a div so the font-size sticks).
var buf: [16]u8 = undefined;
const slice = std.fmt.bufPrint(&buf, "{d}", .{m.counter}) catch return C.AzDom_createBody();
const counter_str = C.AzString_fromUtf8(slice.ptr, slice.len);
const label = C.AzDom_createText(counter_str);
var label_wrapper = C.AzDom_createDiv();
const font_size = C.AzStyleFontSize_px(32.0);
const css_prop = C.AzCssProperty_fontSize(font_size);
const cond = C.AzCssPropertyWithConditions_simple(css_prop);
C.AzDom_addCssProperty(&label_wrapper, cond);
C.AzDom_addChild(&label_wrapper, label);
// Increment button. The typed `AzButton_setOnClick` takes the bare
// `AzButtonOnClickCallbackType` fn pointer directly — no AzCallback
// struct wrapping needed since the typed-callback API change.
const btn_label_bytes = "Increase counter";
const btn_label = C.AzString_fromUtf8(btn_label_bytes.ptr, btn_label_bytes.len);
var button = C.AzButton_create(btn_label);
C.AzButton_setButtonType(&button, C.AzButtonType_Primary);
const data_clone = C.AzRefAny_clone(&d);
C.AzButton_setOnClick(&button, data_clone, onClick);
const button_dom = C.AzButton_dom(button);
// Body.
var body = C.AzDom_createBody();
C.AzDom_addChild(&body, label_wrapper);
C.AzDom_addChild(&body, button_dom);
return body;
}
// ── Main ──────────────────────────────────────────────────────────────
pub fn main() !void {
const model = MyDataModel{ .counter = 5 };
const data = myDataUpcast(model);
var window = C.AzWindowCreateOptions_create(layout);
const title_bytes = "Hello World";
window.window_state.title = C.AzString_fromUtf8(title_bytes.ptr, title_bytes.len);
window.window_state.size.dimensions.width = 400.0;
window.window_state.size.dimensions.height = 300.0;
// NoTitleAutoInject: OS draws close/min/max buttons; framework
// auto-injects a Titlebar with drag support.
window.window_state.flags.decorations = C.AzWindowDecorations_NoTitleAutoInject;
window.window_state.flags.background_material = C.AzWindowBackgroundMaterial_Sidebar;
var app = C.AzApp_create(data, C.AzAppConfig_create());
C.AzApp_run(&app, window);
}
Callbacks are bare C function pointers
onClick and layout are declared callconv(.c), which makes them
ABI-identical to the C typedefs AzButtonOnClickCallbackType and
AzLayoutCallbackType. That means you pass the function itself:
C.AzButton_setOnClick(&button, data_clone, onClick);
var window = C.AzWindowCreateOptions_create(layout);
Note that the typed AzButton_setOnClick takes the bare fn pointer,
not an AzCallback struct. Older snippets that wrapped the pointer with
AzCallback_create(...) predate the typed-callback API change and no
longer compile — if you see a type error at the setOnClick call site,
delete the wrapping and pass the function directly. There is no
host-invoker, no closure allocation, and no hidden registry: the
framework stores your pointer and calls straight back into your Zig
code on the UI thread.
How RefAny works in Zig
RefAny is Azul's type-erased, reference-counted box for your
application state — the C header ships an AZ_REFLECT macro for it, and
the Zig example hand-rolls the same three pieces in ~35 lines:
- Type identity —
myDataTypeId()returns the address of a globalvar. Every Zig type you reflect gets its own token variable, so the address is process-unique and stable, andAzRefAny_isTypecan verify at run time that a downcast targets the right type. - Upcast —
AzRefAny_newCcopies@sizeOf(MyDataModel)bytes into a refcounted heap allocation. Handing it a pointer to a stack local is therefore fine;run_destructor = falsetells libazul not to free the caller's pointer (only the heap copy is destroyed, via your destructor, when the last clone drops). - Downcast —
AzRefAny_isType+AzRefAny_getDataPtrrecover a typed*MyDataModel. Both callbacks bail out gracefully (orelse return ...) when the check fails.
AzRefAny_clone(&d) bumps the (atomic) reference count — it does not
deep-copy your struct. The clone's ownership moves into the button, so
the framework can hand the same data back to onClick later. Data flow
on click: framework matches the hit-test → calls onClick with the
stored RefAny → your code downcasts, increments counter, returns
C.AzUpdate_RefreshDom → the framework re-runs layout, which reads
the new value.
Two more things worth noticing:
- Strings —
AzString_fromUtf8(ptr, len)copies the bytes into a refcounted heap buffer, which is why passingstd.fmt.bufPrintoutput from a stack buffer is safe: theAzStringoutlives your stack frame. - Typed CSS — instead of parsing a CSS string, the example builds
the property programmatically:
AzStyleFontSize_px(32.0)→AzCssProperty_fontSize→AzCssPropertyWithConditions_simple→AzDom_addCssProperty. (String CSS viaAzDom_setCssworks from Zig too, exactly as in the C guide.)
Build and run
# macos
DYLD_LIBRARY_PATH=. zig build run
# linux
LD_LIBRARY_PATH=. zig build run
# windows
zig build run
The zig build run flow uses the downloaded build.zig, which adds the
current directory to both the include path (for azul.h) and the
library path (for libazul). If you prefer a single explicit command
without build.zig, this is the invocation the end-to-end harness uses:
# linux
zig build-exe hello-world.zig -lc -lazul -L. -I. -rpath . -femit-bin=hello-world
LD_LIBRARY_PATH=. ./hello-world
# macos (framework flags matter — see Common errors)
zig build-exe hello-world.zig -lc -lazul -L. -I. -rpath . \
-framework Foundation -framework AppKit -framework OpenGL \
-framework CoreGraphics -framework CoreText -femit-bin=hello-world
DYLD_LIBRARY_PATH=. ./hello-world
You should see the window pictured on the
hello-world landing page. Click the button: the
counter increments, layout re-runs, and the new value renders.
Common errors
error: C import failed—azul.his not on the C include path. The downloadedbuild.zigadds.automatically; with a manualzig build-exeyou must pass-I.yourself.build.zigdoes not compile / unknown field errors — your Zig is too old. The shippedbuild.ziguses theModuleAPI (Zig 0.13+), the example's lowercasecallconv(.c)needs 0.14+, and the release is tested against 0.16. Upgrade Zig rather than editing the manifest.- Type error at
AzButton_setOnClick— you passed anAzCallbackstruct (e.g. viaAzCallback_create) where the typed setter expects the bareAzButtonOnClickCallbackTypefn pointer. PassonClickdirectly. - Runtime:
library not found/cannot open shared object file— the generatedbuild.zigembeds no rpath, so keep theDYLD_LIBRARY_PATH=./LD_LIBRARY_PATH=.prefix from the install steps (or add-rpath .when usingzig build-exe). - Undefined symbols mentioning AppKit/OpenGL on macOS — when the
linker requires the system frameworks explicitly, add
-framework Foundation -framework AppKit -framework OpenGL -framework CoreGraphics -framework CoreText(the flags the e2e harness links with). - Counter does not update on click —
onClickreturnedAzUpdate_DoNothing, or the downcast failed. A failed downcast usually means the type-id does not match: the id must come from the address of the same global token variable used in the upcast.
Coming Up Next
- Application Architecture — architecting a larger Azul application
- Document Object Model — the Dom tree: node types, hierarchy, and CSS
- Hello World [Go]