From b272283e5057d65617246b9ebf64c4e0e281d88b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 30 Sep 2024 16:49:19 +0200 Subject: [PATCH] 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. --- api/Avalonia.nupkg.xml | 6 ++ .../Embedding/INativeControlFactory.cs | 9 -- .../Embedding/INativeTextBoxFactory.cs | 18 ++++ .../Embedding/MacOSTextBoxFactory.cs | 71 +++++++++++++-- .../Embedding/NativeTextBox.cs | 74 ++++++++++++++- .../Embedding/Win32TextBoxFactory.cs | 90 ++++++++++++++++--- .../IntegrationTestApp/Embedding/WinApi.cs | 36 ++++++++ .../Pages/EmbeddingPage.axaml | 1 + .../Pages/EmbeddingPage.axaml.cs | 12 +++ src/Avalonia.Controls/ContextMenu.cs | 1 + src/Avalonia.Controls/Platform/IPopupImpl.cs | 1 + .../Primitives/IPopupHost.cs | 5 ++ .../Primitives/OverlayPopupHost.cs | 5 ++ src/Avalonia.Controls/Primitives/Popup.cs | 47 ++++++++++ src/Avalonia.Controls/Primitives/PopupRoot.cs | 2 + src/Avalonia.Controls/ToolTip.cs | 1 + src/Avalonia.DesignerSupport/Remote/Stubs.cs | 1 + src/Avalonia.Native/PopupImpl.cs | 16 ++++ src/Avalonia.X11/X11Window.cs | 5 ++ .../Avalonia.Headless/HeadlessWindowImpl.cs | 4 + src/Windows/Avalonia.Win32/PopupImpl.cs | 23 +++++ .../ElementExtensions.cs | 6 +- .../EmbeddingTests.cs | 56 ++++++++++++ 23 files changed, 458 insertions(+), 32 deletions(-) delete mode 100644 samples/IntegrationTestApp/Embedding/INativeControlFactory.cs create mode 100644 samples/IntegrationTestApp/Embedding/INativeTextBoxFactory.cs diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index f9306abc56..1edc3b5ec2 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -73,6 +73,12 @@ baseline/netstandard2.0/Avalonia.Controls.dll target/netstandard2.0/Avalonia.Controls.dll + + CP0006 + M:Avalonia.Controls.Primitives.IPopupHost.TakeFocus + baseline/netstandard2.0/Avalonia.Controls.dll + target/netstandard2.0/Avalonia.Controls.dll + CP0009 T:Avalonia.Diagnostics.StyleDiagnostics diff --git a/samples/IntegrationTestApp/Embedding/INativeControlFactory.cs b/samples/IntegrationTestApp/Embedding/INativeControlFactory.cs deleted file mode 100644 index 4e6b290f48..0000000000 --- a/samples/IntegrationTestApp/Embedding/INativeControlFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using Avalonia.Platform; - -namespace IntegrationTestApp.Embedding; - -internal interface INativeControlFactory -{ - IPlatformHandle CreateControl(IPlatformHandle parent, Func createDefault); -} diff --git a/samples/IntegrationTestApp/Embedding/INativeTextBoxFactory.cs b/samples/IntegrationTestApp/Embedding/INativeTextBoxFactory.cs new file mode 100644 index 0000000000..7a50ffc2e2 --- /dev/null +++ b/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); +} diff --git a/samples/IntegrationTestApp/Embedding/MacOSTextBoxFactory.cs b/samples/IntegrationTestApp/Embedding/MacOSTextBoxFactory.cs index c236055ce9..df43c7b794 100644 --- a/samples/IntegrationTestApp/Embedding/MacOSTextBoxFactory.cs +++ b/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 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. + } } } diff --git a/samples/IntegrationTestApp/Embedding/NativeTextBox.cs b/samples/IntegrationTestApp/Embedding/NativeTextBox.cs index 8bbc6dc560..f995d53c2b 100644 --- a/samples/IntegrationTestApp/Embedding/NativeTextBox.cs +++ b/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); + } } diff --git a/samples/IntegrationTestApp/Embedding/Win32TextBoxFactory.cs b/samples/IntegrationTestApp/Embedding/Win32TextBoxFactory.cs index e29699de3f..4818115922 100644 --- a/samples/IntegrationTestApp/Embedding/Win32TextBoxFactory.cs +++ b/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 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.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); + } } } diff --git a/samples/IntegrationTestApp/Embedding/WinApi.cs b/samples/IntegrationTestApp/Embedding/WinApi.cs index ab51988df9..529b905096 100644 --- a/samples/IntegrationTestApp/Embedding/WinApi.cs +++ b/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; + } } diff --git a/samples/IntegrationTestApp/Pages/EmbeddingPage.axaml b/samples/IntegrationTestApp/Pages/EmbeddingPage.axaml index a2011d6636..632893bcd0 100644 --- a/samples/IntegrationTestApp/Pages/EmbeddingPage.axaml +++ b/samples/IntegrationTestApp/Pages/EmbeddingPage.axaml @@ -15,5 +15,6 @@ + diff --git a/samples/IntegrationTestApp/Pages/EmbeddingPage.axaml.cs b/samples/IntegrationTestApp/Pages/EmbeddingPage.axaml.cs index 5e1fa8c517..93855cd13d 100644 --- a/samples/IntegrationTestApp/Pages/EmbeddingPage.axaml.cs +++ b/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(); } } diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 940528bf6f..e8c36546ba 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -327,6 +327,7 @@ namespace Avalonia.Controls { IsLightDismissEnabled = true, OverlayDismissEventPassThrough = true, + TakesFocusFromNativeControl = Popup.GetTakesFocusFromNativeControl(this), }; _popup.Opened += PopupOpened; diff --git a/src/Avalonia.Controls/Platform/IPopupImpl.cs b/src/Avalonia.Controls/Platform/IPopupImpl.cs index 320130bc91..388c447243 100644 --- a/src/Avalonia.Controls/Platform/IPopupImpl.cs +++ b/src/Avalonia.Controls/Platform/IPopupImpl.cs @@ -12,5 +12,6 @@ namespace Avalonia.Platform IPopupPositioner? PopupPositioner { get; } void SetWindowManagerAddShadowHint(bool enabled); + void TakeFocus(); } } diff --git a/src/Avalonia.Controls/Primitives/IPopupHost.cs b/src/Avalonia.Controls/Primitives/IPopupHost.cs index 4cb259db5f..c4f992363e 100644 --- a/src/Avalonia.Controls/Primitives/IPopupHost.cs +++ b/src/Avalonia.Controls/Primitives/IPopupHost.cs @@ -98,5 +98,10 @@ namespace Avalonia.Controls.Primitives /// Hides the popup. /// void Hide(); + + /// + /// Takes focus from any currently focused native control. + /// + void TakeFocus(); } } diff --git a/src/Avalonia.Controls/Primitives/OverlayPopupHost.cs b/src/Avalonia.Controls/Primitives/OverlayPopupHost.cs index 3a602c15b7..3ea7ac5451 100644 --- a/src/Avalonia.Controls/Primitives/OverlayPopupHost.cs +++ b/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. + } + /// [Unstable(ObsoletionMessages.MayBeRemovedInAvalonia12)] public void ConfigurePosition(Visual target, PlacementMode placement, Point offset, diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 28e2a51e06..87962846aa 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -134,6 +134,12 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty TopmostProperty = AvaloniaProperty.Register(nameof(Topmost)); + /// + /// Defines the property. + /// + public static readonly AttachedProperty TakesFocusFromNativeControlProperty = + AvaloniaProperty.RegisterAttached(nameof(TakesFocusFromNativeControl), true); + private bool _isOpenRequested; private bool _ignoreIsOpenChanged; private PopupOpenState? _openState; @@ -364,6 +370,23 @@ namespace Avalonia.Controls.Primitives set => SetValue(TopmostProperty, value); } + /// + /// Gets or sets a value indicating whether the popup, on show, transfers focus from any + /// focused native control to Avalonia. The default is true. + /// + /// + /// 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 + /// false, then the shown popup will not receive input until it receives an + /// interaction which explicitly focuses the popup, such as a mouse click. + /// + public bool TakesFocusFromNativeControl + { + get => GetValue(TakesFocusFromNativeControlProperty); + set => SetValue(TakesFocusFromNativeControlProperty, value); + } + IPopupHost? IPopupHostProvider.PopupHost => Host; event Action? 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 /// public void Close() => CloseCore(); + /// + /// Gets the value of the attached property on the + /// specified control. + /// + /// The control. + public static bool GetTakesFocusFromNativeControl(Control control) + { + return control.GetValue(TakesFocusFromNativeControlProperty); + } + + /// + /// Sets the value of the attached property on the + /// specified control. + /// + /// The control. + /// The value of the TakesFocusFromNativeControl property. + public static void SetTakesFocusFromNativeControl(Control control, bool value) + { + control.SetValue(TakesFocusFromNativeControlProperty, value); + } + /// /// Measures the control. /// diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index b2905b9176..f88ed0b93b 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/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) diff --git a/src/Avalonia.Controls/ToolTip.cs b/src/Avalonia.Controls/ToolTip.cs index fc8da13131..d2b3da81fe 100644 --- a/src/Avalonia.Controls/ToolTip.cs +++ b/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; diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index e21f4dc843..de8f0749e1 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/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 diff --git a/src/Avalonia.Native/PopupImpl.cs b/src/Avalonia.Native/PopupImpl.cs index 14383c93c8..222fa63497 100644 --- a/src/Avalonia.Native/PopupImpl.cs +++ b/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; } } } diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 6c1b381a40..73904d5d63 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/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. + } } } diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs index 5ff9b8d1ae..7366d8235d 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs @@ -443,5 +443,9 @@ namespace Avalonia.Headless zOrder[i] = headlessWindowImpl._zOrder; } } + + public void TakeFocus() + { + } } } diff --git a/src/Windows/Avalonia.Win32/PopupImpl.cs b/src/Windows/Avalonia.Win32/PopupImpl.cs index 388ee505e1..95c8178adc 100644 --- a/src/Windows/Avalonia.Win32/PopupImpl.cs +++ b/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; } } } diff --git a/tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs b/tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs index d9317e8e6a..18e9912faf 100644 --- a/tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs +++ b/tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs @@ -136,7 +136,7 @@ namespace Avalonia.IntegrationTests.Appium /// /// An object which when disposed will cause the newly opened window to close. /// - 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(() => diff --git a/tests/Avalonia.IntegrationTests.Appium/EmbeddingTests.cs b/tests/Avalonia.IntegrationTests.Appium/EmbeddingTests.cs index 15bb5cda62..f4debf8fe3 100644 --- a/tests/Avalonia.IntegrationTests.Appium/EmbeddingTests.cs +++ b/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); + } } }