From 47a328ab878aa61e09c588310c42b4d8fc23f97d Mon Sep 17 00:00:00 2001 From: Nathan Garside Date: Thu, 27 Jan 2022 15:04:44 +0000 Subject: [PATCH 01/19] Add window position offset --- src/Windows/Avalonia.Win32/WindowImpl.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index e4f5268285..9c4037be92 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -448,10 +448,20 @@ namespace Avalonia.Win32 { GetWindowRect(_hwnd, out var rc); + // Windows 10 and 11 add a 7 pixel invisible border on the left/right/bottom of windows for resizing + if (Win32Platform.WindowsVersion.Major >= 10 && HasFullDecorations) + { + return new PixelPoint(rc.left + (int)(7 * _scaling), rc.top); + } + return new PixelPoint(rc.left, rc.top); } set { + if (Win32Platform.WindowsVersion.Major >= 10 && HasFullDecorations) + { + value = new PixelPoint(value.X - (int)(7 * _scaling), value.Y); + } SetWindowPos( Handle.Handle, IntPtr.Zero, From 1ae26b326e503527e6ff4615cd9399d5c950e696 Mon Sep 17 00:00:00 2001 From: Nathan Garside Date: Sat, 29 Jan 2022 12:03:01 +0000 Subject: [PATCH 02/19] Calculate border size --- src/Windows/Avalonia.Win32/WindowImpl.cs | 34 ++++++++++++++++-------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 9c4037be92..94fe9168ab 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -448,20 +448,14 @@ namespace Avalonia.Win32 { GetWindowRect(_hwnd, out var rc); - // Windows 10 and 11 add a 7 pixel invisible border on the left/right/bottom of windows for resizing - if (Win32Platform.WindowsVersion.Major >= 10 && HasFullDecorations) - { - return new PixelPoint(rc.left + (int)(7 * _scaling), rc.top); - } - - return new PixelPoint(rc.left, rc.top); + var border = HiddenBorderSize; + return new PixelPoint(rc.left + border.Width, rc.top + border.Height); } set { - if (Win32Platform.WindowsVersion.Major >= 10 && HasFullDecorations) - { - value = new PixelPoint(value.X - (int)(7 * _scaling), value.Y); - } + var border = HiddenBorderSize; + value = new PixelPoint(value.X - border.Width, value.Y - border.Height); + SetWindowPos( Handle.Handle, IntPtr.Zero, @@ -475,6 +469,24 @@ namespace Avalonia.Win32 private bool HasFullDecorations => _windowProperties.Decorations == SystemDecorations.Full; + private PixelSize HiddenBorderSize + { + get + { + // Windows 10 and 11 add a 7 pixel invisible border on the left/right/bottom of windows for resizing + if (Win32Platform.WindowsVersion.Major < 10 || !HasFullDecorations) + { + return PixelSize.Empty; + } + + DwmGetWindowAttribute(_hwnd, (int)DwmWindowAttribute.DWMWA_EXTENDED_FRAME_BOUNDS, out var clientRect, Marshal.SizeOf(typeof(RECT))); + GetWindowRect(_hwnd, out var frameRect); + var borderWidth = GetSystemMetrics(SystemMetric.SM_CXBORDER); + + return new PixelSize(clientRect.left - frameRect.left - borderWidth, 0); + } + } + public void Move(PixelPoint point) => Position = point; public void SetMinMaxSize(Size minSize, Size maxSize) From b2556d62f5e5c6869ef8baf890a5688d024a93e5 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 16 May 2022 11:40:25 +0200 Subject: [PATCH 03/19] Fix some layout rounding issues. Fixes for #8092: - Always round sizes up, not to the nearest pixel, thereby ensuring that `DesiredSize`s don't get rounded down where possible. - Apply rounding to `Padding` and `BorderThickness` in measure pass as well as arrange pass, to ensure that `DesiredSize` takes this rounding into account. --- src/Avalonia.Base/Layout/LayoutHelper.cs | 71 ++++++++- src/Avalonia.Base/Layout/Layoutable.cs | 24 +-- src/Avalonia.Base/Point.cs | 14 +- .../DataGridColumn.cs | 2 +- .../Presenters/ContentPresenter.cs | 4 +- .../Layout/LayoutableTests.cs | 31 ---- .../Layout/LayoutableTests_LayoutRounding.cs | 140 ++++++++++++++++++ .../Rendering/SceneGraph/SceneBuilderTests.cs | 1 + .../BorderTests.cs | 65 ++++++++ .../DecoratorTests.cs | 41 +++++ .../ContentPresenterTests_Layout.cs | 66 ++++++++- .../Primitives/TrackTests.cs | 4 +- tests/Avalonia.UnitTests/TestRoot.cs | 7 +- ...estrictedHeight_VerticalAlign.expected.png | Bin 752 -> 767 bytes ...estrictedHeight_VerticalAlign.expected.png | Bin 557 -> 532 bytes 15 files changed, 414 insertions(+), 56 deletions(-) create mode 100644 tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs diff --git a/src/Avalonia.Base/Layout/LayoutHelper.cs b/src/Avalonia.Base/Layout/LayoutHelper.cs index d24be57d2b..404d19906a 100644 --- a/src/Avalonia.Base/Layout/LayoutHelper.cs +++ b/src/Avalonia.Base/Layout/LayoutHelper.cs @@ -36,11 +36,28 @@ namespace Avalonia.Layout public static Size MeasureChild(ILayoutable? control, Size availableSize, Thickness padding, Thickness borderThickness) { - return MeasureChild(control, availableSize, padding + borderThickness); + if (IsParentLayoutRounded(control, out double scale)) + { + padding = RoundLayoutThickness(padding, scale, scale); + borderThickness = RoundLayoutThickness(borderThickness, scale, scale); + } + + if (control != null) + { + control.Measure(availableSize.Deflate(padding + borderThickness)); + return control.DesiredSize.Inflate(padding + borderThickness); + } + + return new Size().Inflate(padding + borderThickness); } public static Size MeasureChild(ILayoutable? control, Size availableSize, Thickness padding) { + if (IsParentLayoutRounded(control, out double scale)) + { + padding = RoundLayoutThickness(padding, scale, scale); + } + if (control != null) { control.Measure(availableSize.Deflate(padding)); @@ -137,7 +154,7 @@ namespace Avalonia.Layout /// /// Rounds a size to integer values for layout purposes, compensating for high DPI screen - /// coordinates. + /// coordinates by rounding the size up to the nearest pixel. /// /// Input size. /// DPI along x-dimension. @@ -149,9 +166,9 @@ namespace Avalonia.Layout /// associated with the UseLayoutRounding property and should not be used as a general rounding /// utility. /// - public static Size RoundLayoutSize(Size size, double dpiScaleX, double dpiScaleY) + public static Size RoundLayoutSizeUp(Size size, double dpiScaleX, double dpiScaleY) { - return new Size(RoundLayoutValue(size.Width, dpiScaleX), RoundLayoutValue(size.Height, dpiScaleY)); + return new Size(RoundLayoutValueUp(size.Width, dpiScaleX), RoundLayoutValueUp(size.Height, dpiScaleY)); } /// @@ -178,10 +195,9 @@ namespace Avalonia.Layout ); } - - /// - /// Calculates the value to be used for layout rounding at high DPI. + /// Calculates the value to be used for layout rounding at high DPI by rounding the value + /// up or down to the nearest pixel. /// /// Input value to be rounded. /// Ratio of screen's DPI to layout DPI @@ -217,7 +233,46 @@ namespace Avalonia.Layout return newValue; } - + + /// + /// Calculates the value to be used for layout rounding at high DPI by rounding the value up + /// to the nearest pixel. + /// + /// Input value to be rounded. + /// Ratio of screen's DPI to layout DPI + /// Adjusted value that will produce layout rounding on screen at high dpi. + /// + /// This is a layout helper method. It takes DPI into account and also does not return + /// the rounded value if it is unacceptable for layout, e.g. Infinity or NaN. It's a helper + /// associated with the UseLayoutRounding property and should not be used as a general rounding + /// utility. + /// + public static double RoundLayoutValueUp(double value, double dpiScale) + { + double newValue; + + // If DPI == 1, don't use DPI-aware rounding. + if (!MathUtilities.IsOne(dpiScale)) + { + newValue = Math.Ceiling(value * dpiScale) / dpiScale; + + // If rounding produces a value unacceptable to layout (NaN, Infinity or MaxValue), + // use the original value. + if (double.IsNaN(newValue) || + double.IsInfinity(newValue) || + MathUtilities.AreClose(newValue, double.MaxValue)) + { + newValue = value; + } + } + else + { + newValue = Math.Ceiling(value); + } + + return newValue; + } + /// /// Calculates the min and max height for a control. Ported from WPF. /// diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index df7aa937a0..0b74d5915a 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -549,6 +549,14 @@ namespace Avalonia.Layout if (IsVisible) { var margin = Margin; + var useLayoutRounding = UseLayoutRounding; + var scale = 1.0; + + if (useLayoutRounding) + { + scale = LayoutHelper.GetLayoutScale(this); + margin = LayoutHelper.RoundLayoutThickness(margin, scale, scale); + } ApplyStyling(); ApplyTemplate(); @@ -585,16 +593,14 @@ namespace Avalonia.Layout height = Math.Min(height, MaxHeight); height = Math.Max(height, MinHeight); - width = Math.Min(width, availableSize.Width); - height = Math.Min(height, availableSize.Height); - - if (UseLayoutRounding) + if (useLayoutRounding) { - var scale = LayoutHelper.GetLayoutScale(this); - width = LayoutHelper.RoundLayoutValue(width, scale); - height = LayoutHelper.RoundLayoutValue(height, scale); + (width, height) = LayoutHelper.RoundLayoutSizeUp(new Size(width, height), scale, scale); } + width = Math.Min(width, availableSize.Width); + height = Math.Min(height, availableSize.Height); + return NonNegative(new Size(width, height).Inflate(margin)); } else @@ -679,8 +685,8 @@ namespace Avalonia.Layout if (useLayoutRounding) { - size = LayoutHelper.RoundLayoutSize(size, scale, scale); - availableSizeMinusMargins = LayoutHelper.RoundLayoutSize(availableSizeMinusMargins, scale, scale); + size = LayoutHelper.RoundLayoutSizeUp(size, scale, scale); + availableSizeMinusMargins = LayoutHelper.RoundLayoutSizeUp(availableSizeMinusMargins, scale, scale); } size = ArrangeOverride(size).Constrain(size); diff --git a/src/Avalonia.Base/Point.cs b/src/Avalonia.Base/Point.cs index 67e7d71fbc..2f226caff4 100644 --- a/src/Avalonia.Base/Point.cs +++ b/src/Avalonia.Base/Point.cs @@ -192,7 +192,7 @@ namespace Avalonia } /// - /// Returns a boolean indicating whether the point is equal to the other given point. + /// Returns a boolean indicating whether the point is equal to the other given point (bitwise). /// /// The other point to test equality against. /// True if this point is equal to other; False otherwise. @@ -204,6 +204,18 @@ namespace Avalonia // ReSharper enable CompareOfFloatsByEqualityOperator } + /// + /// Returns a boolean indicating whether the point is equal to the other given point + /// (numerically). + /// + /// The other point to test equality against. + /// True if this point is equal to other; False otherwise. + public bool NearlyEquals(Point other) + { + return MathUtilities.AreClose(_x, other._x) && + MathUtilities.AreClose(_y, other._y); + } + /// /// Checks for equality between a point and an object. /// diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumn.cs b/src/Avalonia.Controls.DataGrid/DataGridColumn.cs index f3ea48ff80..c415f477d4 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumn.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumn.cs @@ -855,7 +855,7 @@ namespace Avalonia.Controls if (OwningGrid != null && OwningGrid.UseLayoutRounding) { var scale = LayoutHelper.GetLayoutScale(HeaderCell); - var roundSize = LayoutHelper.RoundLayoutSize(new Size(leftEdge + ActualWidth, 1), scale, scale); + var roundSize = LayoutHelper.RoundLayoutSizeUp(new Size(leftEdge + ActualWidth, 1), scale, scale); LayoutRoundedWidth = roundSize.Width - leftEdge; } else diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index 996cb29534..c67678837b 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -635,8 +635,8 @@ namespace Avalonia.Controls.Presenters if (useLayoutRounding) { - sizeForChild = LayoutHelper.RoundLayoutSize(sizeForChild, scale, scale); - availableSize = LayoutHelper.RoundLayoutSize(availableSize, scale, scale); + sizeForChild = LayoutHelper.RoundLayoutSizeUp(sizeForChild, scale, scale); + availableSize = LayoutHelper.RoundLayoutSizeUp(availableSize, scale, scale); } switch (horizontalContentAlignment) diff --git a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs index 87fa8cf1f3..f5adaf904e 100644 --- a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs +++ b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs @@ -173,37 +173,6 @@ namespace Avalonia.Base.UnitTests.Layout target.Verify(x => x.InvalidateMeasure(root), Times.Once()); } - [Theory] - [InlineData(16, 6, 5.333333333333333)] - [InlineData(18, 10, 4)] - public void UseLayoutRounding_Arranges_Center_Alignment_Correctly_With_Fractional_Scaling( - double containerWidth, - double childWidth, - double expectedX) - { - Border target; - var root = new TestRoot - { - LayoutScaling = 1.5, - UseLayoutRounding = true, - Child = new Decorator - { - Width = containerWidth, - Height = 100, - Child = target = new Border - { - Width = childWidth, - HorizontalAlignment = HorizontalAlignment.Center, - } - } - }; - - root.Measure(new Size(100, 100)); - root.Arrange(new Rect(target.DesiredSize)); - - Assert.Equal(new Rect(expectedX, 0, childWidth, 100), target.Bounds); - } - [Fact] public void LayoutUpdated_Is_Called_At_End_Of_Layout_Pass() { diff --git a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs new file mode 100644 index 0000000000..77f1a8882d --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests_LayoutRounding.cs @@ -0,0 +1,140 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.UnitTests; +using Xunit; +using Xunit.Sdk; + +namespace Avalonia.Base.UnitTests.Layout +{ + public class LayoutableTests_LayoutRounding + { + [Theory] + [InlineData(100, 100)] + [InlineData(101, 101.33333333333333)] + [InlineData(103, 103.33333333333333)] + public void Measure_Adjusts_DesiredSize_Upwards_When_Constraint_Allows(double desiredSize, double expectedSize) + { + var target = new TestLayoutable(new Size(desiredSize, desiredSize)); + var root = CreateRoot(1.5, target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.Equal(new Size(expectedSize, expectedSize), target.DesiredSize); + } + + [Fact] + public void Measure_Constrains_Adjusted_DesiredSize_To_Constraint() + { + var target = new TestLayoutable(new Size(101, 101)); + var root = CreateRoot(1.5, target, constraint: new Size(101, 101)); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // Desired width/height with layout rounding is 101.3333 but constraint is 101,101 so + // layout rounding should be ignored. + Assert.Equal(new Size(101, 101), target.DesiredSize); + } + + [Fact] + public void Measure_Adjusts_DesiredSize_Upwards_When_Margin_Present() + { + var target = new TestLayoutable(new Size(101, 101), margin: 1); + var root = CreateRoot(1.5, target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // - 1 pixel margin is rounded up to 1.3333; for both sides it is 2.6666 + // - Size of 101 gets rounded up to 101.3333 + // - Final size = 101.3333 + 2.6666 = 104 + AssertEqual(new Size(104, 104), target.DesiredSize); + } + + [Fact] + public void Arrange_Adjusts_Bounds_Upwards_With_Margin() + { + var target = new TestLayoutable(new Size(101, 101), margin: 1); + var root = CreateRoot(1.5, target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // - 1 pixel margin is rounded up to 1.3333 + // - Size of 101 gets rounded up to 101.3333 + AssertEqual(new Point(1.3333333333333333, 1.3333333333333333), target.Bounds.Position); + AssertEqual(new Size(101.33333333333333, 101.33333333333333), target.Bounds.Size); + } + + [Theory] + [InlineData(16, 6, 5.333333333333333)] + [InlineData(18, 10, 4)] + public void Arranges_Center_Alignment_Correctly_With_Fractional_Scaling( + double containerWidth, + double childWidth, + double expectedX) + { + Border target; + var root = new TestRoot + { + LayoutScaling = 1.5, + UseLayoutRounding = true, + Child = new Decorator + { + Width = containerWidth, + Height = 100, + Child = target = new Border + { + Width = childWidth, + HorizontalAlignment = HorizontalAlignment.Center, + } + } + }; + + root.Measure(new Size(100, 100)); + root.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(new Rect(expectedX, 0, childWidth, 100), target.Bounds); + } + + private static TestRoot CreateRoot( + double scaling, + Control child, + Size? constraint = null) + { + return new TestRoot + { + LayoutScaling = scaling, + UseLayoutRounding = true, + Child = child, + ClientSize = constraint ?? new Size(1000, 1000), + }; + } + + private static void AssertEqual(Point expected, Point actual) + { + if (!expected.NearlyEquals(actual)) + { + throw new EqualException(expected, actual); + } + } + + private static void AssertEqual(Size expected, Size actual) + { + if (!expected.NearlyEquals(actual)) + { + throw new EqualException(expected, actual); + } + } + + private class TestLayoutable : Control + { + private Size _desiredSize; + + public TestLayoutable(Size desiredSize, double margin = 0) + { + _desiredSize = desiredSize; + Margin = new Thickness(margin); + } + + protected override Size MeasureOverride(Size availableSize) => _desiredSize; + } + } +} diff --git a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs index 01afe85b8b..be873c4b67 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs @@ -805,6 +805,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph Canvas canvas; var tree = new TestRoot { + ClientSize = new Size(100, 100), Child = decorator = new Decorator { Margin = new Thickness(0, 10, 0, 0), diff --git a/tests/Avalonia.Controls.UnitTests/BorderTests.cs b/tests/Avalonia.Controls.UnitTests/BorderTests.cs index ab33eaaff9..7af7d1cee2 100644 --- a/tests/Avalonia.Controls.UnitTests/BorderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/BorderTests.cs @@ -1,6 +1,8 @@ +using Avalonia.Layout; using Avalonia.Media; using Avalonia.Rendering; using Avalonia.UnitTests; +using Avalonia.VisualTree; using Moq; using Xunit; @@ -60,5 +62,68 @@ namespace Avalonia.Controls.UnitTests renderer.Verify(x => x.AddDirty(target), Times.Once); } + + public class UseLayoutRounding + { + [Fact] + public void Measure_Rounds_Padding() + { + var target = new Border + { + Padding = new Thickness(1), + Child = new Canvas + { + Width = 101, + Height = 101, + } + }; + + var root = CreatedRoot(1.5, target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // - 1 pixel padding is rounded up to 1.3333; for both sides it is 2.6666 + // - Size of 101 gets rounded up to 101.3333 + // - Desired size = 101.3333 + 2.6666 = 104 + Assert.Equal(new Size(104, 104), target.DesiredSize); + } + + [Fact] + public void Measure_Rounds_BorderThickness() + { + var target = new Border + { + BorderThickness = new Thickness(1), + Child = new Canvas + { + Width = 101, + Height = 101, + } + }; + + var root = CreatedRoot(1.5, target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // - 1 pixel border thickness is rounded up to 1.3333; for both sides it is 2.6666 + // - Size of 101 gets rounded up to 101.3333 + // - Desired size = 101.3333 + 2.6666 = 104 + Assert.Equal(new Size(104, 104), target.DesiredSize); + } + + private static TestRoot CreatedRoot( + double scaling, + Control child, + Size? constraint = null) + { + return new TestRoot + { + LayoutScaling = scaling, + UseLayoutRounding = true, + Child = child, + ClientSize = constraint ?? new Size(1000, 1000), + }; + } + } } } diff --git a/tests/Avalonia.Controls.UnitTests/DecoratorTests.cs b/tests/Avalonia.Controls.UnitTests/DecoratorTests.cs index 65749efbf9..fe58cd4c7f 100644 --- a/tests/Avalonia.Controls.UnitTests/DecoratorTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DecoratorTests.cs @@ -1,6 +1,7 @@ using System.Collections.Specialized; using System.Linq; using Avalonia.LogicalTree; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Controls.UnitTests @@ -116,5 +117,45 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Size(16, 16), target.DesiredSize); } + + public class UseLayoutRounding + { + [Fact] + public void Measure_Rounds_Padding() + { + var target = new Decorator + { + Padding = new Thickness(1), + Child = new Canvas + { + Width = 101, + Height = 101, + } + }; + + var root = CreatedRoot(1.5, target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // - 1 pixel padding is rounded up to 1.3333; for both sides it is 2.6666 + // - Size of 101 gets rounded up to 101.3333 + // - Desired size = 101.3333 + 2.6666 = 104 + Assert.Equal(new Size(104, 104), target.DesiredSize); + } + + private static TestRoot CreatedRoot( + double scaling, + Control child, + Size? constraint = null) + { + return new TestRoot + { + LayoutScaling = scaling, + UseLayoutRounding = true, + Child = child, + ClientSize = constraint ?? new Size(1000, 1000), + }; + } + } } } diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs index ed44fbfc32..e82050528f 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs @@ -1,5 +1,6 @@ using Avalonia.Controls.Presenters; using Avalonia.Layout; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Controls.UnitTests.Presenters @@ -232,5 +233,68 @@ namespace Avalonia.Controls.UnitTests.Presenters Assert.Equal(new Rect(32, 32, 0, 0), content.Bounds); } + + public class UseLayoutRounding + { + [Fact] + public void Measure_Rounds_Padding() + { + var target = new ContentPresenter + { + Padding = new Thickness(1), + Content = new Canvas + { + Width = 101, + Height = 101, + } + }; + + var root = CreatedRoot(1.5, target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // - 1 pixel padding is rounded up to 1.3333; for both sides it is 2.6666 + // - Size of 101 gets rounded up to 101.3333 + // - Desired size = 101.3333 + 2.6666 = 104 + Assert.Equal(new Size(104, 104), target.DesiredSize); + } + + [Fact] + public void Measure_Rounds_BorderThickness() + { + var target = new ContentPresenter + { + BorderThickness = new Thickness(1), + Content = new Canvas + { + Width = 101, + Height = 101, + } + }; + + var root = CreatedRoot(1.5, target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // - 1 pixel border thickness is rounded up to 1.3333; for both sides it is 2.6666 + // - Size of 101 gets rounded up to 101.3333 + // - Desired size = 101.3333 + 2.6666 = 104 + Assert.Equal(new Size(104, 104), target.DesiredSize); + } + + private static TestRoot CreatedRoot( + double scaling, + Control child, + Size? constraint = null) + { + return new TestRoot + { + LayoutScaling = scaling, + UseLayoutRounding = true, + Child = child, + ClientSize = constraint ?? new Size(1000, 1000), + }; + } + } } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs index 59276a94d0..f4001a8ca1 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs @@ -67,7 +67,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Measure(new Size(100, 100)); target.Arrange(new Rect(0, 0, 100, 100)); - Assert.Equal(new Rect(33, 0, 33, 12), thumb.Bounds); + Assert.Equal(new Rect(33, 0, 34, 12), thumb.Bounds); } [Fact] @@ -92,7 +92,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Measure(new Size(100, 100)); target.Arrange(new Rect(0, 0, 100, 100)); - Assert.Equal(new Rect(0, 33, 12, 33), thumb.Bounds); + Assert.Equal(new Rect(0, 33, 12, 34), thumb.Bounds); } [Fact] diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs index 4601dd7e5b..41e29a85c4 100644 --- a/tests/Avalonia.UnitTests/TestRoot.cs +++ b/tests/Avalonia.UnitTests/TestRoot.cs @@ -41,7 +41,7 @@ namespace Avalonia.UnitTests Child = child; } - public Size ClientSize { get; set; } = new Size(100, 100); + public Size ClientSize { get; set; } = new Size(1000, 1000); public Size MaxClientSize { get; set; } = Size.Infinity; @@ -110,5 +110,10 @@ namespace Avalonia.UnitTests } Visit(this, true); } + + protected override Size MeasureOverride(Size availableSize) + { + return base.MeasureOverride(ClientSize); + } } } diff --git a/tests/TestFiles/Direct2D1/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png b/tests/TestFiles/Direct2D1/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png index e8624fa457f37565fdc483c474424991e7b696d3..8ca7acb845855a306af678ca22bf356780b0cba5 100644 GIT binary patch delta 679 zcmV;Y0$Ba<1^)$*K~kwnL_t(|UhUekZWBQe#_=;P6++?#fR>V$0tr#VYaoFHqUQ}j zqTo5`httHZz&q=s2Hqv))HO`Y32D$yLd9>u&fZ`DxwlC}=FnUCDFn zb~hwfB%e-?y6(N?m3510L7y(9cZxMYmZy?;*7ZBU6*{8087t3497`|8dQtY)w2jpd zI48ECk8!M=KM(V@`+Pai$bR5kf9XB6Z)|uhd2QVdQz!pLdV0cBZb@`8HRag;K+M;? z;aHdSi`N5*K2`Hla({5Vt(ZOZ#NSvK4$N0EeFe#vL8PG9QqUXUpNcg}-LZ0FO;UF( z=Ij4hlf?awSyyP(qe}|-ZJRF&dLiyeK3W$J+vcl@g4WHd?g*yxM3N(Nf2xO0VUmpqHFGSJWiy@&1&RWbC@rm ztb!hX=IF_7iN`(3BkN9*wOMz~y2TtNqsE0K)nnFRNDefgl-zmV71Czl57L(8d1Q-wI5&g@gbA N002ovPDHLkV1j5cRr>${ delta 663 zcmV;I0%-mJ1@HxsK~kDYL_t(|UhUgIa??N*$8nhhgpv-9Kst&PbZ~-{bWo>HI%eP$ zbliY0bTFi#WQHbHCZVECE@0mCYC&Ev)~@tc(Vy>U=9gVr&L&sF5? z2a=zgt*ZJWIdk3Ow4gWZj_kMw*zr#C&2{Yze1z^>+>Eo|l*ZA6u^yCDYTA#}F7b|b z1-+DUQlBp7XTR-n+9lr6_JMtu-Y0j%e~v?mF0?|+c*2;U(Dpd(9@K4;4-y?YmRFK4 z^g(@eT|_WHMd&L`zBFy;-KXYy|(KU{b8zDnNKxsvFW zH-0;ezHL90^u_#aD^}3IBtx9|jEVjQPyh0jnrD(F8Df4|Lt|DbXw1sJ6tu3b_2LwN zfr_3W`0X%Otk(2a znEu5s`c3DXRyVzJM1r%nkRJ0aMM(FjDeD5@R(^af#<|TF^PCdjI796AKN{2Wx1| x3I&Z>NnUc)osL*?lhFbeli&gb8GzoZ>JL{KmA-v_Vov}7002ovPDHLkV1ik5Q|$l% diff --git a/tests/TestFiles/Skia/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png b/tests/TestFiles/Skia/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png index 7bd622050e00416936f55efbc6d96232c67bd8c8..a76c6a5b2a824ec785875d92dd0b38407e3049c3 100644 GIT binary patch delta 490 zcmZ3>GKFP=N&RV07srr_IdAW5%)1;Q!uDXlX#W9ggKvom4qEyL4~4P0&0{{ck2_jY zz?fNE$h%Z>(W=lXQ?j31ZCk(J|C5mA@slS#KgDbNF)^S4hwFN;%CwgrT&UI0`gre( zyBn{Z`^PAr`Jt|XYkILgL&iym%_{3BPpMbF-QIRwbLpF=FDusivZW`ln|z07Z}8Ve zAHMMX-Jp@P^FYYz`%&?e|M>j%d7pLG=tA@w(@X6Ni@OMP+s%{|8UZRef2 zOxJB?{S~_W*Ou5%Gj2?N7|#)W?H1GDRgZPpV+}8q9Cgq?Fkj{U;Tz2DaF{%vno$(gm^S9kO54e|f1`v3Z(cq0>#J*i*%C*Mn*p3gV8zGmi= zcRgFP)>dv@6<_N1-{NIR@src0`e9#J_nmMI=P`-C@@dwgWy2)Tif!}f*BA9YQBr<>Yv<0+ed$LhGhl$6qoFtRV$DAqv>tEFoHy;= zjj8AUHLmvgqjP}4f0`Ym-l@|ncRSzK*B<`-V%Iyxa-Mt3j*FJP@}0!LwOcIxMdrlE z`EBc_9=&?AZmz%QzU4W~PG3(r%l<3q)s%g)(bchAU-i5`|6g~~!uOv(cvj{7-f@I; zZO^|R^Na=Arn~kV?eHzSTz#h~bwk)M4ct2k#G&)%(W*Dsx)C6j$|eyBU2 z#3_f(+TH&xR=QqWGxcS(?x8Q~dmJTy>788NygzpSjqgWi)>y6GoVM2Iepc=R`^UZi z3We58xyrynyb(IzS%pj{N(kv(qntpW!+KP9r*m+Cc8U!>XmlI yb8njNTQ2ZS)p@fv+pJj4eL2tLs~9ojqI);H&U7`F+X Date: Mon, 16 May 2022 17:08:53 +0200 Subject: [PATCH 04/19] Fix comparison. --- .../Converters/MenuScrollingVisibilityConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Converters/MenuScrollingVisibilityConverter.cs b/src/Avalonia.Controls/Converters/MenuScrollingVisibilityConverter.cs index 6ab2f4c517..9d859a753a 100644 --- a/src/Avalonia.Controls/Converters/MenuScrollingVisibilityConverter.cs +++ b/src/Avalonia.Controls/Converters/MenuScrollingVisibilityConverter.cs @@ -26,7 +26,7 @@ namespace Avalonia.Controls.Converters if (visibility == ScrollBarVisibility.Auto) { - if (extent == viewport) + if (MathUtilities.AreClose(extent, viewport)) { return false; } From a794d1765a7e1d0f19ad6903fa4c3a1ed6f27299 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Wed, 15 Jun 2022 18:46:51 +0200 Subject: [PATCH 05/19] Initial implementation of TextAlignment.Justify --- src/Avalonia.Base/Media/GlyphRun.cs | 7 +- src/Avalonia.Base/Media/TextAlignment.cs | 5 + .../Media/TextFormatting/TextFormatterImpl.cs | 4 +- .../Media/TextFormatting/TextLine.cs | 20 +--- .../Media/TextFormatting/TextLineImpl.cs | 111 ++++++++++++++++++ src/Avalonia.Controls/TextBlock.cs | 4 +- 6 files changed, 128 insertions(+), 23 deletions(-) diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index 25c35a28e5..d6f9043455 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/src/Avalonia.Base/Media/GlyphRun.cs @@ -740,10 +740,9 @@ namespace Avalonia.Media private void Set(ref T field, T value) { - if (_glyphRunImpl != null) - { - throw new InvalidOperationException("GlyphRun can't be changed after it has been initialized.'"); - } + _glyphRunImpl?.Dispose(); + + _glyphRunImpl = null; _glyphRunMetrics = null; diff --git a/src/Avalonia.Base/Media/TextAlignment.cs b/src/Avalonia.Base/Media/TextAlignment.cs index b1a394e157..fdcaea2d46 100644 --- a/src/Avalonia.Base/Media/TextAlignment.cs +++ b/src/Avalonia.Base/Media/TextAlignment.cs @@ -19,5 +19,10 @@ namespace Avalonia.Media /// The text is right-aligned. /// Right, + + /// + /// The text is layed out so each line is stretched to an equal width. + /// + Justify } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index 4205268bc6..f6c9c85889 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -431,9 +431,9 @@ namespace Avalonia.Media.TextFormatting break; } - case DrawableTextRun drawableTextRun: + default: { - textRuns.Add(drawableTextRun); + textRuns.Add(textRun); break; } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLine.cs b/src/Avalonia.Base/Media/TextFormatting/TextLine.cs index 1f69c15acc..afd0516f56 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLine.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLine.cs @@ -218,24 +218,14 @@ namespace Avalonia.Media.TextFormatting return Math.Max(0, (paragraphWidth - width) / 2); case TextAlignment.Right: - return Math.Max(0, paragraphWidth - widthIncludingTrailingWhitespace); + return flowDirection == FlowDirection.LeftToRight ? Math.Max(0, paragraphWidth - widthIncludingTrailingWhitespace) : 0; - default: - return 0; + case TextAlignment.Left: + return flowDirection == FlowDirection.LeftToRight ? 0 : Math.Max(0, paragraphWidth - widthIncludingTrailingWhitespace); } } - - switch (textAlignment) - { - case TextAlignment.Center: - return Math.Max(0, (paragraphWidth - width) / 2); - - case TextAlignment.Right: - return 0; - - default: - return Math.Max(0, paragraphWidth - widthIncludingTrailingWhitespace); - } + + return 0; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 8b5e2cc2ce..ff9d2cc868 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting @@ -708,11 +709,121 @@ namespace Avalonia.Media.TextFormatting { _textLineMetrics = CreateLineMetrics(); + Justify(); + BidiReorder(); return this; } + private void Justify() + { + if (_paragraphProperties.TextAlignment != TextAlignment.Justify) + { + return; + } + + var paragraphWidth = _paragraphWidth; + + if (double.IsInfinity(paragraphWidth)) + { + return; + } + + if(_textLineMetrics.NewLineLength > 0) + { + return; + } + + if(TextLineBreak is not null && TextLineBreak.TextEndOfLine is not null) + { + if(TextLineBreak.RemainingRuns is null || TextLineBreak.RemainingRuns.Count == 0) + { + return; + } + } + + var breakOportunities = new Queue(); + + foreach (var textRun in TextRuns) + { + var text = textRun.Text; + + if (text.IsEmpty) + { + continue; + } + + var start = text.Start; + + var lineBreakEnumerator = new LineBreakEnumerator(text); + + while (lineBreakEnumerator.MoveNext()) + { + var currentBreak = lineBreakEnumerator.Current; + + if (!currentBreak.Required && currentBreak.PositionWrap != text.Length) + { + breakOportunities.Enqueue(start + currentBreak.PositionMeasure); + } + } + } + + if(breakOportunities.Count == 0) + { + return; + } + + var remainingSpace = Math.Max(0, paragraphWidth - WidthIncludingTrailingWhitespace); + var spacing = remainingSpace / breakOportunities.Count; + + foreach (var textRun in TextRuns) + { + var text = textRun.Text; + + if (text.IsEmpty) + { + continue; + } + + if(textRun is ShapedTextCharacters shapedText) + { + var glyphRun = shapedText.GlyphRun; + var shapedBuffer = shapedText.ShapedBuffer; + var currentPosition = text.Start; + + while(breakOportunities.Count > 0) + { + var characterIndex = breakOportunities.Dequeue(); + + if (characterIndex < currentPosition) + { + continue; + } + + var glyphIndex = glyphRun.FindGlyphIndex(characterIndex); + var glyphInfo = shapedBuffer.GlyphInfos[glyphIndex]; + + shapedBuffer.GlyphInfos[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex, glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing); + } + + glyphRun.GlyphAdvances = shapedBuffer.GlyphAdvances; + } + } + + var trailingWhitespaceWidth = _textLineMetrics.WidthIncludingTrailingWhitespace - _textLineMetrics.Width; + + _textLineMetrics = new TextLineMetrics( + _textLineMetrics.HasOverflowed, + _textLineMetrics.Height, + _textLineMetrics.NewLineLength, + _textLineMetrics.Start, + _textLineMetrics.TextBaseline, + _textLineMetrics.TrailingWhitespaceLength, + paragraphWidth - trailingWhitespaceWidth, + paragraphWidth); + } + private static sbyte GetRunBidiLevel(DrawableTextRun run, FlowDirection flowDirection) { if (run is ShapedTextCharacters shapedTextCharacters) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 1a69d1218c..c5893167b3 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -748,14 +748,14 @@ namespace Avalonia.Controls { if (textSourceIndex > _text.Length) { - return null; + return new TextEndOfParagraph(); } var runText = _text.Skip(textSourceIndex); if (runText.IsEmpty) { - return null; + return new TextEndOfParagraph(); } return new TextCharacters(runText, _defaultProperties); From d26a9894064e85b59d1ca569917ca8e5b63e0f19 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Thu, 16 Jun 2022 19:11:18 +0200 Subject: [PATCH 06/19] Add more alignment options --- src/Avalonia.Base/Media/TextAlignment.cs | 20 +++++- .../Media/TextFormatting/TextFormatterImpl.cs | 26 +++---- .../Media/TextFormatting/TextLine.cs | 39 +---------- .../Media/TextFormatting/TextLineImpl.cs | 70 ++++++++++++++++--- 4 files changed, 91 insertions(+), 64 deletions(-) diff --git a/src/Avalonia.Base/Media/TextAlignment.cs b/src/Avalonia.Base/Media/TextAlignment.cs index fdcaea2d46..94416ccde2 100644 --- a/src/Avalonia.Base/Media/TextAlignment.cs +++ b/src/Avalonia.Base/Media/TextAlignment.cs @@ -21,7 +21,25 @@ namespace Avalonia.Media Right, /// - /// The text is layed out so each line is stretched to an equal width. + /// The beginning of the text is aligned to the edge of the available space. + /// + Start, + + /// + /// The end of the text is aligned to the edge of the available space. + /// + End, + + /// + /// Text alignment is inferred from the text content. + /// + /// + /// When the TextAlignment property is set to DetectFromContent, alignment is inferred from the text content of the control. For example, English text is left aligned, and Arabic text is right aligned. + /// + DetectFromContent, + + /// + /// Text is justified within the available space. /// Justify } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index f6c9c85889..5f9c230027 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -15,7 +15,7 @@ namespace Avalonia.Media.TextFormatting TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null) { var textWrapping = paragraphProperties.TextWrapping; - FlowDirection flowDirection; + FlowDirection resolvedFlowDirection; TextLineBreak? nextLineBreak = null; List drawableTextRuns; @@ -24,17 +24,17 @@ namespace Avalonia.Media.TextFormatting if (previousLineBreak?.RemainingRuns != null) { - flowDirection = previousLineBreak.FlowDirection; + resolvedFlowDirection = previousLineBreak.FlowDirection; drawableTextRuns = previousLineBreak.RemainingRuns.ToList(); nextLineBreak = previousLineBreak; } else { - drawableTextRuns = ShapeTextRuns(textRuns, paragraphProperties, out flowDirection); + drawableTextRuns = ShapeTextRuns(textRuns, paragraphProperties, out resolvedFlowDirection); if (nextLineBreak == null && textEndOfLine != null) { - nextLineBreak = new TextLineBreak(textEndOfLine, flowDirection); + nextLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection); } } @@ -45,7 +45,7 @@ namespace Avalonia.Media.TextFormatting case TextWrapping.NoWrap: { textLine = new TextLineImpl(drawableTextRuns, firstTextSourceIndex, textSourceLength, - paragraphWidth, paragraphProperties, flowDirection, nextLineBreak); + paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak); textLine.FinalizeLine(); @@ -55,7 +55,7 @@ namespace Avalonia.Media.TextFormatting case TextWrapping.Wrap: { textLine = PerformTextWrapping(drawableTextRuns, firstTextSourceIndex, paragraphWidth, paragraphProperties, - flowDirection, nextLineBreak); + resolvedFlowDirection, nextLineBreak); break; } default: @@ -404,10 +404,6 @@ namespace Avalonia.Media.TextFormatting { endOfLine = textEndOfLine; - textRuns.Add(textRun); - - textSourceLength += textRun.TextSourceLength; - break; } @@ -552,11 +548,11 @@ namespace Avalonia.Media.TextFormatting /// The first text source index. /// The paragraph width. /// The text paragraph properties. - /// + /// /// The current line break if the line was explicitly broken. /// The wrapped text line. private static TextLineImpl PerformTextWrapping(List textRuns, int firstTextSourceIndex, - double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection flowDirection, + double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection resolvedFlowDirection, TextLineBreak? currentLineBreak) { if(textRuns.Count == 0) @@ -684,16 +680,16 @@ namespace Avalonia.Media.TextFormatting var remainingCharacters = splitResult.Second; var lineBreak = remainingCharacters?.Count > 0 ? - new TextLineBreak(currentLineBreak?.TextEndOfLine, flowDirection, remainingCharacters) : + new TextLineBreak(currentLineBreak?.TextEndOfLine, resolvedFlowDirection, remainingCharacters) : null; if (lineBreak is null && currentLineBreak?.TextEndOfLine != null) { - lineBreak = new TextLineBreak(currentLineBreak.TextEndOfLine, flowDirection); + lineBreak = new TextLineBreak(currentLineBreak.TextEndOfLine, resolvedFlowDirection); } var textLine = new TextLineImpl(splitResult.First, firstTextSourceIndex, measuredLength, - paragraphWidth, paragraphProperties, flowDirection, + paragraphWidth, paragraphProperties, resolvedFlowDirection, lineBreak); return textLine.FinalizeLine(); diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLine.cs b/src/Avalonia.Base/Media/TextFormatting/TextLine.cs index afd0516f56..88a8d9d985 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLine.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLine.cs @@ -15,7 +15,7 @@ namespace Avalonia.Media.TextFormatting /// The contained text runs. /// public abstract IReadOnlyList TextRuns { get; } - + public abstract int FirstTextSourceIndex { get; } public abstract int Length { get; } @@ -75,7 +75,7 @@ namespace Avalonia.Media.TextFormatting /// The number of newline characters. /// public abstract int NewLineLength { get; } - + /// /// Gets the distance that black pixels extend beyond the bottom alignment edge of a line. /// @@ -192,40 +192,5 @@ namespace Avalonia.Media.TextFormatting /// number of characters of the specified range /// an array of bounding rectangles. public abstract IReadOnlyList GetTextBounds(int firstTextSourceCharacterIndex, int textLength); - - /// - /// Gets the text line offset x. - /// - /// The line width. - /// The paragraph width including whitespace. - /// The paragraph width. - /// The text alignment. - /// The flow direction of the line. - /// The paragraph offset. - internal static double GetParagraphOffsetX(double width, double widthIncludingTrailingWhitespace, - double paragraphWidth, TextAlignment textAlignment, FlowDirection flowDirection) - { - if (double.IsPositiveInfinity(paragraphWidth)) - { - return 0; - } - - if (flowDirection == FlowDirection.LeftToRight) - { - switch (textAlignment) - { - case TextAlignment.Center: - return Math.Max(0, (paragraphWidth - width) / 2); - - case TextAlignment.Right: - return flowDirection == FlowDirection.LeftToRight ? Math.Max(0, paragraphWidth - widthIncludingTrailingWhitespace) : 0; - - case TextAlignment.Left: - return flowDirection == FlowDirection.LeftToRight ? 0 : Math.Max(0, paragraphWidth - widthIncludingTrailingWhitespace); - } - } - - return 0; - } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index ff9d2cc868..ce7edaa5da 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -11,10 +11,10 @@ namespace Avalonia.Media.TextFormatting private readonly double _paragraphWidth; private readonly TextParagraphProperties _paragraphProperties; private TextLineMetrics _textLineMetrics; - private readonly FlowDirection _flowDirection; + private readonly FlowDirection _resolvedFlowDirection; public TextLineImpl(List textRuns, int firstTextSourceIndex, int length, double paragraphWidth, - TextParagraphProperties paragraphProperties, FlowDirection flowDirection = FlowDirection.LeftToRight, + TextParagraphProperties paragraphProperties, FlowDirection resolvedFlowDirection = FlowDirection.LeftToRight, TextLineBreak? lineBreak = null, bool hasCollapsed = false) { FirstTextSourceIndex = firstTextSourceIndex; @@ -26,7 +26,7 @@ namespace Avalonia.Media.TextFormatting _paragraphWidth = paragraphWidth; _paragraphProperties = paragraphProperties; - _flowDirection = flowDirection; + _resolvedFlowDirection = resolvedFlowDirection; } /// @@ -137,7 +137,7 @@ namespace Avalonia.Media.TextFormatting } var collapsedLine = new TextLineImpl(collapsedRuns, FirstTextSourceIndex, Length, _paragraphWidth, _paragraphProperties, - _flowDirection, TextLineBreak, true); + _resolvedFlowDirection, TextLineBreak, true); if (collapsedRuns.Count > 0) { @@ -168,7 +168,7 @@ namespace Avalonia.Media.TextFormatting return shapedTextCharacters.GlyphRun.GetCharacterHitFromDistance(distance, out _); } - return _flowDirection == FlowDirection.LeftToRight ? + return _resolvedFlowDirection == FlowDirection.LeftToRight ? new CharacterHit(FirstTextSourceIndex) : new CharacterHit(FirstTextSourceIndex + Length); } @@ -261,7 +261,7 @@ namespace Avalonia.Media.TextFormatting //Look at the left and right edge of the current run if (currentRun.IsLeftToRight) { - if (_flowDirection == FlowDirection.LeftToRight && (lastRun == null || lastRun.IsLeftToRight)) + if (_resolvedFlowDirection == FlowDirection.LeftToRight && (lastRun == null || lastRun.IsLeftToRight)) { if (characterIndex <= currentPosition) { @@ -846,7 +846,7 @@ namespace Avalonia.Media.TextFormatting // Build up the collection of ordered runs. var run = _textRuns[0]; - OrderedBidiRun orderedRun = new(run, GetRunBidiLevel(run, _flowDirection)); + OrderedBidiRun orderedRun = new(run, GetRunBidiLevel(run, _resolvedFlowDirection)); var current = orderedRun; @@ -854,7 +854,7 @@ namespace Avalonia.Media.TextFormatting { run = _textRuns[i]; - current.Next = new OrderedBidiRun(run, GetRunBidiLevel(run, _flowDirection)); + current.Next = new OrderedBidiRun(run, GetRunBidiLevel(run, _resolvedFlowDirection)); current = current.Next; } @@ -873,7 +873,7 @@ namespace Avalonia.Media.TextFormatting { var currentRun = _textRuns[i]; - var level = GetRunBidiLevel(currentRun, _flowDirection); + var level = GetRunBidiLevel(currentRun, _resolvedFlowDirection); if (level > max) { @@ -1353,8 +1353,7 @@ namespace Avalonia.Media.TextFormatting } } - var start = GetParagraphOffsetX(width, widthIncludingWhitespace, _paragraphWidth, - _paragraphProperties.TextAlignment, _paragraphProperties.FlowDirection); + var start = GetParagraphOffsetX(width, widthIncludingWhitespace); if (!double.IsNaN(lineHeight) && !MathUtilities.IsZero(lineHeight)) { @@ -1368,6 +1367,55 @@ namespace Avalonia.Media.TextFormatting -ascent, trailingWhitespaceLength, width, widthIncludingWhitespace); } + /// + /// Gets the text line offset x. + /// + /// The line width. + /// The paragraph width including whitespace. + + /// The paragraph offset. + private double GetParagraphOffsetX(double width, double widthIncludingTrailingWhitespace) + { + if (double.IsPositiveInfinity(_paragraphWidth)) + { + return 0; + } + + var textAlignment = _paragraphProperties.TextAlignment; + var paragraphFlowDirection = _paragraphProperties.FlowDirection; + + switch (textAlignment) + { + case TextAlignment.Start: + { + textAlignment = paragraphFlowDirection == FlowDirection.LeftToRight ? TextAlignment.Left : TextAlignment.Right; + break; + } + case TextAlignment.End: + { + textAlignment = paragraphFlowDirection == FlowDirection.RightToLeft ? TextAlignment.Left : TextAlignment.Right; + break; + } + case TextAlignment.DetectFromContent: + { + textAlignment = _resolvedFlowDirection == FlowDirection.LeftToRight ? TextAlignment.Left : TextAlignment.Right; + break; + } + } + + switch (textAlignment) + { + case TextAlignment.Center: + return Math.Max(0, (_paragraphWidth - width) / 2); + + case TextAlignment.Right: + return Math.Max(0, _paragraphWidth - widthIncludingTrailingWhitespace); + + default: + return 0; + } + } + private sealed class OrderedBidiRun { public OrderedBidiRun(DrawableTextRun run, sbyte level) From 6502fa1ef7682ce86490af304e31a57a51f6bfd3 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 17 Jun 2022 14:18:05 +0200 Subject: [PATCH 07/19] More TextAlignment fixes --- .../Media/TextFormatting/TextFormatterImpl.cs | 4 + .../Media/TextFormatting/TextLayout.cs | 2 +- .../TextFormatting/TextFormatterTests.cs | 97 ++++++++----------- 3 files changed, 46 insertions(+), 57 deletions(-) diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index 5f9c230027..16caadb0dd 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -404,6 +404,10 @@ namespace Avalonia.Media.TextFormatting { endOfLine = textEndOfLine; + textSourceLength += textEndOfLine.TextSourceLength; + + textRuns.Add(textRun); + break; } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index 4f7c43a6d1..85d035c446 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs @@ -439,7 +439,7 @@ namespace Avalonia.Media.TextFormatting var textLine = TextFormatter.Current.FormatLine(_textSource, _textSourceLength, MaxWidth, _paragraphProperties, previousLine?.TextLineBreak); - if(textLine == null || textLine.Length == 0) + if(textLine == null || textLine.Length == 0 || textLine.TextRuns.Count == 0 && textLine.TextLineBreak?.TextEndOfLine is TextEndOfParagraph) { if(previousLine != null && previousLine.NewLineLength > 0) { diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index d395f68f96..48dbfa5985 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -134,7 +134,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default); const string text = "👍 👍 👍 👍"; - + var textSource = new SingleBufferTextSource(text, defaultProperties); var formatter = new TextFormatterImpl(); @@ -144,7 +144,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting new GenericTextParagraphProperties(defaultProperties)); Assert.Equal(1, textLine.TextRuns.Count); - } + } } [Fact] @@ -163,9 +163,9 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new GenericTextParagraphProperties(defaultProperties)); - + var firstRun = textLine.TextRuns[0]; - + Assert.Equal(4, firstRun.Text.Length); } } @@ -191,7 +191,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var textLine = formatter.FormatLine(textSource, currentPosition, 1, - new GenericTextParagraphProperties(defaultProperties, textWrap : TextWrapping.WrapWithOverflow)); + new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.WrapWithOverflow)); if (text.Length - currentPosition > expectedCharactersPerLine) { @@ -347,8 +347,8 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } - [InlineData("Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor", - new []{ "Lorem ipsum ", "dolor sit amet, ", "consectetur ", "adipisicing elit, ", "sed do eiusmod "})] + [InlineData("Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor", + new[] { "Lorem ipsum ", "dolor sit amet, ", "consectetur ", "adipisicing elit, ", "sed do eiusmod " })] [Theory] public void Should_Produce_Wrapped_And_Trimmed_Lines(string text, string[] expectedLines) @@ -368,7 +368,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting new ValueSpan(28, 28, new GenericTextRunProperties(new Typeface("Verdana", FontStyle.Italic),32)) }; - + var textSource = new FormattedTextSource(text.AsMemory(), defaultProperties, styleSpans); var formatter = new TextFormatterImpl(); @@ -389,19 +389,19 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting if (textLine.Width > 300 || currentHeight + textLine.Height > 240) { - textLine = textLine.Collapse(new TextTrailingWordEllipsis(new ReadOnlySlice(new[] {TextTrimming.s_defaultEllipsisChar}), 300, defaultProperties)); + textLine = textLine.Collapse(new TextTrailingWordEllipsis(new ReadOnlySlice(new[] { TextTrimming.s_defaultEllipsisChar }), 300, defaultProperties)); } - + currentHeight += textLine.Height; var currentText = text.Substring(textLine.FirstTextSourceIndex, textLine.Length); - + Assert.Equal(expectedLines[currentLineIndex], currentText); currentLineIndex++; } - - Assert.Equal(expectedLines.Length,currentLineIndex); + + Assert.Equal(expectedLines.Length, currentLineIndex); } } @@ -412,11 +412,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting [InlineData("0123456789", TextAlignment.Left, FlowDirection.RightToLeft)] [InlineData("0123456789", TextAlignment.Center, FlowDirection.RightToLeft)] [InlineData("0123456789", TextAlignment.Right, FlowDirection.RightToLeft)] - + [InlineData("שנבגק", TextAlignment.Left, FlowDirection.RightToLeft)] [InlineData("שנבגק", TextAlignment.Center, FlowDirection.RightToLeft)] [InlineData("שנבגק", TextAlignment.Right, FlowDirection.RightToLeft)] - + [Theory] public void Should_Align_TextLine(string text, TextAlignment textAlignment, FlowDirection flowDirection) { @@ -426,44 +426,29 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var paragraphProperties = new GenericTextParagraphProperties(flowDirection, textAlignment, true, true, defaultProperties, TextWrapping.NoWrap, 0, 0); - + var textSource = new SingleBufferTextSource(text, defaultProperties); var formatter = new TextFormatterImpl(); - + var textLine = formatter.FormatLine(textSource, 0, 100, paragraphProperties); var expectedOffset = 0d; - if (flowDirection == FlowDirection.LeftToRight) + switch (textAlignment) { - switch (textAlignment) - { - case TextAlignment.Center: - expectedOffset = 50 - textLine.Width / 2; - break; - case TextAlignment.Right: - expectedOffset = 100 - textLine.WidthIncludingTrailingWhitespace; - break; - } - } - else - { - switch (textAlignment) - { - case TextAlignment.Left: - expectedOffset = 100 - textLine.WidthIncludingTrailingWhitespace; - break; - case TextAlignment.Center: - expectedOffset = 50 - textLine.Width / 2; - break; - } + case TextAlignment.Center: + expectedOffset = 50 - textLine.Width / 2; + break; + case TextAlignment.Right: + expectedOffset = 100 - textLine.WidthIncludingTrailingWhitespace; + break; } Assert.Equal(expectedOffset, textLine.Start); } } - + [Fact] public void Should_Wrap_Syriac() { @@ -488,7 +473,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting formatter.FormatLine(textSource, textPosition, 50, paragraphProperties, lastBreak); Assert.Equal(textLine.Length, textLine.TextRuns.Sum(x => x.TextSourceLength)); - + textPosition += textLine.Length; lastBreak = textLine.TextLineBreak; @@ -503,13 +488,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var defaultProperties = new GenericTextRunProperties(Typeface.Default); var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); - + var textSource = new SingleBufferTextSource("0123456789_0123456789_0123456789_0123456789", defaultProperties); var formatter = new TextFormatterImpl(); - + var textLine = formatter.FormatLine(textSource, 0, 33, paragraphProperties); - + Assert.NotNull(textLine.TextLineBreak?.RemainingRuns); } } @@ -524,12 +509,12 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting using (Start()) { var formatter = new TextFormatterImpl(); - + var defaultProperties = new GenericTextRunProperties(Typeface.Default); var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.NoWrap); - + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); var expectedTextLine = formatter.FormatLine(new SingleBufferTextSource(text, defaultProperties), @@ -548,16 +533,16 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting new ValueSpan(i, j, new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground)) }; - + var textSource = new FormattedTextSource(text.AsMemory(), defaultProperties, spans); - + var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProperties); - + var shapedRuns = textLine.TextRuns.Cast().ToList(); var actualGlyphs = shapedRuns.SelectMany(x => x.GlyphRun.GlyphIndices).ToList(); - + Assert.Equal(expectedGlyphs, actualGlyphs); } } @@ -575,9 +560,9 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var textLine = TextFormatter.Current.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProperties); - + Assert.Equal(3, textLine.TextRuns.Count); - + Assert.True(textLine.TextRuns[1] is RectangleRun); } } @@ -590,12 +575,12 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultRunProperties = new GenericTextRunProperties(Typeface.Default); var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties); var textSource = new EndOfLineTextSource(); - + var textLine = TextFormatter.Current.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProperties); - + Assert.NotNull(textLine.TextLineBreak); - + Assert.Equal(TextRun.DefaultTextSourceLength, textLine.Length); } } @@ -616,7 +601,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { _text = text; } - + public TextRun GetTextRun(int textSourceIndex) { if (textSourceIndex >= _text.Length + TextRun.DefaultTextSourceLength + _text.Length) From 939f5abfcf5d4ce3b566612329e147c7cad1b130 Mon Sep 17 00:00:00 2001 From: Lubomir Tetak Date: Mon, 20 Jun 2022 10:52:13 +0200 Subject: [PATCH 08/19] OffScreenMargin calculation with different DPI monitors calculation --- src/Windows/Avalonia.Win32/WindowImpl.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 8d836ef452..7d7a146920 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Runtime.InteropServices; using Avalonia.Controls; using Avalonia.Automation.Peers; @@ -208,6 +209,8 @@ namespace Avalonia.Win32 } } + public double PrimaryScreenRenderScaling => Screen.AllScreens.FirstOrDefault(screen => screen.Primary)?.PixelDensity ?? 1; + public double RenderScaling => _scaling; public double DesktopScaling => RenderScaling; @@ -919,7 +922,7 @@ namespace Avalonia.Win32 if (WindowState == WindowState.Maximized) { _extendedMargins = new Thickness(0, (borderCaptionThickness.top - borderThickness.top) / RenderScaling, 0, 0); - _offScreenMargin = new Thickness(borderThickness.left / RenderScaling, borderThickness.top / RenderScaling, borderThickness.right / RenderScaling, borderThickness.bottom / RenderScaling); + _offScreenMargin = new Thickness(borderThickness.left / PrimaryScreenRenderScaling, borderThickness.top / PrimaryScreenRenderScaling, borderThickness.right / PrimaryScreenRenderScaling, borderThickness.bottom / PrimaryScreenRenderScaling); } else { From 04b0cb096cb3a7afe154a103157efe86f566e5ff Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Tue, 21 Jun 2022 07:36:37 +0200 Subject: [PATCH 09/19] Change TextAlignmentProperty default --- src/Avalonia.Controls/TextBlock.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index c5893167b3..db315d3aaf 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -113,7 +113,9 @@ namespace Avalonia.Controls /// Defines the property. /// public static readonly AttachedProperty TextAlignmentProperty = - AvaloniaProperty.RegisterAttached(nameof(TextAlignment), + AvaloniaProperty.RegisterAttached( + nameof(TextAlignment), + defaultValue: TextAlignment.Start, inherits: true); /// From 6ab5774b80722d11a5ea7df7f147037b625dc4cc Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Tue, 21 Jun 2022 08:22:40 +0200 Subject: [PATCH 10/19] Introduce TextLine.Justify for custom justification --- .../TextFormatting/InterWordJustification.cs | 109 ++++++++++++++++ .../TextFormatting/JustificationProperties.cs | 16 +++ .../Media/TextFormatting/TextLayout.cs | 29 +++++ .../Media/TextFormatting/TextLine.cs | 17 ++- .../Media/TextFormatting/TextLineImpl.cs | 118 ++---------------- 5 files changed, 177 insertions(+), 112 deletions(-) create mode 100644 src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs create mode 100644 src/Avalonia.Base/Media/TextFormatting/JustificationProperties.cs diff --git a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs new file mode 100644 index 0000000000..df83ada34a --- /dev/null +++ b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using Avalonia.Media.TextFormatting.Unicode; + +namespace Avalonia.Media.TextFormatting +{ + internal class InterWordJustification : JustificationProperties + { + public InterWordJustification(double width) + { + Width = width; + } + + public override double Width { get; } + + public override void Justify(TextLine textLine) + { + var paragraphWidth = Width; + + if (double.IsInfinity(paragraphWidth)) + { + return; + } + + if (textLine.NewLineLength > 0) + { + return; + } + + var textLineBreak = textLine.TextLineBreak; + + if (textLineBreak is not null && textLineBreak.TextEndOfLine is not null) + { + if (textLineBreak.RemainingRuns is null || textLineBreak.RemainingRuns.Count == 0) + { + return; + } + } + + var breakOportunities = new Queue(); + + foreach (var textRun in textLine.TextRuns) + { + var text = textRun.Text; + + if (text.IsEmpty) + { + continue; + } + + var start = text.Start; + + var lineBreakEnumerator = new LineBreakEnumerator(text); + + while (lineBreakEnumerator.MoveNext()) + { + var currentBreak = lineBreakEnumerator.Current; + + if (!currentBreak.Required && currentBreak.PositionWrap != text.Length) + { + breakOportunities.Enqueue(start + currentBreak.PositionMeasure); + } + } + } + + if (breakOportunities.Count == 0) + { + return; + } + + var remainingSpace = Math.Max(0, paragraphWidth - textLine.WidthIncludingTrailingWhitespace); + var spacing = remainingSpace / breakOportunities.Count; + + foreach (var textRun in textLine.TextRuns) + { + var text = textRun.Text; + + if (text.IsEmpty) + { + continue; + } + + if (textRun is ShapedTextCharacters shapedText) + { + var glyphRun = shapedText.GlyphRun; + var shapedBuffer = shapedText.ShapedBuffer; + var currentPosition = text.Start; + + while (breakOportunities.Count > 0) + { + var characterIndex = breakOportunities.Dequeue(); + + if (characterIndex < currentPosition) + { + continue; + } + + var glyphIndex = glyphRun.FindGlyphIndex(characterIndex); + var glyphInfo = shapedBuffer.GlyphInfos[glyphIndex]; + + shapedBuffer.GlyphInfos[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex, glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing); + } + + glyphRun.GlyphAdvances = shapedBuffer.GlyphAdvances; + } + } + } + } +} diff --git a/src/Avalonia.Base/Media/TextFormatting/JustificationProperties.cs b/src/Avalonia.Base/Media/TextFormatting/JustificationProperties.cs new file mode 100644 index 0000000000..620ad17189 --- /dev/null +++ b/src/Avalonia.Base/Media/TextFormatting/JustificationProperties.cs @@ -0,0 +1,16 @@ +namespace Avalonia.Media.TextFormatting +{ + public abstract class JustificationProperties + { + /// + /// Gets the width in which the range is justified. + /// + public abstract double Width { get; } + + /// + /// Justifies given text line. + /// + /// Text line to collapse. + public abstract void Justify(TextLine textLine); + } +} diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index 85d035c446..f3af240c58 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs @@ -501,6 +501,35 @@ namespace Avalonia.Media.TextFormatting Bounds = new Rect(left, 0, width, height); + if(_paragraphProperties.TextAlignment == TextAlignment.Justify) + { + var whitespaceWidth = 0d; + + foreach (var line in textLines) + { + var lineWhitespaceWidth = line.Width - line.WidthIncludingTrailingWhitespace; + + if(lineWhitespaceWidth > whitespaceWidth) + { + whitespaceWidth = lineWhitespaceWidth; + } + } + + var justificationWidth = width - whitespaceWidth; + + if(justificationWidth > 0) + { + var justificationProperties = new InterWordJustification(justificationWidth); + + for (var i = 0; i < textLines.Count - 1; i++) + { + var line = textLines[i]; + + line.Justify(justificationProperties); + } + } + } + return textLines; } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLine.cs b/src/Avalonia.Base/Media/TextFormatting/TextLine.cs index 88a8d9d985..c8a23097db 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLine.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLine.cs @@ -16,8 +16,14 @@ namespace Avalonia.Media.TextFormatting /// public abstract IReadOnlyList TextRuns { get; } + /// + /// Gets the first TextSource position of the current line. + /// public abstract int FirstTextSourceIndex { get; } + /// + /// Gets the total number of TextSource positions of the current line. + /// public abstract int Length { get; } /// @@ -56,7 +62,7 @@ namespace Avalonia.Media.TextFormatting /// Gets a value that indicates whether content of the line overflows the specified paragraph width. /// /// - /// true, it the line overflows the specified paragraph width; otherwise, false. + /// true, the line overflows the specified paragraph width; otherwise, false. /// public abstract bool HasOverflowed { get; } @@ -149,6 +155,15 @@ namespace Avalonia.Media.TextFormatting /// public abstract TextLine Collapse(params TextCollapsingProperties[] collapsingPropertiesList); + /// + /// Create a justified line based on justification text properties. + /// + /// An object that represent the justification text properties. + /// + /// A value that represents a justified line that can be displayed. + /// + public abstract void Justify(JustificationProperties justificationProperties); + /// /// Gets the character hit corresponding to the specified distance from the beginning of the line. /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index ce7edaa5da..7c686358e2 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting @@ -145,7 +144,14 @@ namespace Avalonia.Media.TextFormatting } return collapsedLine; + } + /// + public override void Justify(JustificationProperties justificationProperties) + { + justificationProperties.Justify(this); + + _textLineMetrics = CreateLineMetrics(); } /// @@ -709,121 +715,11 @@ namespace Avalonia.Media.TextFormatting { _textLineMetrics = CreateLineMetrics(); - Justify(); - BidiReorder(); return this; } - private void Justify() - { - if (_paragraphProperties.TextAlignment != TextAlignment.Justify) - { - return; - } - - var paragraphWidth = _paragraphWidth; - - if (double.IsInfinity(paragraphWidth)) - { - return; - } - - if(_textLineMetrics.NewLineLength > 0) - { - return; - } - - if(TextLineBreak is not null && TextLineBreak.TextEndOfLine is not null) - { - if(TextLineBreak.RemainingRuns is null || TextLineBreak.RemainingRuns.Count == 0) - { - return; - } - } - - var breakOportunities = new Queue(); - - foreach (var textRun in TextRuns) - { - var text = textRun.Text; - - if (text.IsEmpty) - { - continue; - } - - var start = text.Start; - - var lineBreakEnumerator = new LineBreakEnumerator(text); - - while (lineBreakEnumerator.MoveNext()) - { - var currentBreak = lineBreakEnumerator.Current; - - if (!currentBreak.Required && currentBreak.PositionWrap != text.Length) - { - breakOportunities.Enqueue(start + currentBreak.PositionMeasure); - } - } - } - - if(breakOportunities.Count == 0) - { - return; - } - - var remainingSpace = Math.Max(0, paragraphWidth - WidthIncludingTrailingWhitespace); - var spacing = remainingSpace / breakOportunities.Count; - - foreach (var textRun in TextRuns) - { - var text = textRun.Text; - - if (text.IsEmpty) - { - continue; - } - - if(textRun is ShapedTextCharacters shapedText) - { - var glyphRun = shapedText.GlyphRun; - var shapedBuffer = shapedText.ShapedBuffer; - var currentPosition = text.Start; - - while(breakOportunities.Count > 0) - { - var characterIndex = breakOportunities.Dequeue(); - - if (characterIndex < currentPosition) - { - continue; - } - - var glyphIndex = glyphRun.FindGlyphIndex(characterIndex); - var glyphInfo = shapedBuffer.GlyphInfos[glyphIndex]; - - shapedBuffer.GlyphInfos[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex, glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing); - } - - glyphRun.GlyphAdvances = shapedBuffer.GlyphAdvances; - } - } - - var trailingWhitespaceWidth = _textLineMetrics.WidthIncludingTrailingWhitespace - _textLineMetrics.Width; - - _textLineMetrics = new TextLineMetrics( - _textLineMetrics.HasOverflowed, - _textLineMetrics.Height, - _textLineMetrics.NewLineLength, - _textLineMetrics.Start, - _textLineMetrics.TextBaseline, - _textLineMetrics.TrailingWhitespaceLength, - paragraphWidth - trailingWhitespaceWidth, - paragraphWidth); - } - private static sbyte GetRunBidiLevel(DrawableTextRun run, FlowDirection flowDirection) { if (run is ShapedTextCharacters shapedTextCharacters) From 011232d67bc48d482ff12291b288fcc2a34bfec9 Mon Sep 17 00:00:00 2001 From: Lubomir Tetak Date: Fri, 24 Jun 2022 13:39:55 +0200 Subject: [PATCH 11/19] make PrimaryScreenRenderScaling private --- src/Windows/Avalonia.Win32/WindowImpl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 7d7a146920..01e0fb1afc 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -209,7 +209,7 @@ namespace Avalonia.Win32 } } - public double PrimaryScreenRenderScaling => Screen.AllScreens.FirstOrDefault(screen => screen.Primary)?.PixelDensity ?? 1; + private double PrimaryScreenRenderScaling => Screen.AllScreens.FirstOrDefault(screen => screen.Primary)?.PixelDensity ?? 1; public double RenderScaling => _scaling; From cc2512e2e4b032f1cc48c9011f8c68d42f13131c Mon Sep 17 00:00:00 2001 From: Todd Date: Fri, 24 Jun 2022 17:17:17 -0700 Subject: [PATCH 12/19] ignore +12 when hour is exactly 12 and period is 1 --- src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs b/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs index 7f2abb7e98..82815f56d7 100644 --- a/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs +++ b/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs @@ -194,7 +194,7 @@ namespace Avalonia.Controls if (ClockIdentifier == "12HourClock") { - hr = per == 1 ? hr + 12 : per == 0 && hr == 12 ? 0 : hr; + hr = per == 1 ? (hr == 12) ? 12:hr + 12 : per == 0 && hr == 12 ? 0 : hr; } Time = new TimeSpan(hr, min, 0); From eaf31be6f18773afed8db37003625f05b79ac76c Mon Sep 17 00:00:00 2001 From: Oxc3 Date: Sat, 25 Jun 2022 06:47:43 -0700 Subject: [PATCH 13/19] Update TimePickerPresenter.cs formatting change --- src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs b/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs index 82815f56d7..46c5eaeaaa 100644 --- a/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs +++ b/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs @@ -194,7 +194,7 @@ namespace Avalonia.Controls if (ClockIdentifier == "12HourClock") { - hr = per == 1 ? (hr == 12) ? 12:hr + 12 : per == 0 && hr == 12 ? 0 : hr; + hr = per == 1 ? (hr == 12) ? 12 : hr + 12 : per == 0 && hr == 12 ? 0 : hr; } Time = new TimeSpan(hr, min, 0); From 017788cd8e9aeefcd26e0990ddfacc31701140aa Mon Sep 17 00:00:00 2001 From: robloo Date: Sat, 25 Jun 2022 16:58:38 -0500 Subject: [PATCH 14/19] Add Loaded/Unloaded Events (#8277) * Add Loaded/Unloaded events * Don't allow OnLoaded() twice unless OnUnloaded() is called * Call OnLoadedCore within Render() * Call OnLoadedCore() from OnAttachedToVisualTreeCore by scheduling it on the dispatcher * Improve comments * Queue loaded events * Make the loaded queue static * Make more members static per review * Make sure control wasn't already scheduling for Loaded event * Add locks around HashSet usage for when enumerating * Remove from loaded queue in OnUnloadedCore() as failsafe * Make Window raise its own Loaded/Unloaded events * Attempt to fix leak tests to work with Loaded events * Make WindowBase raise its own Loaded/Unloaded events * Move hotkey leak tests to the LeakTest project * Address some code review comments * Attempt at actually queueing Loaded events again * Fix typo * Minor improvements * Update controls benchmark Co-authored-by: Max Katz Co-authored-by: Jumar Macato <16554748+jmacato@users.noreply.github.com> --- src/Avalonia.Base/Visual.cs | 2 + src/Avalonia.Controls/Control.cs | 192 +++++++++++++++++- src/Avalonia.Controls/WindowBase.cs | 21 +- .../Layout/ControlsBenchmark.cs | 18 ++ .../Utils/HotKeyManagerTests.cs | 110 +--------- tests/Avalonia.LeakTests/ControlTests.cs | 165 ++++++++++++++- 6 files changed, 391 insertions(+), 117 deletions(-) diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 4fd21f02f9..bdf8723b81 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -376,7 +376,9 @@ namespace Avalonia if (e.OldValue is IAffectsRender oldValue) { if (sender._affectsRenderWeakSubscriber != null) + { InvalidatedWeakEvent.Unsubscribe(oldValue, sender._affectsRenderWeakSubscriber); + } } if (e.NewValue is IAffectsRender newValue) diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 16d4ef5c15..083182a370 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using Avalonia.Automation.Peers; using Avalonia.Controls.Documents; @@ -10,6 +11,7 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Rendering; using Avalonia.Styling; +using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls @@ -53,21 +55,57 @@ namespace Avalonia.Controls /// Event raised when an element wishes to be scrolled into view. /// public static readonly RoutedEvent RequestBringIntoViewEvent = - RoutedEvent.Register("RequestBringIntoView", RoutingStrategies.Bubble); + RoutedEvent.Register( + "RequestBringIntoView", + RoutingStrategies.Bubble); /// /// Provides event data for the event. /// public static readonly RoutedEvent ContextRequestedEvent = - RoutedEvent.Register(nameof(ContextRequested), + RoutedEvent.Register( + nameof(ContextRequested), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + /// + /// Defines the event. + /// + public static readonly RoutedEvent LoadedEvent = + RoutedEvent.Register( + nameof(Loaded), + RoutingStrategies.Direct); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent UnloadedEvent = + RoutedEvent.Register( + nameof(Unloaded), + RoutingStrategies.Direct); + /// /// Defines the property. /// public static readonly AttachedProperty FlowDirectionProperty = - AvaloniaProperty.RegisterAttached(nameof(FlowDirection), inherits: true); - + AvaloniaProperty.RegisterAttached( + nameof(FlowDirection), + inherits: true); + + // Note the following: + // _loadedQueue : + // Is the queue where any control will be added to indicate that its loaded + // event should be scheduled and called later. + // _loadedProcessingQueue : + // Contains a copied snapshot of the _loadedQueue at the time when processing + // starts and individual events are being fired. This was needed to avoid + // exceptions if new controls were added in the Loaded event itself. + + private static bool _isLoadedProcessing = false; + private static readonly HashSet _loadedQueue = new HashSet(); + private static readonly HashSet _loadedProcessingQueue = new HashSet(); + + private bool _isAttachedToVisualTree = false; + private bool _isLoaded = false; private DataTemplates? _dataTemplates; private IControl? _focusAdorner; private AutomationPeer? _automationPeer; @@ -108,6 +146,15 @@ namespace Avalonia.Controls set => SetValue(ContextFlyoutProperty, value); } + /// + /// Gets a value indicating whether the control is fully constructed in the visual tree + /// and both layout and render are complete. + /// + /// + /// This is set to true while raising the event. + /// + public bool IsLoaded => _isLoaded; + /// /// Gets or sets a user-defined object attached to the control. /// @@ -135,6 +182,35 @@ namespace Avalonia.Controls remove => RemoveHandler(ContextRequestedEvent, value); } + /// + /// Occurs when the control has been fully constructed in the visual tree and both + /// layout and render are complete. + /// + /// + /// This event is guaranteed to occur after the control template is applied and references + /// to objects created after the template is applied are available. This makes it different + /// from OnAttachedToVisualTree which doesn't have these references. This event occurs at the + /// latest possible time in the control creation life-cycle. + /// + public event EventHandler? Loaded + { + add => AddHandler(LoadedEvent, value); + remove => RemoveHandler(LoadedEvent, value); + } + + /// + /// Occurs when the control is removed from the visual tree. + /// + /// + /// This is API symmetrical with and exists for compatibility with other + /// XAML frameworks; however, it behaves the same as OnDetachedFromVisualTree. + /// + public event EventHandler? Unloaded + { + add => AddHandler(UnloadedEvent, value); + remove => RemoveHandler(UnloadedEvent, value); + } + public new IControl? Parent => (IControl?)base.Parent; /// @@ -215,18 +291,124 @@ namespace Avalonia.Controls /// The control that receives the focus adorner. protected virtual IControl? GetTemplateFocusTarget() => this; + private static Action loadedProcessingAction = () => + { + // Copy the loaded queue for processing + // There was a possibility of the "Collection was modified; enumeration operation may not execute." + // exception when only a single hash set was used. This could happen when new controls are added + // within the Loaded callback/event itself. To fix this, two hash sets are used and while one is + // being processed the other accepts adding new controls to process next. + _loadedProcessingQueue.Clear(); + foreach (Control control in _loadedQueue) + { + _loadedProcessingQueue.Add(control); + } + _loadedQueue.Clear(); + + foreach (Control control in _loadedProcessingQueue) + { + control.OnLoadedCore(); + } + + _loadedProcessingQueue.Clear(); + _isLoadedProcessing = false; + + // Restart if any controls were added to the queue while processing + if (_loadedQueue.Count > 0) + { + _isLoadedProcessing = true; + Dispatcher.UIThread.Post(loadedProcessingAction!, DispatcherPriority.Loaded); + } + }; + + /// + /// Schedules to be called for this control. + /// For performance, it will be queued with other controls. + /// + internal void ScheduleOnLoadedCore() + { + if (_isLoaded == false) + { + bool isAdded = _loadedQueue.Add(this); + + if (isAdded && + _isLoadedProcessing == false) + { + _isLoadedProcessing = true; + Dispatcher.UIThread.Post(loadedProcessingAction!, DispatcherPriority.Loaded); + } + } + } + + /// + /// Invoked as the first step of marking the control as loaded and raising the + /// event. + /// + internal void OnLoadedCore() + { + if (_isLoaded == false && + _isAttachedToVisualTree) + { + _isLoaded = true; + OnLoaded(); + } + } + + /// + /// Invoked as the first step of marking the control as unloaded and raising the + /// event. + /// + internal void OnUnloadedCore() + { + if (_isLoaded) + { + // Remove from the loaded event queue here as a failsafe in case the control + // is detached before the dispatcher runs the Loaded jobs. + _loadedQueue.Remove(this); + + _isLoaded = false; + OnUnloaded(); + } + } + + /// + /// Invoked just before the event. + /// + protected virtual void OnLoaded() + { + var eventArgs = new RoutedEventArgs(LoadedEvent); + eventArgs.Source = null; + RaiseEvent(eventArgs); + } + + /// + /// Invoked just before the event. + /// + protected virtual void OnUnloaded() + { + var eventArgs = new RoutedEventArgs(UnloadedEvent); + eventArgs.Source = null; + RaiseEvent(eventArgs); + } + /// protected sealed override void OnAttachedToVisualTreeCore(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTreeCore(e); + _isAttachedToVisualTree = true; InitializeIfNeeded(); + + ScheduleOnLoadedCore(); } /// protected sealed override void OnDetachedFromVisualTreeCore(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTreeCore(e); + _isAttachedToVisualTree = false; + + OnUnloadedCore(); } /// @@ -324,7 +506,9 @@ namespace Avalonia.Controls var keymap = AvaloniaLocator.Current.GetService()?.OpenContextMenu; if (keymap is null) + { return; + } var matches = false; diff --git a/src/Avalonia.Controls/WindowBase.cs b/src/Avalonia.Controls/WindowBase.cs index 12ba143c8a..5d3e51b394 100644 --- a/src/Avalonia.Controls/WindowBase.cs +++ b/src/Avalonia.Controls/WindowBase.cs @@ -169,7 +169,6 @@ namespace Avalonia.Controls } } - [Obsolete("No longer used. Has no effect.")] protected IDisposable BeginAutoSizing() => Disposable.Empty; @@ -186,6 +185,26 @@ namespace Avalonia.Controls } } + /// + protected override void OnClosed(EventArgs e) + { + // Window must manually raise Loaded/Unloaded events as it is a visual root and + // does not raise OnAttachedToVisualTreeCore/OnDetachedFromVisualTreeCore events + OnUnloadedCore(); + + base.OnClosed(e); + } + + /// + protected override void OnOpened(EventArgs e) + { + // Window must manually raise Loaded/Unloaded events as it is a visual root and + // does not raise OnAttachedToVisualTreeCore/OnDetachedFromVisualTreeCore events + ScheduleOnLoadedCore(); + + base.OnOpened(e); + } + protected override void HandleClosed() { _ignoreVisibilityChange = true; diff --git a/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs b/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs index 3493dd0f53..51b52d6130 100644 --- a/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs +++ b/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.CompilerServices; using Avalonia.Controls; +using Avalonia.Threading; using Avalonia.UnitTests; using BenchmarkDotNet.Attributes; @@ -37,6 +38,21 @@ namespace Avalonia.Benchmarks.Layout _root.Child = calendar; _root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + } + + [Benchmark] + [MethodImpl(MethodImplOptions.NoInlining)] + public void CreateCalendarWithLoaded() + { + using var subscription = Control.LoadedEvent.AddClassHandler((c, s) => { }); + + var calendar = new Calendar(); + + _root.Child = calendar; + + _root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); } [Benchmark] @@ -48,6 +64,7 @@ namespace Avalonia.Benchmarks.Layout _root.Child = button; _root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); } [Benchmark] @@ -59,6 +76,7 @@ namespace Avalonia.Benchmarks.Layout _root.Child = textBox; _root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); } public void Dispose() diff --git a/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs index 8d9a4aa599..e4d177f7ca 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs @@ -12,6 +12,7 @@ using Moq; using Xunit; using Avalonia.Input.Raw; using Factory = System.Func, Avalonia.Controls.Window, Avalonia.AvaloniaObject>; +using Avalonia.Threading; namespace Avalonia.Controls.UnitTests.Utils { @@ -60,115 +61,6 @@ namespace Avalonia.Controls.UnitTests.Utils } } - [Fact] - public void HotKeyManager_Should_Release_Reference_When_Control_Detached() - { - using (AvaloniaLocator.EnterScope()) - { - var styler = new Mock(); - - AvaloniaLocator.CurrentMutable - .Bind().ToConstant(new WindowingPlatformMock()) - .Bind().ToConstant(styler.Object); - - var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control); - - WeakReference reference = null; - - var tl = new Window(); - - new Action(() => - { - var button = new Button(); - reference = new WeakReference(button, true); - tl.Content = button; - tl.Template = CreateWindowTemplate(); - tl.ApplyTemplate(); - tl.Presenter.ApplyTemplate(); - HotKeyManager.SetHotKey(button, gesture1); - - // Detach the button from the logical tree, so there is no reference to it - tl.Content = null; - tl.ApplyTemplate(); - })(); - - - // The button should be collected since it's detached from the listbox - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - - Assert.Null(reference?.Target); - } - } - - [Fact] - public void HotKeyManager_Should_Release_Reference_When_Control_In_Item_Template_Detached() - { - using (UnitTestApplication.Start(TestServices.StyledWindow)) - { - var styler = new Mock(); - - AvaloniaLocator.CurrentMutable - .Bind().ToConstant(new WindowingPlatformMock()) - .Bind().ToConstant(styler.Object); - - var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control); - - var weakReferences = new List(); - var tl = new Window { SizeToContent = SizeToContent.WidthAndHeight, IsVisible = true }; - var lm = tl.LayoutManager; - - var keyGestures = new AvaloniaList { gesture1 }; - var listBox = new ListBox - { - Width = 100, - Height = 100, - VirtualizationMode = ItemVirtualizationMode.None, - // Create a button with binding to the KeyGesture in the template and add it to references list - ItemTemplate = new FuncDataTemplate(typeof(KeyGesture), (o, scope) => - { - var keyGesture = o as KeyGesture; - var button = new Button - { - DataContext = keyGesture, [!Button.HotKeyProperty] = new Binding("") - }; - weakReferences.Add(new WeakReference(button, true)); - return button; - }) - }; - // Add the listbox and render it - tl.Content = listBox; - lm.ExecuteInitialLayoutPass(); - listBox.Items = keyGestures; - lm.ExecuteLayoutPass(); - - // Let the button detach when clearing the source items - keyGestures.Clear(); - lm.ExecuteLayoutPass(); - - // Add it again to double check,and render - keyGestures.Add(gesture1); - lm.ExecuteLayoutPass(); - - keyGestures.Clear(); - lm.ExecuteLayoutPass(); - - // The button should be collected since it's detached from the listbox - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - - Assert.True(weakReferences.Count > 0); - foreach (var weakReference in weakReferences) - { - Assert.Null(weakReference.Target); - } - } - } - [Theory] [MemberData(nameof(ElementsFactory), parameters: true)] public void HotKeyManager_Should_Use_CommandParameter(string factoryName, Factory factory) diff --git a/tests/Avalonia.LeakTests/ControlTests.cs b/tests/Avalonia.LeakTests/ControlTests.cs index bb520c16aa..8c05f2a0a7 100644 --- a/tests/Avalonia.LeakTests/ControlTests.cs +++ b/tests/Avalonia.LeakTests/ControlTests.cs @@ -3,7 +3,10 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Disposables; + +using Avalonia.Collections; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; using Avalonia.Data; @@ -67,6 +70,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -100,6 +106,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -141,6 +150,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -179,6 +191,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); dotMemory.Check(memory => @@ -216,6 +231,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -261,6 +279,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); dotMemory.Check(memory => @@ -351,6 +372,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -384,6 +408,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -421,6 +448,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -496,6 +526,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -536,9 +569,12 @@ namespace Avalonia.LeakTests initialMenuCount = memory.GetObjects(where => where.Type.Is()).ObjectsCount; initialMenuItemCount = memory.GetObjects(where => where.Type.Is()).ObjectsCount; }); - + AttachShowAndDetachContextMenu(window); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + Mock.Get(window.PlatformImpl).Invocations.Clear(); dotMemory.Check(memory => Assert.Equal(initialMenuCount, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); @@ -580,10 +616,13 @@ namespace Avalonia.LeakTests initialMenuCount = memory.GetObjects(where => where.Type.Is()).ObjectsCount; initialMenuItemCount = memory.GetObjects(where => where.Type.Is()).ObjectsCount; }); - + BuildAndShowContextMenu(window); BuildAndShowContextMenu(window); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + Mock.Get(window.PlatformImpl).Invocations.Clear(); dotMemory.Check(memory => Assert.Equal(initialMenuCount, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); @@ -623,6 +662,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); @@ -657,6 +699,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -725,14 +770,128 @@ namespace Avalonia.LeakTests Assert.Empty(lb.ItemContainerGenerator.Containers); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } } + [Fact] + public void HotKeyManager_Should_Release_Reference_When_Control_Detached() + { + using (Start()) + { + Func run = () => + { + var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control); + var tl = new Window + { + Content = new ItemsRepeater(), + }; + + tl.Show(); + + var button = new Button(); + tl.Content = button; + tl.Template = CreateWindowTemplate(); + tl.ApplyTemplate(); + tl.Presenter.ApplyTemplate(); + HotKeyManager.SetHotKey(button, gesture1); + + // Detach the button from the logical tree, so there is no reference to it + tl.Content = null; + tl.ApplyTemplate(); + + return tl; + }; + + var result = run(); + + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + + dotMemory.Check(memory => + Assert.Equal(0, memory.GetObjects(where => where.Type.Is