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 8 SDK or newer and the native libazul library for your
platform. (The example project examples/csharp/Hello.csproj and the downloadable
Azul.csproj both target net8.0, which .NET 9 and 10 projects consume as well.)
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). The package id is
Azul.Net (the assembly and namespace are still Azul):
dotnet nuget add source https://azul.rs/ui/nuget/index.json --name azul
dotnet add package Azul.Net --version 0.2.0
Or install the native library + binding by hand:
-
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 -
Add the generated
Azul.csbindings to your project:# Azul.cs ships with the GitHub release, not the site: it is far too # large for the Pages artifact budget. curl -L -O https://github.com/fschutt/azul/releases/download/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.CreateTextDoNotUseWithoutBlockLevelWrapper(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— yourMyDataModelis wrapped into a type-erased handle when you hand it toApp.Create, and the same instance is handed back to every callback.RefanyGet(ptr) as MyDataModelis the runtime cast; it returnsnullon a type mismatch, so returnUpdate.DoNothing/Dom.CreateBody()in that case.- Wrapper-class API, no IntPtr ceremony.
App.Create(...).Run(...),Dom.CreateBody().WithChild(...), andButton.Create(label).WithButtonType(...).OnClick(...).Dom()read like normal fluent C#. TheWithCss("...")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 isFunc<IntPtr, IntPtr, int>returning(int)Update.*. using var appdisposes deterministically —Dispose()calls the C-sidedelete, so native memory is released when theAppgoes 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. Putlibazul.dylib/libazul.so/azul.dllin the project directory (or next to the published executable); the generated resolver probes both.DYLD_LIBRARY_PATH/LD_LIBRARY_PATHalso work as a fallback for non-standard layouts, but note macOS stripsDYLD_*in some launch paths (SIP).- Counter does not advance —
OnClickreturned(int)Update.DoNothing. Return(int)Update.RefreshDomafter mutating. RefanyGet(...) as MyDataModelis null — the handle holds a different type, or it is borrowed elsewhere. ReturnDom.CreateBody()/Update.DoNothing.