Hello World [Odin]
Introduction
Odin talks to Azul through a plain C-ABI binding. Odin has no C-header
importer, so the generated azul.odin translates the whole FFI surface
explicitly: every AzString / AzDom becomes an Odin struct, every
enum an Odin enum with an explicit backing integer, every tagged union
a struct #raw_union, and every exported symbol is declared inside a
@(default_calling_convention="c") foreign azul { ... } block.
Because an Odin procedure declared proc "c" is a real C function
pointer, callbacks are passed to Azul directly — like Zig, Go and C,
Odin needs neither a host-invoker trampoline nor a wrapper-struct dance.
You pass the procedure itself.
You need a recent Odin release (dev-2024 or newer). The binding is
shipped as an azul/ subpackage that the package main driver imports
with import azul "azul".
Installation
There is no package-manager story for Odin yet — you download the native
library, the generated binding into an azul/ subdirectory, and the
hello-world driver, then build the directory with odin build .:
# linux
curl -O https://azul.rs/ui/release/0.2.0/libazul.so
curl --create-dirs -o azul/azul.odin https://azul.rs/ui/release/0.2.0/azul/azul.odin
curl -O https://azul.rs/ui/release/0.2.0/hello-world.odin
odin build . -out:hello-world -extra-linker-flags:"-L."
LD_LIBRARY_PATH=. ./hello-world
# macos
curl -O https://azul.rs/ui/release/0.2.0/libazul.dylib
curl --create-dirs -o azul/azul.odin https://azul.rs/ui/release/0.2.0/azul/azul.odin
curl -O https://azul.rs/ui/release/0.2.0/hello-world.odin
odin build . -out:hello-world -extra-linker-flags:"-L. -framework Foundation -framework AppKit -framework OpenGL -framework CoreGraphics -framework CoreText"
DYLD_LIBRARY_PATH=. ./hello-world
# windows
curl -O https://azul.rs/ui/release/0.2.0/azul.dll
curl --create-dirs -o azul/azul.odin https://azul.rs/ui/release/0.2.0/azul/azul.odin
curl -O https://azul.rs/ui/release/0.2.0/hello-world.odin
odin build . -out:hello-world.exe -extra-linker-flags:"-L."
hello-world.exe
foreign import azul "system:azul" (inside azul.odin) makes the linker
add -lazul; the -L. extra-linker-flag points it at the libazul you
just downloaded. The LD_LIBRARY_PATH=. / DYLD_LIBRARY_PATH=. prefix
is needed at run time because the binary embeds no rpath — the dynamic
loader has to be told where the library lives.
Simple „Counter“ Example
This is the exact hello-world.odin shipped in the release (the same
file the end-to-end test builds and clicks through). It uses the raw
azul.Az* symbols; azul.odin also emits idiomatic aliases without the
Az prefix (e.g. azul.App_create), which are the raw procedures under
a shorter name.
package main
import azul "azul"
MyDataModel :: struct {
counter: u32,
}
MY_DATA_TYPE_TOKEN: u8 = 0
my_data_type_id :: proc "contextless" () -> u64 {
return u64(uintptr(&MY_DATA_TYPE_TOKEN))
}
my_data_destructor :: proc "c" (_: rawptr) {
}
my_data_upcast :: proc(model: MyDataModel) -> azul.AzRefAny {
local := model
type_name_bytes := "MyDataModel"
type_name := azul.AzString_fromUtf8(raw_data(type_name_bytes), uint(len(type_name_bytes)))
ptr_wrapper := azul.AzGlVoidPtrConst{ ptr = &local, run_destructor = false }
return azul.AzRefAny_newC(
ptr_wrapper,
uint(size_of(MyDataModel)),
uint(align_of(MyDataModel)),
my_data_type_id(),
type_name,
my_data_destructor,
0, 0,
)
}
my_data_downcast :: proc "contextless" (refany: ^azul.AzRefAny) -> ^MyDataModel {
if !azul.AzRefAny_isType(refany, my_data_type_id()) {
return nil
}
ptr := azul.AzRefAny_getDataPtr(refany)
if ptr == nil { return nil }
return cast(^MyDataModel)ptr
}
on_click :: proc "c" (data: azul.AzRefAny, info: azul.AzCallbackInfo) -> azul.AzUpdate {
d := data
m := my_data_downcast(&d)
if m == nil { return azul.AzUpdate.DoNothing }
m.counter += 1
return azul.AzUpdate.RefreshDom
}
layout :: proc "c" (data: azul.AzRefAny, info: azul.AzLayoutCallbackInfo) -> azul.AzDom {
d := data
m := my_data_downcast(&d)
if m == nil { return azul.AzDom_createBody() }
buf: [16]u8
n := u32_write(m.counter, buf[:])
counter_str := azul.AzString_fromUtf8(raw_data(buf[:]), uint(n))
label := azul.AzDom_createText(counter_str)
label_wrapper := azul.AzDom_createDiv()
font_size := azul.AzStyleFontSize_px(32.0)
css_prop := azul.AzCssProperty_fontSize(font_size)
cond := azul.AzCssPropertyWithConditions_simple(css_prop)
azul.AzDom_addCssProperty(&label_wrapper, cond)
azul.AzDom_addChild(&label_wrapper, label)
btn_label := azul.AzString_fromUtf8(raw_data("Increase counter"), 16)
button := azul.AzButton_create(btn_label)
azul.AzButton_setButtonType(&button, azul.AzButtonType.Primary)
data_clone := azul.AzRefAny_clone(&d)
azul.AzButton_setOnClick(&button, data_clone, on_click)
button_dom := azul.AzButton_dom(button)
body := azul.AzDom_createBody()
azul.AzDom_addChild(&body, label_wrapper)
azul.AzDom_addChild(&body, button_dom)
return body
}
main :: proc() {
model := MyDataModel{ counter = 5 }
data := my_data_upcast(model)
window := azul.AzWindowCreateOptions_create(layout)
window.window_state.title = azul.AzString_fromUtf8(raw_data("Hello World"), 11)
window.window_state.size.dimensions.width = 400.0
window.window_state.size.dimensions.height = 300.0
window.window_state.flags.decorations = azul.AzWindowDecorations.NoTitleAutoInject
window.window_state.flags.background_material = azul.AzWindowBackgroundMaterial.Sidebar
app := azul.AzApp_create(data, azul.AzAppConfig_create())
azul.AzApp_run(&app, window)
}
(The u32_write helper — a contextless integer-to-decimal formatter — is
elided above; see the shipped example for the full ~15 lines. It avoids
pulling in core:fmt so the proc "c" layout never needs to set up an
Odin context.)
Callbacks are bare C function pointers
on_click and layout are declared proc "c", which makes them
ABI-identical to the C typedefs AzButtonOnClickCallbackType and
AzLayoutCallbackType. You pass the procedure itself:
azul.AzButton_setOnClick(&button, data_clone, on_click)
window := azul.AzWindowCreateOptions_create(layout)
The typed AzButton_setOnClick takes the bare fn pointer, not an
AzButtonOnClickCallback struct — azul.odin binds the raw C variant
whose argument is the proc "c" typedef. There is no host-invoker, no
closure allocation, and no hidden registry: the framework stores your
pointer and calls straight back into your Odin code on the UI thread.
Helper procedures that a proc "c" callback calls (here my_data_type_id
and my_data_downcast) are declared proc "contextless" so they can be
invoked without an Odin context in scope.
How RefAny works in Odin
RefAny is Azul's type-erased, reference-counted box for your
application state. The example hand-rolls the same three pieces the C
AZ_REFLECT macro generates:
- Type identity —
my_data_type_id()returns the address of a package global (&MY_DATA_TYPE_TOKEN). It is process-unique and stable, soAzRefAny_isTypecan verify a downcast at run time. - Upcast —
AzRefAny_newCcopiessize_of(MyDataModel)bytes into a refcounted heap allocation, so pointing it at a stack local is fine;run_destructor = falsetells libazul not to free the caller's pointer. - Downcast —
AzRefAny_isType+AzRefAny_getDataPtrrecover a typed^MyDataModel; both callbacks bail out (return nil/createBody()) when the check fails.
AzRefAny_clone(&d) bumps the (atomic) reference count — it does not
deep-copy your struct. On click the framework matches the hit-test, calls
on_click with the stored RefAny, your code downcasts and increments
counter, returns azul.AzUpdate.RefreshDom, and 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, so passing a stack[16]u8buffer throughraw_data(buf[:])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.
Build and run
# linux
odin build . -out:hello-world -extra-linker-flags:"-L."
LD_LIBRARY_PATH=. ./hello-world
# macos (framework flags matter — see Common errors)
odin build . -out:hello-world -extra-linker-flags:"-L. -framework Foundation -framework AppKit -framework OpenGL -framework CoreGraphics -framework CoreText"
DYLD_LIBRARY_PATH=. ./hello-world
# windows
odin build . -out:hello-world.exe -extra-linker-flags:"-L."
hello-world.exe
odin build . compiles the current directory as package main and
resolves import azul "azul" to the azul/ subpackage next to it. 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
could not find package "azul"— the binding is not where the import expects it.azul.odinmust live in anazul/subdirectory of the directory you runodin build .in (the install stepscurlit toazul/azul.odin).undefined reference to Az...at link time — the linker cannot findlibazul. Keep-extra-linker-flags:"-L."and make sure the native library sits in the current directory.- Runtime:
cannot open shared object file/library not found— the binary embeds no rpath, so keep theLD_LIBRARY_PATH=./DYLD_LIBRARY_PATH=.prefix from the install steps. - Undefined symbols mentioning AppKit/OpenGL on macOS — add the
system frameworks:
-framework Foundation -framework AppKit -framework OpenGL -framework CoreGraphics -framework CoreText. - Counter does not update on click —
on_clickreturnedAzUpdate.DoNothing, or the downcast failed. A failed downcast usually means the type-id does not match: it must come from the address of the same global token 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 [Zig]