Files
PowerToys/.github/skills/wpf-to-winui3-migration/references/threading-and-windowing.md
moooyo 7051b8939b [Skills] Add WPF to WinUI 3 migration agent skill (#46462)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
With this skills, we can easily enable AI to complete most of the tasks
involved in migrating from WPF to WinUI3.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #46464
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:40:33 +00:00

9.0 KiB

Threading and Window Management Migration

Based on patterns from the ImageResizer migration.

Dispatcher → DispatcherQueue

API Mapping

WPF WinUI 3
Dispatcher.Invoke(Action) DispatcherQueue.TryEnqueue(Action)
Dispatcher.BeginInvoke(Action) DispatcherQueue.TryEnqueue(Action)
Dispatcher.Invoke(DispatcherPriority, Action) DispatcherQueue.TryEnqueue(DispatcherQueuePriority, Action)
Dispatcher.CheckAccess() DispatcherQueue.HasThreadAccess
Dispatcher.VerifyAccess() Check DispatcherQueue.HasThreadAccess (no exception-throwing method)

Priority Mapping

WinUI 3 has only 3 levels: High, Normal, Low.

WPF DispatcherPriority WinUI 3 DispatcherQueuePriority
Send High
Normal / Input / Loaded / Render / DataBind Normal
Background / ContextIdle / ApplicationIdle / SystemIdle Low

Pattern: Global DispatcherQueue Access (from ImageResizer)

WPF provided Application.Current.Dispatcher globally. WinUI 3 requires explicit storage:

// Store DispatcherQueue at app startup
private static DispatcherQueue _uiDispatcherQueue;

public static void InitializeDispatcher()
{
    _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread();
}

Usage with thread-check pattern (from Settings.Reload()):

var currentDispatcher = DispatcherQueue.GetForCurrentThread();
if (currentDispatcher != null)
{
    // Already on UI thread
    ReloadCore(jsonSettings);
}
else if (_uiDispatcherQueue != null)
{
    // Dispatch to UI thread
    _uiDispatcherQueue.TryEnqueue(() => ReloadCore(jsonSettings));
}
else
{
    // Fallback (e.g., CLI mode, no UI)
    ReloadCore(jsonSettings);
}

Pattern: DispatcherQueue in ViewModels (from ProgressViewModel)

public class ProgressViewModel
{
    private readonly DispatcherQueue _dispatcherQueue;

    public ProgressViewModel()
    {
        _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
    }

    private void OnProgressChanged(double progress)
    {
        _dispatcherQueue.TryEnqueue(() =>
        {
            Progress = progress;
            // other UI updates...
        });
    }
}

Pattern: Async Dispatch (await)

// WPF
await this.Dispatcher.InvokeAsync(() => { /* UI work */ });

// WinUI 3 (using TaskCompletionSource)
var tcs = new TaskCompletionSource();
this.DispatcherQueue.TryEnqueue(() =>
{
    try { /* UI work */ tcs.SetResult(); }
    catch (Exception ex) { tcs.SetException(ex); }
});
await tcs.Task;

C++/WinRT Threading

Old API New API
winrt::resume_foreground(CoreDispatcher) wil::resume_foreground(DispatcherQueue)
CoreDispatcher.RunAsync() DispatcherQueue.TryEnqueue()

Add Microsoft.Windows.ImplementationLibrary NuGet for wil::resume_foreground.


Window Management

WPF Window vs WinUI 3 Window

Feature WPF Window WinUI 3 Window
Base class ContentControlDependencyObject NOT a control, NOT a DependencyObject
Resources property Yes No — use root container's Resources
DataContext property Yes No — use root Page/UserControl
VisualStateManager Yes No — use inside child controls
Load/Unload events Yes No
SizeToContent Yes (Height/Width/WidthAndHeight) No — must implement manually
WindowState (min/max/normal) Yes No — use AppWindow.Presenter
WindowStyle Yes No — use AppWindow title bar APIs
ResizeMode Yes No — use AppWindow.Presenter
WindowStartupLocation Yes No — calculate manually
Icon Window.Icon AppWindow.SetIcon()
Title Window.Title AppWindow.Title (or Window.Title)
Size (Width/Height) Yes No — use AppWindow.Resize()
Position (Left/Top) Yes No — use AppWindow.Move()
IsDefault/IsCancel on buttons Yes No — handle Enter/Escape in code-behind

Getting AppWindow from Window

using Microsoft.UI;
using Microsoft.UI.Windowing;
using WinRT.Interop;

IntPtr hwnd = WindowNative.GetWindowHandle(window);
WindowId windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
AppWindow appWindow = AppWindow.GetFromWindowId(windowId);

Pattern: SizeToContent Replacement (from ImageResizer)

WinUI 3 has no SizeToContent. ImageResizer implemented a manual equivalent:

private void SizeToContent()
{
    if (Content is not FrameworkElement content)
        return;

    // Measure desired content size
    content.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
    var desiredHeight = content.DesiredSize.Height + WindowChromeHeight + Padding;

    // Account for DPI scaling
    var scaleFactor = Content.XamlRoot.RasterizationScale;
    var pixelHeight = (int)(desiredHeight * scaleFactor);
    var pixelWidth = (int)(WindowWidth * scaleFactor);

    // Resize via AppWindow
    var hwnd = WindowNative.GetWindowHandle(this);
    var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
    var appWindow = AppWindow.GetFromWindowId(windowId);
    appWindow.Resize(new Windows.Graphics.SizeInt32(pixelWidth, pixelHeight));
}

Key details:

  • WindowChromeHeight ≈ 32px for the title bar
  • Must multiply by RasterizationScale for DPI-aware sizing
  • Call SizeToContent() after page navigation or content changes
  • Unsubscribe previous event handlers before subscribing new ones to avoid memory leaks

Window Positioning (Center Screen)

var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Nearest);
var centerX = (displayArea.WorkArea.Width - appWindow.Size.Width) / 2;
var centerY = (displayArea.WorkArea.Height - appWindow.Size.Height) / 2;
appWindow.Move(new Windows.Graphics.PointInt32(centerX, centerY));

Window State (Minimize/Maximize)

(appWindow.Presenter as OverlappedPresenter)?.Maximize();
(appWindow.Presenter as OverlappedPresenter)?.Minimize();
(appWindow.Presenter as OverlappedPresenter)?.Restore();

Title Bar Customization

// Extend content into title bar
this.ExtendsContentIntoTitleBar = true;
this.SetTitleBar(AppTitleBar); // AppTitleBar is a XAML element

// Or via AppWindow API
if (AppWindowTitleBar.IsCustomizationSupported())
{
    var titleBar = appWindow.TitleBar;
    titleBar.ExtendsContentIntoTitleBar = true;
    titleBar.ButtonBackgroundColor = Colors.Transparent;
}

Tracking the Main Window

public partial class App : Application
{
    public static Window MainWindow { get; private set; }

    protected override void OnLaunched(LaunchActivatedEventArgs args)
    {
        MainWindow = new MainWindow();
        MainWindow.Activate();
    }
}

ContentDialog Requires XamlRoot

var dialog = new ContentDialog
{
    Title = "Confirm",
    Content = "Are you sure?",
    PrimaryButtonText = "Yes",
    CloseButtonText = "No",
    XamlRoot = this.Content.XamlRoot  // REQUIRED
};
var result = await dialog.ShowAsync();

File Pickers Require HWND

var picker = new FileOpenPicker();
picker.FileTypeFilter.Add(".jpg");

// REQUIRED for desktop apps
var hwnd = WindowNative.GetWindowHandle(App.MainWindow);
WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);

var file = await picker.PickSingleFileAsync();

Window Close Handling

// WPF
protected override void OnClosing(CancelEventArgs e) { e.Cancel = true; this.Hide(); }

// WinUI 3
this.AppWindow.Closing += (s, e) => { e.Cancel = true; this.AppWindow.Hide(); };

Custom Entry Point (DISABLE_XAML_GENERATED_MAIN)

ImageResizer uses a custom Program.cs entry point instead of the WinUI 3 auto-generated Main. This is needed for:

  • CLI mode (process files without showing UI)
  • Custom initialization before the WinUI 3 App starts
  • Single-instance enforcement

Setup

In .csproj:

<DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants>

Create Program.cs:

public static class Program
{
    [STAThread]
    public static int Main(string[] args)
    {
        if (args.Length > 0)
        {
            // CLI mode — no UI
            return RunCli(args);
        }

        // GUI mode
        WinRT.ComWrappersSupport.InitializeComWrappers();
        Application.Start((p) =>
        {
            var context = new DispatcherQueueSynchronizationContext(
                DispatcherQueue.GetForCurrentThread());
            SynchronizationContext.SetSynchronizationContext(context);
            _ = new App();
        });
        return 0;
    }
}

WPF App Constructor Removal

WPF modules often created new App() to initialize the WPF Application and get Application.Current.Dispatcher. This is no longer needed — the WinUI 3 Application.Start() handles this.

// DELETE (WPF pattern):
_imageResizerApp = new App();
// REPLACE with: Store DispatcherQueue explicitly (see Global DispatcherQueue Access above)