Guide

Hello World [C#]

Introduction

The C# binding talks to the prebuilt libazul native library through P/Invoke, but you almost never see that layer. You write idiomatic C# — plain classes, the wrapper-class App.Create(...).Run(wco) path, and a typed layout delegate that returns a Dom — and the generated Azul.cs handles the marshalling. No Marshal.AllocHGlobal, no .Raw extraction, no IntPtr ceremony in your code.

Installation

You need the .NET 10 SDK and the native libazul library for your platform. (The example project examples/csharp/Hello.csproj and the downloadable Azul.csproj both target net10.0.)

azul isn't on nuget.org, but a self-hosted NuGet v3 feed lives at azul.rs and the package bundles the native libazul for Linux/macOS/Windows under runtimes/<rid>/native (.NET picks the right RID at runtime):

dotnet nuget add source https://azul.rs/ui/nuget/index.json --name azul
dotnet add package azul --version 0.2.0

Or install the native library + binding by hand:

  1. Download the native library for your OS from the release page and keep it next to your binary (or on the loader path):

    # macOS (Apple Silicon; Intel: libazul.x86_64.dylib)
    wget -O libazul.dylib https://azul.rs/ui/release/0.2.0/libazul.dylib
    # linux
    wget -O libazul.so    https://azul.rs/ui/release/0.2.0/libazul.so
    # windows
    # download https://azul.rs/ui/release/0.2.0/azul.dll
    
  2. Add the generated Azul.cs bindings to your project:

    wget https://azul.rs/ui/release/0.2.0/Azul.cs
    # optional project scaffold:
    wget https://azul.rs/ui/release/0.2.0/Azul.csproj
    

That's it for library discovery: the generated Azul.cs installs a DllImportResolver that probes the app's base directory and the current working directory for libazul.dylib / libazul.so / azul.dll, so keeping the native library next to your project (step 1) is sufficient — no DYLD_LIBRARY_PATH / LD_LIBRARY_PATH setup needed for dotnet run.

Simple „Counter“ Example

using System;
using Azul;

namespace HelloWorld
{
    // Plain C# class - the "single source of truth" for app state.
    public sealed class MyDataModel
    {
        public uint Counter;
        public MyDataModel(uint counter) { Counter = counter; }
    }

    public static class Program
    {
        private static readonly MyDataModel _model = new MyDataModel(5);

        // Click callback: returns an Update as an int. RefanyGet recovers
        // your object from the type-erased handle; `as T` is null on mismatch.
        private static int OnClick(IntPtr dataPtr, IntPtr infoPtr)
        {
            var m = HostInvoker.RefanyGet(dataPtr) as MyDataModel;
            if (m == null) return (int)Update.DoNothing;
            m.Counter += 1;
            return (int)Update.RefreshDom;
        }

        // Layout callback: f(data) -> Dom. Runs on startup and again after any
        // callback that returns Update.RefreshDom.
        private static Dom Layout(IntPtr dataPtr, IntPtr infoPtr)
        {
            var m = HostInvoker.RefanyGet(dataPtr) as MyDataModel;
            if (m == null) return Dom.CreateBody();

            var label = Dom.CreateDiv()
                .WithCss("font-size: 32px;")
                .WithChild(Dom.CreateText(m.Counter.ToString()));

            var buttonDom = Button.Create("Increase counter")
                .WithButtonType(ButtonType.Primary)
                .OnClick(m, new Func<IntPtr, IntPtr, int>(OnClick))
                .Dom();

            return Dom.CreateBody()
                .WithChild(label)
                .WithChild(buttonDom);
        }

        public static int Main(string[] args)
        {
            // `using` disposes the App (and calls the C-side delete) on exit.
            using var app = App.Create(HostInvoker.RefanyWrap(_model), AppConfig.Create());
            app.Run(WindowCreateOptions.Create(new Func<IntPtr, IntPtr, Dom>(Layout)));
            return 0;
        }
    }
}

Four things to notice.

  • HostInvoker.RefanyWrap / RefanyGet — your MyDataModel is wrapped into a type-erased handle when you hand it to App.Create, and the same instance is handed back to every callback. RefanyGet(ptr) as MyDataModel is the runtime cast; it returns null on a type mismatch, so return Update.DoNothing / Dom.CreateBody() in that case.
  • Wrapper-class API, no IntPtr ceremony. App.Create(...).Run(...), Dom.CreateBody().WithChild(...), and Button.Create(label).WithButtonType(...).OnClick(...).Dom() read like normal fluent C#. The WithCss("...") builder accepts any CSS string, including :hover { } / @media / @os(...) inline queries.
  • Callbacks are delegates. A layout callback is Func<IntPtr, IntPtr, Dom>; a click handler is Func<IntPtr, IntPtr, int> returning (int)Update.*.
  • using var app disposes deterministically — Dispose() calls the C-side delete, so native memory is released when the App goes out of scope.

Build and run

# all platforms — the native library sits next to the project (step 1),
# and the generated DllImportResolver finds it there
dotnet run

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

Common errors

  • DllNotFoundException / Unable to load shared library 'azul' — the native library wasn't found. Put libazul.dylib / libazul.so / azul.dll in the project directory (or next to the published executable); the generated resolver probes both. DYLD_LIBRARY_PATH / LD_LIBRARY_PATH also work as a fallback for non-standard layouts, but note macOS strips DYLD_* in some launch paths (SIP).
  • Counter does not advanceOnClick returned (int)Update.DoNothing. Return (int)Update.RefreshDom after mutating.
  • RefanyGet(...) as MyDataModel is null — the handle holds a different type, or it is borrowed elsewhere. Return Dom.CreateBody() / Update.DoNothing.

Coming Up Next