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:
-
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: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— 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.
Coming Up Next
- Application Architecture — architecting a larger Azul application
- Document Object Model — the Dom tree: node types, hierarchy, and CSS
- Hello World [Java]