Native desktop UI in plain C#. No XAML, no WPF, no Avalonia, no web view.
CuiSharp binds CUI, a 160-component UI library written in C99 that draws every widget itself. You describe the screen again each frame; your fields are the state.
dotnet new install CuiSharp.Templates
dotnet new cui-app -n MyApp && cd MyApp
dotnet watch run --project MyAppWhat you get: a two-page app — todos, and a simulated 50 Hz market feed with a sparkline per row
and a decimated chart — with a light/dark switch, a footer that reports the frame's own draw time,
interval, frame rate and allocations, a test project that clicks through real frames, and an
AGENTS.md for whoever edits it next.
For a quick experiment, generate a file-based C# app (no project or solution):
dotnet new cui-mini -n demo1
dotnet run demo1.cs # a JIT build of one file: a second or two
dotnet publish demo1.cs -c Release -o . # one native binary beside it, no .NET to install-n demo1 creates demo1.cs and a demo icon demo1.ico in the current directory, without a new
folder; replace the .ico with your own to change the exe and window icon. Add -o <folder>
only when you want a separate output directory. The generated file carries the properties that make
that publish one file of about 1.2 MB, and says so at the top. It starts with
#:package CuiSharp@*, resolving the latest stable package; use --CuiSharpVersion 0.3.3 when
generating to pin a version. Its counter and theme switch use the same frame API as a full app;
CUISHARP_HEADLESS=1 runs it without a window for automation.
Also dotnet add package CuiSharp for an existing project. Requires the .NET 10 SDK.
A frame is a pure function of your fields. No control tree, no binding, no invalidation:
conditional UI is an if, a list is a for.
using CuiSharp;
int clicks = 0;
return CuiApp.Run(new CuiAppOptions { Title = "Hello", Width = 420, Height = 300 }, ui =>
{
using (ui.Column(new Box { Pad = 32, Gap = 16 }))
{
ui.Heading("Hello, CUI", 2);
if (ui.Button("Click me", new ButtonOpts { Variant = ButtonVariant.Primary })) clicks++;
ui.Text($"Clicked {clicks} times");
}
});Ui and every container scope are ref struct: the compiler rejects storing them, capturing them,
or holding them across an await. Nothing can outlive the frame, so nothing can go stale.
| One executable | NativeAOT links CUI in statically: no runtime, no cui.dll, no VC++ redistributable. The gallery, all twenty-nine pages, is 2.8 MB. |
| No UI framework | CUI creates the window, owns the message loop and the GL context. CuiApp.Run is the entire host. |
| Sub-millisecond frames | 0.85 ms mean for the live gallery, software rasterizer, 1240×820 (--fps 900). |
| 0 B per frame | Labels, options and interpolated text come from a per-frame arena. A live chart with a million samples allocates nothing. |
| Idle costs nothing | The window sleeps until an input event, a mutation, or a WantsFrames page asks for a frame. |
| A million rows | Lists virtualize (~12 µs); charts decimate long feeds for free (ChartOpts.MaxPoints). |
| Hot reload | The frame loop is managed, so dotnet watch patches your UI between frames. |
Numbers, method and caveats: docs/PERFORMANCE.md. What is in the published executable, and what each widget costs: docs/SIZE.md.
Eleven pages of the gallery, drawn by the app itself, in light and dark
(all snapshots, regenerated by pwsh tools/snapshots.ps1):
Overview |
Buttons |
Inputs |
Selection |
Navigation |
Overlays |
Feedback |
Data display |
Charts |
Studio |
Tools |
Dark |
using (ui.Column(new Box { Pad = 16, Gap = 8 })) // stacks children
using (ui.Row(new Box { Gap = 8 })) // side by side
using (ui.Card(new() { Id = "editor", Width = Size.Fill }))
using var dialog = ui.Dialog("confirm", ref open); // opens only when open
{
ui.Input("draft", ref draft, new InputOpts { AutoFocus = true });
ui.Select("tone", Tones, ref tone); // interactive widgets take ref
ui.Checkbox("Pinned", ref pinned);
if (ui.Button("Save")) Save();
}- Text is one type.
ui.Text("Saved"),ui.Text("Saved"u8)andui.Text($"Clicked {n,6:D3}")are one method; interpolation formats straight into the frame arena. - Options are structs of public fields, one per component, defaulted to CUI's defaults.
- Ids are stable strings;
ui.Keyed(items, key)gives a list rows ids that survive reordering. - Theme colours come from
Color.Accent,ui.ColorOf(color),ui.TokenColor(token), so light and dark work without a second palette.
A component is a class with fields and a Draw(Ui) method. Composition is a using block, a
foreach, or ui.Component(child):
sealed class TodoPage : Component
{
private readonly List<Todo> _todos = [];
private readonly Mutation<Todo> _add;
public TodoPage(TodoService service) => _add = new Mutation<Todo>(service.AddAsync, Add);
protected override void Draw(Ui ui)
{
using var card = ui.Card(new() { Id = "todos", Pad = 16, Gap = 12 });
ui.Heading("Todos", 2);
foreach (Todo todo in _todos) ui.Checkbox(todo.Title, ref todo.Done);
if (ui.Button("Add", new ButtonOpts { Variant = ButtonVariant.Primary, Disabled = _add.IsRunning }))
_add.Run(new Todo("Untitled"));
if (_add.Error is { } error) ui.Alert(error.Message, new AlertOpts { Tone = Tone.Danger });
}
private void Add(Todo todo) => _todos.Add(todo);
}Async work never runs in a frame: Mutation publishes its result at the next frame boundary, and
WantsFrames keeps the loop awake while something is outstanding.
Custom controls are static extension blocks, indistinguishable from built-in widgets at the
call site — no base class, no registration, no allocation:
internal static class MyControls
{
extension(Ui ui)
{
public void Spark(float[] values) =>
ui.Canvas("spark", Size.Fill, Size.Px(60), (paint, box) =>
{
for (int i = 1; i < values.Length; i++)
paint.DrawLine(box.X + i - 1, box.Y + values[i - 1], box.X + i, box.Y + values[i],
1.5f, Color.Accent);
});
}
}
ui.Spark(Monthly.Revenue);An assistant needs two commands, and the project's instruction file needs one paragraph — never a copy of this README:
dotnet tool install -g CuiSharp.Tools # exposes `dotnet cui`
dotnet cui docs # the topics, the documents, and what to run nextThen this is the whole of what a project's AGENTS.md has to say about CuiSharp:
This project uses CuiSharp. Run
dotnet cui docsbefore writing UI code. Usedotnet cui docs search "<question>"anddotnet cui docs read <topic>for the rules and the pitfalls,dotnet cui api <Name>for an exact signature, anddotnet cui tree/find/click/pngto see what a build actually draws. Anything that must keep holding belongs in the test project, not in a shell history.
dotnet cui docs answers from the guide, the reference and the behaviour pages inside the tool:
no repository, no NuGet cache, no network, nothing built and nothing started. Fourteen topics cover
the tasks an assistant arrives with — getting-started, writing-ui, custom-controls, hosting,
testing, debugging, layout, live-debugging, performance, theming, translation,
publishing, signatures, widgets — and each one is a named set of sections of the shipped pages,
so the tool cannot say anything the documentation does not.
dotnet cui docs # topics, documents, and the commands that come next
dotnet cui docs read testing # one topic, whole
dotnet cui docs read SKILL # the guide
dotnet cui docs read SKILL#pitfalls # one section of it
dotnet cui docs search "decimate chart" # ranked sections, each with the read that opens it
dotnet cui api ChartOpts # exact declarations
dotnet cui icons chart # the glyph indexEvery answer names the command that goes deeper, an unknown topic names the nearest one, and
--json turns any of them into machine-readable output. That is why a beginner's project file can be
short: the framework's rules are versioned with the tool that ships them, instead of being re-explained
in every repository that uses it.
CuiSharp.Testing runs the real component with no window: find a control by what it says, click it,
type into it, assert on the model or on the pixels.
using var app = TestApp.Start(new TodoPage(service), 800, 600);
app.FindButton("Add").Click();
app.Type("Milk");
Assert.Equal("Milk", page.Draft);
Assert.Empty(app.Faults);
Assert.True(app.PaintedAnything());dotnet cui does the same thing to a real app process from a shell, with nothing added to the app:
dotnet cui tree # the controls of the last frame, nested
dotnet cui click Save # click a control, print the frame it produced
dotnet cui click "Button Save" # …when a heading says the same word
dotnet cui click "editor > Save" # …or the path to the control
dotnet cui type Milk # type into the focused field
dotnet cui png shot.png # the frame as a PNG, for a person or a model
dotnet cui tree -a 15036 # the same, on a live window a person is watching
dotnet cui session < script.txt # many commands, one build, one appThe query is one quoted argument — click "Button Save", not click Button Save — because the
app parses that little grammar and this tool hands it over untouched. The tool's own options follow
the command they belong to (dotnet cui tree --frames 2, not dotnet cui --frames 2 tree), and
dotnet cui <command> --help lists exactly the options that command honours.
Frame
Card "editor"
Heading "Note"
Input "draft"
Button "Save"
The app serves this itself (CUISHARP_HEADLESS=1), so what a tool sees is what a user sees — no
debug mode, no mock, no second code path. For a check that has to hold, prefer
CuiSharp.Testing: frames on demand, milliseconds, and the reproduction stays in the suite.
Analyzers ship in the package and turn common frame and AOT mistakes into build errors or
warnings: a blocking call in a frame (CUI001), a container scope without using (CUI002), a
popover body that never checks Open (CUI003), a constant id in a loop (CUI004),
ConfigureAwait(false) or async void in a component (CUI005, CUI006), and an allocation in a
frame (CUI007), plus reflection-based JSON serialization under AOT (CUI008). Each message says
what to do instead. IDE quick fixes add scope disposal/conditional guards (CUI002, CUI003),
key repeated widgets (CUI004), and remove an awaited BCL ConfigureAwait (CUI005). The id fix
uses the widget's receiver and a foreach item or an unambiguous changing integer counter;
nested loops and while/do loops need explicit keys. The other rules still require a manual rewrite.
dotnet add package CuiSharp # library + analyzers + cui.dll/cui.lib
dotnet add package CuiSharp.Testing # tests only
dotnet tool install -g CuiSharp.Tools # exposes `dotnet cui`<!-- one line in your app's csproj; a template app already has it -->
<PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
<PublishAot>true</PublishAot>
</PropertyGroup>dotnet publish -c Release -r win-x64 # one executable, from ~1.2 MB up, nothing beside itcui.lib is linked statically through the package's targets, so there is no cui.dll in the output.
NativeAOT does not cross-compile between operating systems: publish on the platform you target
(libx11-dev is needed at build time on Linux, not at run time).
The native build/release matrix targets win-x64, win-arm64, linux-x64, linux-arm64 and
osx-arm64. The ARM64 legs build on, and are verified by, hosted windows-11-arm and
ubuntu-24.04-arm runners.
The Linux archive needs glibc 2.31 or
newer (Debian 11, Ubuntu 20.04, RHEL 8); an older image fails at load with
version 'GLIBC_x.y' not found, which CuiSharpNativeException restates with the packaged RIDs
and the floor. runtimes/native-provenance.txt in the package records the revision, compiler and
per-file hashes behind it; native-abi.md is the promise itself.
git clone --recurse-submodules <this repo> && cd CuiSharp
pwsh native/build-native.ps1 # or ./native/build-native.sh
dotnet run --project samples/CuiSharp.GalleryThe browser-hosted gallery and GitHub Pages build are documented in
samples/CuiSharp.Gallery.Browser/README.md.
src/CuiSharp/ the library: Interop (generated), Ui, Runtime, Semantics, Hosting, Charting
src/CuiSharp.Analyzers/ CUI001-CUI008, packed into CuiSharp
src/CuiSharp.CodeFixes/ IDE quick fixes, packed beside the analyzers
src/CuiSharp.Testing/ TestApp: drive a component without a window
src/CuiSharp.Tools/ dotnet cui: the guide and the API offline, plus build, run headless, relay one command
src/CuiSharp.Templates/ dotnet new cui-app / cui-mini
samples/CuiSharp.Gallery/ twenty-nine pages, one file per page
samples/CuiSharp.Gallery.Browser/ the same gallery on a software canvas in WebAssembly
native/ the CUI submodule and the two build scripts
tools/ bindgen.cs, regen.ps1, verify-template.ps1,
build-cui-wasm.ps1, test-browser-smoke.py,
snapshots.ps1, page-costs.ps1
bindings/ api.json (generated), overrides.json (inputs), coverage.txt (report)
docs/ PLAN (remaining work), ARCHITECTURE (rules), PERFORMANCE, RELEASING,
API-SURFACE.txt, images/gallery/
skills/cui-dotnet/ what building an app needs: SKILL.md, llms.txt, api/, widgets, hosting, ABI
skills/cui-dotnet/ is the whole of what building an app needs — the guide, the reference, the API
pages, the widget behaviour and the ABI promise — and it is self-contained: nothing in it points at a
file it does not carry. The NuGet package ships that same folder at that same path, so every path
below works in either place.
| Need | Path |
|---|---|
| Start here: how to write a UI, the workflow, the pitfalls | skills/cui-dotnet/SKILL.md |
| Every widget and signature, one line each | skills/cui-dotnet/llms.txt |
| Options and records, one page per section | skills/cui-dotnet/api/ |
Components, Mutation<T>, dependency injection |
skills/cui-dotnet/hosting.md |
| What each component does, and its keyboard | skills/cui-dotnet/widgets.md, widgets-more.md |
| RIDs carried, glibc floor, provenance | skills/cui-dotnet/native-abi.md |
| Rules and decisions, before changing the framework | docs/ARCHITECTURE.md |
An assistant needs the first two: point it at the folder and say "read SKILL.md", or — with
nothing but the tool installed — tell it to run dotnet cui docs, which carries that same folder.
widgets.md and
native-abi.md are copies of CUI's documentation and of the ABI promise, regenerated with
pwsh tools/regen.ps1; the rest of the folder is hand-written or generated from the headers.
dotnet build CuiSharp.slnx -c Release -warnaserror
dotnet test CuiSharp.slnx -c Release
pwsh tools/regen.ps1 -NoNative -NoDiff # generated output is up to datesrc/CuiSharp/Interop/*.g.cs, src/CuiSharp/Ui/*.g.cs, Icons.g.cs, the generated ABI and option
tests, skills/cui-dotnet/{llms.txt,api/*.md} and bindings/{api.json,coverage.txt} come from the CUI
headers. Never edit them: change tools/bindgen.cs or bindings/overrides.json, then regenerate.
Syncing a CUI release is checking out the new tag, running regen.ps1, and reviewing the api.json
diff. skills/cui-dotnet/SKILL.md is the one page written by hand.
Rules and decisions: docs/ARCHITECTURE.md. What is left: docs/PLAN.md. Authoring guide: skills/cui-dotnet/SKILL.md. What changed, per version: CHANGELOG.md.
Pre-1.0: the API may still change. The binding covers all 496 CUI 1.4 functions: 170 option structs, 104 records, 277 generated wrappers, 175 deliberate hand-written ones, 4 excluded with a written reason, 4 variadic skipped, 0 pending — see bindings/coverage.txt. The gallery's 29 pages walk the components section by section, light and dark; what is still missing is the page shape that needs a device — a capture pipeline, an audio stream.
MIT. CUI is MIT. Icons are Lucide (ISC). Fonts are Inter and Geist (OFL 1.1).











