diff --git a/Avalonia.slnx b/Avalonia.slnx
index 80b4042e7a..dba476ed4a 100644
--- a/Avalonia.slnx
+++ b/Avalonia.slnx
@@ -65,6 +65,10 @@
+
+
+
+
@@ -84,6 +88,10 @@
+
+
+
+
@@ -178,6 +186,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f4e677d631..cac3db9cc9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -31,6 +31,8 @@
+
+
diff --git a/build/TargetFrameworks.props b/build/TargetFrameworks.props
index 96541e7ecf..a380009e23 100644
--- a/build/TargetFrameworks.props
+++ b/build/TargetFrameworks.props
@@ -8,6 +8,7 @@
$(AvsCurrentTargetFramework)-ios26.0
$(AvsCurrentTargetFramework)-tvos26.0
$(AvsCurrentTargetFramework)-browser
+ $(AvsCurrentTargetFramework)-windows10.0.19041.0
net8.0
diff --git a/dirs.proj b/dirs.proj
index 20df84439e..43c2518f78 100644
--- a/dirs.proj
+++ b/dirs.proj
@@ -14,12 +14,18 @@
+
+
+
+
+
+
diff --git a/samples/ControlGallery.WinUI/App.xaml b/samples/ControlGallery.WinUI/App.xaml
new file mode 100644
index 0000000000..88b20eca37
--- /dev/null
+++ b/samples/ControlGallery.WinUI/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlGallery.WinUI/App.xaml.cs b/samples/ControlGallery.WinUI/App.xaml.cs
new file mode 100644
index 0000000000..5d26d06383
--- /dev/null
+++ b/samples/ControlGallery.WinUI/App.xaml.cs
@@ -0,0 +1,40 @@
+using global::Avalonia;
+using global::Avalonia.Controls;
+using global::Avalonia.Controls.ApplicationLifetimes;
+using global::Avalonia.Skia;
+using global::Avalonia.Win32;
+using WinUIApplication = Microsoft.UI.Xaml.Application;
+using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
+using Window = Microsoft.UI.Xaml.Window;
+
+namespace ControlGallery.WinUI
+{
+ public partial class App : WinUIApplication
+ {
+ private Window? _window;
+
+ internal static SingleViewLifetime Lifetime { get; } = new();
+
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ AppBuilder.Configure()
+ .UseWin32()
+ .UseSkia()
+ .UseHarfBuzz()
+ .SetupWithLifetime(Lifetime);
+
+ _window = new MainWindow();
+ _window.Activate();
+ }
+ }
+
+ internal sealed class SingleViewLifetime : ISingleViewApplicationLifetime
+ {
+ public Control? MainView { get; set; }
+ }
+}
diff --git a/samples/ControlGallery.WinUI/Assets/LockScreenLogo.scale-200.png b/samples/ControlGallery.WinUI/Assets/LockScreenLogo.scale-200.png
new file mode 100644
index 0000000000..7440f0d4bf
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/LockScreenLogo.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/SplashScreen.scale-200.png b/samples/ControlGallery.WinUI/Assets/SplashScreen.scale-200.png
new file mode 100644
index 0000000000..32f486a867
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/SplashScreen.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/Square150x150Logo.scale-200.png b/samples/ControlGallery.WinUI/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 0000000000..53ee3777ea
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/Square150x150Logo.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/Square44x44Logo.scale-200.png b/samples/ControlGallery.WinUI/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 0000000000..f713bba67f
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/Square44x44Logo.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/samples/ControlGallery.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png
new file mode 100644
index 0000000000..dc9f5bea0c
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/StoreLogo.png b/samples/ControlGallery.WinUI/Assets/StoreLogo.png
new file mode 100644
index 0000000000..a4586f26bd
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/StoreLogo.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/Wide310x150Logo.scale-200.png b/samples/ControlGallery.WinUI/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 0000000000..8b4a5d0dd5
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/ControlGallery.WinUI.csproj b/samples/ControlGallery.WinUI/ControlGallery.WinUI.csproj
new file mode 100644
index 0000000000..4b3d246aaf
--- /dev/null
+++ b/samples/ControlGallery.WinUI/ControlGallery.WinUI.csproj
@@ -0,0 +1,67 @@
+
+
+ WinExe
+ $(AvsCurrentWinUITargetFramework)
+ 10.0.17763.0
+ ControlGallery.WinUI
+ app.manifest
+ x86;x64;ARM64
+ win-x86;win-x64;win-arm64
+ win-$(Platform).pubxml
+ true
+ false
+ true
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+ False
+ True
+ False
+ True
+
+
\ No newline at end of file
diff --git a/samples/ControlGallery.WinUI/MainWindow.xaml b/samples/ControlGallery.WinUI/MainWindow.xaml
new file mode 100644
index 0000000000..9595ef8253
--- /dev/null
+++ b/samples/ControlGallery.WinUI/MainWindow.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/samples/ControlGallery.WinUI/MainWindow.xaml.cs b/samples/ControlGallery.WinUI/MainWindow.xaml.cs
new file mode 100644
index 0000000000..7f9b40b82e
--- /dev/null
+++ b/samples/ControlGallery.WinUI/MainWindow.xaml.cs
@@ -0,0 +1,11 @@
+namespace ControlGallery.WinUI
+{
+ public sealed partial class MainWindow : Microsoft.UI.Xaml.Window
+ {
+ public MainWindow()
+ {
+ InitializeComponent();
+ AvaloniaPanel.Content = App.Lifetime.MainView;
+ }
+ }
+}
diff --git a/samples/ControlGallery.WinUI/Package.appxmanifest b/samples/ControlGallery.WinUI/Package.appxmanifest
new file mode 100644
index 0000000000..fcd91fc1ac
--- /dev/null
+++ b/samples/ControlGallery.WinUI/Package.appxmanifest
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+ ControlGallery.WinUI
+ drasticactions
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlGallery.WinUI/Properties/launchSettings.json b/samples/ControlGallery.WinUI/Properties/launchSettings.json
new file mode 100644
index 0000000000..ddb3c41a5a
--- /dev/null
+++ b/samples/ControlGallery.WinUI/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "ControlGallery.WinUI (Package)": {
+ "commandName": "MsixPackage"
+ },
+ "ControlGallery.WinUI (Unpackaged)": {
+ "commandName": "Project"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/ControlGallery.WinUI/app.manifest b/samples/ControlGallery.WinUI/app.manifest
new file mode 100644
index 0000000000..e6d75d93fc
--- /dev/null
+++ b/samples/ControlGallery.WinUI/app.manifest
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+
+
+
\ No newline at end of file
diff --git a/samples/WinUIEmbedSample/App.xaml b/samples/WinUIEmbedSample/App.xaml
new file mode 100644
index 0000000000..0bfa0c7b64
--- /dev/null
+++ b/samples/WinUIEmbedSample/App.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/App.xaml.cs b/samples/WinUIEmbedSample/App.xaml.cs
new file mode 100644
index 0000000000..6524572edd
--- /dev/null
+++ b/samples/WinUIEmbedSample/App.xaml.cs
@@ -0,0 +1,40 @@
+using global::Avalonia;
+using global::Avalonia.Controls;
+using global::Avalonia.Controls.ApplicationLifetimes;
+using global::Avalonia.Skia;
+using global::Avalonia.Win32;
+using WinUIApplication = Microsoft.UI.Xaml.Application;
+using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
+using Window = Microsoft.UI.Xaml.Window;
+
+namespace WinUIEmbedSample
+{
+ public partial class App : WinUIApplication
+ {
+ private Window? _window;
+
+ internal static SingleViewLifetime Lifetime { get; } = new();
+
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ AppBuilder.Configure()
+ .UseWin32()
+ .UseSkia()
+ .UseHarfBuzz()
+ .SetupWithLifetime(Lifetime);
+
+ _window = new MainWindow();
+ _window.Activate();
+ }
+ }
+
+ internal sealed class SingleViewLifetime : ISingleViewApplicationLifetime
+ {
+ public Control? MainView { get; set; }
+ }
+}
diff --git a/samples/WinUIEmbedSample/AvaloniaApp.axaml b/samples/WinUIEmbedSample/AvaloniaApp.axaml
new file mode 100644
index 0000000000..03c11e5146
--- /dev/null
+++ b/samples/WinUIEmbedSample/AvaloniaApp.axaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs b/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs
new file mode 100644
index 0000000000..854a7bd186
--- /dev/null
+++ b/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs
@@ -0,0 +1,13 @@
+using global::Avalonia;
+using global::Avalonia.Markup.Xaml;
+using AvApplication = global::Avalonia.Application;
+
+namespace WinUIEmbedSample;
+
+public partial class AvaloniaApp : AvApplication
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/samples/WinUIEmbedSample/EmbeddedView.axaml b/samples/WinUIEmbedSample/EmbeddedView.axaml
new file mode 100644
index 0000000000..9cfbd4cb1b
--- /dev/null
+++ b/samples/WinUIEmbedSample/EmbeddedView.axaml
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/EmbeddedView.axaml.cs b/samples/WinUIEmbedSample/EmbeddedView.axaml.cs
new file mode 100644
index 0000000000..0c918b0695
--- /dev/null
+++ b/samples/WinUIEmbedSample/EmbeddedView.axaml.cs
@@ -0,0 +1,183 @@
+using System.Linq;
+using global::Avalonia;
+using global::Avalonia.Controls;
+using global::Avalonia.Controls.Shapes;
+using global::Avalonia.Input;
+using global::Avalonia.Interactivity;
+using global::Avalonia.Markup.Xaml;
+using global::Avalonia.Markup.Xaml.MarkupExtensions;
+using global::Avalonia.Media;
+using global::Avalonia.Platform.Storage;
+
+namespace WinUIEmbedSample;
+
+public partial class EmbeddedView : UserControl
+{
+ private int _clicks;
+ private int _pointCount;
+ private int _intermediateCount;
+
+ public EmbeddedView()
+ {
+ InitializeComponent();
+ AvSlider.PropertyChanged += OnSliderPropertyChanged;
+ HoverPanel.PointerEntered += (_, _) => HoverState.Text = "over";
+ HoverPanel.PointerExited += (_, _) => HoverState.Text = "out";
+ AddHandler(KeyDownEvent, OnKeyReadout, handledEventsToo: true);
+
+ DragDrop.SetAllowDrop(DropTargetBorder, true);
+ DropTargetBorder.AddHandler(DragDrop.DragOverEvent, OnDropDragOver);
+ DropTargetBorder.AddHandler(DragDrop.DropEvent, OnDrop);
+
+ DragSourceBorder.PointerPressed += OnDragSourcePointerPressed;
+ }
+
+ private async void OnClipboardCopy(object? sender, RoutedEventArgs e)
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ {
+ ClipboardStatus.Text = "no clipboard";
+ return;
+ }
+ var data = new global::Avalonia.Input.DataTransfer();
+ data.Add(DataTransferItem.CreateText(ClipboardText.Text ?? ""));
+ await clipboard.SetDataAsync(data);
+ ClipboardStatus.Text = $"copied {(ClipboardText.Text ?? "").Length} chars";
+ }
+
+ private async void OnClipboardPaste(object? sender, RoutedEventArgs e)
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ {
+ ClipboardStatus.Text = "no clipboard";
+ return;
+ }
+ using var data = await clipboard.TryGetDataAsync();
+ var text = data is null ? null : await data.TryGetTextAsync();
+ if (text is not null)
+ {
+ ClipboardText.Text = text;
+ ClipboardStatus.Text = $"pasted {text.Length} chars";
+ }
+ else
+ {
+ ClipboardStatus.Text = "no text on clipboard";
+ }
+ }
+
+ private async void OnDragSourcePointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ var data = new global::Avalonia.Input.DataTransfer();
+ data.Add(DataTransferItem.CreateText("Hello from Avalonia"));
+ try
+ {
+ await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Copy | DragDropEffects.Move);
+ }
+ catch
+ {
+ // Ignore — drag may be cancelled or the source unavailable.
+ }
+ }
+
+ private void OnDropDragOver(object? sender, DragEventArgs e)
+ {
+ e.DragEffects = e.DragEffects & (DragDropEffects.Copy | DragDropEffects.Link | DragDropEffects.Move);
+ if (e.DragEffects == DragDropEffects.None)
+ e.DragEffects = DragDropEffects.Copy;
+ e.Handled = true;
+ }
+
+ private void OnDrop(object? sender, DragEventArgs e)
+ {
+ var dt = e.DataTransfer;
+ var files = dt.TryGetValues(DataFormat.File)?.ToArray() ?? System.Array.Empty();
+ var text = dt.TryGetValue(DataFormat.Text);
+
+ if (files.Length > 0)
+ DropStatus.Text = $"{files.Length} file(s):\n" +
+ string.Join('\n', files.Select(f => f.Path.LocalPath));
+ else if (!string.IsNullOrEmpty(text))
+ DropStatus.Text = $"text: {text}";
+ else
+ DropStatus.Text = "(unknown payload)";
+
+ e.Handled = true;
+ }
+
+ private void OnKeyReadout(object? sender, KeyEventArgs e)
+ {
+ var symbol = e.KeySymbol is { Length: > 0 } s ? $"\"{s}\"" : "null";
+ KeyReadout.Text =
+ $"Key={e.Key} Physical={e.PhysicalKey} Symbol={symbol}\n" +
+ $"Modifiers={e.KeyModifiers}";
+ }
+
+ private void OnAvButtonClick(object? sender, RoutedEventArgs e)
+ {
+ AvClickCount.Text = $"Clicked {++_clicks} times";
+ }
+
+ private void OnSliderPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == Slider.ValueProperty)
+ AvSliderValue.Text = $"Slider: {AvSlider.Value:F0}";
+ }
+
+ private void OnInkPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ AddDot(e.GetPosition(InkCanvas), Brushes.Red);
+ _pointCount++;
+ e.Pointer.Capture(InkCanvas);
+ UpdateStats();
+ e.Handled = true;
+ }
+
+ private void OnInkPointerMoved(object? sender, PointerEventArgs e)
+ {
+ var intermediates = e.GetIntermediatePoints(InkCanvas);
+ for (var i = 0; i < intermediates.Count; i++)
+ AddDot(intermediates[i].Position, Brushes.Orange);
+ _intermediateCount += intermediates.Count;
+
+ AddDot(e.GetPosition(InkCanvas), Brushes.Green);
+ _pointCount++;
+ UpdateStats();
+ }
+
+ private void OnInkPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ AddDot(e.GetPosition(InkCanvas), Brushes.Blue);
+ _pointCount++;
+ UpdateStats();
+ e.Handled = true;
+ }
+
+ private void AddDot(Point position, IBrush brush)
+ {
+ const double size = 6;
+ var dot = new Ellipse
+ {
+ Width = size,
+ Height = size,
+ Fill = brush,
+ };
+ Canvas.SetLeft(dot, position.X - size / 2);
+ Canvas.SetTop(dot, position.Y - size / 2);
+ InkCanvas.Children.Add(dot);
+ }
+
+ private void OnClearInk(object? sender, RoutedEventArgs e)
+ {
+ InkCanvas.Children.Clear();
+ _pointCount = 0;
+ _intermediateCount = 0;
+ UpdateStats();
+ }
+
+ private void UpdateStats()
+ {
+ InkStats.Text = $"{_pointCount} points / {_intermediateCount} intermediate";
+ }
+}
diff --git a/samples/WinUIEmbedSample/MainWindow.xaml b/samples/WinUIEmbedSample/MainWindow.xaml
new file mode 100644
index 0000000000..bc5f9daaf2
--- /dev/null
+++ b/samples/WinUIEmbedSample/MainWindow.xaml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Light
+ Dark
+ System
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/MainWindow.xaml.cs b/samples/WinUIEmbedSample/MainWindow.xaml.cs
new file mode 100644
index 0000000000..9613b2da81
--- /dev/null
+++ b/samples/WinUIEmbedSample/MainWindow.xaml.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Text;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Automation.Peers;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using global::Avalonia.Styling;
+using AvApplication = global::Avalonia.Application;
+
+namespace WinUIEmbedSample
+{
+ public sealed partial class MainWindow : Microsoft.UI.Xaml.Window
+ {
+ private int _clicks;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ App.Lifetime.MainView = new EmbeddedView();
+ AvaloniaPanel.Content = App.Lifetime.MainView;
+
+ // Probe the automation peer *before* the panel is loaded — answers the
+ // open question of whether GetChildren() works before _root.Prepare().
+ AppendDiagnostic("ctor: " + DescribePeer());
+
+ AvaloniaPanel.Loading += (_, _) => AppendDiagnostic("Loading: " + DescribePeer());
+ AvaloniaPanel.Loaded += (_, _) =>
+ {
+ AppendDiagnostic("Loaded: " + DescribePeer());
+ };
+ }
+
+ private string DescribePeer()
+ {
+ try
+ {
+ var peer = FrameworkElementAutomationPeer.CreatePeerForElement(AvaloniaPanel);
+ if (peer is null)
+ return "peer=null";
+ var children = peer.GetChildren();
+ var count = children?.Count ?? 0;
+ var sb = new StringBuilder();
+ sb.Append("peer=").Append(peer.GetType().Name)
+ .Append(" class=").Append(peer.GetClassName())
+ .Append(" childCount=").Append(count);
+ if (count > 0)
+ {
+ sb.Append(" first=[");
+ var first = children![0];
+ sb.Append(first.GetClassName()).Append('/').Append(first.GetAutomationControlType());
+ sb.Append("] firstChildren=").Append(first.GetChildren()?.Count ?? 0);
+ }
+ return sb.ToString();
+ }
+ catch (Exception ex)
+ {
+ return "threw: " + ex.GetType().Name + ": " + ex.Message;
+ }
+ }
+
+ private void ProbePeerButton_Click(object sender, RoutedEventArgs e)
+ {
+ AppendDiagnostic("Probe: " + DescribePeer());
+ }
+
+ private async void ProbeWorkerButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Cache the peer on the UI thread (CreatePeerForElement is UI-thread bound).
+ var peer = FrameworkElementAutomationPeer.CreatePeerForElement(AvaloniaPanel);
+
+ // Walk on a worker thread — mimics how UIA queries (it calls from a separate thread).
+ var result = await System.Threading.Tasks.Task.Run(() =>
+ {
+ var sb = new StringBuilder();
+ sb.Append("Worker-thread walk (TID=").Append(Environment.CurrentManagedThreadId).Append("):\n");
+ try { WalkPeer(peer, 0, sb, maxDepth: 20); }
+ catch (Exception ex) { sb.Append("THREW: ").Append(ex); }
+ return sb.ToString();
+ });
+
+ AppendDiagnostic(result);
+ }
+
+ private void ProbeDeepButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var peer = FrameworkElementAutomationPeer.CreatePeerForElement(AvaloniaPanel);
+ var sb = new StringBuilder();
+ sb.Append("Deep walk:\n");
+ WalkPeer(peer, 0, sb, maxDepth: 20);
+ AppendDiagnostic(sb.ToString());
+ }
+ catch (Exception ex)
+ {
+ AppendDiagnostic("Deep probe threw: " + ex.Message);
+ }
+ }
+
+ private static void WalkPeer(AutomationPeer peer, int depth, StringBuilder sb, int maxDepth)
+ {
+ if (peer is null || depth > maxDepth)
+ return;
+
+ for (var i = 0; i < depth; i++) sb.Append(" ");
+ var className = peer.GetClassName();
+ var name = peer.GetName();
+ var type = peer.GetAutomationControlType();
+ var children = peer.GetChildren();
+ sb.Append('[').Append(className).Append(']')
+ .Append(" type=").Append(type)
+ .Append(" name=\"").Append(name).Append('"')
+ .Append(" children=").Append(children?.Count ?? 0)
+ .Append('\n');
+
+ if (children is null) return;
+ foreach (var child in children)
+ WalkPeer(child, depth + 1, sb, maxDepth);
+ }
+
+ private void AppendDiagnostic(string line)
+ {
+ if (DiagnosticsLog is null)
+ return;
+ DiagnosticsLog.Text += "[" + DateTime.Now.ToString("HH:mm:ss.fff") + "] " + line + "\n";
+ }
+
+ private void WinUiButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
+ {
+ WinUiClickCount.Text = $"Clicked {++_clicks} times";
+ }
+
+ private void WinUiDragSource_DragStarting(
+ Microsoft.UI.Xaml.UIElement sender,
+ Microsoft.UI.Xaml.DragStartingEventArgs e)
+ {
+ e.Data.SetText("Hello from WinUI");
+ e.AllowedOperations =
+ Windows.ApplicationModel.DataTransfer.DataPackageOperation.Copy |
+ Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
+ }
+
+ private void WinUiSlider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (WinUiSliderValue is not null)
+ WinUiSliderValue.Text = $"Slider: {e.NewValue:F0}";
+ }
+
+ private void WinUiThemeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ var selected = WinUiThemeCombo.SelectedIndex;
+
+ if (Content is FrameworkElement root)
+ {
+ root.RequestedTheme = selected switch
+ {
+ 0 => ElementTheme.Light,
+ 1 => ElementTheme.Dark,
+ _ => ElementTheme.Default
+ };
+ }
+
+ if (AvApplication.Current is { } avApp)
+ {
+ avApp.RequestedThemeVariant = selected switch
+ {
+ 0 => ThemeVariant.Light,
+ 1 => ThemeVariant.Dark,
+ _ => ThemeVariant.Default
+ };
+ }
+ }
+ }
+}
diff --git a/samples/WinUIEmbedSample/Package.appxmanifest b/samples/WinUIEmbedSample/Package.appxmanifest
new file mode 100644
index 0000000000..7601bc9f58
--- /dev/null
+++ b/samples/WinUIEmbedSample/Package.appxmanifest
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+ WinUIEmbedSample
+ AvaloniaUI
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/Properties/launchSettings.json b/samples/WinUIEmbedSample/Properties/launchSettings.json
new file mode 100644
index 0000000000..f4df532519
--- /dev/null
+++ b/samples/WinUIEmbedSample/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "WinUIEmbedSample (Package)": {
+ "commandName": "MsixPackage"
+ },
+ "WinUIEmbedSample (Unpackaged)": {
+ "commandName": "Project"
+ }
+ }
+}
diff --git a/samples/WinUIEmbedSample/WinUIEmbedSample.csproj b/samples/WinUIEmbedSample/WinUIEmbedSample.csproj
new file mode 100644
index 0000000000..a19b06e24c
--- /dev/null
+++ b/samples/WinUIEmbedSample/WinUIEmbedSample.csproj
@@ -0,0 +1,66 @@
+
+
+ WinExe
+ $(AvsCurrentWinUITargetFramework)
+ 10.0.17763.0
+ WinUIEmbedSample
+ app.manifest
+ x86;x64;ARM64
+ win-x86;win-x64;win-arm64
+ win-$(Platform).pubxml
+ true
+ false
+ true
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+ False
+ True
+ False
+ True
+
+
diff --git a/samples/WinUIEmbedSample/app.manifest b/samples/WinUIEmbedSample/app.manifest
new file mode 100644
index 0000000000..09e48a29d1
--- /dev/null
+++ b/samples/WinUIEmbedSample/app.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+
+
+
diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj
index 99524857a7..69f5dd54fa 100644
--- a/src/Avalonia.Base/Avalonia.Base.csproj
+++ b/src/Avalonia.Base/Avalonia.Base.csproj
@@ -50,6 +50,7 @@
+
diff --git a/src/Avalonia.Base/Logging/LogArea.cs b/src/Avalonia.Base/Logging/LogArea.cs
index 07553a647e..36e2211581 100644
--- a/src/Avalonia.Base/Logging/LogArea.cs
+++ b/src/Avalonia.Base/Logging/LogArea.cs
@@ -49,6 +49,11 @@ namespace Avalonia.Logging
/// The log event comes from Win32 Platform.
///
public const string Win32Platform = nameof(Win32Platform);
+
+ ///
+ /// The log event comes from the WinUI Platform.
+ ///
+ public const string WinUIPlatform = nameof(WinUIPlatform);
///
/// The log event comes from X11 Platform.
diff --git a/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj b/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj
index ea834e26a6..17796d2f9b 100644
--- a/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj
+++ b/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj
@@ -34,6 +34,7 @@
+
diff --git a/src/Windows/Avalonia.Win32/OpenGl/Angle/SwapChainGlSurface.cs b/src/Windows/Avalonia.Win32/OpenGl/Angle/SwapChainGlSurface.cs
new file mode 100644
index 0000000000..9dfd692da3
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/OpenGl/Angle/SwapChainGlSurface.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia.OpenGL;
+using Avalonia.OpenGL.Egl;
+using Avalonia.OpenGL.Surfaces;
+using Avalonia.Platform;
+using Avalonia.Win32.DirectX;
+using MicroCom.Runtime;
+
+namespace Avalonia.Win32.OpenGl.Angle;
+
+internal unsafe class SwapChainGlSurface : EglGlPlatformSurfaceBase
+{
+ // QI for IDXGISwapChain2 fails on some Windows builds even though
+ // IDXGISwapChain3/4 succeed. Use IDXGISwapChain3 which inherits from
+ // IDXGISwapChain2 and has SetMatrixTransform at the same vtable slot.
+ private static readonly Guid IDXGISwapChain3Guid = new("94d99bdb-f1f8-4ab0-b236-7da0170edab1");
+
+ [DllImport("dxgi.dll", ExactSpelling = true)]
+ private static extern int CreateDXGIFactory2(uint Flags, in Guid riid, out IntPtr ppFactory);
+
+ private readonly Func _getSizeFunc;
+ private readonly Func _getScalingFunc;
+ private readonly Action _setSwapChainCallback;
+ private IDXGISwapChain1? _swapChain;
+ private IntPtr _swapChain3Ptr;
+
+ public SwapChainGlSurface(
+ Func getSizeFunc,
+ Func getScalingFunc,
+ Action setSwapChainCallback)
+ {
+ _getSizeFunc = getSizeFunc;
+ _getScalingFunc = getScalingFunc;
+ _setSwapChainCallback = setSwapChainCallback;
+ }
+
+ public override IGlPlatformSurfaceRenderTarget CreateGlRenderTarget(IGlContext context)
+ {
+ var eglContext = (EglContext)context;
+
+ if (_swapChain is null)
+ {
+ _swapChain = CreateSwapChain(eglContext);
+
+ var swapChainPtr = MicroComRuntime.GetNativeIntPtr(_swapChain);
+ var guid = IDXGISwapChain3Guid;
+ var qiHr = Marshal.QueryInterface(swapChainPtr, in guid, out _swapChain3Ptr);
+ if (qiHr != 0 || _swapChain3Ptr == IntPtr.Zero)
+ throw new InvalidOperationException(
+ $"QI for IDXGISwapChain3 failed: HR=0x{qiHr:X8}, ptr={_swapChain3Ptr}");
+
+ SetInverseScaleTransform(_getScalingFunc());
+
+ _setSwapChainCallback(swapChainPtr);
+ }
+
+ return new SwapChainGlRenderTarget(eglContext, _swapChain, _getSizeFunc, _getScalingFunc, _swapChain3Ptr, this);
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct DXGI_MATRIX_3X2_F
+ {
+ public float _11, _12;
+ public float _21, _22;
+ public float _31, _32;
+ }
+
+ internal void SetInverseScaleTransform(double scaling)
+ {
+ if (_swapChain3Ptr == IntPtr.Zero || scaling <= 0)
+ return;
+
+ var inverseScale = new DXGI_MATRIX_3X2_F
+ {
+ _11 = 1.0f / (float)scaling,
+ _22 = 1.0f / (float)scaling
+ };
+
+ // IDXGISwapChain2::SetMatrixTransform vtable slot:
+ // IUnknown(3) + IDXGIObject(4) + IDXGIDeviceSubObject(1) +
+ // IDXGISwapChain(10) + IDXGISwapChain1(11) +
+ // SetSourceSize, GetSourceSize, SetMaximumFrameLatency,
+ // GetMaximumFrameLatency, GetFrameLatencyWaitableObject = 5
+ // → SetMatrixTransform is at slot 34
+ var vtable = *(IntPtr**)_swapChain3Ptr;
+ var setMatrixTransform = (delegate* unmanaged[Stdcall])vtable[34];
+ var hr = setMatrixTransform(_swapChain3Ptr, &inverseScale);
+ Marshal.ThrowExceptionForHR(hr);
+ }
+
+ private IDXGISwapChain1 CreateSwapChain(EglContext eglContext)
+ {
+ var eglDisplay = (AngleWin32EglDisplay)eglContext.Display;
+ var d3dDevicePtr = eglDisplay.GetDirect3DDevice();
+ var d3dDevice = MicroComRuntime.CreateProxyFor(d3dDevicePtr, false);
+
+ IDXGIDevice dxgiDevice;
+ using (d3dDevice)
+ dxgiDevice = d3dDevice.QueryInterface();
+
+ Guid factoryGuid = MicroComRuntime.GetGuidFor(typeof(IDXGIFactory2));
+ var hr = CreateDXGIFactory2(0, in factoryGuid, out var factoryPtr);
+ Marshal.ThrowExceptionForHR(hr);
+ var dxgiFactory = MicroComRuntime.CreateProxyFor(factoryPtr, true);
+
+ var pixelSize = _getSizeFunc();
+ var desc = new DXGI_SWAP_CHAIN_DESC1
+ {
+ Format = DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM,
+ SampleDesc = new DXGI_SAMPLE_DESC { Count = 1, Quality = 0 },
+ BufferUsage = DxgiRenderTarget.DXGI_USAGE_RENDER_TARGET_OUTPUT,
+ BufferCount = 2,
+ SwapEffect = DXGI_SWAP_EFFECT.DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL,
+ AlphaMode = DXGI_ALPHA_MODE.DXGI_ALPHA_MODE_PREMULTIPLIED,
+ Width = (uint)pixelSize.Width,
+ Height = (uint)pixelSize.Height,
+ Flags = 0
+ };
+
+ var swapChain = dxgiFactory.CreateSwapChainForComposition(dxgiDevice, &desc, null);
+
+ dxgiFactory.Dispose();
+ dxgiDevice.Dispose();
+
+ return swapChain;
+ }
+
+ public void DisposeSwapChain()
+ {
+ if (_swapChain3Ptr != IntPtr.Zero)
+ {
+ Marshal.Release(_swapChain3Ptr);
+ _swapChain3Ptr = IntPtr.Zero;
+ }
+ _swapChain?.Dispose();
+ _swapChain = null;
+ }
+}
+
+internal unsafe class SwapChainGlRenderTarget : EglPlatformSurfaceRenderTargetBase
+{
+ private static readonly Guid ID3D11Texture2DGuid = Guid.Parse("6F15AAF2-D208-4E89-9AB4-489535D34F9C");
+
+ private readonly IDXGISwapChain1 _swapChain;
+ private readonly Func _getSizeFunc;
+ private readonly Func _getScalingFunc;
+ private readonly IntPtr _swapChain3Ptr;
+ private readonly SwapChainGlSurface _owner;
+
+ private IUnknown? _renderTexture;
+ private EglSurface? _surface;
+ private PixelSize _lastSize;
+ private double _lastScaling;
+
+ public SwapChainGlRenderTarget(
+ EglContext context,
+ IDXGISwapChain1 swapChain,
+ Func getSizeFunc,
+ Func getScalingFunc,
+ IntPtr swapChain3Ptr,
+ SwapChainGlSurface owner) : base(context)
+ {
+ _swapChain = swapChain;
+ _getSizeFunc = getSizeFunc;
+ _getScalingFunc = getScalingFunc;
+ _swapChain3Ptr = swapChain3Ptr;
+ _owner = owner;
+ }
+
+ public override IGlPlatformSurfaceRenderingSession BeginDrawCore(IRenderTarget.RenderTargetSceneInfo sceneInfo)
+ {
+ var contextLock = Context.EnsureCurrent();
+ var success = false;
+ try
+ {
+ var size = _getSizeFunc();
+ var scaling = _getScalingFunc();
+
+ if (scaling != _lastScaling)
+ {
+ _owner.SetInverseScaleTransform(scaling);
+ _lastScaling = scaling;
+ }
+
+ if (size != _lastSize)
+ {
+ _surface?.Dispose();
+ _surface = null;
+ _renderTexture?.Dispose();
+ _renderTexture = null;
+
+ _swapChain.ResizeBuffers(2,
+ (ushort)size.Width,
+ (ushort)size.Height,
+ DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM,
+ 0);
+
+ _lastSize = size;
+ }
+
+ if (_renderTexture is null)
+ {
+ _surface?.Dispose();
+ _surface = null;
+
+ Guid textureGuid = ID3D11Texture2DGuid;
+ _renderTexture = MicroComRuntime.CreateProxyFor(
+ _swapChain.GetBuffer(0, &textureGuid), true);
+ }
+
+ if (_surface is null)
+ {
+ _surface = ((AngleWin32EglDisplay)Context.Display).WrapDirect3D11Texture(
+ MicroComRuntime.GetNativeIntPtr(_renderTexture),
+ 0, 0, size.Width, size.Height);
+ }
+
+ var res = base.BeginDraw(_surface, size, scaling, () =>
+ {
+ try
+ {
+ _swapChain.Present(1, 0);
+ }
+ finally
+ {
+ contextLock?.Dispose();
+ }
+ }, true);
+ success = true;
+ return res;
+ }
+ finally
+ {
+ if (!success)
+ {
+ _surface?.Dispose();
+ _surface = null;
+ _renderTexture?.Dispose();
+ _renderTexture = null;
+ contextLock.Dispose();
+ }
+ }
+ }
+
+ public override void Dispose()
+ {
+ base.Dispose();
+ _surface?.Dispose();
+ _renderTexture?.Dispose();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/SwapChainTopLevelImpl.cs b/src/Windows/Avalonia.Win32/SwapChainTopLevelImpl.cs
new file mode 100644
index 0000000000..15ea304709
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/SwapChainTopLevelImpl.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Input.Raw;
+using Avalonia.Input.TextInput;
+using Avalonia.OpenGL.Surfaces;
+using Avalonia.Platform;
+using Avalonia.Platform.Surfaces;
+using Avalonia.Rendering.Composition;
+
+namespace Avalonia.Win32;
+
+///
+/// A minimal that hosts an Avalonia content tree on top of an
+/// externally managed swap-chain surface (e.g. a WinUI SwapChainPanel or any other
+/// host that supplies an ). Sizing, scaling and input
+/// pumping are driven by the host.
+///
+internal class SwapChainTopLevelImpl : ITopLevelImpl
+{
+ private readonly IGlPlatformSurface _glSurface;
+ private Size _clientSize;
+ private double _scaling = 1.0;
+
+ public SwapChainTopLevelImpl(IGlPlatformSurface glSurface)
+ {
+ _glSurface = glSurface;
+ var platformGraphics = AvaloniaLocator.Current.GetService();
+ Compositor = new Compositor(platformGraphics);
+ }
+
+ public Size ClientSize
+ {
+ get => _clientSize;
+ set
+ {
+ _clientSize = value;
+ Resized?.Invoke(value, WindowResizeReason.Unspecified);
+ }
+ }
+
+ public double RenderScaling
+ {
+ get => _scaling;
+ set
+ {
+ _scaling = value;
+ ScalingChanged?.Invoke(value);
+ }
+ }
+
+ public double DesktopScaling => _scaling;
+
+ public IPlatformHandle? Handle => null;
+
+ public Compositor Compositor { get; }
+
+ public IPlatformRenderSurface[] Surfaces => [_glSurface];
+
+ public Action? Input { get; set; }
+
+ public Action? Paint { get; set; }
+
+ public Action? Resized { get; set; }
+
+ public Action? ScalingChanged { get; set; }
+
+ public Action? TransparencyLevelChanged { get; set; }
+
+ public Action? Closed { get; set; }
+
+ public Action? LostFocus { get; set; }
+
+ public WindowTransparencyLevel TransparencyLevel => WindowTransparencyLevel.None;
+
+ public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new(1, 1, 1);
+
+ public IInputRoot? InputRoot { get; private set; }
+
+ public void SetInputRoot(IInputRoot inputRoot) => InputRoot = inputRoot;
+
+ public Point PointToClient(PixelPoint point) => point.ToPoint(_scaling);
+
+ public PixelPoint PointToScreen(Point point) => PixelPoint.FromPoint(point, _scaling);
+
+ ///
+ /// Raised when Avalonia requests a cursor change. The host is responsible
+ /// for translating the (host-supplied) into a
+ /// native cursor and applying it to its surface.
+ ///
+ public Action? CursorChanged { get; set; }
+
+ public void SetCursor(ICursorImpl? cursor) => CursorChanged?.Invoke(cursor);
+
+ // Uses overlays instead of popups.
+ public IPopupImpl? CreatePopup() => null;
+
+ public void SetTransparencyLevelHint(IReadOnlyList transparencyLevels) { }
+
+ public void SetFrameThemeVariant(PlatformThemeVariant themeVariant) { }
+
+ ///
+ /// Optional IME implementation provided by the host.
+ ///
+ public ITextInputMethodImpl? TextInputMethod { get; set; }
+
+ public object? TryGetFeature(Type featureType)
+ {
+ if (featureType == typeof(IClipboard))
+ return AvaloniaLocator.Current.GetService();
+ if (featureType == typeof(ITextInputMethodImpl))
+ return TextInputMethod;
+ return null;
+ }
+
+ public void Dispose()
+ {
+ Closed?.Invoke();
+ }
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaSwapChainPanelAutomationPeer.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaSwapChainPanelAutomationPeer.cs
new file mode 100644
index 0000000000..4b6159a546
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaSwapChainPanelAutomationPeer.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using global::Avalonia.Automation.Provider;
+using Microsoft.UI.Xaml.Automation.Peers;
+using AvPeer = global::Avalonia.Automation.Peers.AutomationPeer;
+using AvControlPeer = global::Avalonia.Automation.Peers.ControlAutomationPeer;
+using XamlAutomationPeer = Microsoft.UI.Xaml.Automation.Peers.AutomationPeer;
+using XamlPoint = global::Windows.Foundation.Point;
+using AvPoint = global::Avalonia.Point;
+using AvRect = global::Avalonia.Rect;
+
+namespace Avalonia.WinUI.Automation;
+
+internal sealed partial class AvaloniaSwapChainPanelAutomationPeer : FrameworkElementAutomationPeer
+{
+ private readonly AvaloniaSwapChainPanel _panel;
+ private IEmbeddedRootProvider? _embeddedRoot;
+ private AvPeer? _embeddedRootPeer;
+
+ public AvaloniaSwapChainPanelAutomationPeer(AvaloniaSwapChainPanel panel) : base(panel)
+ {
+ _panel = panel;
+ }
+
+ internal AvaloniaSwapChainPanel Panel => _panel;
+
+ protected override string GetClassNameCore() => nameof(AvaloniaSwapChainPanel);
+
+ protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Pane;
+
+ protected override IList GetChildrenCore()
+ {
+ var list = base.GetChildrenCore() ?? new List();
+
+ if (TryGetEmbeddedRootPeer() is { } rootPeer)
+ {
+ var proxy = AvaloniaToXamlPeerProxy.GetOrCreate(rootPeer, this);
+ proxy.SetParent(this);
+ list.Add(proxy);
+ }
+
+ return list;
+ }
+
+ protected override object GetFocusedElementCore()
+ {
+ EnsureSubscribed();
+ var focused = _embeddedRoot?.GetFocus();
+ return focused is null
+ ? base.GetFocusedElementCore()
+ : AvaloniaToXamlPeerProxy.GetOrCreate(focused, this);
+ }
+
+ protected override object GetElementFromPointCore(XamlPoint point)
+ {
+ EnsureSubscribed();
+ var hit = _embeddedRoot?.GetPeerFromPoint(new AvPoint(point.X, point.Y));
+ return hit is null
+ ? base.GetElementFromPointCore(point)
+ : AvaloniaToXamlPeerProxy.GetOrCreate(hit, this);
+ }
+
+ ///
+ /// Translate an Avalonia rectangle (in embedded-root coordinates) to screen pixels.
+ ///
+ internal (double x, double y, double width, double height) TransformAvaloniaRectToScreen(AvRect rect)
+ {
+ var transform = _panel.TransformToVisual(null);
+ var topLeft = transform.TransformPoint(new XamlPoint(rect.X, rect.Y));
+ var bottomRight = transform.TransformPoint(new XamlPoint(rect.Right, rect.Bottom));
+
+ var xamlRoot = _panel.XamlRoot;
+ var scale = xamlRoot?.RasterizationScale ?? 1.0;
+
+ var x = topLeft.X * scale;
+ var y = topLeft.Y * scale;
+ var w = (bottomRight.X - topLeft.X) * scale;
+ var h = (bottomRight.Y - topLeft.Y) * scale;
+
+ if (xamlRoot?.ContentIslandEnvironment is { } island)
+ {
+ var hwnd = Microsoft.UI.Win32Interop.GetWindowFromWindowId(island.AppWindowId);
+ if (hwnd != IntPtr.Zero)
+ {
+ var pt = new POINT { x = 0, y = 0 };
+ if (ClientToScreen(hwnd, ref pt))
+ {
+ x += pt.x;
+ y += pt.y;
+ }
+ }
+ }
+
+ return (x, y, w, h);
+ }
+
+ private AvPeer? TryGetEmbeddedRootPeer()
+ {
+ EnsureSubscribed();
+ return _embeddedRootPeer;
+ }
+
+ private void EnsureSubscribed()
+ {
+ if (_embeddedRootPeer is not null)
+ return;
+
+ var root = _panel.GetEmbeddedRootForAutomation();
+ if (root is null)
+ return;
+
+ _embeddedRootPeer = AvControlPeer.CreatePeerForElement(root);
+ _embeddedRoot = _embeddedRootPeer.GetProvider();
+
+ if (_embeddedRoot is not null)
+ _embeddedRoot.FocusChanged += OnEmbeddedFocusChanged;
+ }
+
+ private void OnEmbeddedFocusChanged(object? sender, EventArgs e)
+ {
+ if (!ListenerExists(AutomationEvents.AutomationFocusChanged))
+ return;
+
+ var focused = _embeddedRoot?.GetFocus();
+ if (focused is null)
+ return;
+
+ var proxy = AvaloniaToXamlPeerProxy.GetOrCreate(focused, this);
+ proxy.RaiseAutomationEvent(AutomationEvents.AutomationFocusChanged);
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT
+ {
+ public int x;
+ public int y;
+ }
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.ExpandCollapse.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.ExpandCollapse.cs
new file mode 100644
index 0000000000..8bf2e34058
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.ExpandCollapse.cs
@@ -0,0 +1,29 @@
+using Microsoft.UI.Xaml.Automation;
+using Microsoft.UI.Xaml.Automation.Provider;
+using AvExpand = global::Avalonia.Automation.Provider.IExpandCollapseProvider;
+using AvExpandState = global::Avalonia.Automation.ExpandCollapseState;
+
+namespace Avalonia.WinUI.Automation;
+
+internal sealed partial class AvaloniaToXamlPeerProxy : IExpandCollapseProvider
+{
+ ExpandCollapseState IExpandCollapseProvider.ExpandCollapseState
+ {
+ get
+ {
+ var provider = _peer.GetProvider();
+ if (provider is null)
+ return ExpandCollapseState.LeafNode;
+ return provider.ExpandCollapseState switch
+ {
+ AvExpandState.Collapsed => ExpandCollapseState.Collapsed,
+ AvExpandState.Expanded => ExpandCollapseState.Expanded,
+ AvExpandState.PartiallyExpanded => ExpandCollapseState.PartiallyExpanded,
+ _ => ExpandCollapseState.LeafNode,
+ };
+ }
+ }
+
+ void IExpandCollapseProvider.Expand() => _peer.GetProvider()?.Expand();
+ void IExpandCollapseProvider.Collapse() => _peer.GetProvider()?.Collapse();
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Invoke.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Invoke.cs
new file mode 100644
index 0000000000..93e9857b19
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Invoke.cs
@@ -0,0 +1,9 @@
+using Microsoft.UI.Xaml.Automation.Provider;
+using AvInvoke = global::Avalonia.Automation.Provider.IInvokeProvider;
+
+namespace Avalonia.WinUI.Automation;
+
+internal sealed partial class AvaloniaToXamlPeerProxy : IInvokeProvider
+{
+ void IInvokeProvider.Invoke() => _peer.GetProvider()?.Invoke();
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.RangeValue.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.RangeValue.cs
new file mode 100644
index 0000000000..71466b4baf
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.RangeValue.cs
@@ -0,0 +1,16 @@
+using Microsoft.UI.Xaml.Automation.Provider;
+using AvRange = global::Avalonia.Automation.Provider.IRangeValueProvider;
+
+namespace Avalonia.WinUI.Automation;
+
+internal sealed partial class AvaloniaToXamlPeerProxy : IRangeValueProvider
+{
+ bool IRangeValueProvider.IsReadOnly => _peer.GetProvider()?.IsReadOnly ?? true;
+ double IRangeValueProvider.Minimum => _peer.GetProvider()?.Minimum ?? 0;
+ double IRangeValueProvider.Maximum => _peer.GetProvider()?.Maximum ?? 0;
+ double IRangeValueProvider.Value => _peer.GetProvider()?.Value ?? 0;
+ double IRangeValueProvider.LargeChange => _peer.GetProvider()?.LargeChange ?? 0;
+ double IRangeValueProvider.SmallChange => _peer.GetProvider()?.SmallChange ?? 0;
+
+ void IRangeValueProvider.SetValue(double value) => _peer.GetProvider()?.SetValue(value);
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Scroll.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Scroll.cs
new file mode 100644
index 0000000000..65bc5507e0
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Scroll.cs
@@ -0,0 +1,33 @@
+using Microsoft.UI.Xaml.Automation;
+using Microsoft.UI.Xaml.Automation.Provider;
+using AvScroll = global::Avalonia.Automation.Provider.IScrollProvider;
+using AvScrollAmount = global::Avalonia.Automation.Provider.ScrollAmount;
+
+namespace Avalonia.WinUI.Automation;
+
+internal sealed partial class AvaloniaToXamlPeerProxy : IScrollProvider, IScrollItemProvider
+{
+ bool IScrollProvider.HorizontallyScrollable => _peer.GetProvider()?.HorizontallyScrollable ?? false;
+ double IScrollProvider.HorizontalScrollPercent => _peer.GetProvider()?.HorizontalScrollPercent ?? -1;
+ double IScrollProvider.HorizontalViewSize => _peer.GetProvider()?.HorizontalViewSize ?? 100;
+ bool IScrollProvider.VerticallyScrollable => _peer.GetProvider()?.VerticallyScrollable ?? false;
+ double IScrollProvider.VerticalScrollPercent => _peer.GetProvider()?.VerticalScrollPercent ?? -1;
+ double IScrollProvider.VerticalViewSize => _peer.GetProvider()?.VerticalViewSize ?? 100;
+
+ void IScrollProvider.Scroll(ScrollAmount horizontalAmount, ScrollAmount verticalAmount)
+ => _peer.GetProvider()?.Scroll(ToAvalonia(horizontalAmount), ToAvalonia(verticalAmount));
+
+ void IScrollProvider.SetScrollPercent(double horizontalPercent, double verticalPercent)
+ => _peer.GetProvider()?.SetScrollPercent(horizontalPercent, verticalPercent);
+
+ void IScrollItemProvider.ScrollIntoView() => _peer.BringIntoView();
+
+ private static AvScrollAmount ToAvalonia(ScrollAmount amount) => amount switch
+ {
+ ScrollAmount.LargeDecrement => AvScrollAmount.LargeDecrement,
+ ScrollAmount.SmallDecrement => AvScrollAmount.SmallDecrement,
+ ScrollAmount.LargeIncrement => AvScrollAmount.LargeIncrement,
+ ScrollAmount.SmallIncrement => AvScrollAmount.SmallIncrement,
+ _ => AvScrollAmount.NoAmount,
+ };
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Selection.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Selection.cs
new file mode 100644
index 0000000000..b544ca6528
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Selection.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.UI.Xaml.Automation.Peers;
+using Microsoft.UI.Xaml.Automation.Provider;
+using AvPeer = global::Avalonia.Automation.Peers.AutomationPeer;
+using AvSelection = global::Avalonia.Automation.Provider.ISelectionProvider;
+using AvSelectionItem = global::Avalonia.Automation.Provider.ISelectionItemProvider;
+
+namespace Avalonia.WinUI.Automation;
+
+internal sealed partial class AvaloniaToXamlPeerProxy : ISelectionProvider, ISelectionItemProvider
+{
+ bool ISelectionProvider.CanSelectMultiple => _peer.GetProvider()?.CanSelectMultiple ?? false;
+ bool ISelectionProvider.IsSelectionRequired => _peer.GetProvider()?.IsSelectionRequired ?? false;
+
+ IRawElementProviderSimple[] ISelectionProvider.GetSelection()
+ {
+ var provider = _peer.GetProvider();
+ if (provider is null)
+ return System.Array.Empty();
+
+ var list = new List();
+ foreach (var avChild in provider.GetSelection() ?? (IReadOnlyList)System.Array.Empty())
+ {
+ var proxy = GetOrCreate(avChild, _host);
+ if (ProviderFromPeer(proxy) is IRawElementProviderSimple simple)
+ list.Add(simple);
+ }
+ return list.ToArray();
+ }
+
+ bool ISelectionItemProvider.IsSelected => _peer.GetProvider()?.IsSelected ?? false;
+
+ IRawElementProviderSimple? ISelectionItemProvider.SelectionContainer
+ {
+ get
+ {
+ var container = _peer.GetProvider()?.SelectionContainer;
+ if (container is AvPeer containerPeer)
+ {
+ var proxy = GetOrCreate(containerPeer, _host);
+ return ProviderFromPeer(proxy);
+ }
+ return null;
+ }
+ }
+
+ void ISelectionItemProvider.AddToSelection() => _peer.GetProvider()?.AddToSelection();
+ void ISelectionItemProvider.RemoveFromSelection() => _peer.GetProvider()?.RemoveFromSelection();
+ void ISelectionItemProvider.Select() => _peer.GetProvider()?.Select();
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Toggle.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Toggle.cs
new file mode 100644
index 0000000000..520dc111ad
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Toggle.cs
@@ -0,0 +1,27 @@
+using Microsoft.UI.Xaml.Automation;
+using Microsoft.UI.Xaml.Automation.Provider;
+using AvToggle = global::Avalonia.Automation.Provider.IToggleProvider;
+using AvToggleState = global::Avalonia.Automation.Provider.ToggleState;
+
+namespace Avalonia.WinUI.Automation;
+
+internal sealed partial class AvaloniaToXamlPeerProxy : IToggleProvider
+{
+ ToggleState IToggleProvider.ToggleState
+ {
+ get
+ {
+ var provider = _peer.GetProvider();
+ if (provider is null)
+ return ToggleState.Indeterminate;
+ return provider.ToggleState switch
+ {
+ AvToggleState.Off => ToggleState.Off,
+ AvToggleState.On => ToggleState.On,
+ _ => ToggleState.Indeterminate,
+ };
+ }
+ }
+
+ void IToggleProvider.Toggle() => _peer.GetProvider()?.Toggle();
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Value.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Value.cs
new file mode 100644
index 0000000000..926a5554b1
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.Value.cs
@@ -0,0 +1,13 @@
+using Microsoft.UI.Xaml.Automation.Provider;
+using AvValue = global::Avalonia.Automation.Provider.IValueProvider;
+
+namespace Avalonia.WinUI.Automation;
+
+internal sealed partial class AvaloniaToXamlPeerProxy : IValueProvider
+{
+ bool IValueProvider.IsReadOnly => _peer.GetProvider()?.IsReadOnly ?? true;
+
+ string IValueProvider.Value => _peer.GetProvider()?.Value ?? string.Empty;
+
+ void IValueProvider.SetValue(string value) => _peer.GetProvider()?.SetValue(value);
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.cs b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.cs
new file mode 100644
index 0000000000..18b97964ef
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/AvaloniaToXamlPeerProxy.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using global::Avalonia.Controls.Automation.Peers;
+using Microsoft.UI.Xaml.Automation;
+using Microsoft.UI.Xaml.Automation.Peers;
+using AvPeer = global::Avalonia.Automation.Peers.AutomationPeer;
+using XamlAutomationPeer = Microsoft.UI.Xaml.Automation.Peers.AutomationPeer;
+using XamlRect = global::Windows.Foundation.Rect;
+
+namespace Avalonia.WinUI.Automation;
+
+///
+/// Wraps an Avalonia so it can be exposed to Microsoft UI Automation
+/// through WinUI XAML's automation infrastructure. Mirrors the role of
+/// Avalonia.Win32.Automation.AutomationNode but for WinUI hosts.
+///
+internal sealed partial class AvaloniaToXamlPeerProxy : XamlAutomationPeer
+{
+ private static readonly ConditionalWeakTable s_cache = new();
+ private static long s_nextId;
+
+ private readonly AvaloniaSwapChainPanelAutomationPeer _host;
+ private readonly AvPeer _peer;
+ private readonly bool _isEmbeddedRoot;
+ private readonly string _uniqueId;
+
+ private AvaloniaToXamlPeerProxy(AvPeer peer, AvaloniaSwapChainPanelAutomationPeer host)
+ {
+ _peer = peer;
+ _host = host;
+ _isEmbeddedRoot = peer is EmbeddableControlRootAutomationPeer;
+ _uniqueId = "Avalonia.WinUI.Peer:" + System.Threading.Interlocked.Increment(ref s_nextId).ToString(
+ System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ internal AvPeer Peer => _peer;
+
+ public static AvaloniaToXamlPeerProxy GetOrCreate(AvPeer peer, AvaloniaSwapChainPanelAutomationPeer host)
+ {
+ return s_cache.GetValue(peer, p => new AvaloniaToXamlPeerProxy(p, host));
+ }
+
+ internal static AvaloniaToXamlPeerProxy? TryGet(AvPeer? peer)
+ {
+ if (peer is null)
+ return null;
+ return s_cache.TryGetValue(peer, out var proxy) ? proxy : null;
+ }
+
+ protected override IList GetChildrenCore()
+ {
+ var list = new List();
+ var children = _peer.GetChildren();
+ if (children is null)
+ return list;
+
+ foreach (var child in children)
+ {
+ var childProxy = GetOrCreate(child, _host);
+ childProxy.SetParent(this);
+ list.Add(childProxy);
+ }
+
+ return list;
+ }
+
+ protected override string GetClassNameCore() => _peer.GetClassName() ?? string.Empty;
+
+ protected override string GetNameCore() => _peer.GetName() ?? string.Empty;
+
+ protected override string GetAutomationIdCore() => _peer.GetAutomationId() ?? string.Empty;
+
+ protected override string GetHelpTextCore() => _peer.GetHelpText() ?? string.Empty;
+
+ protected override string GetAcceleratorKeyCore() => _peer.GetAcceleratorKey() ?? string.Empty;
+
+ protected override string GetAccessKeyCore() => _peer.GetAccessKey() ?? string.Empty;
+
+ protected override string GetItemStatusCore() => _peer.GetItemStatus() ?? string.Empty;
+
+ protected override string GetItemTypeCore() => _peer.GetItemType() ?? string.Empty;
+
+ protected override string GetLocalizedControlTypeCore()
+ => _peer.GetLocalizedControlType() ?? base.GetLocalizedControlTypeCore();
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ => ControlTypeMap.ToXaml(_peer.GetAutomationControlType());
+
+ protected override XamlRect GetBoundingRectangleCore()
+ {
+ var rect = _peer.GetBoundingRectangle();
+ if (rect.Width <= 0 && rect.Height <= 0)
+ return default;
+
+ var (screenX, screenY, w, h) = _host.TransformAvaloniaRectToScreen(rect);
+ return new XamlRect(screenX, screenY, w, h);
+ }
+
+ protected override bool HasKeyboardFocusCore() => _peer.HasKeyboardFocus();
+
+ protected override bool IsKeyboardFocusableCore() => _peer.IsKeyboardFocusable();
+
+ protected override bool IsEnabledCore() => _peer.IsEnabled();
+
+ // Collapse the redundant Pane → Pane: the embedded root sits beneath the
+ // panel's own peer and adds nothing to the screen reader's view.
+ protected override bool IsContentElementCore() => !_isEmbeddedRoot && _peer.IsContentElement();
+
+ protected override bool IsControlElementCore() => !_isEmbeddedRoot && _peer.IsControlElement();
+
+ protected override bool IsOffscreenCore() => _peer.IsOffscreen();
+
+ protected override void SetFocusCore() => _peer.SetFocus();
+
+ protected override object GetPatternCore(PatternInterface patternInterface)
+ {
+ return patternInterface switch
+ {
+ PatternInterface.Invoke
+ when _peer.GetProvider() is not null => this,
+ PatternInterface.Toggle
+ when _peer.GetProvider() is not null => this,
+ PatternInterface.Value
+ when _peer.GetProvider() is not null => this,
+ PatternInterface.RangeValue
+ when _peer.GetProvider() is not null => this,
+ PatternInterface.Scroll
+ when _peer.GetProvider() is not null => this,
+ // Every peer supports BringIntoView, so ScrollItem is always available.
+ PatternInterface.ScrollItem => this,
+ PatternInterface.Selection
+ when _peer.GetProvider() is not null => this,
+ PatternInterface.SelectionItem
+ when _peer.GetProvider() is not null => this,
+ PatternInterface.ExpandCollapse
+ when _peer.GetProvider() is not null => this,
+ _ => null!,
+ };
+ }
+}
diff --git a/src/Windows/Avalonia.WinUI/Automation/ControlTypeMap.cs b/src/Windows/Avalonia.WinUI/Automation/ControlTypeMap.cs
new file mode 100644
index 0000000000..43bd221054
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Automation/ControlTypeMap.cs
@@ -0,0 +1,52 @@
+using AvControlType = global::Avalonia.Automation.Peers.AutomationControlType;
+using XamlControlType = Microsoft.UI.Xaml.Automation.Peers.AutomationControlType;
+
+namespace Avalonia.WinUI.Automation;
+
+internal static class ControlTypeMap
+{
+ public static XamlControlType ToXaml(AvControlType type) => type switch
+ {
+ AvControlType.Button => XamlControlType.Button,
+ AvControlType.Calendar => XamlControlType.Calendar,
+ AvControlType.CheckBox => XamlControlType.CheckBox,
+ AvControlType.ComboBox => XamlControlType.ComboBox,
+ AvControlType.ComboBoxItem => XamlControlType.ListItem,
+ AvControlType.Edit => XamlControlType.Edit,
+ AvControlType.Hyperlink => XamlControlType.Hyperlink,
+ AvControlType.Image => XamlControlType.Image,
+ AvControlType.ListItem => XamlControlType.ListItem,
+ AvControlType.List => XamlControlType.List,
+ AvControlType.Menu => XamlControlType.Menu,
+ AvControlType.MenuBar => XamlControlType.MenuBar,
+ AvControlType.MenuItem => XamlControlType.MenuItem,
+ AvControlType.ProgressBar => XamlControlType.ProgressBar,
+ AvControlType.RadioButton => XamlControlType.RadioButton,
+ AvControlType.ScrollBar => XamlControlType.ScrollBar,
+ AvControlType.Slider => XamlControlType.Slider,
+ AvControlType.Spinner => XamlControlType.Spinner,
+ AvControlType.StatusBar => XamlControlType.StatusBar,
+ AvControlType.Tab => XamlControlType.Tab,
+ AvControlType.TabItem => XamlControlType.TabItem,
+ AvControlType.Text => XamlControlType.Text,
+ AvControlType.ToolBar => XamlControlType.ToolBar,
+ AvControlType.ToolTip => XamlControlType.ToolTip,
+ AvControlType.Tree => XamlControlType.Tree,
+ AvControlType.TreeItem => XamlControlType.TreeItem,
+ AvControlType.Group => XamlControlType.Group,
+ AvControlType.Thumb => XamlControlType.Thumb,
+ AvControlType.DataGrid => XamlControlType.DataGrid,
+ AvControlType.DataItem => XamlControlType.DataItem,
+ AvControlType.Document => XamlControlType.Document,
+ AvControlType.SplitButton => XamlControlType.SplitButton,
+ AvControlType.Window => XamlControlType.Window,
+ AvControlType.Pane => XamlControlType.Pane,
+ AvControlType.Header => XamlControlType.Header,
+ AvControlType.HeaderItem => XamlControlType.HeaderItem,
+ AvControlType.Table => XamlControlType.Table,
+ AvControlType.TitleBar => XamlControlType.TitleBar,
+ AvControlType.Separator => XamlControlType.Separator,
+ AvControlType.Expander => XamlControlType.Group,
+ _ => XamlControlType.Custom,
+ };
+}
diff --git a/src/Windows/Avalonia.WinUI/Avalonia.WinUI.csproj b/src/Windows/Avalonia.WinUI/Avalonia.WinUI.csproj
new file mode 100644
index 0000000000..ac25b8ca75
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/Avalonia.WinUI.csproj
@@ -0,0 +1,30 @@
+
+
+ $(AvsCurrentWinUITargetFramework)
+ 10.0.17763.0
+ true
+ false
+ true
+ true
+ enable
+ Windows App SDK (WinUI 3) integration for Avalonia, enabling Avalonia content to be hosted inside WinUI applications on Windows.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(NoWarn);CA1416
+
+
+
+
+
+
diff --git a/src/Windows/Avalonia.WinUI/AvaloniaSwapChainPanel.cs b/src/Windows/Avalonia.WinUI/AvaloniaSwapChainPanel.cs
new file mode 100644
index 0000000000..3494305e31
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/AvaloniaSwapChainPanel.cs
@@ -0,0 +1,915 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using global::Avalonia;
+using global::Avalonia.Controls.Embedding;
+using global::Avalonia.Input;
+using global::Avalonia.Input.Raw;
+using global::Avalonia.Logging;
+using global::Avalonia.Platform;
+using global::Avalonia.Win32;
+using global::Avalonia.Win32.OpenGl.Angle;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Windows.Foundation;
+using Microsoft.UI.Input;
+using WinUIPointerPoint = Microsoft.UI.Input.PointerPoint;
+using AvControl = global::Avalonia.Controls.Control;
+using AvSize = global::Avalonia.Size;
+using AvPoint = global::Avalonia.Point;
+using AvVector = global::Avalonia.Vector;
+using AvRect = global::Avalonia.Rect;
+
+namespace Avalonia.WinUI;
+
+public partial class AvaloniaSwapChainPanel : SwapChainPanel
+{
+ private SwapChainGlSurface? _glSurface;
+ private SwapChainTopLevelImpl? _topLevelImpl;
+ private EmbeddableControlRoot? _root;
+ private AvControl? _content;
+ private readonly MouseDevice _mouseDevice = new();
+ private readonly TouchDevice _touchDevice = new();
+ private readonly PenDevice _penDevice = new(releasePointerOnPenUp: false);
+ private PixelSize _cachedPixelSize = new(1, 1);
+ private double _cachedScaling = 1.0;
+
+ private static readonly List s_intermediatePoints = new();
+
+ private WinUITextInputMethod? _textInputMethod;
+ private bool _ignoreCharacterReceived;
+
+ private readonly CursorOverlay _cursorOverlay;
+
+ // ProtectedCursor is protected on UIElement; derive to expose it.
+ private sealed partial class CursorOverlay : Microsoft.UI.Xaml.Controls.Grid
+ {
+ public void SetCursor(Microsoft.UI.Input.InputCursor? cursor) => ProtectedCursor = cursor;
+ }
+
+ static AvaloniaSwapChainPanel()
+ {
+ AvaloniaLocator.CurrentMutable
+ .Bind()
+ .ToSingleton();
+ }
+
+ // Looking up panels from the drag source: keyed by the Avalonia toplevel
+ // PlatformImpl so multi-panel hosts work.
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary<
+ global::Avalonia.Platform.ITopLevelImpl, AvaloniaSwapChainPanel> s_panelsByImpl = new();
+
+ internal static AvaloniaSwapChainPanel? GetPanelFor(global::Avalonia.Platform.ITopLevelImpl impl)
+ => s_panelsByImpl.TryGetValue(impl, out var p) ? p : null;
+
+ // Most recent WinUI PointerPoint observed by the panel — needed by
+ // StartDragAsync. Avalonia's IPointer.Id is assigned sequentially and
+ // does not correspond to WinUI's PointerId, so we can't look up per
+ // pointer; the latest is good enough for the dominant mouse-drag case.
+ private WinUIPointerPoint? _lastPointerPoint;
+
+ // Cleared in DragStarting and OnUnloaded.
+ private IDataTransfer? _outgoingDragData;
+ private DragDropEffects _outgoingDragAllowed;
+
+ public AvaloniaSwapChainPanel()
+ {
+ IsTabStop = true;
+ // SwapChainPanel disallows Background, but without a hit-testable
+ // surface WinUI won't resolve ProtectedCursor for the panel. Add a
+ // transparent overlay that fills the panel — it costs nothing
+ // visually but participates in cursor hit-testing.
+ _cursorOverlay = new CursorOverlay
+ {
+ Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent),
+ IsHitTestVisible = true,
+ AllowDrop = true,
+ CanDrag = true,
+ };
+ _cursorOverlay.DragEnter += OnDragEnter;
+ _cursorOverlay.DragOver += OnDragOver;
+ _cursorOverlay.DragLeave += OnDragLeave;
+ _cursorOverlay.Drop += OnDrop;
+ _cursorOverlay.DragStarting += OnDragStarting;
+ Children.Add(_cursorOverlay);
+
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ SizeChanged += OnSizeChanged;
+ CompositionScaleChanged += OnCompositionScaleChanged;
+
+ PointerPressed += OnPointerPressed;
+ PointerMoved += OnPointerMoved;
+ PointerReleased += OnPointerReleased;
+ PointerWheelChanged += OnPointerWheelChanged;
+ PointerCanceled += OnPointerCanceled;
+ PointerEntered += OnPointerEntered;
+ PointerExited += OnPointerExited;
+ GotFocus += OnGotFocus;
+ LostFocus += OnLostFocus;
+ AddHandler(KeyDownEvent, new KeyEventHandler(OnKeyDown), handledEventsToo: true);
+ AddHandler(KeyUpEvent, new KeyEventHandler(OnKeyUp), handledEventsToo: true);
+ AddHandler(CharacterReceivedEvent,
+ new TypedEventHandler(OnCharacterReceived),
+ handledEventsToo: true);
+ }
+
+ public AvControl? Content
+ {
+ get => _content;
+ set
+ {
+ _content = value;
+ if (_root is not null)
+ _root.Content = value;
+ }
+ }
+
+ protected override Microsoft.UI.Xaml.Automation.Peers.AutomationPeer OnCreateAutomationPeer()
+ => new global::Avalonia.WinUI.Automation.AvaloniaSwapChainPanelAutomationPeer(this);
+
+ // ---- Diagnostics / internal automation hooks ----
+
+ ///
+ /// Returns the embedded control root, or null if the panel has not loaded yet.
+ /// Used by the automation peer to enumerate Avalonia children; also exposed as
+ /// a diagnostic surface for WinUIEmbedSample to verify peer lifecycle.
+ ///
+ internal global::Avalonia.Controls.Embedding.EmbeddableControlRoot? GetEmbeddedRootForAutomation() => _root;
+
+ private void UpdateCachedSize()
+ {
+ var w = Math.Max(1, (int)(ActualWidth * CompositionScaleX));
+ var h = Math.Max(1, (int)(ActualHeight * CompositionScaleY));
+ _cachedPixelSize = new PixelSize(w, h);
+ _cachedScaling = CompositionScaleX;
+ }
+
+ private PixelSize GetPixelSize() => _cachedPixelSize;
+
+ private double GetScaling() => _cachedScaling;
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (_glSurface is not null)
+ return;
+
+ UpdateCachedSize();
+
+ // Create the GL surface — swap chain creation is deferred to CreateGlRenderTarget
+ // where we have the actual rendering context's D3D device
+ _glSurface = new SwapChainGlSurface(GetPixelSize, GetScaling, OnSwapChainCreated);
+ _topLevelImpl = new SwapChainTopLevelImpl(_glSurface)
+ {
+ ClientSize = new AvSize(ActualWidth, ActualHeight),
+ RenderScaling = CompositionScaleX
+ };
+
+ _textInputMethod = new WinUITextInputMethod(
+ this,
+ () => _topLevelImpl?.Input,
+ () => _topLevelImpl?.InputRoot,
+ () => AvaloniaLocator.Current.GetService());
+ _topLevelImpl.TextInputMethod = _textInputMethod;
+ _topLevelImpl.CursorChanged = OnAvaloniaCursorChanged;
+
+ // Create and start the EmbeddableControlRoot
+ _root = new EmbeddableControlRoot(_topLevelImpl)
+ {
+ Content = _content
+ };
+ _root.Prepare();
+ _root.StartRendering();
+
+ s_panelsByImpl[_topLevelImpl] = this;
+ }
+
+ private unsafe void OnSwapChainCreated(IntPtr swapChainPtr)
+ {
+ // Called from the render thread when the swap chain is first created.
+ // Set it on the panel via ISwapChainPanelNative COM interop.
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ var panelUnknown = Marshal.GetIUnknownForObject(this);
+ try
+ {
+ var iid = new Guid("63aad0b8-7c24-40ff-85a8-640d944cc325");
+ Marshal.QueryInterface(panelUnknown, in iid, out var nativePtr);
+ if (nativePtr != IntPtr.Zero)
+ {
+ try
+ {
+ var vtable = *(IntPtr**)nativePtr;
+ var setSwapChain = (delegate* unmanaged[Stdcall])vtable[3];
+ var hr = setSwapChain(nativePtr, swapChainPtr);
+ Marshal.ThrowExceptionForHR(hr);
+ }
+ finally
+ {
+ Marshal.Release(nativePtr);
+ }
+ }
+ }
+ finally
+ {
+ Marshal.Release(panelUnknown);
+ }
+ });
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ if (_topLevelImpl is not null)
+ s_panelsByImpl.TryRemove(_topLevelImpl, out _);
+ _root?.StopRendering();
+ _root?.Dispose();
+ _root = null;
+ _topLevelImpl = null;
+ _glSurface?.DisposeSwapChain();
+ _glSurface = null;
+ _mouseDevice.Dispose();
+ _touchDevice.Dispose();
+ _penDevice.Dispose();
+ }
+
+ private void OnSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ UpdateCachedSize();
+ if (_topLevelImpl is not null)
+ _topLevelImpl.ClientSize = new AvSize(e.NewSize.Width, e.NewSize.Height);
+ }
+
+ private void OnCompositionScaleChanged(SwapChainPanel sender, object args)
+ {
+ UpdateCachedSize();
+ if (_topLevelImpl is not null)
+ _topLevelImpl.RenderScaling = CompositionScaleX;
+ }
+
+ // Input forwarding
+
+ private IPointerDevice GetPointerDevice(PointerRoutedEventArgs e)
+ {
+ return e.Pointer.PointerDeviceType switch
+ {
+ Microsoft.UI.Input.PointerDeviceType.Touch => _touchDevice,
+ Microsoft.UI.Input.PointerDeviceType.Pen => _penDevice,
+ _ => _mouseDevice
+ };
+ }
+
+ private static RawPointerPoint CreateRawPointerPoint(WinUIPointerPoint point)
+ {
+ var props = point.Properties;
+ var pos = point.Position;
+
+ var rawPoint = new RawPointerPoint
+ {
+ Position = new AvPoint(pos.X, pos.Y),
+ Pressure = props.Pressure,
+ Twist = props.Twist,
+ XTilt = props.XTilt,
+ YTilt = props.YTilt,
+ };
+
+ if (props.ContactRect is { Width: > 0 } or { Height: > 0 })
+ {
+ var cr = props.ContactRect;
+ rawPoint.ContactRect = new AvRect(cr.X, cr.Y, cr.Width, cr.Height);
+ }
+
+ return rawPoint;
+ }
+
+ private RawPointerEventArgs CreatePointerArgs(
+ IInputDevice device, ulong timestamp, IInputRoot inputRoot,
+ RawPointerEventType type, RawPointerPoint point,
+ RawInputModifiers modifiers, uint pointerId)
+ {
+ return device is TouchDevice
+ ? new RawTouchEventArgs(device, timestamp, inputRoot, type, point, modifiers, pointerId)
+ : new RawPointerEventArgs(device, timestamp, inputRoot, type, point, modifiers)
+ {
+ RawPointerId = pointerId
+ };
+ }
+
+ private ulong GetTimestamp(PointerRoutedEventArgs e)
+ {
+ // WinUI PointerPoint.Timestamp is in microseconds; Avalonia expects milliseconds.
+ return e.GetCurrentPoint(this).Timestamp / 1000;
+ }
+
+ private RawInputModifiers GetPointerModifiers(PointerRoutedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(this);
+ var props = point.Properties;
+ var mods = WinUIKeyInterop.ModifiersFromVirtualKeyModifiers(e.KeyModifiers);
+ if (props.IsLeftButtonPressed)
+ mods |= RawInputModifiers.LeftMouseButton;
+ if (props.IsRightButtonPressed)
+ mods |= RawInputModifiers.RightMouseButton;
+ if (props.IsMiddleButtonPressed)
+ mods |= RawInputModifiers.MiddleMouseButton;
+
+ if (e.Pointer.PointerDeviceType == Microsoft.UI.Input.PointerDeviceType.Pen)
+ {
+ if (props.IsBarrelButtonPressed)
+ mods |= RawInputModifiers.PenBarrelButton;
+ if (props.IsEraser)
+ mods |= RawInputModifiers.PenEraser;
+ if (props.IsInverted)
+ mods |= RawInputModifiers.PenInverted;
+ }
+
+ return mods;
+ }
+
+ private void OnPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ _lastPointerPoint = e.GetCurrentPoint(this);
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var device = GetPointerDevice(e);
+ var timestamp = GetTimestamp(e);
+ var rawPoint = CreateRawPointerPoint(e.GetCurrentPoint(this));
+ var modifiers = GetPointerModifiers(e);
+ var pointerId = e.Pointer.PointerId;
+
+ RawPointerEventType type;
+ if (device is TouchDevice)
+ {
+ type = RawPointerEventType.TouchBegin;
+ }
+ else
+ {
+ var point = e.GetCurrentPoint(this);
+ var props = point.Properties;
+ if (props.IsLeftButtonPressed)
+ type = RawPointerEventType.LeftButtonDown;
+ else if (props.IsRightButtonPressed)
+ type = RawPointerEventType.RightButtonDown;
+ else if (props.IsMiddleButtonPressed)
+ type = RawPointerEventType.MiddleButtonDown;
+ else
+ return;
+ }
+
+ Focus(FocusState.Pointer);
+ CapturePointer(e.Pointer);
+
+ input(CreatePointerArgs(device, timestamp, inputRoot, type, rawPoint, modifiers, pointerId));
+ e.Handled = true;
+ }
+
+ private void OnPointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ _lastPointerPoint = e.GetCurrentPoint(this);
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var device = GetPointerDevice(e);
+ var timestamp = GetTimestamp(e);
+ var rawPoint = CreateRawPointerPoint(e.GetCurrentPoint(this));
+ var modifiers = GetPointerModifiers(e);
+ var pointerId = e.Pointer.PointerId;
+
+ var type = device is TouchDevice ? RawPointerEventType.TouchUpdate : RawPointerEventType.Move;
+
+ var args = CreatePointerArgs(device, timestamp, inputRoot, type, rawPoint, modifiers, pointerId);
+ args.IntermediatePoints = new Lazy?>(() => GetIntermediatePoints(e));
+ input(args);
+ }
+
+ private IReadOnlyList? GetIntermediatePoints(PointerRoutedEventArgs e)
+ {
+ // WinUI returns the points oldest-first and includes the current point as the
+ // last entry; drop that last entry (it is the one we already dispatched).
+ var coalesced = e.GetIntermediatePoints(this);
+ if (coalesced is null || coalesced.Count <= 1)
+ return null;
+
+ s_intermediatePoints.Clear();
+ if (s_intermediatePoints.Capacity < coalesced.Count - 1)
+ s_intermediatePoints.Capacity = coalesced.Count - 1;
+
+ for (var i = 0; i < coalesced.Count - 1; i++)
+ s_intermediatePoints.Add(CreateRawPointerPoint(coalesced[i]));
+
+ return s_intermediatePoints;
+ }
+
+ private void OnPointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var device = GetPointerDevice(e);
+ var timestamp = GetTimestamp(e);
+ var rawPoint = CreateRawPointerPoint(e.GetCurrentPoint(this));
+ var modifiers = GetPointerModifiers(e);
+ var pointerId = e.Pointer.PointerId;
+
+ RawPointerEventType type;
+ if (device is TouchDevice)
+ {
+ type = RawPointerEventType.TouchEnd;
+ }
+ else
+ {
+ var point = e.GetCurrentPoint(this);
+ var props = point.Properties;
+ switch (props.PointerUpdateKind)
+ {
+ case Microsoft.UI.Input.PointerUpdateKind.LeftButtonReleased:
+ type = RawPointerEventType.LeftButtonUp;
+ break;
+ case Microsoft.UI.Input.PointerUpdateKind.RightButtonReleased:
+ type = RawPointerEventType.RightButtonUp;
+ break;
+ case Microsoft.UI.Input.PointerUpdateKind.MiddleButtonReleased:
+ type = RawPointerEventType.MiddleButtonUp;
+ break;
+ default:
+ return;
+ }
+ }
+
+ ReleasePointerCapture(e.Pointer);
+ input(CreatePointerArgs(device, timestamp, inputRoot, type, rawPoint, modifiers, pointerId));
+ e.Handled = true;
+ }
+
+ private void OnPointerWheelChanged(object sender, PointerRoutedEventArgs e)
+ {
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var device = GetPointerDevice(e);
+ var timestamp = GetTimestamp(e);
+ var point = e.GetCurrentPoint(this);
+ var delta = point.Properties.MouseWheelDelta;
+ var pos = point.Position;
+
+ input(new RawMouseWheelEventArgs(device as MouseDevice ?? _mouseDevice, timestamp, inputRoot,
+ new AvPoint(pos.X, pos.Y), new AvVector(0, delta), GetPointerModifiers(e)));
+ e.Handled = true;
+ }
+
+ private void OnAvaloniaCursorChanged(ICursorImpl? cursor)
+ {
+ if (cursor is WinUICursorImpl winUiCursor)
+ {
+ _cursorOverlay.SetCursor(winUiCursor.Cursor);
+ }
+ else if (cursor is Win32.CursorImpl win32Cursor)
+ {
+ _cursorOverlay.SetCursor(MapWin32CursorToWinUI(win32Cursor));
+ }
+ else
+ {
+ _cursorOverlay.SetCursor(null);
+ }
+ }
+
+ private static InputCursor? MapWin32CursorToWinUI(Win32.CursorImpl cursor)
+ {
+ var handle = cursor.Handle;
+ if (handle == IntPtr.Zero)
+ return null;
+
+ foreach (StandardCursorType type in Enum.GetValues())
+ {
+ var cached = Win32.CursorFactory.Instance.GetCursor(type);
+ if (cached is Win32.CursorImpl c && c.Handle == handle)
+ {
+ var shape = WinUICursorFactory.Instance.GetCursor(type);
+ return (shape as WinUICursorImpl)?.Cursor;
+ }
+ }
+
+ return InputSystemCursor.Create(InputSystemCursorShape.Arrow);
+ }
+
+ private void OnGotFocus(object sender, RoutedEventArgs e)
+ => _textInputMethod?.OnPanelFocusChanged(true);
+
+ private void OnLostFocus(object sender, RoutedEventArgs e)
+ {
+ _textInputMethod?.OnPanelFocusChanged(false);
+ // Clear Avalonia's internal focus so the previously focused control
+ // doesn't keep its :focus visual once keyboard focus moves to a
+ // native WinUI element.
+ if (_root is not null)
+ global::Avalonia.Input.FocusManager.GetFocusManager(_root)?.Focus(null);
+ }
+
+ private void OnPointerEntered(object sender, PointerRoutedEventArgs e)
+ {
+ // Touch never hovers — enter is implied by the press that produced it.
+ if (e.Pointer.PointerDeviceType == Microsoft.UI.Input.PointerDeviceType.Touch)
+ return;
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var device = GetPointerDevice(e);
+ var timestamp = GetTimestamp(e);
+ var rawPoint = CreateRawPointerPoint(e.GetCurrentPoint(this));
+ var modifiers = GetPointerModifiers(e);
+ var pointerId = e.Pointer.PointerId;
+
+ input(CreatePointerArgs(device, timestamp, inputRoot,
+ RawPointerEventType.Move, rawPoint, modifiers, pointerId));
+ }
+
+ private void OnPointerExited(object sender, PointerRoutedEventArgs e)
+ {
+ if (e.Pointer.PointerDeviceType == Microsoft.UI.Input.PointerDeviceType.Touch)
+ return;
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var device = GetPointerDevice(e);
+ var timestamp = GetTimestamp(e);
+ var rawPoint = CreateRawPointerPoint(e.GetCurrentPoint(this));
+ var modifiers = GetPointerModifiers(e);
+ var pointerId = e.Pointer.PointerId;
+
+ input(CreatePointerArgs(device, timestamp, inputRoot,
+ RawPointerEventType.LeaveWindow, rawPoint, modifiers, pointerId));
+ }
+
+ private void OnPointerCanceled(object sender, PointerRoutedEventArgs e)
+ {
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var device = GetPointerDevice(e);
+ var timestamp = GetTimestamp(e);
+ var rawPoint = CreateRawPointerPoint(e.GetCurrentPoint(this));
+ var modifiers = GetPointerModifiers(e);
+ var pointerId = e.Pointer.PointerId;
+
+ var type = device is TouchDevice ? RawPointerEventType.TouchCancel : RawPointerEventType.LeaveWindow;
+
+ input(CreatePointerArgs(device, timestamp, inputRoot, type, rawPoint, modifiers, pointerId));
+ e.Handled = true;
+ }
+
+ private void OnKeyDown(object sender, KeyRoutedEventArgs e)
+ => DispatchKey(e, RawKeyEventType.KeyDown);
+
+ private void OnKeyUp(object sender, KeyRoutedEventArgs e)
+ => DispatchKey(e, RawKeyEventType.KeyUp);
+
+ private void DispatchKey(KeyRoutedEventArgs e, RawKeyEventType type)
+ {
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var (key, physicalKey, keySymbol) = WinUIKeyInterop.Resolve(e.Key, e.KeyStatus);
+ if (key == Key.None && physicalKey == PhysicalKey.None)
+ return;
+
+ // Tab boundary: when Avalonia would wrap focus inside its toplevel
+ // (TopLevelHost.TabNavigation = Cycle), forward to WinUI so focus
+ // escapes to the next/previous native control instead.
+ if (type == RawKeyEventType.KeyDown && key == Key.Tab && TryEscapeTabBoundary(e))
+ return;
+
+ var keyboard = GetKeyboardDevice();
+ if (keyboard is null)
+ return;
+
+ var args = new RawKeyEventArgs(keyboard, (ulong)Environment.TickCount64, inputRoot,
+ type, key, GetCurrentModifiers(), physicalKey, keySymbol);
+ input(args);
+
+ if (type == RawKeyEventType.KeyDown)
+ _ignoreCharacterReceived = key == Key.ImeProcessed || args.Handled;
+ else if (type == RawKeyEventType.KeyUp)
+ _ignoreCharacterReceived = false;
+
+ // Only mark handled if Avalonia consumed the event — marking a KeyDown
+ // handled suppresses the matching CharacterReceived and breaks text input.
+ if (args.Handled)
+ e.Handled = true;
+ }
+
+ ///
+ /// If the current Avalonia focus is at the first or last tab stop in the
+ /// embedded content, handle Tab / Shift+Tab here by moving WinUI focus to
+ /// the next or previous native element. Returns true if the event was
+ /// consumed (the caller must not forward it to Avalonia).
+ ///
+ private bool TryEscapeTabBoundary(KeyRoutedEventArgs e)
+ {
+ if (_root is null)
+ return false;
+
+ var focusManager = global::Avalonia.Input.FocusManager.GetFocusManager(_root);
+ var current = focusManager?.GetFocusedElement();
+ if (current is null)
+ return false;
+
+ var shift = global::Microsoft.UI.Input.InputKeyboardSource
+ .GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift)
+ .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+
+ var first = global::Avalonia.Input.KeyboardNavigationHandler.GetNext(
+ _root, global::Avalonia.Input.NavigationDirection.Next);
+ if (first is null)
+ return false;
+
+ bool atBoundary;
+ if (shift)
+ {
+ // At the first tab stop — backward tab would wrap.
+ atBoundary = ReferenceEquals(current, first);
+ }
+ else
+ {
+ // At the last tab stop — forward tab would wrap. Cycle mode means
+ // GetNext from the last returns the first; detect that.
+ var next = global::Avalonia.Input.KeyboardNavigationHandler.GetNext(
+ current, global::Avalonia.Input.NavigationDirection.Next);
+ atBoundary = next is null || ReferenceEquals(next, first);
+ }
+
+ if (!atBoundary)
+ return false;
+
+ var direction = shift
+ ? Microsoft.UI.Xaml.Input.FocusNavigationDirection.Previous
+ : Microsoft.UI.Xaml.Input.FocusNavigationDirection.Next;
+
+ // WinUI Desktop requires SearchRoot — the parameterless TryMoveFocus
+ // overload only works in UWP. Use the panel's XamlRoot.Content as the
+ // search scope; that covers the whole window's visual tree.
+ var searchRoot = XamlRoot?.Content as DependencyObject;
+ if (searchRoot is null)
+ {
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.WinUIPlatform)?.Log(this, "Tab boundary: no XamlRoot.Content available; letting Avalonia handle.");
+ return false;
+ }
+
+ var options = new Microsoft.UI.Xaml.Input.FindNextElementOptions
+ {
+ SearchRoot = searchRoot,
+ };
+ var moved = Microsoft.UI.Xaml.Input.FocusManager.TryMoveFocus(direction, options);
+ if (moved)
+ {
+ e.Handled = true;
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.WinUIPlatform)?.Log(this, $"Tab boundary: moved WinUI focus {direction}.");
+ return true;
+ }
+
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.WinUIPlatform)?.Log(this, "Tab boundary detected but TryMoveFocus returned false; letting Avalonia handle.");
+ return false;
+ }
+
+ private void OnCharacterReceived(UIElement sender, CharacterReceivedRoutedEventArgs e)
+ {
+ if (_ignoreCharacterReceived)
+ return;
+
+ if (_topLevelImpl?.Input is not { } input || _topLevelImpl.InputRoot is not { } inputRoot)
+ return;
+
+ var keyboard = GetKeyboardDevice();
+ if (keyboard is null) return;
+
+ var ch = e.Character;
+ if (!char.IsControl(ch) || ch == '\r' || ch == '\n' || ch == '\t')
+ {
+ input(new RawTextInputEventArgs(keyboard, (ulong)Environment.TickCount64, inputRoot,
+ new string(ch, 1)));
+ e.Handled = true;
+ }
+ }
+
+ // Incoming drag-and-drop
+ private DataTransfer? _activeDrag;
+
+ private async void OnDragEnter(object sender, Microsoft.UI.Xaml.DragEventArgs e)
+ {
+ var deferral = e.GetDeferral();
+ try
+ {
+ ((IDisposable?)_activeDrag)?.Dispose();
+ _activeDrag = await BuildDataTransferAsync(e.DataView);
+ UpdateDragUi(e, RawDragEventType.DragEnter);
+ }
+ finally
+ {
+ deferral.Complete();
+ }
+ }
+
+ private void OnDragOver(object sender, Microsoft.UI.Xaml.DragEventArgs e)
+ => UpdateDragUi(e, RawDragEventType.DragOver);
+
+ private void OnDragLeave(object sender, Microsoft.UI.Xaml.DragEventArgs e)
+ {
+ UpdateDragUi(e, RawDragEventType.DragLeave);
+ ((IDisposable?)_activeDrag)?.Dispose();
+ _activeDrag = null;
+ }
+
+ private void OnDrop(object sender, Microsoft.UI.Xaml.DragEventArgs e)
+ {
+ UpdateDragUi(e, RawDragEventType.Drop);
+ ((IDisposable?)_activeDrag)?.Dispose();
+ _activeDrag = null;
+ }
+
+ private void UpdateDragUi(Microsoft.UI.Xaml.DragEventArgs e, RawDragEventType type)
+ {
+ if (_topLevelImpl?.Input is not { } input
+ || _topLevelImpl.InputRoot is not { } inputRoot
+ || _activeDrag is null)
+ return;
+
+ var device = AvaloniaLocator.Current.GetService();
+ if (device is null)
+ return;
+
+ var pt = e.GetPosition(this);
+ var allowed = (DragDropEffects)(int)e.AllowedOperations;
+ if (allowed == DragDropEffects.None)
+ allowed = DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link;
+ var modifiers = GetCurrentModifiers();
+
+ var args = new RawDragEvent(device, type, inputRoot,
+ new AvPoint(pt.X, pt.Y), _activeDrag, allowed, modifiers);
+ input(args);
+
+ e.AcceptedOperation = (Windows.ApplicationModel.DataTransfer.DataPackageOperation)(int)args.Effects;
+ e.Handled = true;
+ }
+
+ private static async System.Threading.Tasks.Task BuildDataTransferAsync(
+ Windows.ApplicationModel.DataTransfer.DataPackageView view)
+ {
+ var dt = new DataTransfer();
+
+ if (view.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.StorageItems))
+ {
+ try
+ {
+ var items = await view.GetStorageItemsAsync();
+ foreach (var item in items)
+ {
+ var path = item.Path;
+ if (string.IsNullOrEmpty(path))
+ continue;
+ // Reuse Avalonia's BclStorage* via the same helper Win32 uses
+ // (IVT-granted to Avalonia.WinUI on Avalonia.Base).
+ if (global::Avalonia.Platform.Storage.FileIO.StorageProviderHelpers
+ .TryCreateBclStorageItem(path) is { } storage)
+ {
+ dt.Add(DataTransferItem.CreateFile(storage));
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Warning, LogArea.WinUIPlatform)?.Log(null, "Failed to resolve dragged storage items: {Exception}", ex);
+ }
+ }
+
+ if (view.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text))
+ {
+ try
+ {
+ var text = await view.GetTextAsync();
+ if (!string.IsNullOrEmpty(text))
+ dt.Add(DataTransferItem.CreateText(text));
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Warning, LogArea.WinUIPlatform)?.Log(null, "Failed to resolve dragged text: {Exception}", ex);
+ }
+ }
+
+ return dt;
+ }
+
+ // Outgoing drag — invoked by WinUIDragSource on the UI thread.
+ internal async Task StartOutgoingDragAsync(
+ IDataTransfer data, DragDropEffects allowed)
+ {
+ if (_lastPointerPoint is not { } pp)
+ {
+ Logger.TryGet(LogEventLevel.Warning, LogArea.WinUIPlatform)?.Log(this, "StartOutgoingDragAsync called with no cached PointerPoint; drag suppressed.");
+ return DragDropEffects.None;
+ }
+
+ _outgoingDragData = data;
+ _outgoingDragAllowed = allowed;
+ try
+ {
+ var op = await _cursorOverlay.StartDragAsync(pp);
+ return (DragDropEffects)(int)op;
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Warning, LogArea.WinUIPlatform)?.Log(this, "StartDragAsync threw: {Exception}", ex);
+ return DragDropEffects.None;
+ }
+ finally
+ {
+ _outgoingDragData = null;
+ _outgoingDragAllowed = DragDropEffects.None;
+ }
+ }
+
+ private async void OnDragStarting(UIElement sender, DragStartingEventArgs e)
+ {
+ if (_outgoingDragData is null)
+ {
+ // The overlay was the drag origin (CanDrag=true) but no Avalonia
+ // drag was in flight — most likely a user grab on empty panel
+ // space. Suppress the native drag rather than emit an empty one.
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.WinUIPlatform)?.Log(this, "DragStarting cancelled: no active Avalonia drag.");
+ e.Cancel = true;
+ return;
+ }
+
+ e.AllowedOperations = (Windows.ApplicationModel.DataTransfer.DataPackageOperation)(int)_outgoingDragAllowed;
+ var deferral = e.GetDeferral();
+ try
+ {
+ await PopulateDataPackageAsync(e.Data, _outgoingDragData);
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Warning, LogArea.WinUIPlatform)?.Log(this, "Populating outgoing DataPackage threw: {Exception}", ex);
+ }
+ finally
+ {
+ deferral.Complete();
+ }
+ }
+
+ private static async Task PopulateDataPackageAsync(
+ Windows.ApplicationModel.DataTransfer.DataPackage package, IDataTransfer source)
+ {
+ // Text — direct copy.
+ var text = source.TryGetValue(DataFormat.Text);
+ if (!string.IsNullOrEmpty(text))
+ package.SetText(text);
+
+ // Files — convert Avalonia IStorageItem paths to native
+ // Windows.Storage.IStorageItem instances via the OS APIs.
+ var avFiles = source.TryGetValues(DataFormat.File);
+ if (avFiles is not null)
+ {
+ var winuiItems = new System.Collections.Generic.List();
+ foreach (var av in avFiles)
+ {
+ string? path = null;
+ if (av.Path is { IsAbsoluteUri: true, Scheme: "file" } uri)
+ path = uri.LocalPath;
+ if (string.IsNullOrEmpty(path))
+ continue;
+ try
+ {
+ Windows.Storage.IStorageItem item =
+ System.IO.Directory.Exists(path)
+ ? await Windows.Storage.StorageFolder.GetFolderFromPathAsync(path)
+ : await Windows.Storage.StorageFile.GetFileFromPathAsync(path);
+ winuiItems.Add(item);
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Warning, LogArea.WinUIPlatform)?.Log(null, "Failed to expose dragged path '{Path}' as Windows.Storage item: {Exception}", path, ex);
+ }
+ }
+ if (winuiItems.Count > 0)
+ package.SetStorageItems(winuiItems);
+ }
+ }
+
+ private static IKeyboardDevice? GetKeyboardDevice()
+ => AvaloniaLocator.Current.GetService();
+
+ private static RawInputModifiers GetCurrentModifiers()
+ {
+ var mods = RawInputModifiers.None;
+ var ctrlState = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Control);
+ if (ctrlState.HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down))
+ mods |= RawInputModifiers.Control;
+ var shiftState = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift);
+ if (shiftState.HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down))
+ mods |= RawInputModifiers.Shift;
+ var altState = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Menu);
+ if (altState.HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down))
+ mods |= RawInputModifiers.Alt;
+ return mods;
+ }
+}
diff --git a/src/Windows/Avalonia.WinUI/WinUICursorFactory.cs b/src/Windows/Avalonia.WinUI/WinUICursorFactory.cs
new file mode 100644
index 0000000000..0987f73156
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/WinUICursorFactory.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Concurrent;
+using global::Avalonia;
+using global::Avalonia.Input;
+using global::Avalonia.Media.Imaging;
+using global::Avalonia.Platform;
+using Microsoft.UI.Input;
+
+namespace Avalonia.WinUI;
+
+///
+/// Wraps a WinUI so it can flow through Avalonia's
+/// platform-agnostic cursor APIs.
+///
+internal sealed class WinUICursorImpl : ICursorImpl
+{
+ public WinUICursorImpl(InputCursor? cursor)
+ {
+ Cursor = cursor;
+ }
+
+ public InputCursor? Cursor { get; }
+
+ public void Dispose()
+ {
+ // InputSystemCursor instances are cached — never disposed.
+ }
+}
+
+internal sealed class WinUICursorFactory : ICursorFactory
+{
+ public static WinUICursorFactory Instance { get; } = new();
+
+ private readonly ConcurrentDictionary _cache = new();
+
+ private WinUICursorFactory() { }
+
+ public ICursorImpl GetCursor(StandardCursorType cursorType)
+ {
+ return _cache.GetOrAdd(cursorType, static t =>
+ {
+ var shape = MapShape(t);
+ InputCursor? cursor = shape is { } s ? InputSystemCursor.Create(s) : null;
+ return new WinUICursorImpl(cursor);
+ });
+ }
+
+ public ICursorImpl CreateCursor(Bitmap cursor, PixelPoint hotSpot)
+ {
+ // WinUI 3 exposes no public API to build an InputCursor from an
+ // in-memory image (only InputSystemCursor.Create(shape) and
+ // InputDesktopResourceCursor.CreateFromResource(fileName) are
+ // available). Fall back to the default arrow so the caller still
+ // gets a usable cursor.
+
+ // TODO: Custom bitmap cursors...
+ return _cache.GetOrAdd(StandardCursorType.Arrow, static t =>
+ new WinUICursorImpl(InputSystemCursor.Create(InputSystemCursorShape.Arrow)));
+ }
+
+ private static InputSystemCursorShape? MapShape(StandardCursorType t) => t switch
+ {
+ StandardCursorType.None => null,
+ StandardCursorType.Arrow => InputSystemCursorShape.Arrow,
+ StandardCursorType.Ibeam => InputSystemCursorShape.IBeam,
+ StandardCursorType.Wait => InputSystemCursorShape.Wait,
+ StandardCursorType.AppStarting => InputSystemCursorShape.Wait,
+ StandardCursorType.Cross => InputSystemCursorShape.Cross,
+ StandardCursorType.Help => InputSystemCursorShape.Help,
+ StandardCursorType.Hand => InputSystemCursorShape.Hand,
+ StandardCursorType.No => InputSystemCursorShape.UniversalNo,
+ StandardCursorType.SizeAll => InputSystemCursorShape.SizeAll,
+ StandardCursorType.SizeNorthSouth => InputSystemCursorShape.SizeNorthSouth,
+ StandardCursorType.SizeWestEast => InputSystemCursorShape.SizeWestEast,
+ StandardCursorType.UpArrow => InputSystemCursorShape.UpArrow,
+ StandardCursorType.TopSide => InputSystemCursorShape.SizeNorthSouth,
+ StandardCursorType.BottomSide => InputSystemCursorShape.SizeNorthSouth,
+ StandardCursorType.LeftSide => InputSystemCursorShape.SizeWestEast,
+ StandardCursorType.RightSide => InputSystemCursorShape.SizeWestEast,
+ StandardCursorType.TopLeftCorner => InputSystemCursorShape.SizeNorthwestSoutheast,
+ StandardCursorType.BottomRightCorner => InputSystemCursorShape.SizeNorthwestSoutheast,
+ StandardCursorType.TopRightCorner => InputSystemCursorShape.SizeNortheastSouthwest,
+ StandardCursorType.BottomLeftCorner => InputSystemCursorShape.SizeNortheastSouthwest,
+ // No built-in drag cursors — fall back to Arrow; OLE drag operations on
+ // Win32 traditionally use ole32.dll resources we can't reach from here.
+ StandardCursorType.DragMove => InputSystemCursorShape.Arrow,
+ StandardCursorType.DragCopy => InputSystemCursorShape.Arrow,
+ StandardCursorType.DragLink => InputSystemCursorShape.Arrow,
+ _ => InputSystemCursorShape.Arrow,
+ };
+}
diff --git a/src/Windows/Avalonia.WinUI/WinUIDragSource.cs b/src/Windows/Avalonia.WinUI/WinUIDragSource.cs
new file mode 100644
index 0000000000..ed165aada9
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/WinUIDragSource.cs
@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using global::Avalonia.Controls;
+using global::Avalonia.Input;
+using global::Avalonia.Input.Platform;
+using global::Avalonia.Logging;
+
+namespace Avalonia.WinUI;
+
+///
+/// implementation that initiates native WinUI
+/// drags (via ) for
+/// content hosted inside an . WinUI only
+/// permits starting a drag with a
+/// captured during an active pointer interaction; the panel caches the most
+/// recent pointer point (regardless of pointer id) and uses it to start the drag.
+///
+internal sealed class WinUIDragSource : IPlatformDragSource
+{
+ public async Task DoDragDropAsync(
+ PointerPressedEventArgs triggerEvent,
+ IDataTransfer dataTransfer,
+ DragDropEffects allowedEffects)
+ {
+ // Resolve the panel that hosts the visual that started the drag.
+ var topLevel = TopLevel.GetTopLevel(triggerEvent.Source as Visual);
+ var panel = topLevel?.PlatformImpl is { } impl
+ ? AvaloniaSwapChainPanel.GetPanelFor(impl)
+ : null;
+
+ if (panel is null)
+ {
+ Logger.TryGet(LogEventLevel.Warning, LogArea.WinUIPlatform)?.Log(this,
+ "Could not resolve AvaloniaSwapChainPanel for drag source. " +
+ "TopLevel={TopLevel}, PlatformImpl={PlatformImpl}.",
+ topLevel?.GetType().Name ?? "null",
+ topLevel?.PlatformImpl?.GetType().Name ?? "null");
+ return DragDropEffects.None;
+ }
+
+ return await panel.StartOutgoingDragAsync(dataTransfer, allowedEffects);
+ }
+}
diff --git a/src/Windows/Avalonia.WinUI/WinUIKeyInterop.cs b/src/Windows/Avalonia.WinUI/WinUIKeyInterop.cs
new file mode 100644
index 0000000000..1c8f7f60ef
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/WinUIKeyInterop.cs
@@ -0,0 +1,62 @@
+using global::Avalonia.Input;
+using global::Avalonia.Input.Raw;
+using global::Avalonia.Win32.Input;
+using Windows.System;
+using Windows.UI.Core;
+
+namespace Avalonia.WinUI;
+
+internal static class WinUIKeyInterop
+{
+ ///
+ /// Resolves WinUI key-event metadata to Avalonia's ,
+ /// and key symbol, using the shared Win32 mapping
+ /// tables in . WinUI's KeyRoutedEventArgs
+ /// exposes virtual-key + scan-code + extended flag — packing those into the
+ /// same lParam layout the Win32 backend uses lets us reuse all of its
+ /// mapping logic (Numpad disambiguation, left/right modifier split, etc.).
+ ///
+ public static (Key Key, PhysicalKey PhysicalKey, string? KeySymbol) Resolve(
+ VirtualKey virtualKey, CorePhysicalKeyStatus status)
+ {
+ var keyData = EncodeKeyData(status);
+ var vk = (int)virtualKey;
+ var key = KeyInterop.KeyFromVirtualKey(vk, keyData);
+ var physical = KeyInterop.PhysicalKeyFromVirtualKey(vk, keyData);
+ var symbol = KeyInterop.GetKeySymbolFromVirtualKey(vk);
+ return (key, physical, symbol);
+ }
+
+ public static Key KeyFromVirtualKey(VirtualKey virtualKey)
+ {
+ // Fallback used by callers that don't have KeyStatus (rare).
+ return KeyInterop.KeyFromVirtualKey((int)virtualKey, 0);
+ }
+
+ public static RawInputModifiers ModifiersFromVirtualKeyModifiers(VirtualKeyModifiers modifiers)
+ {
+ var result = RawInputModifiers.None;
+ if (modifiers.HasFlag(VirtualKeyModifiers.Control))
+ result |= RawInputModifiers.Control;
+ if (modifiers.HasFlag(VirtualKeyModifiers.Shift))
+ result |= RawInputModifiers.Shift;
+ if (modifiers.HasFlag(VirtualKeyModifiers.Menu))
+ result |= RawInputModifiers.Alt;
+ if (modifiers.HasFlag(VirtualKeyModifiers.Windows))
+ result |= RawInputModifiers.Meta;
+ return result;
+ }
+
+ private static int EncodeKeyData(CorePhysicalKeyStatus status)
+ {
+ // Mirror the WM_KEYDOWN lParam layout that Win32's KeyInterop expects:
+ // bits 16-23 : scan code (low byte)
+ // bit 24 : extended-key flag
+ // Repeat count, context, previous/transition bits are unused by the
+ // key/physical-key/symbol resolvers, so leave them zero.
+ var data = (int)((status.ScanCode & 0xFF) << 16);
+ if (status.IsExtendedKey)
+ data |= 1 << 24;
+ return data;
+ }
+}
diff --git a/src/Windows/Avalonia.WinUI/WinUITextInputMethod.cs b/src/Windows/Avalonia.WinUI/WinUITextInputMethod.cs
new file mode 100644
index 0000000000..f1517a593d
--- /dev/null
+++ b/src/Windows/Avalonia.WinUI/WinUITextInputMethod.cs
@@ -0,0 +1,384 @@
+using System;
+using System.Runtime.InteropServices;
+using global::Avalonia;
+using global::Avalonia.Input;
+using global::Avalonia.Input.Raw;
+using global::Avalonia.Input.TextInput;
+using global::Avalonia.Logging;
+using Microsoft.UI.Xaml;
+using Windows.UI.Text.Core;
+using AvRect = global::Avalonia.Rect;
+
+namespace Avalonia.WinUI;
+
+///
+/// Bridges Avalonia's contract to the WinRT .
+///
+internal sealed class WinUITextInputMethod : ITextInputMethodImpl
+{
+ private readonly AvaloniaSwapChainPanel _panel;
+ private readonly Func?> _getInput;
+ private readonly Func _getInputRoot;
+ private readonly Func _getKeyboardDevice;
+
+ private CoreTextEditContext? _editContext;
+ private TextInputMethodClient? _client;
+ private bool _hasFocus;
+ private bool _isComposing;
+
+ private string _imeText = string.Empty;
+ private CoreTextRange _imeSelection;
+ private int _compositionStart;
+ private int _compositionLength;
+ private AvRect _cursorRect;
+ private bool _suppressClientEcho;
+
+ public WinUITextInputMethod(
+ AvaloniaSwapChainPanel panel,
+ Func?> getInput,
+ Func getInputRoot,
+ Func getKeyboardDevice)
+ {
+ _panel = panel;
+ _getInput = getInput;
+ _getInputRoot = getInputRoot;
+ _getKeyboardDevice = getKeyboardDevice;
+ }
+
+ private CoreTextEditContext GetOrCreateContext()
+ {
+ if (_editContext is not null)
+ return _editContext;
+
+ var manager = CoreTextServicesManager.GetForCurrentView();
+ var ctx = manager.CreateEditContext();
+ ctx.InputPaneDisplayPolicy = CoreTextInputPaneDisplayPolicy.Automatic;
+ ctx.InputScope = CoreTextInputScope.Text;
+
+ ctx.TextRequested += OnTextRequested;
+ ctx.SelectionRequested += OnSelectionRequested;
+ ctx.LayoutRequested += OnLayoutRequested;
+ ctx.TextUpdating += OnTextUpdating;
+ ctx.SelectionUpdating += OnSelectionUpdating;
+ ctx.FormatUpdating += OnFormatUpdating;
+ ctx.CompositionStarted += OnCompositionStarted;
+ ctx.CompositionCompleted += OnCompositionCompleted;
+ ctx.FocusRemoved += OnFocusRemoved;
+
+ _editContext = ctx;
+ return ctx;
+ }
+
+ public void SetClient(TextInputMethodClient? client)
+ {
+ if (_client is not null)
+ {
+ _client.SurroundingTextChanged -= OnClientSurroundingTextChanged;
+ _client.SelectionChanged -= OnClientSelectionChanged;
+ }
+
+ _client = client;
+ ResetCompositionState();
+
+ if (client is null)
+ {
+ if (_editContext is not null && _hasFocus)
+ _editContext.NotifyFocusLeave();
+ return;
+ }
+
+ client.SurroundingTextChanged += OnClientSurroundingTextChanged;
+ client.SelectionChanged += OnClientSelectionChanged;
+
+ var ctx = GetOrCreateContext();
+ SyncFromClient(notifyServer: true);
+ if (_hasFocus)
+ ctx.NotifyFocusEnter();
+ }
+
+ public void SetCursorRect(Rect rect)
+ {
+ _cursorRect = rect;
+ _editContext?.NotifyLayoutChanged();
+ }
+
+ public void SetOptions(TextInputOptions options)
+ {
+ if (_editContext is null)
+ return;
+ _editContext.InputScope = options.ContentType switch
+ {
+ TextInputContentType.Email => CoreTextInputScope.EmailAddress,
+ TextInputContentType.Number => CoreTextInputScope.Number,
+ TextInputContentType.Password => CoreTextInputScope.Password,
+ TextInputContentType.Digits => CoreTextInputScope.Digits,
+ TextInputContentType.Url => CoreTextInputScope.Url,
+ TextInputContentType.Search => CoreTextInputScope.Search,
+ _ => CoreTextInputScope.Text,
+ };
+ }
+
+ public void Reset()
+ {
+ if (_isComposing)
+ _client?.SetPreeditText(null);
+ ResetCompositionState();
+ SyncFromClient(notifyServer: true);
+ }
+
+ /// Called by the panel when it gains/loses focus.
+ public void OnPanelFocusChanged(bool hasFocus)
+ {
+ _hasFocus = hasFocus;
+ if (_editContext is null)
+ return;
+ if (hasFocus && _client is not null)
+ _editContext.NotifyFocusEnter();
+ else
+ _editContext.NotifyFocusLeave();
+ }
+
+ private void ResetCompositionState()
+ {
+ _isComposing = false;
+ _compositionStart = 0;
+ _compositionLength = 0;
+ }
+
+ private void SyncFromClient(bool notifyServer)
+ {
+ if (_client is null)
+ {
+ _imeText = string.Empty;
+ _imeSelection = default;
+ return;
+ }
+
+ _imeText = _client.SurroundingText ?? string.Empty;
+ var sel = _client.Selection;
+ _imeSelection = new CoreTextRange
+ {
+ StartCaretPosition = Math.Clamp(sel.Start, 0, _imeText.Length),
+ EndCaretPosition = Math.Clamp(sel.End, 0, _imeText.Length),
+ };
+
+ if (notifyServer && _editContext is not null)
+ {
+ _editContext.NotifyTextChanged(
+ new CoreTextRange { StartCaretPosition = 0, EndCaretPosition = int.MaxValue },
+ _imeText.Length,
+ _imeSelection);
+ _editContext.NotifyLayoutChanged();
+ }
+ }
+
+ private void OnClientSurroundingTextChanged(object? sender, EventArgs e)
+ {
+ if (_suppressClientEcho || _isComposing)
+ return;
+ SyncFromClient(notifyServer: true);
+ }
+
+ private void OnClientSelectionChanged(object? sender, EventArgs e)
+ {
+ if (_suppressClientEcho || _isComposing || _client is null || _editContext is null)
+ return;
+ var sel = _client.Selection;
+ _imeSelection = new CoreTextRange
+ {
+ StartCaretPosition = Math.Clamp(sel.Start, 0, _imeText.Length),
+ EndCaretPosition = Math.Clamp(sel.End, 0, _imeText.Length),
+ };
+ _editContext.NotifySelectionChanged(_imeSelection);
+ }
+
+ private void OnTextRequested(CoreTextEditContext sender, CoreTextTextRequestedEventArgs args)
+ {
+ var range = args.Request.Range;
+ var start = Math.Clamp(range.StartCaretPosition, 0, _imeText.Length);
+ var end = Math.Clamp(range.EndCaretPosition, start, _imeText.Length);
+ args.Request.Text = _imeText.Substring(start, end - start);
+ }
+
+ private void OnSelectionRequested(CoreTextEditContext sender, CoreTextSelectionRequestedEventArgs args)
+ {
+ args.Request.Selection = _imeSelection;
+ }
+
+ private void OnLayoutRequested(CoreTextEditContext sender, CoreTextLayoutRequestedEventArgs args)
+ {
+ try
+ {
+ var screenRect = ComputeScreenCursorRect();
+ args.Request.LayoutBounds.TextBounds = screenRect;
+ args.Request.LayoutBounds.ControlBounds = screenRect;
+ }
+ catch (Exception ex)
+ {
+ // Panel not in tree yet, or transform unavailable — leave bounds default.
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.WinUIPlatform)?.Log(this, "LayoutRequested couldn't compute screen rect: {Message}", ex.Message);
+ }
+ }
+
+ private Windows.Foundation.Rect ComputeScreenCursorRect()
+ {
+ var transform = _panel.TransformToVisual(null);
+ var topLeft = transform.TransformPoint(
+ new Windows.Foundation.Point(_cursorRect.X, _cursorRect.Y));
+ var width = Math.Max(_cursorRect.Width, 1);
+ var height = Math.Max(_cursorRect.Height, 16);
+
+ var xamlRoot = _panel.XamlRoot;
+ var scale = xamlRoot?.RasterizationScale ?? 1.0;
+
+ var clientX = topLeft.X * scale;
+ var clientY = topLeft.Y * scale;
+ var w = width * scale;
+ var h = height * scale;
+
+ if (xamlRoot?.ContentIslandEnvironment is { } island)
+ {
+ var hwnd = Microsoft.UI.Win32Interop.GetWindowFromWindowId(island.AppWindowId);
+ if (hwnd != IntPtr.Zero)
+ {
+ var pt = new POINT { x = 0, y = 0 };
+ if (ClientToScreen(hwnd, ref pt))
+ {
+ clientX += pt.x;
+ clientY += pt.y;
+ }
+ }
+ }
+
+ return new Windows.Foundation.Rect(clientX, clientY, w, h);
+ }
+
+ private void OnTextUpdating(CoreTextEditContext sender, CoreTextTextUpdatingEventArgs args)
+ {
+ var newText = args.Text ?? string.Empty;
+ var range = args.Range;
+ var start = Math.Clamp(range.StartCaretPosition, 0, _imeText.Length);
+ var end = Math.Clamp(range.EndCaretPosition, start, _imeText.Length);
+
+ _imeText = _imeText.Substring(0, start) + newText + _imeText.Substring(end);
+ _imeSelection = args.NewSelection;
+
+ if (_isComposing)
+ {
+ if (_compositionLength == 0)
+ _compositionStart = start;
+ _compositionLength = Math.Max(0, _imeSelection.EndCaretPosition - _compositionStart);
+ _compositionLength = Math.Min(_compositionLength, _imeText.Length - _compositionStart);
+
+ var preedit = _imeText.Substring(_compositionStart, _compositionLength);
+ _client?.SetPreeditText(preedit, _imeSelection.EndCaretPosition - _compositionStart);
+ }
+ else if (!string.IsNullOrEmpty(newText))
+ {
+ DispatchRawText(newText, notifyServer: true);
+ }
+
+ args.Result = CoreTextTextUpdatingResult.Succeeded;
+ }
+
+ private void OnSelectionUpdating(CoreTextEditContext sender, CoreTextSelectionUpdatingEventArgs args)
+ {
+ _imeSelection = args.Selection;
+ if (!_isComposing && _client is not null)
+ {
+ _suppressClientEcho = true;
+ try
+ {
+ _client.Selection = new TextSelection(
+ _imeSelection.StartCaretPosition, _imeSelection.EndCaretPosition);
+ }
+ finally { _suppressClientEcho = false; }
+ }
+ args.Result = CoreTextSelectionUpdatingResult.Succeeded;
+ }
+
+ private void OnFormatUpdating(CoreTextEditContext sender, CoreTextFormatUpdatingEventArgs args)
+ {
+ args.Result = CoreTextFormatUpdatingResult.Succeeded;
+ }
+
+ private void OnCompositionStarted(CoreTextEditContext sender, CoreTextCompositionStartedEventArgs args)
+ {
+ if (_imeSelection.StartCaretPosition != _imeSelection.EndCaretPosition)
+ {
+ var input = _getInput();
+ var inputRoot = _getInputRoot();
+ var keyboard = _getKeyboardDevice();
+ if (input is not null && inputRoot is not null && keyboard is not null)
+ {
+ _suppressClientEcho = true;
+ try
+ {
+ var timestamp = (ulong)Environment.TickCount64;
+ input(new RawKeyEventArgs(keyboard, timestamp, inputRoot,
+ RawKeyEventType.KeyDown, Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null));
+ input(new RawKeyEventArgs(keyboard, timestamp, inputRoot,
+ RawKeyEventType.KeyUp, Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null));
+ }
+ finally
+ {
+ _suppressClientEcho = false;
+ }
+ }
+ }
+
+ _isComposing = true;
+ _compositionStart = _imeSelection.StartCaretPosition;
+ _compositionLength = 0;
+ }
+
+ private void OnCompositionCompleted(CoreTextEditContext sender, CoreTextCompositionCompletedEventArgs args)
+ {
+ _isComposing = false;
+ var len = Math.Max(0, Math.Min(_compositionLength, _imeText.Length - _compositionStart));
+ var committed = len > 0 ? _imeText.Substring(_compositionStart, len) : string.Empty;
+
+ _client?.SetPreeditText(null);
+ _compositionLength = 0;
+
+ if (!string.IsNullOrEmpty(committed))
+ DispatchRawText(committed, notifyServer: false);
+ }
+
+ private void OnFocusRemoved(CoreTextEditContext sender, object args)
+ {
+ _hasFocus = false;
+ }
+
+ private void DispatchRawText(string text, bool notifyServer)
+ {
+ var input = _getInput();
+ var inputRoot = _getInputRoot();
+ var keyboard = _getKeyboardDevice();
+ if (input is null || inputRoot is null || keyboard is null)
+ return;
+
+ _suppressClientEcho = true;
+ try
+ {
+ input(new RawTextInputEventArgs(keyboard, (ulong)Environment.TickCount64, inputRoot, text));
+ }
+ finally
+ {
+ _suppressClientEcho = false;
+ }
+
+ SyncFromClient(notifyServer);
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT
+ {
+ public int x;
+ public int y;
+ }
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
+}