Guide

Hello World [Fortran]

Introduction

The Fortran binding targets Fortran 2003+ and talks to the prebuilt libazul native library through iso_c_binding. Everything lives in a single generated module, azul.f90, which has two layers:

  • az_* — raw bind(C) interfaces mirroring azul.h one-to-one (az_dom_create_body(), az_button_create(...), ...). Structs are passed by value with the exact C size and alignment; tagged unions (every AzOption* / AzResult* / union type) are emitted as ABI-opaque blobs, because Fortran has no native union — you construct and inspect them exclusively through the C-API helper functions, never through field access.
  • azul_* — a small host-invoker convenience layer on top: azul_register_<kind>() turns a bind(C) module procedure into an Az<Kind>Callback value, and azul_refany_create() / azul_refany_get() wrap and recover your data model pointer.

Callbacks dispatch through libazul's host-invoker plumbing: each registered procedure gets a handle id, and when the framework fires the callback it calls back into a per-kind invoker inside azul.f90 that looks the procedure up and invokes it. You wire those invokers up once at startup with azul_host_invoker_init().

Installation

You need GFortran (any recent version; on macOS brew install gcc provides it, on Windows use the MinGW-w64 gfortran) and make. The download set is: the native library, the generated azul.f90 module, the generated Makefile, and the counter example source.

# linux
curl -O https://azul.rs/ui/release/0.2.0/libazul.so
curl -O https://azul.rs/ui/release/0.2.0/azul.f90
curl -O https://azul.rs/ui/release/0.2.0/Makefile
curl -O https://azul.rs/ui/release/0.2.0/hello_world.f90
make
./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.f90
curl -O https://azul.rs/ui/release/0.2.0/Makefile
curl -O https://azul.rs/ui/release/0.2.0/hello_world.f90
make
DYLD_LIBRARY_PATH=. ./hello_world
# windows (MSYS2 / MinGW-w64 shell, azul.dll next to the .exe)
curl -O https://azul.rs/ui/release/0.2.0/azul.dll
curl -O https://azul.rs/ui/release/0.2.0/azul.f90
curl -O https://azul.rs/ui/release/0.2.0/Makefile
curl -O https://azul.rs/ui/release/0.2.0/hello_world.f90
make
hello_world.exe

Use the shipped Makefile instead of invoking gfortran by hand: it carries the required -ffree-line-length-none flag. The generated azul.f90 contains declaration lines beyond the F2008 132-column limit (long widget/callback type names), and without the flag -std=f2008 turns each of them into a hard „Line truncated“ error.

On macOS the Makefile's embedded rpath ($ORIGIN) is an ELF convention that the Mach-O loader ignores, so run the binary with DYLD_LIBRARY_PATH=. as shown above (or fix the install name once with install_name_tool).

Compiling azul.f90 takes a few seconds and produces azul.o plus a compiler-managed azul.mod that your program uses — both are cached by make, so incremental rebuilds only recompile your own source.

Simple „Counter“ Example

This is the complete, verified hello_world.f90 (the same file the install step downloads):

! Full-GUI Fortran hello-world: counter label + "Increase counter" button.
!
! Build & run:  make && ./hello_world     (Makefile ships next to azul.f90)
!
! Callbacks go through azul.f90's host-invoker dispatch: register a
! bind(C) module procedure via azul_register_<kind>() and the returned
! Az<Kind>Callback value round-trips its handle id back into the
! registered procedure. Callbacks MUST live in a module (not as internal
! procedures) so c_funloc() needs no executable-stack trampoline.

module hello_impl
  use, intrinsic :: iso_c_binding
  use azul
  implicit none

  type :: t_model
    integer :: counter = 5
  end type t_model
  type(t_model), target, save :: model

contains

  function mk_str(s) result(r)
    character(len=*), intent(in) :: s
    type(AzString) :: r
    character(kind=c_char), dimension(max(len(s), 1)), target :: buf
    integer :: i
    do i = 1, len(s)
      buf(i) = s(i:i)
    end do
    ! AzString_fromUtf8 copies the bytes, so the automatic buffer is fine.
    r = az_string_from_utf8(c_loc(buf(1)), int(len(s), c_size_t))
  end function mk_str

  ! ButtonOnClick user callback: bump the counter, request a DOM refresh.
  ! arg0 = AzRefAny* (model handle), arg1 = CallbackInfo*, out_ptr = AzUpdate*.
  subroutine my_on_click(arg0, arg1, out_ptr) bind(C)
    type(c_ptr), value :: arg0, arg1, out_ptr
    type(c_ptr) :: praw
    type(t_model), pointer :: m
    integer(c_int), pointer :: update_out
    praw = azul_refany_get(arg0)
    if (c_associated(praw)) then
      call c_f_pointer(praw, m)
      m%counter = m%counter + 1
    end if
    if (c_associated(out_ptr)) then
      call c_f_pointer(out_ptr, update_out)
      update_out = AzUpdate_RefreshDom
    end if
    if (c_associated(arg1)) return
  end subroutine my_on_click

  ! Layout user callback: build body > [ div.font-size-32 > text(counter),
  ! button ]. arg0 = AzRefAny*, arg1 = LayoutCallbackInfo*, out_ptr = AzDom*.
  subroutine my_layout(arg0, arg1, out_ptr) bind(C)
    type(c_ptr), value :: arg0, arg1, out_ptr
    type(c_ptr) :: praw
    type(t_model), pointer :: m
    type(AzDom), pointer :: dom_out
    type(AzDom) :: body, label_wrap
    type(AzButton) :: btn
    type(AzButtonOnClickCallback) :: click_cb
    type(AzRefAny) :: click_data
    character(len=32) :: num
    body = az_dom_create_body()
    praw = azul_refany_get(arg0)
    if (c_associated(praw)) then
      call c_f_pointer(praw, m)
      write (num, '(I0)') m%counter

      label_wrap = az_dom_create_div()
      label_wrap = az_dom_with_css(label_wrap, mk_str('font-size: 32px;'))
      label_wrap = az_dom_with_child(label_wrap, &
                                     az_dom_create_text(mk_str(trim(num))))

      click_cb = azul_register_buttononclickcallback(my_on_click)
      click_data = azul_refany_create(c_loc(model))
      btn = az_button_create(mk_str('Increase counter'))
      btn = az_button_with_button_type(btn, AzButtonType_Primary)
      btn = az_button_with_on_click(btn, click_data, click_cb)

      body = az_dom_with_child(body, label_wrap)
      body = az_dom_with_child(body, az_button_dom(btn))
    end if
    if (c_associated(out_ptr)) then
      call c_f_pointer(out_ptr, dom_out)
      dom_out = body
    end if
    if (c_associated(arg1)) return
  end subroutine my_layout

end module hello_impl

program hello_world
  use, intrinsic :: iso_c_binding
  use azul
  use hello_impl
  implicit none

  ! NB: Fortran is case-insensitive — `app` would collide with the
  ! wrapper type `App` exported by the azul module.
  type(AzRefAny) :: app_data
  type(AzLayoutCallback) :: layout_cb
  type(AzWindowCreateOptions) :: wco
  type(AzApp), target :: the_app

  print '(A)', '[azul] Fortran full-GUI hello-world starting.'

  call azul_host_invoker_init()

  app_data = azul_refany_create(c_loc(model))
  layout_cb = azul_register_layoutcallback(my_layout)

  wco = az_window_create_options_default()
  wco%window_state%layout_callback = layout_cb
  wco%window_state%title = mk_str('Hello World')

  the_app = az_app_create(app_data, az_app_config_create())
  call az_app_run(c_loc(the_app), wco)
end program hello_world

Six things to notice.

  • Callbacks are bind(C) module procedures. They MUST live in a module, not as internal procedures of the main program: c_funloc() on a module procedure yields a plain C function pointer, while an internal procedure would require a compiler-generated executable-stack trampoline that crashes on hardened systems.
  • azul_host_invoker_init() — call it once, before az_app_run. It hands libazul the Fortran-side invoker for every callback kind (layout, button-click, checkbox-toggle, ...) plus the handle releaser. Skip it and no callback ever fires: the window opens but stays blank.
  • azul_register_<kind>(proc) — stores c_funloc(proc) in a handle table and returns the Az<Kind>Callback value you pass to the framework (azul_register_layoutcallback for the window, azul_register_buttononclickcallback for the button). When the event fires, libazul calls the registered invoker with the handle id, which looks up your procedure and calls it.
  • azul_refany_create(c_loc(model)) / azul_refany_get(arg0) — the RefAny round-trip. azul_refany_create wraps a raw c_ptr to your model (which must be a target, save variable so the address stays valid for the app's lifetime); inside a callback, azul_refany_get recovers the raw pointer and c_f_pointer turns it back into a typed Fortran pointer.
  • Results go out through out_ptr. User callbacks are subroutines, not functions: the framework passes an out-pointer (AzUpdate* for click callbacks, AzDom* for layout callbacks) and the invoker reads whatever you wrote there. Always write it on every path — update_out = AzUpdate_RefreshDom queues a re-layout, AzUpdate_DoNothing (or writing nothing at all: don't) skips it.
  • mk_str copies. az_string_from_utf8 copies the bytes into an owned, refcounted AzString, so building it from a stack-local character buffer is fine. There is no character(*)-taking overload in the binding yet; mk_str is the four-line idiom to write once per project.

Also note the naming comment in the main program: Fortran is case-insensitive, so a variable named app collides with the App wrapper type exported by the azul module. Prefix your locals (the_app, app_data) to stay clear.

Build and run

make
./hello_world                       # linux
DYLD_LIBRARY_PATH=. ./hello_world   # macOS

You should see the window pictured on the hello-world landing page: the label renders „5“, and every click on the button increments it — the click callback bumps model%counter, returns AzUpdate_RefreshDom, and the framework re-runs my_layout with the new value.

To run the same headless counter scenario the CI uses:

AZ_E2E=path/to/hello_world_counter.json AZ_BACKEND=headless make run

Common errors

  • Thousands of „Line truncated ... -Werror=line-truncation“ errors compiling azul.f90 — you compiled by hand without -ffree-line-length-none. Use the shipped Makefile, or add the flag to your own build.
  • make tries to run f77 — an ancient GNU make builtin default. The shipped Makefile works around it; in your own Makefile set FC = gfortran explicitly (a plain FC ?= does not override the builtin).
  • Window opens but the button does nothing / stays blank — you forgot call azul_host_invoker_init() before az_app_run, so libazul has no way to dispatch into Fortran.
  • Counter renders but never updates — the click callback did not write AzUpdate_RefreshDom through out_ptr (or wrote nothing: the out-value is read by the framework, leaving it unwritten is undefined). Write the out-pointer on every code path.
  • Segfault inside a callback — the model was not declared target, save, so c_loc(model) went stale; or the callback is an internal procedure instead of a module procedure.
  • macOS: dyld: Library not loaded: libazul.dylib — the Makefile's $ORIGIN rpath is Linux-only. Run with DYLD_LIBRARY_PATH=. or rewrite the install name with install_name_tool.
  • “Procedure ... is already defined“ or garbled option/union values — symptoms of a stale azul.f90 from an older release. Re-download azul.f90 and the Makefile from the same 0.2.0 as the library; since 0.2.0 tagged unions are ABI-exact opaque blobs and all factory names are unique.
  • Trying to read AzOption* / union fields directly — not supported by design: Fortran has no unions, so these types are opaque byte blobs. Construct and inspect them through the C-API helper functions only.

Coming Up Next