Guide

Hello World [Pascal]

Introduction

The Pascal binding is a single generated Azul unit (azul.pas) for Free Pascal 3.2+ that declares every Az* function as cdecl; external 'azul', plus a small „host-invoker“ layer that lets you write callbacks as ordinary Object Pascal classes. There is no extra runtime and no code generator to run on your side: you download one .pas file and the native library, and fpc does the rest.

Two compiler directives make the FFI work and must be present in any program that uses the unit (the example below has them):

  • {$mode objfpc}{$H+} — Object Pascal mode: classes, override, and ($H+) AnsiString as the default string type.
  • {$PACKRECORDS C} — forces C-ABI struct layout, so Az* records passed by value match the Rust extern "C" ABI exactly. Without it, field offsets silently differ and calls corrupt memory.

Installation

You need the Free Pascal Compiler (fpc, 3.2+ — apt install fp-compiler / brew install fpc), the generated azul.pas unit, and the native library. All three downloads come from the release page and are built by the release CI.

Linux:

curl -O https://azul.rs/ui/release/0.2.0/libazul.so
curl -O https://azul.rs/ui/release/0.2.0/azul.pas
curl -O https://azul.rs/ui/release/0.2.0/hello-world.pas
fpc -Mobjfpc -Sh -Fl. hello-world.pas
LD_LIBRARY_PATH=. ./hello-world

macOS:

curl -O https://azul.rs/ui/release/0.2.0/libazul.dylib
curl -O https://azul.rs/ui/release/0.2.0/azul.pas
curl -O https://azul.rs/ui/release/0.2.0/hello-world.pas
fpc -Mobjfpc -Sh -Fl. hello-world.pas
DYLD_LIBRARY_PATH=. ./hello-world

Windows:

curl -O https://azul.rs/ui/release/0.2.0/azul.dll
curl -O https://azul.rs/ui/release/0.2.0/azul.pas
curl -O https://azul.rs/ui/release/0.2.0/hello-world.pas
fpc -Mobjfpc -Sh -Fl. hello-world.pas
hello-world.exe

-Fl. adds the current directory to the linker's library search path. azul.pas carries a {$linklib azul} directive, so FPC links against libazul automatically — you do not need -k-lazul on the command line, only -Fl. (or a system-installed libazul) so the linker can find the library file.

Simple „Counter“ Example

This is the exact program shipped as examples/pascal/hello-world.pas:

program HelloWorld;

{$mode objfpc}{$H+}
{$PACKRECORDS C}

uses
  ctypes, sysutils,
  Azul;

type
  { Plain data model. }
  TMyModel = class
    Counter: Integer;
    constructor Create(c: Integer);
  end;

  { Click handler: bump counter and request a DOM refresh.
    Button.onClick is TYPED since the typed-callback API change: derive
    from TAzButtonOnClickCallbackInvoker, not the generic TAzCallbackInvoker. }
  TMyClickHandler = class(TAzButtonOnClickCallbackInvoker)
    procedure Invoke(id: cuint64; arg0: Pointer; arg1: Pointer; out_ptr: Pointer); override;
  end;

  { Layout handler: build the DOM. }
  TMyLayoutHandler = class(TAzLayoutCallbackInvoker)
    procedure Invoke(id: cuint64; arg0: Pointer; arg1: Pointer; out_ptr: Pointer); override;
  end;

constructor TMyModel.Create(c: Integer);
begin
  Counter := c;
end;

function MakeAzString(const s: ansistring): TAzString;
begin
  if Length(s) = 0 then
    Result := AzString_fromUtf8(nil, 0)
  else
    Result := AzString_fromUtf8(PChar(@s[1]), Length(s));
end;

procedure TMyClickHandler.Invoke(id: cuint64; arg0: Pointer; arg1: Pointer; out_ptr: Pointer);
var
  m: TObject;
begin
  m := azul_refany_get(PAzRefAny(arg0));
  if (m <> nil) and (m is TMyModel) then
    TMyModel(m).Counter := TMyModel(m).Counter + 1;
  if out_ptr <> nil then
    PAzUpdate(out_ptr)^ := TAzUpdate_RefreshDom;
end;

procedure TMyLayoutHandler.Invoke(id: cuint64; arg0: Pointer; arg1: Pointer; out_ptr: Pointer);
var
  m: TObject;
  counter_text, label_wrap, body: TDom;
  btn: TButton;
  click_handler: TMyClickHandler;
  click_cb: TAzButtonOnClickCallback;
  click_data: TAzRefAny;
begin
  m := azul_refany_get(PAzRefAny(arg0));
  if (m = nil) or not (m is TMyModel) then
  begin
    body := TDom.CreateBody;
    if out_ptr <> nil then
      PAzDom(out_ptr)^ := body.Release;
    body.Free;
    Exit;
  end;

  { Idiomatic wrapper classes: TDom.CreateText / CreateDiv / CreateBody are
    named constructors; builder methods return fresh TDom wrappers and
    consume their by-value inputs, so .Free on a consumed wrapper only
    releases the object shell, never the DOM it was moved into. }
  counter_text := TDom.CreateText(MakeAzString(IntToStr(TMyModel(m).Counter)));
  label_wrap := TDom.CreateDiv.WithCss(MakeAzString('font-size: 32px;'))
                              .WithChild(counter_text);

  click_handler := TMyClickHandler.Create;
  click_cb := azul_register_buttononclickcallback(click_handler);
  click_data := azul_refany_create(TMyModel(m));

  btn := TButton.Create(MakeAzString('Increase counter'))
                .WithButtonType(TAzButtonType_Primary)
                .WithOnClick(click_data, click_cb);

  body := TDom.CreateBody.WithChild(label_wrap).WithChild(btn.Dom);

  { Release detaches the raw record: ownership passes to libazul via out_ptr. }
  if out_ptr <> nil then
    PAzDom(out_ptr)^ := body.Release;

  { Free the wrapper shells (their records were consumed / released above). }
  counter_text.Free;
  label_wrap.Free;
  btn.Free;
  body.Free;
end;

var
  model: TMyModel;
  layout_handler: TMyLayoutHandler;
  data: TAzRefAny;
  layout_cb: TAzLayoutCallback;
  wco: TAzWindowCreateOptions;
  cfg: TAzAppConfig;
  app: TAzApp;

begin
  WriteLn('[azul] Pascal full-GUI hello-world starting.');

  model := TMyModel.Create(5);
  data := azul_refany_create(model);

  layout_handler := TMyLayoutHandler.Create;
  layout_cb := azul_register_layoutcallback(layout_handler);

  wco := AzWindowCreateOptions_default();
  wco.window_state.layout_callback := layout_cb;
  wco.window_state.size.dimensions.width := 400.0;
  wco.window_state.size.dimensions.height := 300.0;
  wco.window_state.flags.decorations := TAzWindowDecorations_NoTitleAutoInject;
  wco.window_state.flags.background_material := TAzWindowBackgroundMaterial_Sidebar;

  cfg := AzAppConfig_create();
  app := AzApp_create(data, cfg);
  AzApp_run(@app, wco);
end.

Five things to notice.

  • The host-invoker pattern — FPC cannot hand libazul a per-callback function pointer that captures state (there are no closures with a C ABI, and struct-by-value callback signatures are off-limits for most managed FFIs). Instead, the Azul unit registers one C stub per callback kind with libazul when the unit loads (its initialization block calls AzApp_setButtonOnClickCallbackInvoker, AzApp_setLayoutCallbackInvoker, and so on — about 20 kinds). Your handler is a plain object: subclass the matching invoker class, override Invoke, then call the matching azul_register_* function. That returns a small TAz*Callback record carrying a numeric handle; when the event fires, libazul calls the unit's stub with that handle, the stub looks your object up in a handle table and dispatches to your Invoke.
  • Typed invoker classes — since the typed-callback change every widget event has its own pair: derive from TAzButtonOnClickCallbackInvoker and register with azul_register_buttononclickcallback (analogously TAzLayoutCallbackInvoker / azul_register_layoutcallback). Deriving from a generic invoker class will not compile against the current unit — the override has no matching virtual method.
  • azul_refany_create / azul_refany_get — the Pascal analogue of RefAny. azul_refany_create(TObject) stores your object in the unit's handle table and wraps the handle in a TAzRefAny that libazul carries around; azul_refany_get(PAzRefAny(arg0)) in a callback hands the same instance back. Always guard with <> nil and is before the typecast, and fall back to an empty body / no-op on mismatch.
  • Ownership — the handle table owns every object you pass to azul_refany_create or azul_register_*, and Frees it exactly once when libazul drops the last reference (the table dedups by object identity and refcounts, so re-wrapping the same model on each relayout is safe). So do not Free those objects yourself — only the short-lived TDom / TButton wrapper shells you build inside a layout callback are yours to Free.
  • Raw Invoke signaturesarg0 is the data PAzRefAny, arg1 the (unused here) info pointer, and out_ptr is where the result is written: a click handler stores PAzUpdate(out_ptr)^ := TAzUpdate_RefreshDom, a layout handler stores PAzDom(out_ptr)^ := body.Release (the wrapper form) or PAzDom(out_ptr)^ := body (the raw record form). Forgetting the out_ptr write is the classic „window opens but nothing happens“ bug.
  • Fluent wrapper styleTDom.CreateDiv / TButton.Create are named constructors returning wrapper objects; WithCss, WithChild, WithButtonType, WithOnClick return a fresh wrapper so you can chain them (TDom.CreateDiv.WithCss(...).WithChild(...)). body.Release detaches the raw record and hands ownership to libazul through out_ptr. (The raw AzDom_* / AzButton_* record functions are still exported if you prefer the C-style by-value builder.) Strings cross the FFI as UTF-8 buffers: MakeAzString copies an AnsiString via AzString_fromUtf8.

Build and run

From the directory containing azul.pas, hello-world.pas and the native library:

fpc -Mobjfpc -Sh -Fl. hello-world.pas

# Linux
LD_LIBRARY_PATH=. ./hello-world
# macOS
DYLD_LIBRARY_PATH=. ./hello-world
# Windows
hello-world.exe

-Mobjfpc -Sh mirror the {$mode objfpc}{$H+} directives on the command line. azul.pas's {$linklib azul} handles the -lazul for you. If your linker still cannot find the library file, pass its directory to the linker explicitly:

fpc -Mobjfpc -Sh -Fl. -k-L. hello-world.pas

Compiling azul.pas takes about two seconds and prints two Comment level 2 found warnings — these come from directive text quoted inside the generated header comment and are harmless.

You should see the window pictured on the hello-world landing page. Click the button: the counter increments, TMyLayoutHandler.Invoke re-runs, and the new value renders.

Common errors

  • ld: symbol(s) not found / cannot find -lazul at the link step — the native library is not where the linker looks. Build from the directory holding libazul.{so,dylib} / azul.dll and keep -Fl. (or add the explicit -k-L. linker path shown above).
  • Runtime: „library not found“ — the loader cannot find the library. Export LD_LIBRARY_PATH=. (Linux) / DYLD_LIBRARY_PATH=. (macOS), or place azul.dll next to the .exe on Windows.
  • There is no method in an ancestor class to be overridden — you derived from the wrong invoker class or changed the Invoke signature. It must be exactly Invoke(id: cuint64; arg0: Pointer; arg1: Pointer; out_ptr: Pointer); override; on the typed per-widget class (TAzButtonOnClickCallbackInvoker, TAzLayoutCallbackInvoker, ...).
  • Counter does not update on click — the handler never wrote PAzUpdate(out_ptr)^ := TAzUpdate_RefreshDom, or azul_refany_get returned nil / a different class and the is guard skipped the increment. WriteLn in the failure branch to verify.
  • Random crashes when passing your own records to Az* functions — a unit is missing {$PACKRECORDS C}. Every unit that declares or passes Az* records by value needs it.
  • Growing memory in long-running apps — handler objects passed to azul_register_* are currently not freed when the native side releases the handle. The example creates a fresh TMyClickHandler per layout pass, which is fine for a demo but leaks a small object per relayout; in a long-running app, create your invoker instances once and reuse them.
  • Threads — the handle table behind azul_refany_create / azul_register_* is not synchronized. Register callbacks and create RefAnys from the main thread only; do not register/release from AzThread callbacks concurrently.

Coming Up Next