Guide

Hello World [Julia]

Experimental / CI-validated. The Julia binding is an off-frontpage, C-ABI-direct target. It is generated and exercised end-to-end by CI, but is not part of the curated front-page language set.

Introduction

Julia talks to Azul through a plain C-ABI binding. The generated azul.jl translates the whole FFI surface explicitly: every AzString / AzDom becomes an isbits Julia struct, every enum an @enum with an explicit backing integer, every tagged union an isbits byte-blob struct, and every exported symbol a thin ccall wrapper function inside module Azul.

Because @cfunction(f, Ret, (Args...,)) mints a real C function pointer from any top-level Julia function, callbacks are passed to Azul directly — like Odin, Zig, Go and C, Julia needs neither a host-invoker trampoline nor a wrapper-struct dance. You pass the pointer itself. (A callback fired from a foreign thread needs extra @cfunction care, but the counter's callbacks fire on the main event-loop thread, so this is fine.)

You need a recent Julia (1.6+). No packages are required — ccall, @cfunction and @enum are all in Base. The binding is shipped as an azul/ subdirectory that the driver loads with include(".../azul/azul.jl"); using .Azul.

Installation

There is no package-manager story for Julia yet — you download the native library, the generated binding into an azul/ subdirectory, and the hello-world driver, then run it (Julia JIT-compiles on the fly, so there is no separate build step):

# linux
curl -O https://azul.rs/ui/release/0.2.0/libazul.so
curl --create-dirs -o azul/azul.jl https://azul.rs/ui/release/0.2.0/azul/azul.jl
curl -O https://azul.rs/ui/release/0.2.0/hello-world.jl
AZUL_LIB=$PWD/libazul.so julia hello-world.jl
# macos
curl -O https://azul.rs/ui/release/0.2.0/libazul.dylib
curl --create-dirs -o azul/azul.jl https://azul.rs/ui/release/0.2.0/azul/azul.jl
curl -O https://azul.rs/ui/release/0.2.0/hello-world.jl
AZUL_LIB=$PWD/libazul.dylib julia hello-world.jl
# windows (PowerShell)
curl -O https://azul.rs/ui/release/0.2.0/azul.dll
curl --create-dirs -o azul/azul.jl https://azul.rs/ui/release/0.2.0/azul/azul.jl
curl -O https://azul.rs/ui/release/0.2.0/hello-world.jl
$env:AZUL_LIB = "$PWD\azul.dll"; julia hello-world.jl

The binding's ccall((:AzApp_create, LIBAZUL), …) dlopens libazul at call time. LIBAZUL defaults to the plain library name (libazul / azul); set the AZUL_LIB environment variable to an absolute path — as above — so the loader finds the library you just downloaded without having to touch LD_LIBRARY_PATH / DYLD_LIBRARY_PATH.

Simple „Counter“ Example

This is the exact hello-world.jl shipped in the release (the same file the end-to-end test runs and clicks through). It uses the raw Azul.Az* names; azul.jl also emits idiomatic aliases without the Az prefix (e.g. Azul.App_create), which are the raw wrappers under a shorter name.

include(joinpath(@__DIR__, "azul", "azul.jl"))
using .Azul

struct MyDataModel
    counter::UInt32
end

const MY_DATA_TOKEN = Ref{UInt8}(0)
my_data_type_id() = UInt64(UInt(pointer_from_objref(MY_DATA_TOKEN)))
my_data_destructor(::Ptr{Cvoid})::Cvoid = nothing
vptr(r::Ref) = Ptr{Cvoid}(pointer_from_objref(r))

function my_data_upcast(model::MyDataModel)
    local_ref = Ref(model)
    return GC.@preserve local_ref begin
        wrapper = Azul.AzGlVoidPtrConst(vptr(local_ref), false)
        dtor = @cfunction(my_data_destructor, Cvoid, (Ptr{Cvoid},))
        Azul.AzRefAny_newC(
            wrapper,
            Csize_t(sizeof(MyDataModel)),
            Csize_t(Base.datatype_alignment(MyDataModel)),
            my_data_type_id(),
            Azul.az_string("MyDataModel"),
            dtor, C_NULL, C_NULL)
    end
end

function my_data_ptr(dref::Ref{Azul.AzRefAny})
    p = vptr(dref)
    Azul.AzRefAny_isType(p, my_data_type_id()) || return Ptr{MyDataModel}(C_NULL)
    return Ptr{MyDataModel}(Azul.AzRefAny_getDataPtr(p))
end

function on_click(data::Azul.AzRefAny, info::Azul.AzCallbackInfo)::Azul.AzUpdate
    dref = Ref(data)
    return GC.@preserve dref begin
        mp = my_data_ptr(dref)
        if mp == Ptr{MyDataModel}(C_NULL)
            Azul.AzUpdate_DoNothing
        else
            m = unsafe_load(mp)
            unsafe_store!(mp, MyDataModel(m.counter + UInt32(1)))
            Azul.AzUpdate_RefreshDom
        end
    end
end

function layout(data::Azul.AzRefAny, info::Azul.AzLayoutCallbackInfo)::Azul.AzDom
    dref = Ref(data)
    counter = GC.@preserve dref begin
        mp = my_data_ptr(dref)
        mp == Ptr{MyDataModel}(C_NULL) ? nothing : unsafe_load(mp).counter
    end
    counter === nothing && return Azul.AzDom_createBody()

    label = Azul.AzDom_createText(Azul.az_string(string(counter)))
    label_wrapper = Ref(Azul.AzDom_createDiv())
    css_prop = Azul.AzCssProperty_fontSize(Azul.AzStyleFontSize_px(32.0f0))
    cond = Azul.AzCssPropertyWithConditions_simple(css_prop)
    GC.@preserve label_wrapper begin
        Azul.AzDom_addCssProperty(vptr(label_wrapper), cond)
        Azul.AzDom_addChild(vptr(label_wrapper), label)
    end

    button = Ref(Azul.AzButton_create(Azul.az_string("Increase counter")))
    on_click_ptr = @cfunction(on_click, Azul.AzUpdate, (Azul.AzRefAny, Azul.AzCallbackInfo))
    button_dom = GC.@preserve button dref begin
        Azul.AzButton_setButtonType(vptr(button), Azul.AzButtonType_Primary)
        data_clone = Azul.AzRefAny_clone(vptr(dref))
        Azul.AzButton_setOnClick(vptr(button), data_clone, on_click_ptr)
        Azul.AzButton_dom(button[])
    end

    body = Ref(Azul.AzDom_createBody())
    GC.@preserve body begin
        Azul.AzDom_addChild(vptr(body), label_wrapper[])
        Azul.AzDom_addChild(vptr(body), button_dom)
    end
    return body[]
end

function main()
    data = my_data_upcast(MyDataModel(UInt32(5)))
    layout_ptr = @cfunction(layout, Azul.AzDom, (Azul.AzRefAny, Azul.AzLayoutCallbackInfo))
    window = Azul.AzWindowCreateOptions_create(layout_ptr)

    ws = window.window_state
    window = Azul.setfields(window;
        window_state = Azul.setfields(ws;
            title = Azul.az_string("Hello World"),
            size = Azul.setfields(ws.size;
                dimensions = Azul.setfields(ws.size.dimensions; width = 400.0f0, height = 300.0f0)),
            flags = Azul.setfields(ws.flags;
                decorations = Azul.AzWindowDecorations_NoTitleAutoInject,
                background_material = Azul.AzWindowBackgroundMaterial_Sidebar)))

    app = Ref(Azul.AzApp_create(data, Azul.AzAppConfig_create()))
    GC.@preserve app Azul.AzApp_run(vptr(app), window)
end

main()

Callbacks are bare C function pointers

@cfunction(on_click, AzUpdate, (AzRefAny, AzCallbackInfo)) compiles on_click into a native C function pointer (Ptr{Cvoid}) that is ABI-identical to the C typedef AzButtonOnClickCallbackType. You pass the pointer itself:

Azul.AzButton_setOnClick(vptr(button), data_clone, on_click_ptr)
window = Azul.AzWindowCreateOptions_create(layout_ptr)

The typed AzButton_setOnClick takes the bare fn pointer, not an AzButtonOnClickCallback struct — azul.jl binds the raw C variant whose argument is the callback typedef. There is no host-invoker, no closure allocation, and no hidden registry: the framework stores your pointer and calls straight back into your Julia code on the UI thread.

Structs are isbits (passed by value)

Every Az* type is emitted as an isbits Julia struct, so ccall passes it by value with the C ABI — no boxing, no wrapper. The trade-off: isbits structs are immutable, so you cannot write window.window_state.title = …. The generated setfields(x; field = …) helper returns a copy with the named fields replaced, and nested updates compose:

window = Azul.setfields(window;
    window_state = Azul.setfields(ws; title = Azul.az_string("Hello World")))

For C functions that mutate through a &mut self pointer (AzDom_addChild, AzButton_setOnClick), keep the value in a Ref, pass a Ptr{Cvoid} to its storage (vptr(ref) under GC.@preserve), and read the result back with ref[].

Tagged unions

A Rust #[repr(C, u8)] tagged union (e.g. AzCssProperty) has no Julia equivalent, so azul.jl emits it as an isbits byte-blob struct whose size and alignment are computed at module load from the per-variant structs. It is byte-for-byte ABI-compatible with the C union; you never read its fields in Julia — you construct it via a C function (AzCssProperty_fontSize(…)) and pass it straight back (AzCssPropertyWithConditions_simple(…)).

How RefAny works in Julia

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 identitymy_data_type_id() returns the address of a module-global Ref (pointer_from_objref(MY_DATA_TOKEN)). It is process-unique and stable, so AzRefAny_isType can verify a downcast at run time.
  • UpcastAzRefAny_newC copies sizeof(MyDataModel) bytes into a refcounted heap allocation, so pointing it at a Ref-boxed local is fine; run_destructor = false tells libazul not to free the caller's pointer.
  • DowncastAzRefAny_isType + AzRefAny_getDataPtr recover a Ptr{MyDataModel}; both callbacks bail out when the check fails.

AzRefAny_clone(vptr(dref)) bumps the (atomic) reference count — it does not deep-copy your struct. On click the framework calls on_click with the stored RefAny; your code recovers the pointer, unsafe_store!s the incremented counter, returns Azul.AzUpdate_RefreshDom, and the framework re-runs layout, which reads the new value.

Two more things worth noticing:

  • Stringsaz_string(s) wraps AzString_fromUtf8(ptr, len), which copies the bytes into a refcounted heap buffer, so a temporary Julia string is safe.
  • Typed CSS — instead of parsing a CSS string, the example builds the property programmatically: AzStyleFontSize_px(32.0f0)AzCssProperty_fontSizeAzCssPropertyWithConditions_simpleAzDom_addCssProperty.

Run

# linux
AZUL_LIB=$PWD/libazul.so julia hello-world.jl

# macos
AZUL_LIB=$PWD/libazul.dylib julia hello-world.jl

# windows (PowerShell)
$env:AZUL_LIB = "$PWD\azul.dll"; julia hello-world.jl

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 load library "libazul" — the loader cannot find the native library. Set AZUL_LIB to its absolute path (the install steps do this), or put it on LD_LIBRARY_PATH / DYLD_LIBRARY_PATH / PATH.
  • UndefVarError: Azul not defined — the binding is not where the include expects it. azul.jl must live in an azul/ subdirectory next to hello-world.jl (the install steps curl it to azul/azul.jl).
  • @cfunction / signature errors — the callback's Julia signature must match the C typedef exactly: on_click(::AzRefAny, ::AzCallbackInfo) -> AzUpdate, layout(::AzRefAny, ::AzLayoutCallbackInfo) -> AzDom. All three types are isbits, so they cross the boundary by value.
  • Counter does not update on clickon_click returned AzUpdate_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