From 118a1174e215d52290a28500734790ac6a95ee09 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Mon, 30 Mar 2026 19:36:38 +0000 Subject: [PATCH] Fix Mouse pointer triggering focus change on pointer release. (#21009) * add tests * explicitly check which pointer events trigger focus change * add test checking for change in focus while mouse is pressed. --- src/Avalonia.Base/Input/FocusManager.cs | 14 ++- .../Input/InputElement_Focus.cs | 50 ++++----- .../Input/MouseDeviceTests.cs | 103 ++++++++++++++++-- .../Input/PointerTestsBase.cs | 2 - .../Input/TouchDeviceTests.cs | 67 ++++++++++-- 5 files changed, 186 insertions(+), 50 deletions(-) diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index a273ff6d89..651210fc2b 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -111,7 +111,7 @@ namespace Avalonia.Input scope.ClearValue(FocusedElementProperty); } - if (Current == removedElement) + if (Current == removedElement) Focus(null); } @@ -158,7 +158,7 @@ namespace Avalonia.Input /// internal static FocusManager? GetFocusManager(IInputElement? element) { - + // Element might not be a visual, and not attached to the root. // But IFocusManager is always expected to be a FocusManager. return (FocusManager?)(element as Visual)?.GetInputRoot()?.FocusManager @@ -188,8 +188,12 @@ namespace Avalonia.Input { if (CanFocus(e)) { - if (ev.Pointer.Type == PointerType.Mouse || ev is PointerReleasedEventArgs) - return true; + return ev switch + { + PointerReleasedEventArgs releasedEventArgs when releasedEventArgs.Pointer.Type != PointerType.Mouse => true, + PointerPressedEventArgs pressedEventArgs when pressedEventArgs.Pointer.Type == PointerType.Mouse => true, + _ => false, + }; } return false; @@ -229,7 +233,7 @@ namespace Avalonia.Input var root = v.PresentationSource?.InputRoot.FocusRoot as Visual; while (root is IHostedVisualTreeRoot hosted && - hosted.Host?.PresentationSource?.InputRoot.FocusRoot is {} parentRoot) + hosted.Host?.PresentationSource?.InputRoot.FocusRoot is { } parentRoot) { root = parentRoot; } diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index a09eccc1a3..45d665b591 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -24,7 +24,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.Same(target, root.FocusManager.GetFocusedElement()); } } - + [Fact] public void Invisible_Controls_Should_Not_Receive_Focus() { @@ -34,20 +34,20 @@ namespace Avalonia.Base.UnitTests.Input { var root = new TestRoot { - Child = target = new Button() { IsVisible = false} + Child = target = new Button() { IsVisible = false } }; - + Assert.Null(root.FocusManager.GetFocusedElement()); target.Focus(); - + Assert.False(target.IsFocused); Assert.False(target.IsKeyboardFocusWithin); Assert.Null(root.FocusManager.GetFocusedElement()); } } - + [Fact] public void Effectively_Invisible_Controls_Should_Not_Receive_Focus() { @@ -64,11 +64,11 @@ namespace Avalonia.Base.UnitTests.Input Children = { target } } }; - + Assert.Null(root.FocusManager.GetFocusedElement()); target.Focus(); - + Assert.False(target.IsFocused); Assert.False(target.IsKeyboardFocusWithin); @@ -87,7 +87,7 @@ namespace Avalonia.Base.UnitTests.Input var root = new TestRoot { Child = new StackPanel - { + { Children = { (first = new Button()), @@ -365,7 +365,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.False(target2.Classes.Contains(":focus-visible")); } } - + [Fact] public void Control_FocusWithin_PseudoClass_Should_Be_Applied() { @@ -398,7 +398,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(root.IsKeyboardFocusWithin); } } - + [Fact] public void Control_FocusWithin_PseudoClass_Should_Be_Applied_and_Removed() { @@ -419,7 +419,7 @@ namespace Avalonia.Base.UnitTests.Input } } }; - + target1.ApplyTemplate(); target2.ApplyTemplate(); @@ -433,9 +433,9 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(root.Child.IsKeyboardFocusWithin); Assert.True(root.Classes.Contains(":focus-within")); Assert.True(root.IsKeyboardFocusWithin); - + target2.Focus(); - + Assert.False(target1.IsFocused); Assert.False(target1.Classes.Contains(":focus-within")); Assert.False(target1.IsKeyboardFocusWithin); @@ -445,7 +445,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(root.Child.IsKeyboardFocusWithin); Assert.True(root.Classes.Contains(":focus-within")); Assert.True(root.IsKeyboardFocusWithin); - + Assert.True(target2.IsFocused); Assert.True(target2.Classes.Contains(":focus-within")); Assert.True(target2.IsKeyboardFocusWithin); @@ -453,7 +453,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(panel2.IsKeyboardFocusWithin); } } - + [Fact] public void Control_FocusWithin_Pseudoclass_Should_Be_Removed_When_Removed_From_Tree() { @@ -487,11 +487,11 @@ namespace Avalonia.Base.UnitTests.Input var keyboardDevice = KeyboardDevice.Instance!; Assert.Equal(keyboardDevice.FocusedElement, target1); - + root.Child = null; - + Assert.Null(keyboardDevice.FocusedElement); - + Assert.False(target1.IsFocused); Assert.False(target1.Classes.Contains(":focus-within")); Assert.False(target1.IsKeyboardFocusWithin); @@ -499,7 +499,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.False(root.IsKeyboardFocusWithin); } } - + [Fact] public void Control_FocusWithin_Pseudoclass_Should_Be_Removed_Focus_Moves_To_Different_Root() { @@ -507,7 +507,7 @@ namespace Avalonia.Base.UnitTests.Input { var target1 = new Decorator { Focusable = true }; var target2 = new Decorator { Focusable = true }; - + var root1 = new TestRoot { Child = new StackPanel @@ -518,7 +518,7 @@ namespace Avalonia.Base.UnitTests.Input } } }; - + var root2 = new TestRoot { Child = new StackPanel @@ -543,9 +543,9 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(root1.IsKeyboardFocusWithin); Assert.Equal(KeyboardDevice.Instance!.FocusedElement, target1); - + target2.Focus(); - + Assert.False(target1.IsFocused); Assert.False(target1.Classes.Contains(":focus-within")); Assert.False(target1.IsKeyboardFocusWithin); @@ -553,7 +553,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.False(root1.Child.IsKeyboardFocusWithin); Assert.False(root1.Classes.Contains(":focus-within")); Assert.False(root1.IsKeyboardFocusWithin); - + Assert.True(target2.IsFocused); Assert.True(target2.Classes.Contains(":focus-within")); Assert.True(target2.IsKeyboardFocusWithin); @@ -1031,7 +1031,7 @@ namespace Avalonia.Base.UnitTests.Input [XYFocus.UpProperty] = target3, [XYFocus.DownProperty] = target4, }; - var container = new Canvas + var container = new Canvas { Children = { diff --git a/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs b/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs index 6380f71935..1ec6ec2be5 100644 --- a/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs @@ -4,7 +4,6 @@ using Avalonia.Input.Raw; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Rendering; -using Avalonia.Threading; using Avalonia.UnitTests; using Moq; using Xunit; @@ -18,7 +17,7 @@ namespace Avalonia.Base.UnitTests.Input { using var scope = AvaloniaLocator.EnterScope(); var settingsMock = new Mock(); - + AvaloniaLocator.CurrentMutable.BindToSelf(this) .Bind().ToConstant(settingsMock.Object); @@ -32,7 +31,7 @@ namespace Avalonia.Base.UnitTests.Input var control = new Control(); var root = CreateInputRoot(impl.Object, control, renderer.Object); - + MouseButton button = default; root.PointerReleased += (s, e) => button = e.InitialPressMouseButton; @@ -50,10 +49,10 @@ namespace Avalonia.Base.UnitTests.Input impl.Object.Input!(up); Assert.Equal(MouseButton.Left, button); - + impl.Object.Input!(up); - Assert.Equal(MouseButton.None, button); + Assert.Equal(MouseButton.None, button); } [Fact] @@ -85,7 +84,7 @@ namespace Avalonia.Base.UnitTests.Input impl.Object.Input!(CreateRawPointerMovedArgs(device, root)); Assert.NotNull(result); - + result.Capture(control); Assert.Same(control, result.Captured); @@ -115,8 +114,8 @@ namespace Avalonia.Base.UnitTests.Input }) } }, renderer.Object); - - + + Point? result = null; root.PointerMoved += (_, a) => { @@ -128,5 +127,93 @@ namespace Avalonia.Base.UnitTests.Input Assert.Equal(new Point(1, 11), result); } + + [Fact] + public void Mouse_Pointer_Should_Set_Focus_On_Pointer_Pressed() + { + using var scope = AvaloniaLocator.EnterScope(); + var settingsMock = new Mock(); + + AvaloniaLocator.CurrentMutable.BindToSelf(this) + .Bind().ToConstant(settingsMock.Object); + + using var app = UnitTestApplication.Start( + TestServices.RealFocus); + + var renderer = new Mock(); + var impl = CreateTopLevelImplMock(); + + var control = new Button() + { + Focusable = true + }; + var root = CreateInputRoot(impl.Object, control, renderer.Object); + + var device = new MouseDevice(); + + var down = CreateRawPointerArgs(device, root, RawPointerEventType.LeftButtonDown); + var up = CreateRawPointerArgs(device, root, RawPointerEventType.LeftButtonUp); + + SetHit(renderer, control); + + Assert.False(control.IsFocused); + + impl.Object.Input!(down); + + Assert.True(control.IsFocused); + impl.Object.Input!(up); + + Assert.True(control.IsFocused); + } + + [Fact] + public void Control_Should_Not_Gain_Focus_On_Mouse_Release() + { + using var scope = AvaloniaLocator.EnterScope(); + var settingsMock = new Mock(); + + AvaloniaLocator.CurrentMutable.BindToSelf(this) + .Bind().ToConstant(settingsMock.Object); + + using var app = UnitTestApplication.Start( + TestServices.RealFocus); + + var renderer = new Mock(); + var impl = CreateTopLevelImplMock(); + + var control1 = new Button() + { + Focusable = true + }; + + var control2 = new Button() + { + Focusable = true + }; + var stack = new StackPanel() + { + Children = { control1, control2 } + }; + var root = CreateInputRoot(impl.Object, stack, renderer.Object); + + var device = new MouseDevice(); + + var down = CreateRawPointerArgs(device, root, RawPointerEventType.LeftButtonDown); + var up = CreateRawPointerArgs(device, root, RawPointerEventType.LeftButtonUp); + + SetHit(renderer, control1); + + Assert.False(control1.IsFocused); + + impl.Object.Input!(down); + + Assert.True(control1.IsFocused); + + control2.Focus(); + + impl.Object.Input!(up); + + Assert.False(control1.IsFocused); + } } } diff --git a/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs b/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs index c75ff56d82..1675dc9e62 100644 --- a/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs +++ b/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs @@ -7,9 +7,7 @@ using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.Platform; using Avalonia.Rendering; -using Avalonia.Rendering.Composition; using Avalonia.UnitTests; -using Avalonia.VisualTree; using Moq; namespace Avalonia.Base.UnitTests.Input; diff --git a/tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs b/tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs index 97f83b1a69..600856c643 100644 --- a/tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs @@ -1,14 +1,16 @@ using System; +using Avalonia.Base.UnitTests.Input; +using Avalonia.Controls; using Avalonia.Input.Raw; using Avalonia.Platform; -using Avalonia.Threading; +using Avalonia.Rendering; using Avalonia.UnitTests; using Moq; using Xunit; namespace Avalonia.Input.UnitTests { - public class TouchDeviceTests + public class TouchDeviceTests : PointerTestsBase { [Fact] public void Tapped_Event_Is_Fired_With_Touch() @@ -141,6 +143,36 @@ namespace Avalonia.Input.UnitTests Assert.Equal(0, doubleTappedExecutedTimes); } + [Fact] + public void Touch_Pointer_Should_Set_Focus_On_Pointer_Released() + { + using var scope = AvaloniaLocator.EnterScope(); + using var app = UnitTestApplication.Start( + TestServices.RealFocus); + + var impl = CreateTopLevelImplMock(); + + var renderer = new Mock(); + var root = new TestTopLevel(impl.Object) + { + HitTesterOverride = renderer.Object, + }; + var host = root.TopLevelHost; + + host.Focusable = true; + var touchDevice = new TouchDevice(); + var inputManager = InputManager.Instance!; + + Assert.False(host.IsFocused); + + Press(InputManager.Instance!, touchDevice, root.InputRoot); + + Assert.False(host.IsFocused); + Release(InputManager.Instance!, touchDevice, root.InputRoot); + + Assert.True(host.IsFocused); + } + [Fact] public void Click_Counting_Should_Work_Correctly_With_Few_Touch_Contacts() { @@ -241,14 +273,12 @@ namespace Avalonia.Input.UnitTests private static void TapOnce(IInputManager inputManager, TouchDevice device, IInputRoot root, ulong timestamp = 0, long touchPointId = 0) { - inputManager.ProcessInput(new RawPointerEventArgs(device, timestamp, - root, - RawPointerEventType.TouchBegin, - new Point(0, 0), - RawInputModifiers.None) - { - RawPointerId = touchPointId - }); + Press(inputManager, device, root, timestamp, touchPointId); + Release(inputManager, device, root, timestamp, touchPointId); + } + + private static void Release(IInputManager inputManager, TouchDevice device, IInputRoot root, ulong timestamp = 0, long touchPointId = 0) + { inputManager.ProcessInput(new RawPointerEventArgs(device, timestamp, root, RawPointerEventType.TouchEnd, @@ -258,5 +288,22 @@ namespace Avalonia.Input.UnitTests RawPointerId = touchPointId }); } + + private static void Press(IInputManager inputManager, TouchDevice device, IInputRoot root, ulong timestamp = 0, long touchPointId = 0) + { + inputManager.ProcessInput(new RawPointerEventArgs(device, timestamp, + root, + RawPointerEventType.TouchBegin, + new Point(0, 0), + RawInputModifiers.None) + { + RawPointerId = touchPointId + }); + } + + private class TestTopLevel(ITopLevelImpl impl) : TopLevel(impl) + { + + } } }