From d9ce64e1b03da867fdf7981796ad9b5d93f8e672 Mon Sep 17 00:00:00 2001 From: Luis von der Eltz Date: Thu, 14 Jul 2022 14:29:43 +0200 Subject: [PATCH 01/18] Skip disabled controls when moving to first/last item --- src/Avalonia.Controls/ItemsControl.cs | 29 ++++++++++++------- .../Platform/DefaultMenuInteractionHandler.cs | 4 +-- .../Primitives/SelectingItemsControlTests.cs | 24 +++++++-------- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 56b0014c05..c7348f8609 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -502,25 +502,34 @@ namespace Avalonia.Controls IInputElement? from, bool wrap) { - IInputElement? result; - var c = from; - - do + for(;;) { - result = container.GetControl(direction, c, wrap); + var result = container.GetControl(direction, from, wrap); + + if (result is null || result == from) + { + return null; + } - if (result != null && - result.Focusable && + if (result.Focusable && result.IsEffectivelyEnabled && result.IsEffectivelyVisible) { return result; } - c = result; - } while (c != null && c != from && direction != NavigationDirection.First && direction != NavigationDirection.Last); + direction = direction switch + { + //We did not find an enabled first item. Move downwards until we find one. + NavigationDirection.First => NavigationDirection.Down, + + //We did not find an enabled last item. Move upwards until we find one. + NavigationDirection.Last => NavigationDirection.Up, + _ => direction + }; - return null; + from = result; + } } private void PresenterChildIndexChanged(object? sender, ChildIndexChangedEventArgs e) diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 868cce879a..ce1cddc8cd 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -53,7 +53,7 @@ namespace Avalonia.Controls.Platform Menu.PointerPressed += PointerPressed; Menu.PointerReleased += PointerReleased; Menu.AddHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed); - Menu.AddHandler(Avalonia.Controls.Menu.MenuOpenedEvent, MenuOpened); + Menu.AddHandler(MenuBase.MenuOpenedEvent, MenuOpened); Menu.AddHandler(MenuItem.PointerEnteredItemEvent, PointerEntered); Menu.AddHandler(MenuItem.PointerExitedItemEvent, PointerExited); Menu.AddHandler(InputElement.PointerMovedEvent, PointerMoved); @@ -89,7 +89,7 @@ namespace Avalonia.Controls.Platform Menu.PointerPressed -= PointerPressed; Menu.PointerReleased -= PointerReleased; Menu.RemoveHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed); - Menu.RemoveHandler(Avalonia.Controls.Menu.MenuOpenedEvent, MenuOpened); + Menu.RemoveHandler(MenuBase.MenuOpenedEvent, MenuOpened); Menu.RemoveHandler(MenuItem.PointerEnteredItemEvent, PointerEntered); Menu.RemoveHandler(MenuItem.PointerExitedItemEvent, PointerExited); Menu.RemoveHandler(InputElement.PointerMovedEvent, PointerMoved); diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 3d36395c3a..e861f4a5db 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -1615,48 +1615,44 @@ namespace Avalonia.Controls.UnitTests.Primitives target.MoveSelection(NavigationDirection.Next, true); } - [Fact(Timeout = 2000)] - public async Task MoveSelection_Does_Not_Hang_With_No_Focusable_Controls_And_Moving_Selection_To_The_First_Item() + [Fact] + public void MoveSelection_Skips_Non_Focusable_Controls_When_Moving_To_Last_Item() { var target = new TestSelector { Template = Template(), Items = new[] { - new ListBoxItem { Focusable = false }, new ListBoxItem(), + new ListBoxItem { Focusable = false }, } }; target.Measure(new Size(100, 100)); target.Arrange(new Rect(0, 0, 100, 100)); + target.MoveSelection(NavigationDirection.Last, true); - // Timeout in xUnit doesen't work with synchronous methods so we need to apply hack below. - // https://github.com/xunit/xunit/issues/2222 - await Task.Run(() => target.MoveSelection(NavigationDirection.First, true)); - Assert.Equal(-1, target.SelectedIndex); + Assert.Equal(0, target.SelectedIndex); } - [Fact(Timeout = 2000)] - public async Task MoveSelection_Does_Not_Hang_With_No_Focusable_Controls_And_Moving_Selection_To_The_Last_Item() + [Fact] + public void MoveSelection_Skips_Non_Focusable_Controls_When_Moving_To_First_Item() { var target = new TestSelector { Template = Template(), Items = new[] { - new ListBoxItem(), new ListBoxItem { Focusable = false }, + new ListBoxItem(), } }; target.Measure(new Size(100, 100)); target.Arrange(new Rect(0, 0, 100, 100)); + target.MoveSelection(NavigationDirection.Last, true); - // Timeout in xUnit doesen't work with synchronous methods so we need to apply hack below. - // https://github.com/xunit/xunit/issues/2222 - await Task.Run(() => target.MoveSelection(NavigationDirection.Last, true)); - Assert.Equal(-1, target.SelectedIndex); + Assert.Equal(1, target.SelectedIndex); } [Fact] From 107fc7162e0a7082d2e612316362229973f86532 Mon Sep 17 00:00:00 2001 From: Luis von der Eltz Date: Fri, 15 Jul 2022 13:41:14 +0200 Subject: [PATCH 02/18] Fix infinite loop when all items are disabled --- src/Avalonia.Controls/ItemsControl.cs | 16 +++++--- .../Primitives/SelectingItemsControlTests.cs | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index c7348f8609..9f52371d8c 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -518,15 +518,21 @@ namespace Avalonia.Controls return result; } - direction = direction switch + switch (direction) { //We did not find an enabled first item. Move downwards until we find one. - NavigationDirection.First => NavigationDirection.Down, + case NavigationDirection.First: + direction = NavigationDirection.Down; + wrap = false; + break; //We did not find an enabled last item. Move upwards until we find one. - NavigationDirection.Last => NavigationDirection.Up, - _ => direction - }; + case NavigationDirection.Last: + direction = NavigationDirection.Up; + wrap = false; + break; + + } from = result; } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index e861f4a5db..76729d8e41 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -1655,6 +1655,46 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(1, target.SelectedIndex); } + [Fact(Timeout = 2000)] + public void MoveSelection_Does_Not_Hang_When_All_Items_Are_Non_Focusable_And_We_Move_To_First_Item() + { + var target = new TestSelector + { + Template = Template(), + Items = new[] + { + new ListBoxItem { Focusable = false }, + new ListBoxItem { Focusable = false }, + } + }; + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + target.MoveSelection(NavigationDirection.First, true); + + Assert.Equal(-1, target.SelectedIndex); + } + + [Fact(Timeout = 2000)] + public void MoveSelection_Does_Not_Hang_When_All_Items_Are_Non_Focusable_And_We_Move_To_Last_Item() + { + var target = new TestSelector + { + Template = Template(), + Items = new[] + { + new ListBoxItem { Focusable = false }, + new ListBoxItem { Focusable = false }, + } + }; + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + target.MoveSelection(NavigationDirection.Last, true); + + Assert.Equal(-1, target.SelectedIndex); + } + [Fact] public void MoveSelection_Does_Select_Disabled_Controls() { From e64a4430a06c00ad588823fcf2bed4a090f727ef Mon Sep 17 00:00:00 2001 From: Luis von der Eltz Date: Fri, 15 Jul 2022 13:58:01 +0200 Subject: [PATCH 03/18] Apply async hack to make timeout work --- .../Primitives/SelectingItemsControlTests.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 76729d8e41..19fdf0c569 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -1656,7 +1656,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact(Timeout = 2000)] - public void MoveSelection_Does_Not_Hang_When_All_Items_Are_Non_Focusable_And_We_Move_To_First_Item() + public async Task MoveSelection_Does_Not_Hang_When_All_Items_Are_Non_Focusable_And_We_Move_To_First_Item() { var target = new TestSelector { @@ -1670,13 +1670,16 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Measure(new Size(100, 100)); target.Arrange(new Rect(0, 0, 100, 100)); - target.MoveSelection(NavigationDirection.First, true); + + // Timeout in xUnit doesn't work with synchronous methods so we need to apply hack below. + // https://github.com/xunit/xunit/issues/2222 + await Task.Run(() => target.MoveSelection(NavigationDirection.First, true)); Assert.Equal(-1, target.SelectedIndex); } [Fact(Timeout = 2000)] - public void MoveSelection_Does_Not_Hang_When_All_Items_Are_Non_Focusable_And_We_Move_To_Last_Item() + public async Task MoveSelection_Does_Not_Hang_When_All_Items_Are_Non_Focusable_And_We_Move_To_Last_Item() { var target = new TestSelector { @@ -1690,7 +1693,10 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Measure(new Size(100, 100)); target.Arrange(new Rect(0, 0, 100, 100)); - target.MoveSelection(NavigationDirection.Last, true); + + // Timeout in xUnit doesn't work with synchronous methods so we need to apply hack below. + // https://github.com/xunit/xunit/issues/2222 + await Task.Run(() => target.MoveSelection(NavigationDirection.Last, true)); Assert.Equal(-1, target.SelectedIndex); } From ace7787526c412e112e60f03aa82b2b5e20ddd09 Mon Sep 17 00:00:00 2001 From: Luis von der Eltz Date: Fri, 15 Jul 2022 14:16:25 +0200 Subject: [PATCH 04/18] Fix arrow left on all-disabled submenu closing menu --- .../Platform/DefaultMenuInteractionHandler.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index ce1cddc8cd..16aeb2f559 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -175,7 +175,11 @@ namespace Avalonia.Controls.Platform case Key.Left: { - if (item?.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen) + if (item is { IsSubMenuOpen: true, SelectedItem: null }) + { + item.Close(); + } + else if (item?.Parent is IMenuItem { IsTopLevel: false, IsSubMenuOpen: true } parent) { parent.Close(); parent.Focus(); From 25b19931e830c51ba0a895a69995c227a86edc0f Mon Sep 17 00:00:00 2001 From: Luis von der Eltz Date: Fri, 15 Jul 2022 16:13:03 +0200 Subject: [PATCH 05/18] Add timeout to "hang" UT --- .../Primitives/SelectingItemsControlTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 19fdf0c569..4b6b6a1182 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -1595,8 +1595,8 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { "Bar" }, selectedItems); } - [Fact] - public void MoveSelection_Wrap_Does_Not_Hang_With_No_Focusable_Controls() + [Fact(Timeout = 2000)] + public async Task MoveSelection_Wrap_Does_Not_Hang_With_No_Focusable_Controls() { // Issue #3094. var target = new TestSelector @@ -1612,7 +1612,10 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Measure(new Size(100, 100)); target.Arrange(new Rect(0, 0, 100, 100)); - target.MoveSelection(NavigationDirection.Next, true); + + // Timeout in xUnit doesn't work with synchronous methods so we need to apply hack below. + // https://github.com/xunit/xunit/issues/2222 + await Task.Run(() => target.MoveSelection(NavigationDirection.Next, true)); } [Fact] From 16d75632fe362ddd79d5ed0e9dfe33357e0c6952 Mon Sep 17 00:00:00 2001 From: Luis von der Eltz Date: Fri, 15 Jul 2022 16:29:49 +0200 Subject: [PATCH 06/18] Properly terminate when reaching "from" element again --- src/Avalonia.Controls/ItemsControl.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 9f52371d8c..6aa4006ddb 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -502,11 +502,13 @@ namespace Avalonia.Controls IInputElement? from, bool wrap) { - for(;;) + var current = from; + + for (;;) { - var result = container.GetControl(direction, from, wrap); + var result = container.GetControl(direction, current, wrap); - if (result is null || result == from) + if (result is null || current == from) { return null; } @@ -534,7 +536,7 @@ namespace Avalonia.Controls } - from = result; + current = result; } } From 0973e66d04505264f2e34c9fba0769be8f35c15f Mon Sep 17 00:00:00 2001 From: Luis von der Eltz Date: Fri, 15 Jul 2022 16:31:50 +0200 Subject: [PATCH 07/18] Remove wrong fix --- src/Avalonia.Controls/ItemsControl.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 6aa4006ddb..c6b572abba 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -525,13 +525,11 @@ namespace Avalonia.Controls //We did not find an enabled first item. Move downwards until we find one. case NavigationDirection.First: direction = NavigationDirection.Down; - wrap = false; break; //We did not find an enabled last item. Move upwards until we find one. case NavigationDirection.Last: direction = NavigationDirection.Up; - wrap = false; break; } From 4b7feeb4f4905bce85ba0787d9cd60b6063b3a98 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 18 Jul 2022 13:55:53 +0300 Subject: [PATCH 08/18] Fixed various invalidation and hit-testing issues --- .../Composition/CompositingRenderer.cs | 34 +- .../Composition/CompositionDrawListVisual.cs | 13 +- .../Composition/CompositionTarget.cs | 44 +- .../ICompositionTargetDebugEvents.cs | 6 + .../Server/ServerCompositionTarget.cs | 3 +- .../Server/ServerCompositionVisual.cs | 35 +- .../Transport/BatchStreamArrayPool.cs | 10 +- .../Rendering/Composition/Visual.cs | 2 +- .../Rendering/CompositorHitTestingTests.cs | 417 ++++++++++++++++++ .../Rendering/CompositorInvalidationTests.cs | 109 +++++ .../Rendering/CompositorTestsBase.cs | 208 +++++++++ .../MockPlatformRenderInterface.cs | 27 +- 12 files changed, 834 insertions(+), 74 deletions(-) create mode 100644 src/Avalonia.Base/Rendering/Composition/ICompositionTargetDebugEvents.cs create mode 100644 tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs create mode 100644 tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs create mode 100644 tests/Avalonia.Base.UnitTests/Rendering/CompositorTestsBase.cs diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs index 282973c26a..a571a0518b 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -24,11 +24,12 @@ public class CompositingRenderer : IRendererWithCompositor DrawingContext _recordingContext; private HashSet _dirty = new(); private HashSet _recalculateChildren = new(); - private readonly CompositionTarget _target; private bool _queuedUpdate; private Action _update; private Action _invalidateScene; + internal CompositionTarget CompositionTarget; + /// /// Asks the renderer to only draw frames on the render thread. Makes Paint to wait until frame is rendered. /// @@ -40,8 +41,8 @@ public class CompositingRenderer : IRendererWithCompositor _root = root; _compositor = compositor; _recordingContext = new DrawingContext(_recorder); - _target = compositor.CreateCompositionTarget(root.CreateRenderTarget); - _target.Root = ((Visual)root!.VisualRoot!).AttachToCompositor(compositor); + CompositionTarget = compositor.CreateCompositionTarget(root.CreateRenderTarget); + CompositionTarget.Root = ((Visual)root!.VisualRoot!).AttachToCompositor(compositor); _update = Update; _invalidateScene = InvalidateScene; } @@ -49,15 +50,15 @@ public class CompositingRenderer : IRendererWithCompositor /// public bool DrawFps { - get => _target.DrawFps; - set => _target.DrawFps = value; + get => CompositionTarget.DrawFps; + set => CompositionTarget.DrawFps = value; } /// public bool DrawDirtyRects { - get => _target.DrawDirtyRects; - set => _target.DrawDirtyRects = value; + get => CompositionTarget.DrawDirtyRects; + set => CompositionTarget.DrawDirtyRects = value; } /// @@ -81,12 +82,11 @@ public class CompositingRenderer : IRendererWithCompositor /// public IEnumerable HitTest(Point p, IVisual root, Func? filter) { - var res = _target.TryHitTest(p, filter); + var res = CompositionTarget.TryHitTest(p, filter); if(res == null) yield break; - for (var index = res.Count - 1; index >= 0; index--) + foreach(var v in res) { - var v = res[index]; if (v is CompositionDrawListVisual dv) { if (filter == null || filter(dv.Visual)) @@ -234,8 +234,8 @@ public class CompositingRenderer : IRendererWithCompositor SyncChildren(v); _dirty.Clear(); _recalculateChildren.Clear(); - _target.Size = _root.ClientSize; - _target.Scaling = _root.RenderScaling; + CompositionTarget.Size = _root.ClientSize; + CompositionTarget.Scaling = _root.RenderScaling; Compositor.InvokeOnNextCommit(_invalidateScene); } @@ -246,24 +246,24 @@ public class CompositingRenderer : IRendererWithCompositor public void Paint(Rect rect) { Update(); - _target.RequestRedraw(); + CompositionTarget.RequestRedraw(); if(RenderOnlyOnRenderThread && Compositor.Loop.RunsInBackground) Compositor.RequestCommitAsync().Wait(); else - _target.ImmediateUIThreadRender(); + CompositionTarget.ImmediateUIThreadRender(); } - public void Start() => _target.IsEnabled = true; + public void Start() => CompositionTarget.IsEnabled = true; public void Stop() { - _target.IsEnabled = false; + CompositionTarget.IsEnabled = false; } public void Dispose() { Stop(); - _target.Dispose(); + CompositionTarget.Dispose(); // Wait for the composition batch to be applied and rendered to guarantee that // render target is not used anymore and can be safely disposed diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs b/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs index 47cfcd325b..49aea1c3dc 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs @@ -54,13 +54,20 @@ internal class CompositionDrawListVisual : CompositionContainerVisual internal override bool HitTest(Point pt, Func? filter) { - if (DrawList == null) + var custom = Visual as ICustomHitTest; + if (DrawList == null && custom == null) return false; if (filter != null && !filter(Visual)) return false; - if (Visual is ICustomHitTest custom) + if (custom != null) + { + // Simulate the old behavior + // TODO: Change behavior once legacy renderers are removed + pt += new Point(Offset.X, Offset.Y); return custom.HitTest(pt); - foreach (var op in DrawList) + } + + foreach (var op in DrawList!) if (op.Item.HitTest(pt)) return true; return false; diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs index 25bbd4dc88..01b2d0d5d9 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs @@ -62,7 +62,7 @@ namespace Avalonia.Rendering.Composition bool TryGetInvertedTransform(CompositionVisual visual, out Matrix matrix) { - var m = visual.TryGetServerTransform(); + var m = visual.TryGetServerGlobalTransform(); if (m == null) { matrix = default; @@ -73,52 +73,44 @@ namespace Avalonia.Rendering.Composition return m33.TryInvert(out matrix); } - bool TryTransformTo(CompositionVisual visual, ref Point v) + bool TryTransformTo(CompositionVisual visual, Point globalPoint, out Point v) { + v = default; if (TryGetInvertedTransform(visual, out var m)) { - v = v * m; + v = globalPoint * m; return true; } return false; } - bool HitTestCore(CompositionVisual visual, Point point, PooledList result, + void HitTestCore(CompositionVisual visual, Point globalPoint, PooledList result, Func? filter) { - //TODO: Check readback too if (visual.Visible == false) - return false; - if (!TryTransformTo(visual, ref point)) - return false; + return; + if (!TryTransformTo(visual, globalPoint, out var point)) + return; if (visual.ClipToBounds && (point.X < 0 || point.Y < 0 || point.X > visual.Size.X || point.Y > visual.Size.Y)) - return false; - if (visual.Clip?.FillContains(point) == false) - return false; + return; - bool success = false; - // Hit-test the current node - if (visual.HitTest(point, filter)) - { - result.Add(visual); - success = true; - } - - // Inspect children too + if (visual.Clip?.FillContains(point) == false) + return; + + // Inspect children if (visual is CompositionContainerVisual cv) for (var c = cv.Children.Count - 1; c >= 0; c--) { var ch = cv.Children[c]; - var hit = HitTestCore(ch, point, result, filter); - if (hit) - return true; + HitTestCore(ch, globalPoint, result, filter); } - - return success; - + + // Hit-test the current node + if (visual.HitTest(point, filter)) + result.Add(visual); } /// diff --git a/src/Avalonia.Base/Rendering/Composition/ICompositionTargetDebugEvents.cs b/src/Avalonia.Base/Rendering/Composition/ICompositionTargetDebugEvents.cs new file mode 100644 index 0000000000..045a4f8cc6 --- /dev/null +++ b/src/Avalonia.Base/Rendering/Composition/ICompositionTargetDebugEvents.cs @@ -0,0 +1,6 @@ +namespace Avalonia.Rendering.Composition; + +internal interface ICompositionTargetDebugEvents +{ + void RectInvalidated(Rect rc); +} \ No newline at end of file diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs index 0fde86e484..882b66bf70 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs @@ -33,7 +33,7 @@ namespace Avalonia.Rendering.Composition.Server private HashSet _attachedVisuals = new(); private Queue _adornerUpdateQueue = new(); - + public ICompositionTargetDebugEvents? DebugEvents { get; set; } public ReadbackIndices Readback { get; } = new(); public int RenderedVisuals { get; set; } @@ -173,6 +173,7 @@ namespace Avalonia.Rendering.Composition.Server if(rect.IsEmpty) return; var snapped = SnapToDevicePixels(rect, Scaling); + DebugEvents?.RectInvalidated(rect); _dirtyRect = _dirtyRect.Union(snapped); _redrawRequested = true; } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs index 6fdf105e58..c0e487f209 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs @@ -119,14 +119,15 @@ namespace Avalonia.Rendering.Composition.Server var oldTransformedContentBounds = TransformedOwnContentBounds; var oldCombinedTransformedClipBounds = _combinedTransformedClipBounds; - - var dirtyOldBounds = false; + if (_parent?.IsDirtyComposition == true) { IsDirtyComposition = true; _isDirtyForUpdate = true; - dirtyOldBounds = true; } + + var invalidateOldBounds = _isDirtyForUpdate; + var invalidateNewBounds = _isDirtyForUpdate; GlobalTransformMatrix = newTransform; @@ -157,30 +158,20 @@ namespace Avalonia.Rendering.Composition.Server EffectiveOpacity = Opacity * (Parent?.EffectiveOpacity ?? 1); - IsVisibleInFrame = Visible && EffectiveOpacity > 0.04 && !_isBackface && + IsVisibleInFrame = _parent?.IsVisibleInFrame != false && Visible && EffectiveOpacity > 0.04 && !_isBackface && !_combinedTransformedClipBounds.IsEmpty; - - if (wasVisible != IsVisibleInFrame) - _isDirtyForUpdate = true; - - // Invalidate previous rect and queue new rect based on visibility - if (positionChanged) - { - if (wasVisible) - dirtyOldBounds = true; - if (IsVisibleInFrame) - _isDirtyForUpdate = true; + if (wasVisible != IsVisibleInFrame || positionChanged) + { + invalidateOldBounds |= wasVisible; + invalidateNewBounds |= IsVisibleInFrame; } - + // Invalidate new bounds - if (IsVisibleInFrame && _isDirtyForUpdate) - { - dirtyOldBounds = true; + if (invalidateNewBounds) AddDirtyRect(TransformedOwnContentBounds.Intersect(_combinedTransformedClipBounds)); - } - if (dirtyOldBounds && wasVisible) + if (invalidateOldBounds) AddDirtyRect(oldTransformedContentBounds.Intersect(oldCombinedTransformedClipBounds)); @@ -190,7 +181,7 @@ namespace Avalonia.Rendering.Composition.Server var i = Root!.Readback; ref var readback = ref GetReadback(i.WriteIndex); readback.Revision = root.Revision; - readback.Matrix = CombinedTransformMatrix; + readback.Matrix = GlobalTransformMatrix; readback.TargetId = Root.Id; readback.Visible = IsVisibleInFrame; } diff --git a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs index 32b4ed3026..b0a89c6f92 100644 --- a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs +++ b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.ConstrainedExecution; using System.Runtime.InteropServices; +using Avalonia.Platform; using Avalonia.Threading; namespace Avalonia.Rendering.Composition.Transport; @@ -17,6 +17,7 @@ internal abstract class BatchStreamPoolBase : IDisposable int _usage; readonly int[] _usageStatistics = new int[10]; int _usageStatisticsSlot; + bool _reclaimImmediately; public int CurrentUsage => _usage; public int CurrentPool => _pool.Count; @@ -27,7 +28,10 @@ internal abstract class BatchStreamPoolBase : IDisposable GC.SuppressFinalize(needsFinalize); var updateRef = new WeakReference>(this); - StartUpdateTimer(startTimer, updateRef); + if (AvaloniaLocator.Current.GetService() == null) + _reclaimImmediately = true; + else + StartUpdateTimer(startTimer, updateRef); } static void StartUpdateTimer(Action>? startTimer, WeakReference> updateRef) @@ -90,7 +94,7 @@ internal abstract class BatchStreamPoolBase : IDisposable lock (_pool) { _usage--; - if (!_disposed) + if (!_disposed && !_reclaimImmediately) { _pool.Push(item); return; diff --git a/src/Avalonia.Base/Rendering/Composition/Visual.cs b/src/Avalonia.Base/Rendering/Composition/Visual.cs index f9e1eae2ab..7ebbb0aa96 100644 --- a/src/Avalonia.Base/Rendering/Composition/Visual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Visual.cs @@ -31,7 +31,7 @@ namespace Avalonia.Rendering.Composition } } - internal Matrix4x4? TryGetServerTransform() + internal Matrix4x4? TryGetServerGlobalTransform() { if (Root == null) return null; diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs new file mode 100644 index 0000000000..705c8fad9c --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs @@ -0,0 +1,417 @@ +using System; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Shapes; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Moq; +using Xunit; + +namespace Avalonia.Base.UnitTests.Rendering; + +public class CompositorHitTestingTests : CompositorTestsBase +{ + + [Fact] + public void HitTest_Should_Find_Controls_At_Point() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + var border = new Border + { + Width = 100, + Height = 100, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + s.TopLevel.Content = border; + + s.AssertHitTest(new Point(100, 100), null, border); + } + } + + [Fact] + public void HitTest_Should_Not_Find_Empty_Controls_At_Point() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + var border = new Border + { + Width = 100, + Height = 100, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + s.TopLevel.Content = border; + + s.AssertHitTest(new Point(100, 100), null); + } + } + + [Fact] + public void HitTest_Should_Not_Find_Invisible_Controls_At_Point() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + Border visible, border; + s.TopLevel.Content = border = new Border + { + Width = 100, + Height = 100, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + IsVisible = false, + Child = visible = new Border + { + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + } + }; + + s.AssertHitTest(new Point(100, 100), null); + } + } + + [Fact] + public void HitTest_Should_Not_Find_Control_Outside_Point() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + var border = new Border + { + Width = 100, + Height = 100, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + + }; + s.TopLevel.Content = border; + + s.AssertHitTest(new Point(10, 10), null); + } + } + + [Fact] + public void HitTest_Should_Return_Top_Controls_First() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + Panel container = new Panel + { + Width = 200, + Height = 200, + Children = + { + new Border + { + Width = 100, + Height = 100, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }, + new Border + { + Width = 50, + Height = 50, + Background = Brushes.Blue, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + } + }; + s.TopLevel.Content = container; + + s.AssertHitTest(new Point(100, 100), null, container.Children[1], container.Children[0]); + } + } + + [Fact] + public void HitTest_Should_Return_Top_Controls_First_With_ZIndex() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + Panel container = new Panel + { + Width = 200, + Height = 200, + Children = + { + new Border + { + Width = 100, + Height = 100, + ZIndex = 1, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }, + new Border + { + Width = 50, + Height = 50, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }, + new Border + { + Width = 75, + Height = 75, + ZIndex = 2, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + } + + }; + s.TopLevel.Content = container; + + s.AssertHitTest(new Point(100, 100), null, new[] { container.Children[2], container.Children[0], container.Children[1] }); + } + } + + [Fact] + public void HitTest_Should_Find_Control_Translated_Outside_Parent_Bounds() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + Border target; + Panel container = new Panel + { + Width = 200, + Height = 200, + Background = Brushes.Red, + ClipToBounds = false, + Children = + { + new Border + { + Width = 100, + Height = 100, + ZIndex = 1, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Child = target = new Border + { + Width = 50, + Height = 50, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + RenderTransform = new TranslateTransform(110, 110), + } + }, + } + }; + s.TopLevel.Content = container; + + s.AssertHitTest(new Point(120, 120), null, target, container); + } + } + + [Fact] + public void HitTest_Should_Not_Find_Control_Outside_Parent_Bounds_When_Clipped() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + Border target; + Panel container = new Panel + { + Width = 100, + Height = 200, + Background = Brushes.Red, + Children = + { + new Panel() + { + Width = 100, + Height = 100, + Background = Brushes.Red, + Margin = new Thickness(0, 100, 0, 0), + ClipToBounds = true, + Children = + { + (target = new Border() + { + Width = 100, + Height = 100, + Background = Brushes.Red, + Margin = new Thickness(0, -100, 0, 0) + }) + } + } + } + + }; + s.TopLevel.Content = container; + + s.AssertHitTest(new Point(50, 50), null, container); + } + } + + [Fact] + public void HitTest_Should_Not_Find_Control_Outside_Scroll_Viewport() + { + using (var s = new CompositorServices(new Size(100, 200))) + { + Border target; + Border item1; + Border item2; + ScrollContentPresenter scroll; + Panel container = new Panel + { + Width = 100, + Height = 200, + Background = Brushes.Red, + Children = + { + (target = new Border() + { + Name = "b1", + Width = 100, + Height = 100, + Background = Brushes.Red, + }), + new Border() + { + Name = "b2", + Width = 100, + Height = 100, + Background = Brushes.Red, + Margin = new Thickness(0, 100, 0, 0), + Child = scroll = new ScrollContentPresenter() + { + CanHorizontallyScroll = true, + CanVerticallyScroll = true, + Content = new StackPanel() + { + Children = + { + (item1 = new Border() + { + Name = "b3", + Width = 100, + Height = 100, + Background = Brushes.Red, + }), + (item2 = new Border() + { + Name = "b4", + Width = 100, + Height = 100, + Background = Brushes.Red, + }), + } + } + } + } + } + }; + s.TopLevel.Content = container; + + scroll.UpdateChild(); + + s.AssertHitTestFirst(new Point(50, 150), null, item1); + + s.AssertHitTestFirst(new Point(50,50), null, target); + + scroll.Offset = new Vector(0, 100); + + s.AssertHitTestFirst(new Point(50, 150), null, item2); + + s.AssertHitTestFirst(new Point(50,50), null, target); + } + } + + [Fact] + public void HitTest_Should_Not_Find_Path_When_Outside_Fill() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + Path path = new Path + { + Width = 200, + Height = 200, + Fill = Brushes.Red, + Data = StreamGeometry.Parse("M100,0 L0,100 100,100") + }; + s.TopLevel.Content = path; + + s.AssertHitTest(new Point(100, 100), null, path); + s.AssertHitTest(new Point(10, 10), null); + } + } + + [Fact] + public void HitTest_Should_Respect_Geometry_Clip() + { + using (var s = new CompositorServices(new Size(400, 400))) + { + Canvas canvas; + Border border = new Border + { + Background = Brushes.Red, + Clip = StreamGeometry.Parse("M100,0 L0,100 100,100"), + Width = 200, + Height = 200, + Child = canvas = new Canvas + { + Background = Brushes.Yellow, + Margin = new Thickness(10), + } + + }; + s.TopLevel.Content = border; + + s.RunJobs(); + Assert.Equal(new Rect(100, 100, 200, 200), border.Bounds); + + s.AssertHitTest(new Point(200,200), null, canvas, border); + + s.AssertHitTest(new Point(110, 110), null); + } + } + + [Fact] + public void HitTest_Should_Accommodate_ICustomHitTest() + { + using (var s = new CompositorServices(new Size(300, 200))) + { + Border border = new CustomHitTestBorder + { + ClipToBounds = false, + Width = 100, + Height = 100, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + s.TopLevel.Content = border; + + s.AssertHitTest(75, 100, null, border); + s.AssertHitTest(125, 100, null, border); + s.AssertHitTest(175, 100, null); + } + } + + private IDisposable TestApplication() + { + return UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + } + +} \ No newline at end of file diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs new file mode 100644 index 0000000000..699f450223 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs @@ -0,0 +1,109 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Xunit; + +namespace Avalonia.Base.UnitTests.Rendering; + +public class CompositorInvalidationTests : CompositorTestsBase +{ + [Fact] + public void Control_Should_Invalidate_Own_Rect_When_Added() + { + using (var s = new CompositorCanvas()) + { + var control = new Border() + { + Background = Brushes.Red, Width = 20, Height = 10, + [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 50 + }; + s.Canvas.Children.Add(control); + s.AssertRects(new Rect(30, 50, 20, 10)); + } + } + + [Fact] + public void Control_Should_Invalidate_Own_Rect_When_Removed() + { + using (var s = new CompositorCanvas()) + { + var control = new Border() + { + Background = Brushes.Red, Width = 20, Height = 10, + [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 50 + }; + s.Canvas.Children.Add(control); + s.RunJobs(); + s.Events.Rects.Clear(); + s.Canvas.Children.Remove(control); + s.AssertRects(new Rect(30, 50, 20, 10)); + } + } + + [Fact] + public void Control_Should_Invalidate_Both_Own_Rects_When_Moved() + { + using (var s = new CompositorCanvas()) + { + var control = new Border() + { + Background = Brushes.Red, Width = 20, Height = 10, + [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 50 + }; + s.Canvas.Children.Add(control); + s.RunJobs(); + s.Events.Rects.Clear(); + control[Canvas.LeftProperty] = 55; + s.AssertRects(new Rect(30, 50, 20, 10), + new Rect(55, 50, 20, 10) + ); + } + } + + [Fact] + public void Control_Should_Invalidate_Child_Rects_When_Moved() + { + using (var s = new CompositorCanvas()) + { + var control = new Decorator() + { + [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 50, + Padding = new Thickness(10), + Child = new Border() + { + Width = 20, Height = 10, + Background = Brushes.Red + } + }; + s.Canvas.Children.Add(control); + s.RunJobs(); + s.Events.Rects.Clear(); + control[Canvas.LeftProperty] = 55; + s.AssertRects(new Rect(40, 60, 20, 10), + new Rect(65, 60, 20, 10) + ); + } + } + + [Fact] + public void Control_Should_Invalidate_Child_Rects_When_Becomes_Invisible() + { + using (var s = new CompositorCanvas()) + { + var control = new Decorator() + { + [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 50, + Padding = new Thickness(10), + Child = new Border() + { + Width = 20, Height = 10, + Background = Brushes.Red + } + }; + s.Canvas.Children.Add(control); + s.RunJobs(); + s.Events.Rects.Clear(); + control.IsVisible = false; + s.AssertRects(new Rect(40, 60, 20, 10)); + } + } +} \ No newline at end of file diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorTestsBase.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorTestsBase.cs new file mode 100644 index 0000000000..fa722a23b8 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorTestsBase.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.Embedding; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Rendering.Composition; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using JetBrains.Annotations; +using Xunit; + +namespace Avalonia.Base.UnitTests.Rendering; + +public class CompositorTestsBase +{ + public class DebugEvents : ICompositionTargetDebugEvents + { + public List Rects = new(); + + public void RectInvalidated(Rect rc) + { + Rects.Add(rc); + } + + public void Reset() + { + Rects.Clear(); + } + } + + public class ManualRenderTimer : IRenderTimer + { + public event Action Tick; + public bool RunsInBackground => false; + public void TriggerTick() => Tick?.Invoke(TimeSpan.Zero); + public Task TriggerBackgroundTick() => Task.Run(TriggerTick); + } + + class TopLevelImpl : ITopLevelImpl + { + private readonly Compositor _compositor; + public CompositingRenderer Renderer { get; private set; } + + public TopLevelImpl(Compositor compositor, Size clientSize) + { + ClientSize = clientSize; + _compositor = compositor; + } + + public void Dispose() + { + + } + + public Size ClientSize { get; } + public Size? FrameSize { get; } + public double RenderScaling => 1; + public IEnumerable Surfaces { get; } = Array.Empty(); + public Action Input { get; set; } + public Action Paint { get; set; } + public Action Resized { get; set; } + public Action ScalingChanged { get; set; } + public Action TransparencyLevelChanged { get; set; } + + public IRenderer CreateRenderer(IRenderRoot root) + { + return Renderer = new CompositingRenderer(root, _compositor); + } + + public void Invalidate(Rect rect) + { + } + + public void SetInputRoot(IInputRoot inputRoot) + { + } + + public Point PointToClient(PixelPoint point) => default; + + public PixelPoint PointToScreen(Point point) => new(); + + public void SetCursor(ICursorImpl cursor) + { + } + + public Action Closed { get; set; } + public Action LostFocus { get; set; } + public IMouseDevice MouseDevice { get; } = new MouseDevice(); + public IPopupImpl CreatePopup() => throw new NotImplementedException(); + + public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel) + { + } + + public WindowTransparencyLevel TransparencyLevel { get; } + public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } + } + + protected class CompositorServices : IDisposable + { + private readonly IDisposable _app; + public Compositor Compositor { get; } + public ManualRenderTimer Timer { get; } = new(); + public EmbeddableControlRoot TopLevel { get; } + public CompositingRenderer Renderer { get; } = null!; + public DebugEvents Events { get; } = new(); + + public void Dispose() + { + TopLevel.Renderer.Stop(); + TopLevel.Dispose(); + _app.Dispose(); + } + + public CompositorServices(Size? size = null) + { + _app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + try + { + AvaloniaLocator.CurrentMutable.Bind().ToConstant(Timer); + AvaloniaLocator.CurrentMutable.Bind() + .ToConstant(new RenderLoop(Timer, Dispatcher.UIThread)); + + Compositor = new Compositor(AvaloniaLocator.Current.GetRequiredService(), null); + var impl = new TopLevelImpl(Compositor, size ?? new Size(1000, 1000)); + TopLevel = new EmbeddableControlRoot(impl) + { + Template = new FuncControlTemplate((parent, scope) => + { + var presenter = new ContentPresenter + { + [~ContentPresenter.ContentProperty] = new TemplateBinding(ContentControl.ContentProperty) + }; + scope.Register("PART_ContentPresenter", presenter); + return presenter; + }) + }; + Renderer = impl.Renderer; + TopLevel.Prepare(); + TopLevel.Renderer.Start(); + RunJobs(); + Renderer.CompositionTarget.Server.DebugEvents = Events; + } + catch + { + _app.Dispose(); + throw; + } + } + + public void RunJobs() + { + Dispatcher.UIThread.RunJobs(); + Timer.TriggerTick(); + Dispatcher.UIThread.RunJobs(); + } + + public void AssertRects(params Rect[] rects) + { + RunJobs(); + var toAssert = rects.Select(x => x.ToString()).Distinct().OrderBy(x => x); + var invalidated = Events.Rects.Select(x => x.ToString()).Distinct().OrderBy(x => x); + Assert.Equal(toAssert, invalidated); + Events.Rects.Clear(); + } + + public void AssertHitTest(double x, double y, Func filter, params object[] expected) + => AssertHitTest(new Point(x, y), filter, expected); + public void AssertHitTest(Point pt, Func filter, params object[] expected) + { + RunJobs(); + var tested = Renderer.HitTest(pt, TopLevel, filter); + Assert.Equal(expected, tested); + } + + public void AssertHitTestFirst(Point pt, Func filter, object expected) + { + RunJobs(); + var tested = Renderer.HitTest(pt, TopLevel, filter).First(); + Assert.Equal(expected, tested); + } + } + + + protected class CompositorCanvas : CompositorServices + { + public Canvas Canvas { get; } = new(); + + public CompositorCanvas() + { + TopLevel.Content = Canvas; + RunJobs(); + Events.Reset(); + } + } +} \ No newline at end of file diff --git a/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs b/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs index bf4ac9c1f6..f6a127b573 100644 --- a/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs +++ b/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs @@ -4,6 +4,7 @@ using System.IO; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Media.Imaging; +using Avalonia.Rendering; using Moq; namespace Avalonia.UnitTests @@ -25,9 +26,33 @@ namespace Avalonia.UnitTests return Mock.Of(x => x.Bounds == rect); } + class MockRenderTarget : IRenderTarget + { + public void Dispose() + { + + } + + public IDrawingContextImpl CreateDrawingContext(IVisualBrushRenderer visualBrushRenderer) + { + var m = new Mock(); + m.Setup(c => c.CreateLayer(It.IsAny())) + .Returns(() => + { + var r = new Mock(); + r.Setup(r => r.CreateDrawingContext(It.IsAny())) + .Returns(CreateDrawingContext(null)); + return r.Object; + } + ); + return m.Object; + + } + } + public IRenderTarget CreateRenderTarget(IEnumerable surfaces) { - return Mock.Of(); + return new MockRenderTarget(); } public IRenderTargetBitmapImpl CreateRenderTargetBitmap(PixelSize size, Vector dpi) From 235713823fcaf2014240da78721a5358327304f3 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 20 Jul 2022 00:48:59 -0400 Subject: [PATCH 09/18] Abstract linux DBus and GTK storage providers, use async initialization --- Avalonia.sln | 1 + src/Avalonia.FreeDesktop/DBusHelper.cs | 3 +- src/Avalonia.FreeDesktop/DBusSystemDialog.cs | 33 +++++---- src/Avalonia.X11/NativeDialogs/Gtk.cs | 2 - .../NativeDialogs/GtkNativeFileDialogs.cs | 30 +++----- .../NativeDialogs/LinuxStorageProvider.cs | 72 +++++++++++++++++++ src/Avalonia.X11/X11Platform.cs | 8 +-- src/Avalonia.X11/X11Window.cs | 4 +- 8 files changed, 104 insertions(+), 49 deletions(-) create mode 100644 src/Avalonia.X11/NativeDialogs/LinuxStorageProvider.cs diff --git a/Avalonia.sln b/Avalonia.sln index 071d0457b8..4999719676 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -559,6 +559,7 @@ Global {2B390431-288C-435C-BB6B-A374033BD8D1} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} {EABE2161-989B-42BF-BD8D-1E34B20C21F1} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {1BBFAD42-B99E-47E0-B00A-A4BC6B6BB4BB} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} + {4D36CEC8-53F2-40A5-9A37-79AAE356E2DA} = {86C53C40-57AA-45B8-AD42-FAE0EFDF0F2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/src/Avalonia.FreeDesktop/DBusHelper.cs b/src/Avalonia.FreeDesktop/DBusHelper.cs index 9f9d75b411..ef99838208 100644 --- a/src/Avalonia.FreeDesktop/DBusHelper.cs +++ b/src/Avalonia.FreeDesktop/DBusHelper.cs @@ -24,8 +24,7 @@ namespace Avalonia.FreeDesktop if (_ctx is not null) _ctx?.Post(d, state); else - lock (_lock) - d(state); + d(state); } } diff --git a/src/Avalonia.FreeDesktop/DBusSystemDialog.cs b/src/Avalonia.FreeDesktop/DBusSystemDialog.cs index c17d5b993c..e3fc7526d8 100644 --- a/src/Avalonia.FreeDesktop/DBusSystemDialog.cs +++ b/src/Avalonia.FreeDesktop/DBusSystemDialog.cs @@ -15,27 +15,26 @@ namespace Avalonia.FreeDesktop { internal class DBusSystemDialog : BclStorageProvider { - private static readonly Lazy s_fileChooser = new(() => + private static readonly Lazy s_fileChooser = new(() => DBusHelper.Connection? + .CreateProxy("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop")); + + internal static async Task TryCreate(IPlatformHandle handle) { - var fileChooser = DBusHelper.Connection?.CreateProxy("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop"); - if (fileChooser is null) - return null; - try + if (handle.HandleDescriptor == "XID" && s_fileChooser.Value is { } fileChooser) { - _ = fileChooser.GetVersionAsync(); - return fileChooser; - } - catch (Exception e) - { - Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform)?.Log(null, $"Unable to connect to org.freedesktop.portal.Desktop: {e.Message}"); - return null; + try + { + await fileChooser.GetVersionAsync(); + return new DBusSystemDialog(fileChooser, handle); + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform)?.Log(null, $"Unable to connect to org.freedesktop.portal.Desktop: {e.Message}"); + return null; + } } - }); - internal static DBusSystemDialog? TryCreate(IPlatformHandle handle) - { - return handle.HandleDescriptor == "XID" && s_fileChooser.Value is { } fileChooser - ? new DBusSystemDialog(fileChooser, handle) : null; + return null; } private readonly IFileChooser _fileChooser; diff --git a/src/Avalonia.X11/NativeDialogs/Gtk.cs b/src/Avalonia.X11/NativeDialogs/Gtk.cs index c9e482db86..ae04c072a5 100644 --- a/src/Avalonia.X11/NativeDialogs/Gtk.cs +++ b/src/Avalonia.X11/NativeDialogs/Gtk.cs @@ -264,8 +264,6 @@ namespace Avalonia.X11.NativeDialogs public static Task StartGtk() { return StartGtkCore(); - lock (s_startGtkLock) - return s_startGtkTask ??= StartGtkCore(); } private static void GtkThread(TaskCompletionSource tcs) diff --git a/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs b/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs index 89d08a3974..ca3e0cd33d 100644 --- a/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs +++ b/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs @@ -17,10 +17,10 @@ namespace Avalonia.X11.NativeDialogs { internal class GtkSystemDialog : BclStorageProvider { - private Task? _initialized; + private static Task? _initialized; private readonly X11Window _window; - public GtkSystemDialog(X11Window window) + private GtkSystemDialog(X11Window window) { _window = window; } @@ -31,10 +31,15 @@ namespace Avalonia.X11.NativeDialogs public override bool CanPickFolder => true; - public override async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + internal static async Task TryCreate(X11Window window) { - await EnsureInitialized(); + _initialized ??= StartGtk(); + + return await _initialized ? new GtkSystemDialog(window) : null; + } + public override async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + { return await await RunOnGlibThread(async () => { var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.Open, @@ -46,8 +51,6 @@ namespace Avalonia.X11.NativeDialogs public override async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) { - await EnsureInitialized(); - return await await RunOnGlibThread(async () => { var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.SelectFolder, @@ -59,8 +62,6 @@ namespace Avalonia.X11.NativeDialogs public override async Task SaveFilePickerAsync(FilePickerSaveOptions options) { - await EnsureInitialized(); - return await await RunOnGlibThread(async () => { var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.Save, @@ -225,19 +226,6 @@ namespace Avalonia.X11.NativeDialogs return tcs.Task; } - private async Task EnsureInitialized() - { - if (_initialized == null) - { - _initialized = StartGtk(); - } - - if (!(await _initialized)) - { - throw new Exception("Unable to initialize GTK on separate thread"); - } - } - private static void UpdateParent(IntPtr chooser, IWindowImpl parentWindow) { var xid = parentWindow.Handle.Handle; diff --git a/src/Avalonia.X11/NativeDialogs/LinuxStorageProvider.cs b/src/Avalonia.X11/NativeDialogs/LinuxStorageProvider.cs new file mode 100644 index 0000000000..75293e12fb --- /dev/null +++ b/src/Avalonia.X11/NativeDialogs/LinuxStorageProvider.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.FreeDesktop; +using Avalonia.Platform.Storage; + +namespace Avalonia.X11.NativeDialogs; + +internal class LinuxStorageProvider : IStorageProvider +{ + private readonly X11Window _window; + public LinuxStorageProvider(X11Window window) + { + _window = window; + } + + public bool CanOpen => true; + public bool CanSave => true; + public bool CanPickFolder => true; + + private async Task EnsureStorageProvider() + { + var options = AvaloniaLocator.Current.GetService() ?? new X11PlatformOptions(); + + if (options.UseDBusFilePicker) + { + var dBusDialog = await DBusSystemDialog.TryCreate(_window.Handle); + if (dBusDialog is not null) + { + return dBusDialog; + } + } + + var gtkDialog = await GtkSystemDialog.TryCreate(_window); + if (gtkDialog is not null) + { + return gtkDialog; + } + + throw new InvalidOperationException("Neither DBus nor GTK are available on the system"); + } + + public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + { + var provider = await EnsureStorageProvider().ConfigureAwait(false); + return await provider.OpenFilePickerAsync(options).ConfigureAwait(false); + } + + public async Task SaveFilePickerAsync(FilePickerSaveOptions options) + { + var provider = await EnsureStorageProvider().ConfigureAwait(false); + return await provider.SaveFilePickerAsync(options).ConfigureAwait(false); + } + + public async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) + { + var provider = await EnsureStorageProvider().ConfigureAwait(false); + return await provider.OpenFolderPickerAsync(options).ConfigureAwait(false); + } + + public async Task OpenFileBookmarkAsync(string bookmark) + { + var provider = await EnsureStorageProvider().ConfigureAwait(false); + return await provider.OpenFileBookmarkAsync(bookmark).ConfigureAwait(false); + } + + public async Task OpenFolderBookmarkAsync(string bookmark) + { + var provider = await EnsureStorageProvider().ConfigureAwait(false); + return await provider.OpenFolderBookmarkAsync(bookmark).ConfigureAwait(false); + } +} diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index edb320d4f0..7043c60ae7 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -216,16 +216,16 @@ namespace Avalonia public bool OverlayPopups { get; set; } /// - /// Enables native file dialogs as well as global menu support on Linux desktop environments where it's supported (e. g. XFCE and MATE with plugin, KDE, etc). + /// Enables global menu support on Linux desktop environments where it's supported (e. g. XFCE and MATE with plugin, KDE, etc). /// The default value is true. /// public bool UseDBusMenu { get; set; } = true; /// - /// Enables GTK file picker instead of default FreeDesktop. - /// The default value is true. And FreeDesktop file picker is used instead if available. + /// Enables DBus file picker instead of GTK. + /// The default value is true. /// - public bool UseGtkFilePicker { get; set; } = false; + public bool UseDBusFilePicker { get; set; } = true; /// /// Deferred renderer would be used when set to true. Immediate renderer when set to false. The default value is true. diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 2f92448f4b..ef8ae2f70f 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -215,9 +215,7 @@ namespace Avalonia.X11 _x11.Atoms.XA_CARDINAL, 32, PropertyMode.Replace, ref _xSyncCounter, 1); } - var canUseFreeDekstopPicker = !platform.Options.UseGtkFilePicker && platform.Options.UseDBusMenu; - StorageProvider = canUseFreeDekstopPicker && DBusSystemDialog.TryCreate(Handle) is {} dBusStorage - ? dBusStorage : new NativeDialogs.GtkSystemDialog(this); + StorageProvider = new NativeDialogs.LinuxStorageProvider(this); } class SurfaceInfo : EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo From a51acea5e02ddde0a05c2102a45f48e395e314ae Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Tue, 19 Jul 2022 15:32:37 +0100 Subject: [PATCH 10/18] MenuItem might have to look up the logical tree to find its menu parent (as in trayicon) --- src/Avalonia.Controls/MenuItem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index 11c42f2ef3..5e7c83fcbd 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -304,7 +304,7 @@ namespace Avalonia.Controls bool IMenuItem.IsPointerOverSubMenu => _popup?.IsPointerOverPopup ?? false; /// - IMenuElement? IMenuItem.Parent => Parent as IMenuElement; + IMenuElement? IMenuItem.Parent => this.FindLogicalAncestorOfType(); protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute; From 961c693e343ee698227448da77823ec6d1dc2da2 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 20 Jul 2022 11:37:11 +0100 Subject: [PATCH 11/18] fix win32native to managed menu exporter. --- src/Avalonia.Controls/MenuItem.cs | 2 +- .../Win32NativeToManagedMenuExporter.cs | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index 5e7c83fcbd..11c42f2ef3 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -304,7 +304,7 @@ namespace Avalonia.Controls bool IMenuItem.IsPointerOverSubMenu => _popup?.IsPointerOverPopup ?? false; /// - IMenuElement? IMenuItem.Parent => this.FindLogicalAncestorOfType(); + IMenuElement? IMenuItem.Parent => Parent as IMenuElement; protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute; diff --git a/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs b/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs index fa6f9927b5..da8357a6f1 100644 --- a/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs +++ b/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Controls.Platform; @@ -15,13 +16,15 @@ namespace Avalonia.Win32 _nativeMenu = nativeMenu; } - private IEnumerable Populate(NativeMenu nativeMenu) + private AvaloniaList Populate(NativeMenu nativeMenu) { + var result = new AvaloniaList(); + foreach (var menuItem in nativeMenu.Items) { if (menuItem is NativeMenuItemSeparator) { - yield return new MenuItem { Header = "-" }; + result.Add(new MenuItem { Header = "-" }); } else if (menuItem is NativeMenuItem item) { @@ -36,12 +39,14 @@ namespace Avalonia.Win32 newItem.Click += (_, __) => bridge.RaiseClicked(); } - yield return newItem; + result.Add(newItem); } } + + return result; } - public IEnumerable? GetMenu() + public AvaloniaList? GetMenu() { if (_nativeMenu != null) { From 949b7f2d9427e3922e43721a03f1acc13b209680 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Tue, 19 Jul 2022 21:24:29 -0400 Subject: [PATCH 12/18] Call GetHicon only once when creation a windows icon --- src/Windows/Avalonia.Win32/IconImpl.cs | 20 +++----------------- src/Windows/Avalonia.Win32/TrayIconImpl.cs | 4 +++- src/Windows/Avalonia.Win32/Win32Platform.cs | 8 ++++++-- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/Windows/Avalonia.Win32/IconImpl.cs b/src/Windows/Avalonia.Win32/IconImpl.cs index 8548801555..4d2b89b0cc 100644 --- a/src/Windows/Avalonia.Win32/IconImpl.cs +++ b/src/Windows/Avalonia.Win32/IconImpl.cs @@ -1,6 +1,5 @@ using System; using System.Drawing; -using System.Drawing.Imaging; using System.IO; using Avalonia.Platform; @@ -8,31 +7,18 @@ namespace Avalonia.Win32 { class IconImpl : IWindowIconImpl { - private Bitmap bitmap; - private Icon icon; - - public IconImpl(Bitmap bitmap) - { - this.bitmap = bitmap; - } + private readonly Icon icon; public IconImpl(Icon icon) { this.icon = icon; } - public IntPtr HIcon => icon?.Handle ?? bitmap.GetHicon(); + public IntPtr HIcon => icon.Handle; public void Save(Stream outputStream) { - if (icon != null) - { - icon.Save(outputStream); - } - else - { - bitmap.Save(outputStream, ImageFormat.Png); - } + icon.Save(outputStream); } } } diff --git a/src/Windows/Avalonia.Win32/TrayIconImpl.cs b/src/Windows/Avalonia.Win32/TrayIconImpl.cs index 4d537a16a4..346d6e5adb 100644 --- a/src/Windows/Avalonia.Win32/TrayIconImpl.cs +++ b/src/Windows/Avalonia.Win32/TrayIconImpl.cs @@ -18,6 +18,8 @@ namespace Avalonia.Win32 [Unstable] public class TrayIconImpl : ITrayIconImpl { + private static readonly IntPtr s_emptyIcon = new System.Drawing.Bitmap(32, 32).GetHicon(); + private readonly int _uniqueId; private static int s_nextUniqueId; private bool _iconAdded; @@ -86,7 +88,7 @@ namespace Avalonia.Win32 uID = _uniqueId, uFlags = NIF.TIP | NIF.MESSAGE, uCallbackMessage = (int)CustomWindowsMessage.WM_TRAYMOUSE, - hIcon = _icon?.HIcon ?? new IconImpl(new System.Drawing.Bitmap(32, 32)).HIcon, + hIcon = _icon?.HIcon ?? s_emptyIcon, szTip = _tooltipText ?? "" }; diff --git a/src/Windows/Avalonia.Win32/Win32Platform.cs b/src/Windows/Avalonia.Win32/Win32Platform.cs index 73ef50052c..2b8070fb04 100644 --- a/src/Windows/Avalonia.Win32/Win32Platform.cs +++ b/src/Windows/Avalonia.Win32/Win32Platform.cs @@ -359,7 +359,7 @@ namespace Avalonia.Win32 using (var memoryStream = new MemoryStream()) { bitmap.Save(memoryStream); - return new IconImpl(new System.Drawing.Bitmap(memoryStream)); + return CreateIconImpl(memoryStream); } } @@ -367,11 +367,15 @@ namespace Avalonia.Win32 { try { + // new Icon() will work only if stream is an "ico" file. return new IconImpl(new System.Drawing.Icon(stream)); } catch (ArgumentException) { - return new IconImpl(new System.Drawing.Bitmap(stream)); + // Fallback to Bitmap creation and converting into a windows icon. + using var icon = new System.Drawing.Bitmap(stream); + var hIcon = icon.GetHicon(); + return new IconImpl(System.Drawing.Icon.FromHandle(hIcon)); } } From 62d1b4f3b69eb97460a0c3bc0999caff8ed16ecb Mon Sep 17 00:00:00 2001 From: Max Katz Date: Tue, 19 Jul 2022 23:25:37 -0400 Subject: [PATCH 13/18] Add missing IsEnabled binding --- src/Avalonia.Themes.Default/Controls/NativeMenuBar.xaml | 1 + src/Avalonia.Themes.Fluent/Controls/NativeMenuBar.xaml | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Avalonia.Themes.Default/Controls/NativeMenuBar.xaml b/src/Avalonia.Themes.Default/Controls/NativeMenuBar.xaml index 81bd8f39c5..3b0019eea1 100644 --- a/src/Avalonia.Themes.Default/Controls/NativeMenuBar.xaml +++ b/src/Avalonia.Themes.Default/Controls/NativeMenuBar.xaml @@ -14,6 +14,7 @@