2024-06-01

wpf · dotnet

WPF without XAML

People assume WPF means XAML. It doesn't have to — you can drive the whole thing from C# if you accept a bit of ceremony and stop pretending Application is magic.

Novolis does not use this. The studios are Avalonia. Frank.Wpf is the older box, and this post is about that box.

What actually boots

`Application.Run` is the message pump. The pump wants an STA thread. A worker project gives you the generic host: config, logging, DI, and a place to hang a `BackgroundService`.

The host starts on Main. The window does not. Run does not return until the dispatcher shuts down, so if you call it on Main the host never finishes starting, and the only way out is to kill the process. Give the window a thread.

PieceJob
Host.CreateApplicationBuilderLogging, config, DI. This is what the worker template emits now.
WindowHostStarts the STA thread. Stays off it.
Application.RunThe dispatcher loop.

Why bother

I don't like XAML. Even with MVVM it is fiddly XML that fights you the moment you want real nesting, dynamic trees, or anything that is not a designer-friendly toy demo.

ApproachUpsideDownside
XAML + MVVMDesigner tooling, markup and styles split apartVerbosity, and merge-dictionary rabbit holes
C# treesRefactoring works. A dynamic tree is just objects.The construction code is long
Worker + hostSame DI and config as a headless serviceOne extra thread, and you have to mean it

Three ways to get this wrong

  1. Scoped window. The scope ends, the window is still on screen, and the next resolve builds another one. One process, one Application, one MainWindow. Register both once.
  2. `async void` on a click. The exception never reaches you.
  3. `Environment.Exit`. The host does not stop. It gets shot. Close the window with IHostApplicationLifetime.StopApplication. When the host is stopping, call Application.Shutdown through Dispatcher.InvokeAsync. BeginInvoke will not take the method on its own.

The project

On Windows:

dotnet new worker -n Demo.WpfApplication -f net10.0

Point the target at net10.0-windows and turn WPF on. Delete the generated Worker.cs. Keep the hosting package. The worker SDK does not reference it for you, and without it IHost is just a name. The version is whatever the template wrote.

<Project Sdk="Microsoft.NET.Sdk.Worker">
  <PropertyGroup>
    <TargetFramework>net10.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWpf>true</UseWpf>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
  </ItemGroup>
</Project>

App

Still an Application. No XAML file, and no StartupUri. The host owns startup.

using System.Windows;

namespace Demo.WpfApplication;

public sealed class App : Application
{
}

Window

The logger comes from DI. The tree is a button.

using System.Windows;
using System.Windows.Controls;
using Microsoft.Extensions.Logging;

namespace Demo.WpfApplication;

public sealed class MainWindow : Window
{
    public MainWindow(ILogger<MainWindow> logger)
    {
        Title = "WPF without XAML";
        Width = 420;
        Height = 220;
        WindowStartupLocation = WindowStartupLocation.CenterScreen;

        var button = new Button { Content = "Log something" };
        button.Click += (_, _) => logger.LogInformation("Something");
        Content = button;
    }
}

Window host

Construct the Window on the STA thread. Doing it earlier throws. ExecuteAsync returns as soon as that thread is running, so the host can finish starting.

using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Demo.WpfApplication;

public sealed class WindowHost(IServiceProvider services, IHostApplicationLifetime lifetime) : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var thread = new Thread(() => Run(stoppingToken))
        {
            IsBackground = true,
            Name = "WPF"
        };

        thread.SetApartmentState(ApartmentState.STA);
        thread.Start();
        return Task.CompletedTask;
    }

    private void Run(CancellationToken stoppingToken)
    {
        var app = services.GetRequiredService<App>();
        var window = services.GetRequiredService<MainWindow>();

        stoppingToken.Register(() =>
        {
            if (app.Dispatcher.HasShutdownStarted)
                return;

            app.Dispatcher.InvokeAsync(app.Shutdown);
        });

        window.Closed += (_, _) => lifetime.StopApplication();
        app.Run(window);
    }
}

Program

The template's Program.cs is top-level statements. Those cannot carry [STAThread], and the entry point is still a COM citizen, so this file has a real Main. CreateApplicationBuilder is the host the template uses. Logging is already registered. The pump is the other thread.

using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Demo.WpfApplication;

public static class Program
{
    [STAThread]
    public static void Main(string[] args)
    {
        var builder = Host.CreateApplicationBuilder(args);

        builder.Services.AddSingleton<App>();
        builder.Services.AddSingleton<MainWindow>();
        builder.Services.AddHostedService<WindowHost>();

        builder.Build().Run();
    }
}

The WPF overview is here if you want the desktop model from Microsoft's side: WPF. The worker template is documented with the generic host worker notes.

All posts