Browse Source

Make popup focus stealing configurable. (#16642)

* Show Avalonia context menu and toolip...

In native text box in integration test app. Only implemented for win32 right now.

This is to test the two popup behaviors required for native controls:

- The context menu needs focus to be transferred to Avalonia
- The ToolTip must not transfer focus to Avalonia

* Added Popup.TakesFocusFromNativeControl.

By default, if a popup is shown when a native control is focused, focus is transferred back to Avalonia in order for the popup to receive input. If this property is set to false, then the shown popup will not receive input until it receives an interaction which explicitly focuses the popup, such as a mouse click.

The effect of this property can be seen in the Embedding tag of the IntegrationTestApp: hovering over the native text box shows an Avalonia `ToolTip` which does not steal focus from the native text box. Right-clicking to open an Avalonia `ContextMenu` does steal focus so the menu items can be selected using the arrow keys.

Currently only implemented on a win32.

* Show tooltip and context menu on macOS.

* Implement TakeFocus on macOS.

* Add integration tests.

Only tested on win32 so far.

* Integration tests won't work on macOS.

As can be expected at this point, really.

* Update API diff.
Steven Kirk 1 year ago
parent
commit
b272283e50

+ 6 - 0
api/Avalonia.nupkg.xml

@@ -73,6 +73,12 @@
     <Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left>
     <Right>target/netstandard2.0/Avalonia.Controls.dll</Right>
   </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0006</DiagnosticId>
+    <Target>M:Avalonia.Controls.Primitives.IPopupHost.TakeFocus</Target>
+    <Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left>
+    <Right>target/netstandard2.0/Avalonia.Controls.dll</Right>
+  </Suppression>
   <Suppression>
     <DiagnosticId>CP0009</DiagnosticId>
     <Target>T:Avalonia.Diagnostics.StyleDiagnostics</Target>

+ 0 - 9
samples/IntegrationTestApp/Embedding/INativeControlFactory.cs

@@ -1,9 +0,0 @@
-using System;
-using Avalonia.Platform;
-
-namespace IntegrationTestApp.Embedding;
-
-internal interface INativeControlFactory
-{
-    IPlatformHandle CreateControl(IPlatformHandle parent, Func<IPlatformHandle> createDefault);
-}

+ 18 - 0
samples/IntegrationTestApp/Embedding/INativeTextBoxFactory.cs

@@ -0,0 +1,18 @@
+using System;
+using Avalonia.Platform;
+
+namespace IntegrationTestApp.Embedding;
+
+internal interface INativeTextBoxImpl
+{
+    IPlatformHandle Handle { get; }
+    string Text { get; set; }
+    event EventHandler? ContextMenuRequested;
+    event EventHandler? Hovered;
+    event EventHandler? PointerExited;
+}
+
+internal interface INativeTextBoxFactory
+{
+    INativeTextBoxImpl CreateControl(IPlatformHandle parent);
+}

+ 64 - 7
samples/IntegrationTestApp/Embedding/MacOSTextBoxFactory.cs

@@ -1,20 +1,77 @@
 using System;
-using System.Text;
 using Avalonia.Platform;
+using Avalonia.Threading;
 using MonoMac.AppKit;
-using MonoMac.WebKit;
+using MonoMac.Foundation;
 
 namespace IntegrationTestApp.Embedding;
 
-internal class MacOSTextBoxFactory : INativeControlFactory
+internal class MacOSTextBoxFactory : INativeTextBoxFactory
 {
-    public IPlatformHandle CreateControl(IPlatformHandle parent, Func<IPlatformHandle> createDefault)
+    public INativeTextBoxImpl CreateControl(IPlatformHandle parent)
     {
         MacHelper.EnsureInitialized();
+        return new MacOSTextBox();
+    }
+
+    private class MacOSTextBox : NSTextView, INativeTextBoxImpl
+    {
+        private DispatcherTimer _timer;
+        
+        public MacOSTextBox()
+        {
+            TextStorage.Append(new("Native text box"));
+            Handle = new MacOSViewHandle(this);
+            _timer = new DispatcherTimer();
+            _timer.Interval = TimeSpan.FromMilliseconds(400);
+            _timer.Tick += (_, _) =>
+            {
+                Hovered?.Invoke(this, EventArgs.Empty);
+                _timer.Stop();
+            };
+        }
+
+        public new IPlatformHandle Handle { get; }
+
+        public string Text
+        {
+            get => TextStorage.Value;
+            set => TextStorage.Replace(new NSRange(0, TextStorage.Length), value);
+        }
+
+        public event EventHandler? ContextMenuRequested;
+        public event EventHandler? Hovered;
+        public event EventHandler? PointerExited;
+
+        public override void MouseEntered(NSEvent theEvent)
+        {
+            _timer.Stop();
+            _timer.Start();
+            base.MouseEntered(theEvent);
+        }
+
+        public override void MouseExited(NSEvent theEvent)
+        {
+            _timer.Stop();
+            PointerExited?.Invoke(this, EventArgs.Empty);
+            base.MouseExited(theEvent);
+        }
+
+        public override void MouseMoved(NSEvent theEvent)
+        {
+            _timer.Stop();
+            _timer.Start();
+            base.MouseMoved(theEvent);
+        }
 
-        var textView = new NSTextView();
-        textView.TextStorage.Append(new("Native text box"));
+        public override void RightMouseDown(NSEvent theEvent)
+        {
+            ContextMenuRequested?.Invoke(this, EventArgs.Empty);
+        }
 
-        return new MacOSViewHandle(textView);
+        public override void RightMouseUp(NSEvent theEvent)
+        {
+            // Don't call base to prevent default action.
+        }
     }
 }

+ 70 - 4
samples/IntegrationTestApp/Embedding/NativeTextBox.cs

@@ -1,20 +1,86 @@
-using Avalonia.Controls;
+using System;
+using Avalonia.Controls;
 using Avalonia.Platform;
 
 namespace IntegrationTestApp.Embedding;
 
 internal class NativeTextBox : NativeControlHost
 {
-    public static INativeControlFactory? Factory { get; set; }
+    private ContextMenu? _contextMenu;
+    private INativeTextBoxImpl? _impl;
+    private TextBlock _tipTextBlock;
+    private string _initialText = string.Empty;
+
+    public NativeTextBox()
+    {
+        _tipTextBlock = new TextBlock
+        {
+            Text = "Avalonia ToolTip",
+            Name = "NativeTextBoxToolTip",
+        };
+
+        ToolTip.SetTip(this, _tipTextBlock);
+        ToolTip.SetShowDelay(this, 1000);
+        ToolTip.SetServiceEnabled(this, false);
+    }
+
+    public string Text
+    {
+        get => _impl?.Text ?? _initialText;
+        set
+        {
+            if (_impl is not null)
+                _impl.Text = value;
+            else
+                _initialText = value;
+        }
+    }
+
+    public static INativeTextBoxFactory? Factory { get; set; }
 
     protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
     {
-        return Factory?.CreateControl(parent, () => base.CreateNativeControlCore(parent))
-            ?? base.CreateNativeControlCore(parent); 
+        if (Factory is null)
+            return base.CreateNativeControlCore(parent);
+
+        _impl = Factory.CreateControl(parent);
+        _impl.Text = _initialText;
+        _impl.ContextMenuRequested += OnContextMenuRequested;
+        _impl.Hovered += OnHovered;
+        _impl.PointerExited += OnPointerExited;
+        return _impl.Handle;
     }
 
     protected override void DestroyNativeControlCore(IPlatformHandle control)
     {
         base.DestroyNativeControlCore(control);
     }
+
+    private void OnContextMenuRequested(object? sender, EventArgs e)
+    {
+        if (_contextMenu is null)
+        {
+            var menuItem = new MenuItem { Header = "Custom Menu Item" };
+            menuItem.Click += (s, e) => _impl!.Text = "Context menu item clicked";
+
+            _contextMenu = new ContextMenu
+            {
+                Name = "NativeTextBoxContextMenu",
+                Items = { menuItem }
+            };
+        }
+
+        ToolTip.SetIsOpen(this, false);
+        _contextMenu.Open(this);
+    }
+
+    private void OnHovered(object? sender, EventArgs e)
+    {
+        ToolTip.SetIsOpen(this, true);
+    }
+
+    private void OnPointerExited(object? sender, EventArgs e)
+    {
+        ToolTip.SetIsOpen(this, false);
+    }
 }

+ 79 - 11
samples/IntegrationTestApp/Embedding/Win32TextBoxFactory.cs

@@ -1,21 +1,89 @@
 using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
 using System.Text;
+using Avalonia.Controls;
 using Avalonia.Platform;
+using static IntegrationTestApp.Embedding.WinApi;
 
 namespace IntegrationTestApp.Embedding;
 
-internal class Win32TextBoxFactory : INativeControlFactory
+internal class Win32TextBoxFactory : INativeTextBoxFactory
 {
-    public IPlatformHandle CreateControl(IPlatformHandle parent, Func<IPlatformHandle> createDefault)
+    public INativeTextBoxImpl CreateControl(IPlatformHandle parent)
     {
-        var handle = WinApi.CreateWindowEx(0, "EDIT",
-            @"Native text box",
-            (uint)(WinApi.WindowStyles.WS_CHILD | WinApi.WindowStyles.WS_VISIBLE | WinApi.WindowStyles.WS_BORDER), 
-            0, 0, 1, 1, 
-            parent.Handle,
-            IntPtr.Zero, 
-            WinApi.GetModuleHandle(null), 
-            IntPtr.Zero);
-        return new Win32WindowControlHandle(handle, "HWND");
+        return new Win32TextBox(parent);
+    }
+
+    private class Win32TextBox : INativeTextBoxImpl
+    {
+        private readonly IntPtr _oldWndProc;
+        private readonly WndProcDelegate _wndProc;
+        private TRACKMOUSEEVENT _trackMouseEvent;
+
+        public Win32TextBox(IPlatformHandle parent)
+        {
+            var handle = CreateWindowEx(0, "EDIT",
+                string.Empty,
+                (uint)(WinApi.WindowStyles.WS_CHILD | WinApi.WindowStyles.WS_VISIBLE | WinApi.WindowStyles.WS_BORDER),
+                0, 0, 1, 1,
+                parent.Handle,
+                IntPtr.Zero,
+                GetModuleHandle(null),
+                IntPtr.Zero);
+
+            _wndProc = new(WndProc);
+            _oldWndProc = SetWindowLongPtr(handle, WinApi.GWL_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProc));
+
+            _trackMouseEvent.cbSize = Marshal.SizeOf<TRACKMOUSEEVENT>();
+            _trackMouseEvent.dwFlags = TME_HOVER | TME_LEAVE;
+            _trackMouseEvent.hwndTrack = handle;
+            _trackMouseEvent.dwHoverTime = 400;
+
+            Handle = new Win32WindowControlHandle(handle, "HWND");
+        }
+
+        public IPlatformHandle Handle { get; }
+
+        public string Text
+        {
+            get
+            {
+                var sb = new StringBuilder(256);
+                GetWindowText(Handle.Handle, sb, sb.Capacity);
+                return sb.ToString();
+            }
+            set => SetWindowText(Handle.Handle, value);
+        }
+
+        public event EventHandler? ContextMenuRequested;
+        public event EventHandler? Hovered;
+        public event EventHandler? PointerExited;
+
+        private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
+        {
+            switch (msg)
+            {
+                case WM_CONTEXTMENU:
+                    if (ContextMenuRequested is not null)
+                    {
+                        ContextMenuRequested?.Invoke(this, EventArgs.Empty);
+                        return IntPtr.Zero;
+                    }
+                    break;
+                case WM_MOUSELEAVE:
+                    PointerExited?.Invoke(this, EventArgs.Empty);
+                    break;
+                case WM_MOUSEHOVER:
+                    Hovered?.Invoke(this, EventArgs.Empty);
+                    break;
+                case WM_MOUSEMOVE:
+                    TrackMouseEvent(ref _trackMouseEvent);
+                    break;
+
+            }
+
+            return CallWindowProc(_oldWndProc, hWnd, msg, wParam, lParam);
+        }
     }
 }

+ 36 - 0
samples/IntegrationTestApp/Embedding/WinApi.cs

@@ -1,10 +1,21 @@
 using System;
 using System.Runtime.InteropServices;
+using System.Text;
 
 namespace IntegrationTestApp.Embedding;
 
 internal class WinApi
 {
+    public const int GWL_WNDPROC = -4;
+    public const uint TME_HOVER = 1;
+    public const uint TME_LEAVE = 2;
+    public const uint WM_CONTEXTMENU = 0x007B;
+    public const uint WM_MOUSELEAVE = 0x02A3;
+    public const uint WM_MOUSEHOVER = 0x02A1;
+    public const uint WM_MOUSEMOVE = 0x0200;
+
+    public delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
+
     [Flags]
     public enum WindowStyles : uint
     {
@@ -59,12 +70,19 @@ internal class WinApi
         WS_EX_NOACTIVATE = 0x08000000
     }
 
+    [DllImport("user32.dll", SetLastError = true)]
+    public static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
+
     [DllImport("user32.dll", SetLastError = true)]
     public static extern bool DestroyWindow(IntPtr hwnd);
 
     [DllImport("kernel32.dll")]
     public static extern IntPtr GetModuleHandle(string? lpModuleName);
 
+    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
+    public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
+
+
     [DllImport("user32.dll", SetLastError = true)]
     public static extern IntPtr CreateWindowEx(
         int dwExStyle,
@@ -79,4 +97,22 @@ internal class WinApi
         IntPtr hMenu,
         IntPtr hInstance,
         IntPtr lpParam);
+
+    [DllImport("user32.dll", SetLastError = true)]
+    public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
+
+    [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+    public static extern bool SetWindowText(IntPtr hwnd, String lpString);
+
+    [DllImport("user32.dll")]
+    public static extern bool TrackMouseEvent(ref TRACKMOUSEEVENT lpEventTrack);
+
+    [StructLayout(LayoutKind.Sequential)]
+    public struct TRACKMOUSEEVENT
+    {
+        public int cbSize;
+        public uint dwFlags;
+        public IntPtr hwndTrack;
+        public uint dwHoverTime;
+    }
 }

+ 1 - 0
samples/IntegrationTestApp/Pages/EmbeddingPage.axaml

@@ -15,5 +15,6 @@
         <embedding:NativeTextBox Name="NativeTextBoxInPopup" Width="200" Height="23"/>
       </Popup>
     </StackPanel>
+    <Button Name="Reset" Click="Reset_Click">Reset</Button>
   </StackPanel>
 </UserControl>

+ 12 - 0
samples/IntegrationTestApp/Pages/EmbeddingPage.axaml.cs

@@ -1,4 +1,5 @@
 using Avalonia.Controls;
+using Avalonia.Interactivity;
 
 namespace IntegrationTestApp;
 
@@ -7,5 +8,16 @@ public partial class EmbeddingPage : UserControl
     public EmbeddingPage()
     {
         InitializeComponent();
+        ResetText();
+    }
+
+    private void ResetText()
+    {
+        NativeTextBox.Text = NativeTextBoxInPopup.Text = "Native text box";
+    }
+
+    private void Reset_Click(object? sender, RoutedEventArgs e)
+    {
+        ResetText();
     }
 }

+ 1 - 0
src/Avalonia.Controls/ContextMenu.cs

@@ -327,6 +327,7 @@ namespace Avalonia.Controls
                 {
                     IsLightDismissEnabled = true,
                     OverlayDismissEventPassThrough = true,
+                    TakesFocusFromNativeControl = Popup.GetTakesFocusFromNativeControl(this),
                 };
 
                 _popup.Opened += PopupOpened;

+ 1 - 0
src/Avalonia.Controls/Platform/IPopupImpl.cs

@@ -12,5 +12,6 @@ namespace Avalonia.Platform
         IPopupPositioner? PopupPositioner { get; }
 
         void SetWindowManagerAddShadowHint(bool enabled);
+        void TakeFocus();
     }
 }

+ 5 - 0
src/Avalonia.Controls/Primitives/IPopupHost.cs

@@ -98,5 +98,10 @@ namespace Avalonia.Controls.Primitives
         /// Hides the popup.
         /// </summary>
         void Hide();
+
+        /// <summary>
+        /// Takes focus from any currently focused native control.
+        /// </summary>
+        void TakeFocus();
     }
 }

+ 5 - 0
src/Avalonia.Controls/Primitives/OverlayPopupHost.cs

@@ -73,6 +73,11 @@ namespace Avalonia.Controls.Primitives
             _shown = false;
         }
 
+        public void TakeFocus()
+        {
+            // Nothing to do here: overlay popups are implemented inside the window.
+        }
+
         /// <inheritdoc />
         [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)]
         public void ConfigurePosition(Visual target, PlacementMode placement, Point offset,

+ 47 - 0
src/Avalonia.Controls/Primitives/Popup.cs

@@ -134,6 +134,12 @@ namespace Avalonia.Controls.Primitives
         public static readonly StyledProperty<bool> TopmostProperty =
             AvaloniaProperty.Register<Popup, bool>(nameof(Topmost));
 
+        /// <summary>
+        /// Defines the <see cref="TakesFocusFromNativeControl"/> property.
+        /// </summary>
+        public static readonly AttachedProperty<bool> TakesFocusFromNativeControlProperty =
+            AvaloniaProperty.RegisterAttached<Popup, Control, bool>(nameof(TakesFocusFromNativeControl), true);
+
         private bool _isOpenRequested;
         private bool _ignoreIsOpenChanged;
         private PopupOpenState? _openState;
@@ -364,6 +370,23 @@ namespace Avalonia.Controls.Primitives
             set => SetValue(TopmostProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether the popup, on show, transfers focus from any
+        /// focused native control to Avalonia. The default is <c>true</c>.
+        /// </summary>
+        /// <remarks>
+        /// This property only applies to advanced native control embedding scenarios. By default,
+        /// if a popup is shown when a native control is focused, focus is transferred back to
+        /// Avalonia in order for the popup to receive input. If this property is set to
+        /// <c>false</c>, then the shown popup will not receive input until it receives an
+        /// interaction which explicitly focuses the popup, such as a mouse click.
+        /// </remarks>
+        public bool TakesFocusFromNativeControl
+        {
+            get => GetValue(TakesFocusFromNativeControlProperty);
+            set => SetValue(TakesFocusFromNativeControlProperty, value);
+        }
+
         IPopupHost? IPopupHostProvider.PopupHost => Host;
 
         event Action<IPopupHost?>? IPopupHostProvider.PopupHostChanged 
@@ -520,6 +543,9 @@ namespace Avalonia.Controls.Primitives
 
             popupHost.Show();
 
+            if (TakesFocusFromNativeControl)
+                popupHost.TakeFocus();
+
             using (BeginIgnoringIsOpen())
             {
                 SetCurrentValue(IsOpenProperty, true);
@@ -535,6 +561,27 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         public void Close() => CloseCore();
 
+        /// <summary>
+        /// Gets the value of the <see cref="TakesFocusFromNativeControl"/> attached property on the
+        /// specified control.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        public static bool GetTakesFocusFromNativeControl(Control control)
+        {
+            return control.GetValue(TakesFocusFromNativeControlProperty);
+        }
+
+        /// <summary>
+        /// Sets the value of the <see cref="TakesFocusFromNativeControl"/> attached property on the
+        /// specified control.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        /// <param name="value">The value of the TakesFocusFromNativeControl property.</param>
+        public static void SetTakesFocusFromNativeControl(Control control, bool value)
+        {
+            control.SetValue(TakesFocusFromNativeControlProperty, value);
+        }
+
         /// <summary>
         /// Measures the control.
         /// </summary>

+ 2 - 0
src/Avalonia.Controls/Primitives/PopupRoot.cs

@@ -156,6 +156,8 @@ namespace Avalonia.Controls.Primitives
 
         public void SetChild(Control? control) => Content = control;
 
+        public void TakeFocus() => PlatformImpl?.TakeFocus();
+
         Visual IPopupHost.HostedVisualTreeRoot => this;
         
         protected override Size MeasureOverride(Size availableSize)

+ 1 - 0
src/Avalonia.Controls/ToolTip.cs

@@ -408,6 +408,7 @@ namespace Avalonia.Controls
             {
                 _popup = new Popup();
                 _popup.Child = this;
+                _popup.TakesFocusFromNativeControl = false;
                 _popup.WindowManagerAddShadowHint = false;
 
                 _popup.Opened += OnPopupOpened;

+ 1 - 0
src/Avalonia.DesignerSupport/Remote/Stubs.cs

@@ -198,6 +198,7 @@ namespace Avalonia.DesignerSupport.Remote
 
         public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new AcrylicPlatformCompensationLevels(1, 1, 1);
         public object TryGetFeature(Type featureType) => null;
+        public void TakeFocus() { }
     }
 
     class ClipboardStub : IClipboard

+ 16 - 0
src/Avalonia.Native/PopupImpl.cs

@@ -87,6 +87,22 @@ namespace Avalonia.Native
         {
         }
 
+        public void TakeFocus()
+        {
+            var parent = _parent;
+
+            while (parent != null)
+            {
+                if (parent is PopupImpl popup)
+                    parent = popup._parent;
+                else
+                    break;
+            }
+            
+            if (parent is WindowImpl w)
+                w.Native.TakeFocusFromChildren();
+        }
+
         public IPopupPositioner PopupPositioner { get; }
     }
 }

+ 5 - 0
src/Avalonia.X11/X11Window.cs

@@ -1562,5 +1562,10 @@ namespace Avalonia.X11
                 }
             }
         }
+
+        public void TakeFocus()
+        {
+            // TODO: Not yet implemented: need to check if this is required on X11 or not.
+        }
     }
 }

+ 4 - 0
src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs

@@ -443,5 +443,9 @@ namespace Avalonia.Headless
                     zOrder[i] = headlessWindowImpl._zOrder;
             }
         }
+
+        public void TakeFocus() 
+        {
+        }
     }
 }

+ 23 - 0
src/Windows/Avalonia.Win32/PopupImpl.cs

@@ -22,6 +22,7 @@ namespace Avalonia.Win32
         {
             // Popups are always shown non-activated.
             UnmanagedMethods.ShowWindow(Handle.Handle, UnmanagedMethods.ShowWindowCommand.ShowNoActivate);
+
         }
 
         protected override bool ShouldTakeFocusOnClick => false;
@@ -147,6 +148,28 @@ namespace Avalonia.Win32
             EnableBoxShadow(Handle.Handle, enabled);
         }
 
+        public void TakeFocus()
+        {
+            var parent = _parent;
+
+            while (parent != null)
+            {
+                if (parent is PopupImpl pi)
+                    parent = pi._parent;
+                else
+                    break;
+            }
+
+            if (parent == null)
+                return;
+
+            var focusOwner = UnmanagedMethods.GetFocus();
+            if (focusOwner != IntPtr.Zero &&
+                UnmanagedMethods.GetAncestor(focusOwner, UnmanagedMethods.GetAncestorFlags.GA_ROOT)
+                == parent.Handle?.Handle)
+                UnmanagedMethods.SetFocus(parent.Handle.Handle);
+        }
+
         public IPopupPositioner PopupPositioner { get; }
     }
 }

+ 5 - 1
tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs

@@ -136,7 +136,7 @@ namespace Avalonia.IntegrationTests.Appium
         /// <returns>
         /// An object which when disposed will cause the newly opened window to close.
         /// </returns>
-        public static IDisposable OpenWindowWithClick(this AppiumWebElement element)
+        public static IDisposable OpenWindowWithClick(this AppiumWebElement element, TimeSpan? delay = null)
         {
             var session = element.WrappedDriver;
 
@@ -148,6 +148,9 @@ namespace Avalonia.IntegrationTests.Appium
 
                 element.Click();
 
+                if (delay is not null)
+                    Thread.Sleep((int)delay.Value.TotalMilliseconds);
+
                 var newHandle = session.WindowHandles.Except(oldHandles).SingleOrDefault();
 
                 if (newHandle is not null)
@@ -167,6 +170,7 @@ namespace Avalonia.IntegrationTests.Appium
                     // that a child window was opened. These don't appear in session.WindowHandles
                     // so we have to use an XPath query to get hold of it.
                     var newChildWindows = session.FindElements(By.XPath("//Window"));
+                    var pageSource = session.PageSource;
                     var childWindow = Assert.Single(newChildWindows.Except(oldChildWindows));
 
                     return Disposable.Create(() =>

+ 56 - 0
tests/Avalonia.IntegrationTests.Appium/EmbeddingTests.cs

@@ -1,4 +1,7 @@
 using System;
+using System.Threading;
+using OpenQA.Selenium;
+using OpenQA.Selenium.Interactions;
 using Xunit;
 
 namespace Avalonia.IntegrationTests.Appium
@@ -9,6 +12,8 @@ namespace Avalonia.IntegrationTests.Appium
         public EmbeddingTests(DefaultAppFixture fixture)
             : base(fixture, "Embedding")
         {
+            var reset = Session.FindElementByAccessibilityId("Reset");
+            reset.Click();
         }
 
         [PlatformFact(TestPlatforms.Windows, "Not yet working on macOS")]
@@ -60,5 +65,56 @@ namespace Avalonia.IntegrationTests.Appium
                 checkBox.Click();
             }
         }
+
+        [PlatformFact(TestPlatforms.Windows, "Not yet working on macOS")]
+        public void Showing_ToolTip_Does_Not_Steal_Focus_From_Native_TextBox()
+        {
+            // Appium has different XPath syntax between Windows and macOS.
+            var textBox = OperatingSystem.IsWindows() ?
+                Session.FindElementByXPath($"//*[@AutomationId='NativeTextBox']//*[1]") :
+                Session.FindElementByXPath($"//*[@identifier='NativeTextBox']//*[1]");
+
+            // Clicking on the text box causes the cursor to hover over it, opening the tooltip.
+            textBox.Click();
+            Thread.Sleep(1000);
+
+            // Ensure the tooltip has opened.
+            Session.FindElementByAccessibilityId("NativeTextBoxToolTip");
+
+            // The tooltip should not have stolen focus from the text box, so text entry should work.
+            new Actions(Session).SendKeys("Hello world!").Perform();
+
+            // SendKeys behaves differently between Windows and macOS. On Windows it inserts at the start
+            // of the text box, on macOS it replaces the text for some reason. Sigh.
+            var expected = OperatingSystem.IsWindows() ?
+                "Native text boxHello world!" :
+                "Hello world!";
+
+            Assert.Equal(expected, textBox.Text);
+        }
+
+        [PlatformFact(TestPlatforms.Windows, "Not yet working on macOS")]
+        public void Showing_ContextMenu_Steals_Focus_From_Native_TextBox()
+        {
+            // Appium has different XPath syntax between Windows and macOS.
+            var textBox = OperatingSystem.IsWindows() ?
+                Session.FindElementByXPath($"//*[@AutomationId='NativeTextBox']//*[1]") :
+                Session.FindElementByXPath($"//*[@identifier='NativeTextBox']//*[1]");
+
+            // Click on the text box the right-click to show the context menu.
+            textBox.Click();
+            new Actions(Session).ContextClick(textBox).Perform();
+
+            // Ensure the context menu has opened.
+            Session.FindElementByAccessibilityId("NativeTextBoxContextMenu");
+
+            // Select the first menu item with the keyboard.
+            new Actions(Session)
+                .SendKeys(Keys.ArrowDown)
+                .SendKeys(Keys.Enter)
+                .Perform();
+
+            Assert.Equal("Context menu item clicked", textBox.Text);
+        }
     }
 }