Hello World [Go]
Introduction
The Go story is cgo directly against azul.h: the counter example
below puts the C header in a cgo preamble and calls C.AzApp_run,
C.AzDom_createBody etc. straight through. Two cgo features make this
work without any host-invoker machinery:
//exportturns a Go function into a real C symbol, so the framework can call back into Go with a plain C function pointer, and- cgo compiles a small C preamble into your package, which is where the fn-pointer cast helpers live (see the walkthrough below for why they are needed).
Because this is cgo, you need a C compiler at build time (gcc on
Linux, Xcode Command Line Tools clang on macOS, MinGW gcc on
Windows) in addition to Go 1.21+, and CGO_ENABLED=1 (the default on
native builds). Fair warning, straight from the binding header:
cross-compiling cgo programs is genuinely painful — build on the target
platform if you can.
A generated wrapper package (azul.go, types.go, functions.go,
wrappers.go — package azul) also ships with the release; the
verified example does not import it, it calls the C ABI directly.
Installation
You download the prebuilt library, the C header, and the cgo
hello-world, then build — main.go calls the C API directly through
cgo, so no generated Go package is needed:
# linux (requires gcc on PATH)
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/main.go
go mod init hello-world
CGO_CFLAGS="-I." CGO_LDFLAGS="-L. -lazul -lpthread -lm -ldl" go build -o hello-world .
LD_LIBRARY_PATH=. ./hello-world
# macos (requires Xcode CLT: xcode-select --install)
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/main.go
go mod init hello-world
CGO_CFLAGS="-I." CGO_LDFLAGS="-L. -lazul -framework AppKit -framework OpenGL -framework CoreGraphics -framework CoreText -framework CoreFoundation" go build -o hello-world .
DYLD_LIBRARY_PATH=. ./hello-world
:: windows (requires MinGW gcc on PATH)
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/main.go
go mod init hello-world
curl -O https://azul.rs/ui/release/0.2.0/azul.dll.lib
set CGO_ENABLED=1
set CGO_CFLAGS=-I.
set CGO_LDFLAGS=azul.dll.lib
go build -o hello-world.exe .
hello-world.exe
The CGO_CFLAGS / CGO_LDFLAGS environment variables are not
optional: main.go's own cgo block only says -lazul, so the include
path, library path, and the platform-specific link flags (system libs
on Linux, frameworks on macOS, the azul.dll.lib import library on
Windows) must come from the environment. On Windows, cgo links the
import library directly and azul.dll resolves from the current
directory at run time.
Simple „Counter“ Example
This is the exact main.go shipped in the release (the same file the
end-to-end test builds and clicks through):
// CGO_CFLAGS="-I." CGO_LDFLAGS="-L." go build && LD_LIBRARY_PATH=. ./hello-world
package main
/*
#cgo LDFLAGS: -lazul
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "azul.h"
// Forward declarations for the Go-exported callbacks below. cgo
// generates a header `_cgo_export.h` with these too, but pulling them
// in here lets the C-side cast lift `AzCallbackType` / `AzLayoutCallbackType`
// out into single helpers.
extern AzUpdate goOnClick (AzRefAny data, AzCallbackInfo info);
extern AzDom goLayout (AzRefAny data, AzLayoutCallbackInfo info);
extern void myDataDestructor (void* m);
// AzButton_setOnClick / AzWindowCreateOptions_create take a RAW C-ABI
// function pointer (AzCallbackType / AzLayoutCallbackType), NOT the
// AzCallback wrapper struct. cgo maps a raw fn-pointer typedef to
// `*[0]byte` and a struct to `_Ctype_struct_Az...`, so returning the
// struct here is a type error at the Go call site. Return the raw
// fn-pointer types directly.
static inline AzCallbackType make_click_callback (void) { return (AzCallbackType)goOnClick; }
static inline AzLayoutCallbackType make_layout_callback (void) { return (AzLayoutCallbackType)goLayout; }
static inline AzRefAnyDestructorType make_my_data_destructor (void) { return (AzRefAnyDestructorType)myDataDestructor; }
*/
import "C"
import (
"fmt"
"unsafe"
)
// Compile-time-unique type id: the address of a package var. upcast wraps
// the struct in an AzRefAny; downcast recovers a typed pointer.
type myDataModel struct {
counter C.uint32_t
}
// The address of this package var is the per-type RTTI id.
var myDataTypeToken byte
var myDataTypeID = C.uint64_t(uintptr(unsafe.Pointer(&myDataTypeToken)))
//export myDataDestructor
func myDataDestructor(_ unsafe.Pointer) {}
func myDataUpcast(model myDataModel) C.AzRefAny {
local := model // stack copy; AzRefAny_newC copies the bytes
typeName := []byte("MyDataModel")
cTypeName := C.AzString_fromUtf8((*C.uint8_t)(unsafe.Pointer(&typeName[0])), C.size_t(len(typeName)))
ptr := C.AzGlVoidPtrConst{
ptr: unsafe.Pointer(&local),
run_destructor: C.bool(false),
}
return C.AzRefAny_newC(
ptr,
C.size_t(unsafe.Sizeof(local)),
C.size_t(unsafe.Alignof(local)),
myDataTypeID,
cTypeName,
C.make_my_data_destructor(),
0, // serialize_fn
0, // deserialize_fn
)
}
func myDataDowncast(refany *C.AzRefAny) *myDataModel {
if !bool(C.AzRefAny_isType(refany, myDataTypeID)) {
return nil
}
raw := C.AzRefAny_getDataPtr(refany)
if raw == nil {
return nil
}
return (*myDataModel)(raw)
}
// ── Callback: button click ────────────────────────────────────────────
//export goOnClick
func goOnClick(data C.AzRefAny, _ C.AzCallbackInfo) C.AzUpdate {
d := data
m := myDataDowncast(&d)
if m == nil {
return C.AzUpdate_DoNothing
}
m.counter++
return C.AzUpdate_RefreshDom
}
// ── Layout callback ───────────────────────────────────────────────────
//export goLayout
func goLayout(data C.AzRefAny, _ C.AzLayoutCallbackInfo) C.AzDom {
d := data
m := myDataDowncast(&d)
if m == nil {
return C.AzDom_createBody()
}
// Counter label (wrapped in a div so the font-size sticks).
counterStr := []byte(fmt.Sprintf("%d", m.counter))
counterAz := C.AzString_fromUtf8((*C.uint8_t)(unsafe.Pointer(&counterStr[0])), C.size_t(len(counterStr)))
label := C.AzDom_createText(counterAz)
labelWrapper := C.AzDom_createDiv()
fontSize := C.AzStyleFontSize_px(C.float(32.0))
cssProp := C.AzCssProperty_fontSize(fontSize)
cond := C.AzCssPropertyWithConditions_simple(cssProp)
C.AzDom_addCssProperty(&labelWrapper, cond)
C.AzDom_addChild(&labelWrapper, label)
// AzButton_setOnClick takes the bare fn-pointer typedef; the C helper
// casts the //export'd goOnClick to AzCallbackType (see the preamble).
btnLabelBytes := []byte("Increase counter")
btnLabel := C.AzString_fromUtf8((*C.uint8_t)(unsafe.Pointer(&btnLabelBytes[0])), C.size_t(len(btnLabelBytes)))
button := C.AzButton_create(btnLabel)
C.AzButton_setButtonType(&button, C.AzButtonType_Primary)
dataClone := C.AzRefAny_clone(&d)
C.AzButton_setOnClick(&button, dataClone, C.make_click_callback())
buttonDom := C.AzButton_dom(button)
// Body.
body := C.AzDom_createBody()
C.AzDom_addChild(&body, labelWrapper)
C.AzDom_addChild(&body, buttonDom)
return body
}
// ── Main ──────────────────────────────────────────────────────────────
func main() {
model := myDataModel{counter: 5}
data := myDataUpcast(model)
window := C.AzWindowCreateOptions_create(C.make_layout_callback())
titleBytes := []byte("Hello World")
window.window_state.title = C.AzString_fromUtf8((*C.uint8_t)(unsafe.Pointer(&titleBytes[0])), C.size_t(len(titleBytes)))
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
app := C.AzApp_create(data, C.AzAppConfig_create())
C.AzApp_run(&app, window)
}
How callbacks work with cgo
Three moving parts, all visible in the preamble at the top of the file:
//export goOnClick— cgo compiles the Go function into a real C symbol with the exact C-ABI signatureAzUpdate (AzRefAny, AzCallbackInfo). The framework calls it like any other C function pointer; no reflection, no trampoline.externforward declarations — cgo generates these in_cgo_export.hanyway, but repeating them in the preamble lets the next piece reference the functions.static inline make_*_callback()cast helpers — the one cgo quirk.AzButton_setOnClickandAzWindowCreateOptions_createtake raw C fn-pointer typedefs (AzButtonOnClickCallbackType,AzLayoutCallbackType) — not a callback wrapper struct. cgo maps a raw fn-pointer typedef to the opaque Go type*[0]byte, and Go will not let you cast a Go function to that at the call site. So the cast happens once, on the C side, in a tiny helper that returns the already-cast pointer:C.make_click_callback(). (Ignore the stale inline comment above the button code that mentionsAzCallback_create— as the preamble comment explains, the setter takes the raw fn pointer, and that is what the code passes.)
Data flow on click: the framework matches the hit-test, calls the
exported goOnClick with the button's stored RefAny, your code
mutates counter and returns C.AzUpdate_RefreshDom, and the
framework re-invokes goLayout to rebuild the DOM with the new value.
How RefAny works in Go
RefAny is Azul's type-erased, refcounted box for application state.
The example hand-rolls the C AZ_REFLECT macro in ~30 lines:
- Type identity — the address of the package variable
myDataTypeTokenis process-unique and stable, so it serves as the runtime type id thatAzRefAny_isTypechecks before every downcast. - Upcast —
AzRefAny_newCcopiesunsafe.Sizeof(local)bytes into libazul's own heap allocation. This is also what makes the cgo pointer-passing rules happy: the Go pointer&localis only read during the call and never retained by C, so no Go memory is pinned.run_destructor: falsemeans libazul will not try to free the Go pointer — only its own heap copy is destroyed (via the exported destructor) when the last clone drops. - Downcast —
AzRefAny_isType+AzRefAny_getDataPtrrecover a typed*myDataModelpointing at libazul's heap copy (which is whym.counter++persists between callbacks). Both callbacks return a safe default when the downcast fails. AzRefAny_clone— bumps the atomic refcount (no deep copy); the clone's ownership moves into the button so the framework can hand the data back togoOnClicklater.
Strings work the same way: C.AzString_fromUtf8(ptr, len) copies the
bytes out of the Go byte slice into a refcounted buffer during the
call, so the slice can be garbage-collected afterwards.
Build and run
# macos
CGO_CFLAGS="-I." CGO_LDFLAGS="-L. -lazul -framework AppKit -framework OpenGL -framework CoreGraphics -framework CoreText -framework CoreFoundation" go build -o hello-world .
DYLD_LIBRARY_PATH=. ./hello-world
# linux
CGO_CFLAGS="-I." CGO_LDFLAGS="-L. -lazul -lpthread -lm -ldl" go build -o hello-world .
LD_LIBRARY_PATH=. ./hello-world
You should see the window pictured on the
hello-world landing page. Click the button: the
counter increments, goLayout re-runs, and the new value renders.
Common errors
found packages azul (azul.go) and main (main.go)— you also downloaded the generated Go package files (azul.go,types.go,functions.go,wrappers.go— allpackage azul). This example does not use them; keep them in a separate directory (or delete them), since Go allows only one package per directory.azul.h: No such file or directory/cannot find -lazul— theCGO_CFLAGS="-I."/CGO_LDFLAGS="-L. -lazul ..."environment variables are missing.main.go's cgo block only contains-lazul; the search paths and platform link flags must come from the environment (or edit the#cgolines).C compiler "gcc" not found/cgo: C compiler not available— cgo needs a native C toolchain:gcc(Linux), Xcode CLT (macOS,xcode-select --install), MinGW (Windows). Also checkCGO_ENABLED=1— it defaults to0when cross-compiling.- Runtime:
library not found/cannot open shared object file— the loader cannot findlibazul.{so,dylib}; keep theLD_LIBRARY_PATH=./DYLD_LIBRARY_PATH=.prefix from the install steps (on Windows,azul.dlljust has to sit next to the.exe). - Counter does not update on click —
goOnClickreturnedC.AzUpdate_DoNothing, or the downcast failed. A failing downcast usually means the type id passed toAzRefAny_newCand the one checked inmyDataDowncastare not the same package-var address. - Cross-compiling fails — expected; cgo cross-compiles need a full foreign C toolchain + sysroot. Build natively on each target platform.
Coming Up Next
- Application Architecture — architecting a larger Azul application
- Document Object Model — the Dom tree: node types, hierarchy, and CSS
- Hello World [Zig]