diff --git a/parameters.cake b/parameters.cake index d13e503bd0..4ef7e8e05a 100644 --- a/parameters.cake +++ b/parameters.cake @@ -64,7 +64,7 @@ public class Parameters IsPullRequest = buildSystem.AppVeyor.Environment.PullRequest.IsPullRequest; IsMainRepo = StringComparer.OrdinalIgnoreCase.Equals(MainRepo, context.EnvironmentVariable("BUILD_REPOSITORY_URI")); IsMasterBranch = StringComparer.OrdinalIgnoreCase.Equals(MasterBranch, context.EnvironmentVariable("BUILD_SOURCEBRANCHNAME")); - IsReleaseBranch = context.EnvironmentVariable("BUILD_SOURCEBRANCH").ToLower().StartsWith(ReleaseBranchPrefix.ToLower()); + IsReleaseBranch = (context.EnvironmentVariable("BUILD_SOURCEBRANCH")??"").StartsWith(ReleaseBranchPrefix, StringComparison.OrdinalIgnoreCase); IsTagged = buildSystem.AppVeyor.Environment.Repository.Tag.IsTag && !string.IsNullOrWhiteSpace(buildSystem.AppVeyor.Environment.Repository.Tag.Name); IsReleasable = StringComparer.OrdinalIgnoreCase.Equals(ReleaseConfiguration, Configuration); diff --git a/samples/ControlCatalog/Pages/LayoutTransformControlPage.xaml b/samples/ControlCatalog/Pages/LayoutTransformControlPage.xaml index b428cd1b9f..f7e1c08cac 100644 --- a/samples/ControlCatalog/Pages/LayoutTransformControlPage.xaml +++ b/samples/ControlCatalog/Pages/LayoutTransformControlPage.xaml @@ -19,7 +19,7 @@ - Layout Transform + diff --git a/src/Avalonia.Base/Collections/AvaloniaListExtensions.cs b/src/Avalonia.Base/Collections/AvaloniaListExtensions.cs index 1731950222..58f3413780 100644 --- a/src/Avalonia.Base/Collections/AvaloniaListExtensions.cs +++ b/src/Avalonia.Base/Collections/AvaloniaListExtensions.cs @@ -104,7 +104,12 @@ namespace Avalonia.Collections case NotifyCollectionChangedAction.Move: case NotifyCollectionChangedAction.Replace: Remove(e.OldStartingIndex, e.OldItems); - Add(e.NewStartingIndex, e.NewItems); + int newIndex = e.NewStartingIndex; + if(newIndex > e.OldStartingIndex) + { + newIndex -= e.OldItems.Count; + } + Add(newIndex, e.NewItems); break; case NotifyCollectionChangedAction.Remove: 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/src/Avalonia.Controls/Primitives/AdornerLayer.cs b/src/Avalonia.Controls/Primitives/AdornerLayer.cs index 4b58197ef3..5308a062ec 100644 --- a/src/Avalonia.Controls/Primitives/AdornerLayer.cs +++ b/src/Avalonia.Controls/Primitives/AdornerLayer.cs @@ -89,7 +89,7 @@ namespace Avalonia.Controls.Primitives control.Clip = clip; } - clip.Rect = bounds.Clip.TransformToAABB(-bounds.Transform); + clip.Rect = bounds.Bounds; } private void ChildrenCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) diff --git a/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs b/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs index d373e7ef2a..08f3803e9b 100644 --- a/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs +++ b/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs @@ -125,6 +125,20 @@ namespace Avalonia.Rendering if (m.HasValue) { var bounds = new Rect(visual.Bounds.Size).TransformToAABB(m.Value); + + //use transformedbounds as previous render state of the visual bounds + //so we can invalidate old and new bounds of a control in case it moved/shrinked + if (visual.TransformedBounds.HasValue) + { + var trb = visual.TransformedBounds.Value; + var trBounds = trb.Bounds.TransformToAABB(trb.Transform); + + if (trBounds != bounds) + { + _renderRoot?.Invalidate(trBounds); + } + } + _renderRoot?.Invalidate(bounds); } } @@ -191,7 +205,7 @@ namespace Avalonia.Rendering } } - static IEnumerable HitTest( + private static IEnumerable HitTest( IVisual visual, Point p, Func filter) diff --git a/src/Avalonia.Visuals/Visual.cs b/src/Avalonia.Visuals/Visual.cs index e5fcf1ba1d..f26c21d1b6 100644 --- a/src/Avalonia.Visuals/Visual.cs +++ b/src/Avalonia.Visuals/Visual.cs @@ -304,7 +304,7 @@ namespace Avalonia { var thisOffset = GetOffsetFrom(common, this); var thatOffset = GetOffsetFrom(common, visual); - return Matrix.CreateTranslation(-thatOffset) * Matrix.CreateTranslation(thisOffset); + return -thatOffset * thisOffset; } return null; @@ -454,13 +454,28 @@ namespace Avalonia /// The ancestor visual. /// The visual. /// The visual offset. - private static Vector GetOffsetFrom(IVisual ancestor, IVisual visual) + private static Matrix GetOffsetFrom(IVisual ancestor, IVisual visual) { - var result = new Vector(); + var result = Matrix.Identity; while (visual != ancestor) { - result = new Vector(result.X + visual.Bounds.X, result.Y + visual.Bounds.Y); + if (visual.RenderTransform?.Value != null) + { + var origin = visual.RenderTransformOrigin.ToPixels(visual.Bounds.Size); + var offset = Matrix.CreateTranslation(origin); + var renderTransform = (-offset) * visual.RenderTransform.Value * (offset); + + result *= renderTransform; + } + + var topLeft = visual.Bounds.TopLeft; + + if (topLeft != default) + { + result *= Matrix.CreateTranslation(topLeft); + } + visual = visual.VisualParent; if (visual == null) diff --git a/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListExtenionsTests.cs b/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListExtenionsTests.cs index b996db8d48..7f118a2c1d 100644 --- a/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListExtenionsTests.cs +++ b/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListExtenionsTests.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using Avalonia.Collections; using Xunit; @@ -82,13 +81,31 @@ namespace Avalonia.Base.UnitTests.Collections Assert.Equal(source, result); } - [Fact] - public void CreateDerivedList_Handles_MoveRange() + [Theory] + [InlineData(0, 2, 3)] + [InlineData(0, 2, 4)] + [InlineData(0, 2, 5)] + [InlineData(0, 4, 4)] + [InlineData(1, 2, 0)] + [InlineData(1, 2, 4)] + [InlineData(1, 2, 5)] + [InlineData(1, 4, 0)] + [InlineData(2, 2, 0)] + [InlineData(2, 2, 1)] + [InlineData(2, 2, 3)] + [InlineData(2, 2, 4)] + [InlineData(2, 2, 5)] + [InlineData(4, 2, 0)] + [InlineData(4, 2, 1)] + [InlineData(4, 2, 3)] + [InlineData(5, 1, 0)] + [InlineData(5, 1, 3)] + public void CreateDerivedList_Handles_MoveRange(int oldIndex, int count, int newIndex) { - var source = new AvaloniaList(new[] { 0, 1, 2, 3 }); + var source = new AvaloniaList(new[] { 0, 1, 2, 3, 4, 5 }); var target = source.CreateDerivedList(x => new Wrapper(x)); - source.MoveRange(1, 2, 0); + source.MoveRange(oldIndex, count, newIndex); var result = target.Select(x => x.Value).ToList(); 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 +} diff --git a/tests/Avalonia.Visuals.UnitTests/Rendering/ImmediateRendererTests.cs b/tests/Avalonia.Visuals.UnitTests/Rendering/ImmediateRendererTests.cs new file mode 100644 index 0000000000..82294246b1 --- /dev/null +++ b/tests/Avalonia.Visuals.UnitTests/Rendering/ImmediateRendererTests.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using Avalonia.Collections; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.VisualTree; +using Moq; +using Xunit; + +namespace Avalonia.Visuals.UnitTests.Rendering +{ + public class ImmediateRendererTests + { + [Fact] + public void AddDirty_Call_RenderRoot_Invalidate() + { + var visual = new Mock(); + var child = new Mock() { CallBase = true }; + var renderRoot = visual.As(); + + visual.As().Setup(v => v.Bounds).Returns(new Rect(0, 0, 400, 400)); + + child.As().Setup(v => v.Bounds).Returns(new Rect(10, 10, 100, 100)); + child.As().Setup(v => v.VisualParent).Returns(visual.Object); + + var target = new ImmediateRenderer(visual.Object); + + target.AddDirty(child.Object); + + renderRoot.Verify(v => v.Invalidate(new Rect(10, 10, 100, 100))); + } + + [Fact] + public void AddDirty_With_RenderTransform_Call_RenderRoot_Invalidate() + { + var visual = new Mock(); + var child = new Mock() { CallBase = true }; + var renderRoot = visual.As(); + + visual.As().Setup(v => v.Bounds).Returns(new Rect(0, 0, 400, 400)); + + child.As().Setup(v => v.Bounds).Returns(new Rect(100, 100, 100, 100)); + child.As().Setup(v => v.VisualParent).Returns(visual.Object); + child.Object.RenderTransform = new ScaleTransform() { ScaleX = 2, ScaleY = 2 }; + + var target = new ImmediateRenderer(visual.Object); + + target.AddDirty(child.Object); + + renderRoot.Verify(v => v.Invalidate(new Rect(50, 50, 200, 200))); + } + + [Fact] + public void AddDirty_For_Child_Moved_Should_Invalidate_Previous_Bounds() + { + var visual = new Mock() { CallBase = true }; + var child = new Mock() { CallBase = true }; + var renderRoot = visual.As(); + var renderTarget = visual.As(); + + renderRoot.Setup(r => r.CreateRenderTarget()).Returns(renderTarget.Object); + renderTarget.Setup(r => r.CreateDrawingContext(It.IsAny())).Returns(Mock.Of()); + + visual.As().Setup(v => v.Bounds).Returns(new Rect(0, 0, 400, 400)); + visual.As().Setup(v => v.VisualChildren).Returns(new AvaloniaList() { child.As().Object }); + + Rect childBounds = new Rect(0, 0, 100, 100); + child.As().Setup(v => v.Bounds).Returns(() => childBounds); + child.As().Setup(v => v.VisualParent).Returns(visual.Object); + child.As().Setup(v => v.VisualChildren).Returns(new AvaloniaList()); + + var invalidationCalls = new List(); + + renderRoot.Setup(v => v.Invalidate(It.IsAny())).Callback(v => invalidationCalls.Add(v)); + + var target = new ImmediateRenderer(visual.Object); + + target.AddDirty(child.Object); + + Assert.Equal(new Rect(0, 0, 100, 100), invalidationCalls[0]); + + target.Paint(new Rect(0, 0, 100, 100)); + + //move child 100 pixels bottom/right + childBounds = new Rect(100, 100, 100, 100); + + //renderer should invalidate old child bounds with new one + //as on old area there can be artifacts + target.AddDirty(child.Object); + + //invalidate first old position + Assert.Equal(new Rect(0, 0, 100, 100), invalidationCalls[1]); + + //then new position + Assert.Equal(new Rect(100, 100, 100, 100), invalidationCalls[2]); + } + } +} diff --git a/tests/Avalonia.Visuals.UnitTests/VisualTests.cs b/tests/Avalonia.Visuals.UnitTests/VisualTests.cs index b62bf5858d..0414ac4c74 100644 --- a/tests/Avalonia.Visuals.UnitTests/VisualTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/VisualTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using Avalonia.Controls; +using Avalonia.Media; using Avalonia.Rendering; using Avalonia.UnitTests; using Avalonia.VisualTree; @@ -192,5 +193,48 @@ namespace Avalonia.Visuals.UnitTests Assert.Throws(() => root2.Child = child); Assert.Empty(root2.GetVisualChildren()); } + + [Fact] + public void TransformToVisual_Should_Work() + { + var child = new Decorator { Width = 100, Height = 100 }; + var root = new TestRoot() { Child = child, Width = 400, Height = 400 }; + + root.Measure(Size.Infinity); + root.Arrange(new Rect(new Point(), root.DesiredSize)); + + var tr = child.TransformToVisual(root); + + Assert.NotNull(tr); + + var point = root.Bounds.TopLeft * tr; + + //child is centered (400 - 100)/2 + Assert.Equal(new Point(150, 150), point); + } + + [Fact] + public void TransformToVisual_With_RenderTransform_Should_Work() + { + var child = new Decorator + { + Width = 100, + Height = 100, + RenderTransform = new ScaleTransform() { ScaleX = 2, ScaleY = 2 } + }; + var root = new TestRoot() { Child = child, Width = 400, Height = 400 }; + + root.Measure(Size.Infinity); + root.Arrange(new Rect(new Point(), root.DesiredSize)); + + var tr = child.TransformToVisual(root); + + Assert.NotNull(tr); + + var point = root.Bounds.TopLeft * tr; + + //child is centered (400 - 100*2 scale)/2 + Assert.Equal(new Point(100, 100), point); + } } }