Guide

Hello World [Crystal]

Experimental / CI-validated. The Crystal binding is generated and compiled in CI but is not yet a front-page target. The FFI surface is the same one the C / Zig / Odin bindings use.

Introduction

Crystal talks to Azul through a plain C-ABI binding. Crystal has a first-class C interop layer (lib), so the generated azul.cr translates the whole FFI surface explicitly into one @[Link("azul")] lib LibAzul block: every AzString / AzDom becomes a Crystal struct, every enum a Crystal enum with an explicit backing integer, every tagged union a Crystal union, every callback typedef a Proc alias, and every exported symbol a fun binding whose C name is preserved verbatim.

Because a non-capturing Crystal proc (->(a : T) { ... }) compiles to a real C function pointer, callbacks are passed to Azul directly — like Zig, Go, Odin and C, Crystal needs neither a host-invoker trampoline nor a wrapper-struct dance. You pass the proc itself. (The proc must not capture any outer local, or Crystal will refuse to hand it to a C function; the example keeps its callbacks non-closure by reaching helpers through module constants.)

You need a recent Crystal release (1.0 or newer). The binding is shipped as a single azul.cr that the driver requires.

Installation

There is no shard/package-manager story for Crystal yet — you download the native library, the generated binding, and the hello-world driver into one directory, then build it with crystal build:

# linux
curl -O $HOSTNAME/ui/release/0.2.0/libazul.so
curl -O $HOSTNAME/ui/release/0.2.0/azul.cr
curl -O $HOSTNAME/ui/release/0.2.0/hello-world.cr
crystal build hello-world.cr --link-flags "-L."
LD_LIBRARY_PATH=. ./hello-world
# macos
curl -O $HOSTNAME/ui/release/0.2.0/libazul.dylib
curl -O $HOSTNAME/ui/release/0.2.0/azul.cr
curl -O $HOSTNAME/ui/release/0.2.0/hello-world.cr
crystal build hello-world.cr --link-flags "-L. -framework Foundation -framework AppKit -framework OpenGL -framework CoreGraphics -framework CoreText"
DYLD_LIBRARY_PATH=. ./hello-world
# windows
curl -O $HOSTNAME/ui/release/0.2.0/azul.dll
curl -O $HOSTNAME/ui/release/0.2.0/azul.cr
curl -O $HOSTNAME/ui/release/0.2.0/hello-world.cr
crystal build hello-world.cr --link-flags "/LIBPATH:."
hello-world.exe

@[Link("azul")] (inside azul.cr) makes the linker add -lazul; the --link-flags "-L." 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.cr shipped in the release (the same file the end-to-end test builds and clicks through). It calls the LibAzul.* functions directly; azul.cr also emits idiomatic type aliases without the Az prefix under a module Azul (e.g. Azul::Dom), namespaced so they never shadow Crystal core types like String.

require "./azul"

module MyData
  struct Model
    property counter : UInt32

    def initialize(@counter : UInt32)
    end
  end

  TOKEN = Pointer(UInt8).malloc(1)

  def self.type_id : UInt64
    TOKEN.address.to_u64
  end

  DESTRUCTOR = ->(_ptr : Void*) { }

  def self.upcast(model : Model) : LibAzul::AzRefAny
    local = model
    type_name = "Model"
    name = LibAzul.azString_fromUtf8(type_name.to_unsafe, LibC::SizeT.new(type_name.bytesize))

    wrapper = LibAzul::AzGlVoidPtrConst.new
    wrapper.ptr = pointerof(local).as(Void*)
    wrapper.run_destructor = false

    LibAzul.azRefAny_newC(
      wrapper,
      LibC::SizeT.new(sizeof(Model)),
      LibC::SizeT.new(alignof(Model)),
      type_id,
      name,
      DESTRUCTOR,
      LibC::SizeT.new(0),
      LibC::SizeT.new(0)
    )
  end

  def self.downcast(refany : LibAzul::AzRefAny*) : Model*
    return Pointer(Model).null unless LibAzul.azRefAny_isType(refany, type_id)
    ptr = LibAzul.azRefAny_getDataPtr(refany)
    return Pointer(Model).null if ptr.null?
    ptr.as(Model*)
  end
end

ON_CLICK = ->(data : LibAzul::AzRefAny, _info : LibAzul::AzCallbackInfo) : LibAzul::AzUpdate {
  d = data
  m = MyData.downcast(pointerof(d))
  next LibAzul::AzUpdate::DoNothing if m.null?
  m.value.counter += 1
  LibAzul::AzUpdate::RefreshDom
}

LAYOUT = ->(data : LibAzul::AzRefAny, _info : LibAzul::AzLayoutCallbackInfo) : LibAzul::AzDom {
  d = data
  m = MyData.downcast(pointerof(d))
  next LibAzul.azDom_createBody if m.null?

  text = m.value.counter.to_s
  counter_str = LibAzul.azString_fromUtf8(text.to_unsafe, LibC::SizeT.new(text.bytesize))
  label = LibAzul.azDom_createText(counter_str)

  label_wrapper = LibAzul.azDom_createDiv
  font_size = LibAzul.azStyleFontSize_px(32.0_f32)
  css_prop = LibAzul.azCssProperty_fontSize(font_size)
  cond = LibAzul.azCssPropertyWithConditions_simple(css_prop)
  LibAzul.azDom_addCssProperty(pointerof(label_wrapper), cond)
  LibAzul.azDom_addChild(pointerof(label_wrapper), label)

  btn_label = "Increase counter"
  button = LibAzul.azButton_create(
    LibAzul.azString_fromUtf8(btn_label.to_unsafe, LibC::SizeT.new(btn_label.bytesize))
  )
  LibAzul.azButton_setButtonType(pointerof(button), LibAzul::AzButtonType::Primary)
  data_clone = LibAzul.azRefAny_clone(pointerof(d))
  LibAzul.azButton_setOnClick(pointerof(button), data_clone, ON_CLICK)
  button_dom = LibAzul.azButton_dom(button)

  body = LibAzul.azDom_createBody
  LibAzul.azDom_addChild(pointerof(body), label_wrapper)
  LibAzul.azDom_addChild(pointerof(body), button_dom)
  body
}

model = MyData::Model.new(5_u32)
data = MyData.upcast(model)

window = LibAzul.azWindowCreateOptions_create(LAYOUT)
title = "Hello World"
window.window_state.title = LibAzul.azString_fromUtf8(title.to_unsafe, LibC::SizeT.new(title.bytesize))
window.window_state.size.dimensions.width = 400.0_f32
window.window_state.size.dimensions.height = 300.0_f32
window.window_state.flags.decorations = LibAzul::AzWindowDecorations::NoTitleAutoInject
window.window_state.flags.background_material = LibAzul::AzWindowBackgroundMaterial::Sidebar

app = LibAzul.azApp_create(data, LibAzul.azAppConfig_create)
LibAzul.azApp_run(pointerof(app), window)

Callbacks are bare C function pointers

ON_CLICK and LAYOUT are non-capturing procs, which makes them ABI-identical to the C typedefs AzButtonOnClickCallbackType and AzLayoutCallbackType (emitted in azul.cr as Proc aliases). You pass the proc itself:

LibAzul.azButton_setOnClick(pointerof(button), data_clone, ON_CLICK)
window = LibAzul.azWindowCreateOptions_create(LAYOUT)

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

The one Crystal-specific rule: a proc handed to a C function must not capture. The example keeps its callbacks closure-free by reaching every helper through a constant (MyData.type_id, MyData.downcast, ON_CLICK) rather than an outer local variable. Had they closed over a local, Crystal would reject the assignment at compile time (“can't pass closure to C function“).

How RefAny works in Crystal

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 identityMyData.type_id returns the address of a one-byte heap token (TOKEN). It is process-unique and stable, so azRefAny_isType can verify a downcast at run time.
  • UpcastazRefAny_newC copies sizeof(Model) bytes into a refcounted heap allocation, so pointing it at a stack local is fine; run_destructor = false tells libazul not to free the caller's pointer.
  • DowncastazRefAny_isType + azRefAny_getDataPtr recover a typed Model*; both callbacks bail out (Pointer(Model).null / createBody) when the check fails.

azRefAny_clone(pointerof(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 (m.value.counter += 1 writes through the pointer), returns AzUpdate::RefreshDom, and the framework re-runs LAYOUT, which reads the new value.

Two more things worth noticing:

  • StringsazString_fromUtf8(ptr, len) copies the bytes into a refcounted heap buffer, so passing a Crystal String's to_unsafe pointer is safe: the AzString owns its own copy.
  • Typed CSS — instead of parsing a CSS string, the example builds the property programmatically: azStyleFontSize_px(32.0_f32)azCssProperty_fontSizeazCssPropertyWithConditions_simpleazDom_addCssProperty.

Build and run

# linux
crystal build hello-world.cr --link-flags "-L."
LD_LIBRARY_PATH=. ./hello-world

# macos (framework flags matter — see Common errors)
crystal build hello-world.cr --link-flags "-L. -framework Foundation -framework AppKit -framework OpenGL -framework CoreGraphics -framework CoreText"
DYLD_LIBRARY_PATH=. ./hello-world

# windows
crystal build hello-world.cr --link-flags "/LIBPATH:."
hello-world.exe

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

  • can't find file './azul' — the generated binding is not next to the driver. azul.cr must sit in the same directory you run crystal build hello-world.cr in (the install steps curl it there).
  • undefined reference to Az... at link time — the linker cannot find libazul. Keep --link-flags "-L." and make sure the native library sits in the current directory.
  • can't pass closure to C function — a callback proc captured an outer local. Reach helpers through constants / module methods (as the example does) so the proc stays non-closure.
  • Runtime: cannot open shared object file / library not found — the binary embeds no rpath, so keep the LD_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 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 TOKEN used in the upcast.

Coming Up Next