Skip to content
Mike Strobel edited this page Jun 26, 2026 · 1 revision

XAML: runtime loader & source generator

Cursorial lets you author your UI declaratively in XAML — the same shapes WPF and Avalonia use ({Binding}, ControlTemplate, ResourceDictionary, x:Name/x:Class). You get it two ways: a runtime loader that parses and instantiates markup at runtime, and a Roslyn source generator that validates your XAML at build time, emits typed code-behind, and produces an AOT-clean (reflection-free) load path. Both run the same parser, so what compiles in the generator loads identically at runtime.

Reach for XAML when you'd rather express a control tree as markup than as constructor soup, when you want MVVM-style data binding, or when you want compiler-checked bindings and trim/AOT-friendly publishing.

The two ways to use it

Runtime loader (Cursorial.UI.Xaml) Source generator (Cursorial.UI.Xaml.Generator)
When errors surface At Load(...) time, as XamlParseException At build time, as CUR#### compiler diagnostics
Code-behind Hand-wired (FindName, manual field assignment) Generated typed x:Name fields + InitializeComponent()
Binding checks Path resolved reflectively at runtime x:DataType paths checked at build; compiled bindings
Reflection Reflective metadata provider by default Emits an AOT-clean provider — no runtime reflection
Setup Reference the package, call the loader Add the generator, give the .xaml an x:Class partial

Both share the netstandard2.0 frontend (Cursorial.UI.Xaml.Frontend) — the parser, the structure-of-arrays document model, the markup-extension grammar, and the diagnostics. The generator runs that frontend over Roslyn symbols; the loader runs it over text.


The runtime loader

XamlLoader has a process-wide default instance, XamlLoader.Shared. The simplest path parses and instantiates in one call:

using Cursorial.UI.Xaml;

const string Xaml = """
    <StackPanel xmlns="https://cursorial.dev/ui" Spacing="1" Margin="2,1">
      <TextBlock Text="{Binding Title}" />
      <Button Content="_Save"
              Command="{Binding SaveCommand}"
              Background="{StaticResource AccentBrush}" />
    </StackPanel>
    """;

var root = XamlLoader.Shared.Load<StackPanel>(Xaml);
root.DataContext = viewModel;

Load<T>(string) casts the root to T and throws a descriptive InvalidOperationException if it isn't that type. The non-generic Load(string) returns object. Both have Stream and TextReader overloads.

Parse once, instantiate many

Parsing and instantiation are split. Parse(...) returns an immutable, thread-safe XamlDocument you can parse off the UI thread and reuse; Load(XamlDocument) instantiates a fresh object graph on the UI thread:

XamlDocument doc = XamlLoader.Shared.Parse(Xaml);     // pure, thread-safe, host-free
var a = XamlLoader.Shared.Load<StackPanel>(doc);      // independent instances
var b = XamlLoader.Shared.Load<StackPanel>(doc);

GetOrParse(uri, xml) memoizes the document per loader, keyed by URI — handy for templates loaded repeatedly.

Populating an existing instance

LoadComponent(component, document) (or the (component, sourceXaml, source) overload) sets members on an object you already created instead of producing a new root — the mechanism behind generated code-behind. The parameterless LoadComponent(object) (the WPF embedded-resource convention) is not wired up; use the explicit overloads.

The default namespace and xmlns

The default UI namespace is https://cursorial.dev/ui. Declared on the root, it maps unprefixed elements to the Cursorial UI assembly's public namespaces (Cursorial.UI, Cursorial.UI.Controls, Cursorial.UI.Data, and the drawing media types). Reach your own types with a using: (or clr-namespace:) prefix:

<UserControl xmlns="https://cursorial.dev/ui"
             xmlns:x="https://cursorial.dev/xaml"
             xmlns:vm="using:MyApp.ViewModels">
  ...
</UserControl>

The x: directive namespace is https://cursorial.dev/xaml. Namespace declarations are root-only (Avalonia parity) — a non-root xmlns is a CUR2004 error.

Markup extensions

The loader ships the four extensions you reach for most, plus a base class (MarkupExtension) for your own:

  • {Binding} — paths, indexers, attached-property segments, converters, modes, RelativeSource, ElementName, plus TemplatedParent. Honors x:DataType for typed paths. See Data binding.
  • {StaticResource Key} — resolved eagerly against the lexical resource scope at load time.
  • {DynamicResource Key} — a live producer; re-resolves when the resource (or theme) changes.
  • {TemplateBinding Property} — the fast path from a ControlTemplate part to its templated parent.
<Border Background="{DynamicResource SurfaceBrush}"
        BorderPen="{StaticResource Hairline}">
  <TextBlock Text="{Binding User.Name}" Foreground="{TemplateBinding Foreground}" />
</Border>

Templates, resources, and theming

  • ControlTemplate — captured as a deferred subtree and expanded per target, each with its own name scope; {TemplateBinding} and template-scoped names resolve against the templated parent. See Controls.
  • ResourceDictionary — keys can be strings or types (for implicit DataTemplates); supports MergedDictionaries (own entries beat later-merged) and ThemeDictionaries (light/dark variants). See Styling & themes.
  • Themes ship as code or as embedded XAML; the built-in control themes are authored in a box-drawing idiom and are overridable per app.

Styles and selectors

A Style carries a selector grammar (type / .class / #name / :pseudo-class / descendant & child / :is()). The namespace-qualified type token uses a prefix|Type form (CSS / Avalonia style), resolved against the document's root xmlns:

<Styles xmlns="https://cursorial.dev/ui" xmlns:c="using:MyApp.Controls">
  <Style Selector="Button:pointerover">
    <Setter Property="Background" Value="{DynamicResource AccentBrush}" />
  </Style>
  <Style Selector=":is(c|CardBase).highlighted">
    <Setter Property="BorderPen" Value="{StaticResource AccentPen}" />
  </Style>
</Styles>

A Style's selector is built at activation (not folded at parse), so prefix|Type tokens bind the document namespace. A TargetType builds an exact-type selector; an explicit Selector wins when both are present.

Access keys

A leading underscore in content marks an access key — Content="_Save" registers Alt+S. Folding happens on genuinely access-text-typed members; an object-typed Content="_Save" stays the raw string and the control folds it on demand, so the underscore always means the same thing whether you read it as text or as content.

x:Name and diagnostics

x:Name registers an element in the document's name scope; after loading, find it with UIElement.FindName. On a parse or resolution error the loader throws XamlParseException, which carries the failing Line and Column (1-based) and the full Diagnostics list. Diagnostic codes are banded:

  • CUR1xxx — parse / grammar errors (malformed XML, unterminated markup extension, bad intrinsic).
  • CUR2xxx — resolution errors (type not found — with a "did you mean?" suggestion — unknown member, undeclared prefix, xmlns not on root).
  • CUR3xxx — instantiation errors (activation failure, duplicate name).

The source generator

Add Cursorial.UI.Xaml.Generator to your project and the same XAML is validated and compiled at build time. Give the markup an x:Class directive and the generator emits a matching partial class.

Build-time validation

The generator resolves types and members purely from Roslyn symbols — it never loads Cursorial.UI into the compiler — and runs the frontend parser over those symbols. Every parse and resolution diagnostic surfaces as a CUR#### build error at the .xaml file's line and column, so an unknown type or a typo'd property fails the build instead of the first Load(...):

MyView.xaml(7,18): error CUR2002: The type 'Buttom' was not found. Did you mean 'Button'?

Typed code-behind

For each x:Class document the generator emits a partial class with a typed field per document-scope x:Name and an InitializeComponent() that loads the markup and assigns the fields. (Names inside resource dictionaries or template content live in their own scopes and don't become fields.)

<!-- MyView.xaml -->
<StackPanel xmlns="https://cursorial.dev/ui"
            xmlns:x="https://cursorial.dev/xaml"
            x:Class="MyApp.Views.MyView"
            Spacing="1">
  <TextBlock x:Name="Heading" Text="{Binding Title}" />
  <Button x:Name="SaveButton" Content="_Save" Command="{Binding SaveCommand}" />
</StackPanel>
// MyView.xaml.cs — your half of the partial
namespace MyApp.Views;

public partial class MyView : StackPanel
{
    public MyView()
    {
        InitializeComponent();          // generated: loads the XAML, populates the fields
        Heading.Foreground = Colors.Teal;   // SaveButton / Heading are typed fields
        SaveButton.Click += (_, _) => Heading.Text = "Saved!";
    }
}

The generated InitializeComponent() loads a once-parsed, cached XamlDocument through a loader bound to the assembly's own generated metadata provider, then assigns each typed field from the document name scope.

Compiled bindings and x:DataType

Declare an x:DataType and the generator validates each DataContext-relative {Binding} path against that type's members at build time — a bad path is a build error, not a silent runtime no-op. The scope is lexical, so a binding inside a DataTemplate is checked against that template's x:DataType, not an outer one:

<DataTemplate x:DataType="vm:FileItem">
  <TextBlock Text="{Binding Name}" />   <!-- checked against FileItem.Name at build -->
</DataTemplate>

Compiled bindings are typed and avoid per-update boxing — a single-hop binding's steady-state push allocates nothing. (You can also opt into compiled bindings from C# via Binding.Compiled(...); see Data binding.)

AOT-clean metadata provider

The generator emits one IXamlTypeMetadataProvider per compilation, installed via a module initializer as the loader's default. With a generated provider plus standard markup, the load path uses no runtime reflection — the reflective default provider can be dropped for NativeAOT / trimmed publishing. A dual-run gate asserts the generated provider renders byte-identically to the reflective one, so moving to the generator changes nothing about how your UI looks.

The build-integration MSBuild props/targets and a verified AOT-publish demo are still in progress; the generator core — symbol-backed parse, CUR#### diagnostics, typed code-behind, the emitted provider, and x:DataType path checks — is in place and exercised in-repo.

See also

Clone this wiki locally