diff --git a/src/Avalonia.Controls/Button.cs b/src/Avalonia.Controls/Button.cs index 24b2af7996..1f3fcbafb3 100644 --- a/src/Avalonia.Controls/Button.cs +++ b/src/Avalonia.Controls/Button.cs @@ -2,10 +2,12 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.Linq; using System.Windows.Input; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.VisualTree; namespace Avalonia.Controls { @@ -251,7 +253,10 @@ namespace Avalonia.Controls IsPressed = false; e.Handled = true; - if (ClickMode == ClickMode.Release && new Rect(Bounds.Size).Contains(e.GetPosition(this))) + var hittest = this.GetVisualsAt(e.GetPosition(this)); + + if (ClickMode == ClickMode.Release && + hittest.Any(c => c == this || (c as IStyledElement)?.TemplatedParent == this)) { OnClick(); } @@ -261,9 +266,9 @@ namespace Avalonia.Controls protected override void UpdateDataValidation(AvaloniaProperty property, BindingNotification status) { base.UpdateDataValidation(property, status); - if(property == CommandProperty) + if (property == CommandProperty) { - if(status?.ErrorType == BindingErrorType.Error) + if (status?.ErrorType == BindingErrorType.Error) { IsEnabled = false; } diff --git a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs index d218960726..76f2898700 100644 --- a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs @@ -1,7 +1,12 @@ using System; using System.Windows.Input; using Avalonia.Data; -using Avalonia.Markup.Data; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.VisualTree; +using Moq; using Xunit; namespace Avalonia.Controls.UnitTests @@ -92,6 +97,205 @@ namespace Avalonia.Controls.UnitTests Assert.False(target.IsEnabled); } + [Fact] + public void Button_Raises_Click() + { + var mouse = Mock.Of(); + var renderer = Mock.Of(); + IInputElement captured = null; + Mock.Get(mouse).Setup(m => m.GetPosition(It.IsAny())).Returns(new Point(50, 50)); + Mock.Get(mouse).Setup(m => m.Capture(It.IsAny())).Callback(v => captured = v); + Mock.Get(mouse).Setup(m => m.Captured).Returns(() => captured); + Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns>((p, r, f) => + r.Bounds.Contains(p) ? new IVisual[] { r } : new IVisual[0]); + + var target = new TestButton() + { + Bounds = new Rect(0, 0, 100, 100), + Renderer = renderer + }; + + bool clicked = false; + + target.Click += (s, e) => clicked = true; + + RaisePointerEnter(target, mouse); + RaisePointerMove(target, mouse); + RaisePointerPressed(target, mouse, 1, MouseButton.Left); + + Assert.Equal(captured, target); + + RaisePointerReleased(target, mouse, MouseButton.Left); + + Assert.Equal(captured, null); + + Assert.True(clicked); + } + + [Fact] + public void Button_Does_Not_Raise_Click_When_PointerReleased_Outside() + { + var mouse = Mock.Of(); + var renderer = Mock.Of(); + IInputElement captured = null; + Mock.Get(mouse).Setup(m => m.GetPosition(It.IsAny())).Returns(new Point(200, 50)); + Mock.Get(mouse).Setup(m => m.Capture(It.IsAny())).Callback(v => captured = v); + Mock.Get(mouse).Setup(m => m.Captured).Returns(() => captured); + Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns>((p, r, f) => + r.Bounds.Contains(p) ? new IVisual[] { r } : new IVisual[0]); + + var target = new TestButton() + { + Bounds = new Rect(0, 0, 100, 100), + Renderer = renderer + }; + + bool clicked = false; + + target.Click += (s, e) => clicked = true; + + RaisePointerEnter(target, mouse); + RaisePointerMove(target, mouse); + RaisePointerPressed(target, mouse, 1, MouseButton.Left); + RaisePointerLeave(target, mouse); + + Assert.Equal(captured, target); + + RaisePointerReleased(target, mouse, MouseButton.Left); + + Assert.Equal(captured, null); + + Assert.False(clicked); + } + + [Fact] + public void Button_With_RenderTransform_Raises_Click() + { + var mouse = Mock.Of(); + var renderer = Mock.Of(); + IInputElement captured = null; + Mock.Get(mouse).Setup(m => m.GetPosition(It.IsAny())).Returns(new Point(150, 50)); + Mock.Get(mouse).Setup(m => m.Capture(It.IsAny())).Callback(v => captured = v); + Mock.Get(mouse).Setup(m => m.Captured).Returns(() => captured); + Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns>((p, r, f) => + r.Bounds.Contains(p.Transform(r.RenderTransform.Value.Invert())) ? + new IVisual[] { r } : new IVisual[0]); + + var target = new TestButton() + { + Bounds = new Rect(0, 0, 100, 100), + RenderTransform = new TranslateTransform { X = 100, Y = 0 }, + Renderer = renderer + }; + + //actual bounds of button should be 100,0,100,100 x -> translated 100 pixels + //so mouse with x=150 coordinates should trigger click + //button shouldn't count on bounds to calculate pointer is in the over or not, but + //on avalonia event system, as renderer hit test will properly calculate whether to send + //mouse over events to button based on rendered bounds + //note: button also may have not rectangular shape and only renderer hit testing is reliable + + bool clicked = false; + + target.Click += (s, e) => clicked = true; + + RaisePointerEnter(target, mouse); + RaisePointerMove(target, mouse); + RaisePointerPressed(target, mouse, 1, MouseButton.Left); + + Assert.Equal(captured, target); + + RaisePointerReleased(target, mouse, MouseButton.Left); + + Assert.Equal(captured, null); + + Assert.True(clicked); + } + + private class TestButton : Button, IRenderRoot + { + public TestButton() + { + IsVisible = true; + } + + public new Rect Bounds + { + get => base.Bounds; + set => base.Bounds = value; + } + + public Size ClientSize => throw new NotImplementedException(); + + public IRenderer Renderer { get; set; } + + public double RenderScaling => throw new NotImplementedException(); + + public IRenderTarget CreateRenderTarget() => throw new NotImplementedException(); + + public void Invalidate(Rect rect) => throw new NotImplementedException(); + + public Point PointToClient(Point point) => throw new NotImplementedException(); + + public Point PointToScreen(Point point) => throw new NotImplementedException(); + } + + private void RaisePointerPressed(Button button, IMouseDevice device, int clickCount, MouseButton mouseButton) + { + button.RaiseEvent(new PointerPressedEventArgs + { + RoutedEvent = InputElement.PointerPressedEvent, + Source = button, + MouseButton = mouseButton, + ClickCount = clickCount, + Device = device, + }); + } + + private void RaisePointerReleased(Button button, IMouseDevice device, MouseButton mouseButton) + { + button.RaiseEvent(new PointerReleasedEventArgs + { + RoutedEvent = InputElement.PointerReleasedEvent, + Source = button, + MouseButton = mouseButton, + Device = device, + }); + } + + private void RaisePointerEnter(Button button, IMouseDevice device) + { + button.RaiseEvent(new PointerEventArgs + { + RoutedEvent = InputElement.PointerEnterEvent, + Source = button, + Device = device, + }); + } + + private void RaisePointerLeave(Button button, IMouseDevice device) + { + button.RaiseEvent(new PointerEventArgs + { + RoutedEvent = InputElement.PointerLeaveEvent, + Source = button, + Device = device, + }); + } + + private void RaisePointerMove(Button button, IMouseDevice device) + { + button.RaiseEvent(new PointerEventArgs + { + RoutedEvent = InputElement.PointerMovedEvent, + Source = button, + Device = device, + }); + } + private class TestCommand : ICommand { private bool _enabled; @@ -123,4 +327,4 @@ namespace Avalonia.Controls.UnitTests } } } -} \ No newline at end of file +}