WPF is a bindable, retained-mode toolkit — not a compiler-plugin framework like SwiftUI/Compose — so
Windows Presentation Foundation uses the same pure-C# "translate to controls" route as
GTK and WinUI 3: no native shim. A retained-mode interpreter maps the node
tree to real System.Windows.Controls elements keyed by node path and applies the same
replace/updateProps/setChildren patches; WPF events call straight back into C#.
There are two ways to put SwiftDotNet on WPF, and they are different products:
| What you get | Use it when | |
|---|---|---|
This backend (SwiftDotNet.Wpf) |
Real WPF controls — Win32 look, WPF theming, UI Automation accessibility | You want the app to be a WPF app |
Skia on WPF (SwiftDotNet.Skia.Wpf) |
One self-drawn canvas, pixel-identical to every other Skia head, complete feature set | You want the uniform look, or a feature this backend degrades |
Status: 🧩 Scaffolded — compiles clean, never run. Every project here builds on macOS (via
EnableWindowsTargeting) and on awindows-latestrunner in CI, so unlike the WinUI 3 backend nothing on this page is un-compiled. But the repo's dev machine is macOS: no window has ever been opened. Treat runtime behaviour as intended, not verified. There are no WPF rendering tests — a WPF project targetsnet10.0-windows, which thenet10.0test project cannot reference — so CI covers compilation only.
src/SwiftDotNet.Wpf (net10.0-windows, UseWPF) — a separate project
for the same reason GTK is: a Windows-only TFM inside the multi-target library would force its dependency
on every consumer.
| File | Role |
|---|---|
WpfBridge.cs |
IBridge; Host is a Grid (content + dialog overlays); applies patches to the element tree. |
WpfNode.cs |
Node → WPF; an Element/Inner split so modifiers wrap in a Border. |
WpfStyle.cs |
Color / font / gradient / curve / emoji tokens. |
WpfNavController.cs |
Header + content stack for NavigationStack/NavigationLink. |
WpfRenderers.cs |
Custom-renderer registry. |
SwiftDotNetWpfHost.cs |
CreateRootElement(View) → UIElement. |
SwiftDotNetWpfApplication.cs |
Reusable Application base. |
// Program.cs — the whole head.
static class Program
{
[STAThread] // WPF requires an STA thread
static void Main() => new SampleApplication().Run();
}
sealed class SampleApplication : SwiftDotNetWpfApplication
{
protected override SwiftDotNetApp CreateSwiftApp() => SwiftProgram.CreateSwiftApp();
protected override string WindowTitle => "SwiftDotNet · WPF";
}Or drop the tree into a window you already own:
window.Content = SwiftDotNetWpfHost.CreateRootElement(new ContentView());See sample/SampleApp.Wpf for the head this is taken from, including a custom
native primitive registered through WpfRenderers.
Text→TextBlock, Button→Button, V/HStack→StackPanel, ZStack→Grid, ScrollView→ScrollViewer,
Grid→Grid (Column/RowDefinitions + SetColumnSpan/SetRowSpan), AbsoluteLayout→Canvas,
List→Border+StackPanel, Form→ScrollViewer+StackPanel, DisclosureGroup→Expander,
TabView→TabControl, Menu→Button+ContextMenu, TextField→TextBox, SecureField→PasswordBox,
TextEditor→TextBox (AcceptsReturn), Toggle→CheckBox, Slider→Slider,
Stepper→RepeatButton pair, Picker→ComboBox, DatePicker→DatePicker, ColorPicker→swatch
Button+Popup palette, NavigationStack→a WpfNavController, Sheet/Alert/ActionSheet→a scrim +
card overlay layer, ProgressView/Gauge→ProgressBar, Link→Hyperlink in a TextBlock,
WebView→WebView2, Image→Image+BitmapImage, shapes→Rectangle/Ellipse.
WinUI ships several controls WPF does not. None of them are dropped; each is built from WPF parts.
| DSL view | WinUI | WPF | Consequence |
|---|---|---|---|
Toggle |
ToggleSwitch |
CheckBox |
Different chrome; identical behaviour. |
Stepper |
NumberBox |
TextBlock + two RepeatButtons |
No typed entry — hold to repeat, as a spin box does. |
ProgressView (indeterminate) |
ProgressRing |
indeterminate ProgressBar |
A bar, not a ring. |
Link |
HyperlinkButton |
Hyperlink |
WPF does not launch the browser for you; the backend calls Process.Start(… UseShellExecute = true). |
ColorPicker |
in-box ColorPicker |
swatch + Popup palette |
Palette only. Deliberate: the wire vocabulary is named tokens and #rrggbb, so a full HSV wheel would offer colours the DSL cannot round-trip. |
Sheet / Alert / ActionSheet |
ContentDialog |
scrim + card overlay in the host Grid |
See the gotcha below — and note this backend has no three-button limit, unlike the WinUI one. |
WebView |
in-box WebView2 |
Microsoft.Web.WebView2 package |
Needs the Evergreen WebView2 runtime (present on Windows 11 and current Windows 10). |
The universal modifier pass maps almost completely, and two modifiers land better here than on WinUI:
.Shadowuses a realDropShadowEffect, which takes blur/offset/colour directly and derives the silhouette from the content's alpha. A rounded or non-rectangular element casts the right shape, matching Web and Skia — where the WinUI backend's Composition sprite always casts a rectangle. No wrapper element is needed at all. The offset is polar in WPF (Directioncounter-clockwise from +x with y pointing up), so the wire'sdyis negated..DisabledisUIElement.IsEnabled, which disables the whole subtree and greys native controls — noControl/non-Controlsplit like the WinUI port needs..Keyframes(…)runs as WPFDoubleAnimationUsingKeyFramesclocks applied straight to properties viaIAnimatable.BeginAnimation, withSplineDoubleKeyFrameper stop so per-segment easing survives. There is deliberately noStoryboard: a storyboard would have to find the transform objects through a name scope, andBeginAnimationdoes not.Width/Heightneed no opt-in here either (WinUI'sEnableDependentAnimation) — WPF has no compositor/dependent-animation split..Materialis a translucent tint, not real acrylic. Same fallback as GTK and WinUI; real acrylic needs DWM composition interop..Animationis a no-op. WPF has no implicit layout-transition system (WinUI'sRepositionThemeTransition, Compose'sanimateContentSize), so an element that moves or resizes because the tree changed jumps rather than sliding. Explicit.Keyframes(…)timelines are unaffected — those are real animations.
WPF raises manipulation events for touch only, and only after IsManipulationEnabled — a mouse never
produces them. So every continuous gesture is resolved from raw mouse events instead of the
ManipulationDelta route the WinUI backend uses:
| Gesture | How |
|---|---|
.OnTapGesture |
MouseLeftButtonUp, matched against e.ClickCount for the double-tap variant. |
.OnLongPress |
A DispatcherTimer started on button-down, cancelled by an early release or by leaving the 8px tap slop. MouseRightButtonUp also fires it. |
.OnSwipe |
Captured drag; direction and a 40px threshold decided on release. |
.OnDrag |
Captured drag emitting the <phase>;tx,ty;lx,ly;vx,vy wire form. Velocity is estimated from the last two samples, since WPF reports none. |
.OnMagnify |
Ctrl + mouse wheel — the desktop zoom convention, matching the Silk and Skia desktop heads. A precision touchpad delivers ctrl+wheel for its own pinch, so real pinch hardware works through the same path. |
- Core's type names shadow the WPF ones.
Platforms-style aliasing is mandatory here, exactly as on WinUI: this project compiles into theSwiftDotNetnamespace, where Core declaresGrid,Button,Image,Slider,Rectangle,DatePicker,Brush,Color,GradientStop,Form,Label,Menu,List… plus the enumsHorizontalAlignment/VerticalAlignment. A simple name binds to the enclosing namespace's member first, so every WPF type is reached through a renamed alias (WpfGrid,WpfButton, …). A plainusing Grid = System.Windows.Controls.Grid;does not work — that alias itself collides with the namespace member (CS0576). Core'sFormis additionallysealed, so: Formfails with CS0509 rather than an ambiguity. - Dialogs are overlays, not
Window.ShowDialog(). A modalWindowblocks its caller until dismissed, and the caller here isRender(), part-way through applying a patch — the render loop would stall and re-enter. SoWpfBridge.Hostis aGridand a presented dialog is stacked into the same cell as a dimmed scrim plus a card. Scrim click and Esc both report"false", the wire's own dismissed-without-choosing token. StackPanelhas noSpacing(WinUI's does), so.Spacing(n)becomes a leadingMarginon every child after the first. It is assigned absolutely, never added — otherwise asetChildrenpatch would compound it on every re-lay. Nothing else in this backend writesMargin; padding goes on a wrapperBorder.Gridhas noColumnSpacing/RowSpacingeither, so gutters are a margin on every cell not in the first column/row.- A
Borderdoes not clip its child toCornerRadius. WinUI's does. WithoutClipToBounds, a rounded background is drawn under square content and the corners look filled in — so the modifier wrapper sets it whenever a corner radius is present. - A
Panel,BorderorControlwith a nullBackgroundis invisible to hit-testing. Mouse events pass straight through it. Every gesture modifier therefore forces a transparent background on the element it attaches to, and unselectedListrows get one too or they simply would not be clickable. TabControl.SelectionChangedis a bubbling routed event. AComboBoxorListBoxinside a tab raises it on theTabControlas well, so without ane.OriginalSourceguard, changing aPickeris reported as a tab change and knocks the app to whatever index the picker selected.- WPF forbids two logical parents. A keyed row reused across a
setChildrenpatch, and aNavigationLinkdestination pushed onto the nav stack, are both still parented where they were built — so each is explicitly detached before being re-adopted. Adding an already-parented element throws. PasswordBoxgets no placeholder.TextField's watermark is a hit-test-invisibleTextBlocklayered over theTextBoxin aGrid(WPF has no placeholder property at all). That trick cannot be layered the same way overPasswordBox, whose value is a plain CLR property rather than a DependencyProperty, so the placeholder is dropped rather than faked.InputScopesteers the touch keyboard only. The F9keyboardprop maps to a WPFInputScope, which affects the tablet input panel and handwriting recognition — it does not restrict what a physical keyboard can type. Core's binding is what keeps a numeric field numeric. The F9returnKeyprop has no WPF equivalent at all and is ignored rather than faked.Spacerin aStackPanelcontributes nothing — a StackPanel gives every child its desired size along the stack axis. Shared with the WinUI backend; it works inside aZStackor aGridcell. The Skia backend implements the real SwiftUI semantics.- Do not raise the target platform version to
10.0.19041. That TFM is also compatible with SwiftDotNet'snet10.0-windows10.0.19041.0asset, which carries the WinUI 3 backend — so the compilation would contain two backends at once. This is also why the entry points are namedSwiftDotNetWpfHost/SwiftDotNetWpfApplicationrather than theSwiftDotNetHost/SwiftDotNetApplicationthe GTK and WinUI backends use: raising the TPV is a common thing for a WPF app to do (it is how one reaches WinRT APIs), and identical names in one namespace would be ambiguous.
dotnet run --project sample/SampleApp.Wpf # Windows onlyIt builds anywhere — EnableWindowsTargeting pulls the Windows targeting packs on macOS and Linux — so
a break is caught on the dev machine and in CI's windows-desktop job without a Windows box.
WpfRenderers.Register(type, ctx => FrameworkElement) is hooked into the interpreter's default case, so
custom native primitives need no interpreter fork:
WpfRenderers.Register("NativeRating", ctx =>
{
var slider = new System.Windows.Controls.Slider { Minimum = 0, Maximum = 5, Value = ctx.Number("value") ?? 0 };
slider.ValueChanged += (_, e) => ctx.Emit(((int)e.NewValue).ToString());
return slider;
});See Custom Controls.
🧩 Expected, not run. dotnet watch run --project sample/SampleApp.Wpf should work with no extra setup:
WPF installs a real DispatcherSynchronizationContext on the UI thread (which SwiftApp.Run captures), and
WpfBridge already applies a mid-session replace. See Hot Reload.
- Windows / WinForms — why WinForms gets a canvas instead of a control tree.
- Windows / WinUI 3 — the sibling native backend this one was ported from.
- Skia — the self-drawing backend, hostable in a WPF window via
SwiftDotNet.Skia.Wpf.