Skip to content
slaveOftimePublic

About

Native desktop UI in plain C# — no XAML, no web view. Immediate mode over a C99 engine: ref structs, 0 B per frame, analyzers, NativeAOT to one 2 MB file.

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

CuiSharp

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.

.NET 10 NativeAOT alloc License: MIT

The gallery: a workspace form, metric cards, a sparkline and a team list

dotnet new install CuiSharp.Templates
dotnet new cui-app -n MyApp && cd MyApp
dotnet watch run --project MyApp

What 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.

The one idea

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.

Why it is fast, and stays fast

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.

What it looks like

Eleven pages of the gallery, drawn by the app itself, in light and dark (all snapshots, regenerated by pwsh tools/snapshots.ps1):

Overview Overview Buttons Buttons Inputs Inputs
Selection Selection Navigation Navigation Overlays Overlays
Feedback Feedback Data display Data display Charts Charts
Studio Studio Tools Tools Dark Dark

Writing UI

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) and ui.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);

Give an agent the whole thing

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 next

Then this is the whole of what a project's AGENTS.md has to say about CuiSharp:

This project uses CuiSharp. Run dotnet cui docs before writing UI code. Use dotnet cui docs search "<question>" and dotnet cui docs read <topic> for the rules and the pitfalls, dotnet cui api <Name> for an exact signature, and dotnet cui tree / find / click / png to 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 index

Every 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.

Test it, and let an agent drive 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 app

The 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.

Install and publish

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 it

cui.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.

Gallery and layout

git clone --recurse-submodules <this repo> && cd CuiSharp
pwsh native/build-native.ps1     # or ./native/build-native.sh
dotnet run --project samples/CuiSharp.Gallery

The 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

Where to read

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.

Contributing

dotnet build CuiSharp.slnx -c Release -warnaserror
dotnet test  CuiSharp.slnx -c Release
pwsh tools/regen.ps1 -NoNative -NoDiff     # generated output is up to date

src/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.

Status

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.

License

MIT. CUI is MIT. Icons are Lucide (ISC). Fonts are Inter and Geist (OFL 1.1).

About

Native desktop UI in plain C# — no XAML, no web view. Immediate mode over a C99 engine: ref structs, 0 B per frame, analyzers, NativeAOT to one 2 MB file.

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages