From 9489ff658a5f26824bcdbb46b491119bfd11c177 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Tue, 11 Feb 2025 17:05:52 +0100 Subject: [PATCH] Layout performance improvements --- src/Avalonia.Base/Layout/LayoutHelper.cs | 176 ++++++++---------- src/Avalonia.Base/Layout/Layoutable.cs | 100 +++++----- src/Avalonia.Base/Layout/MinMax.cs | 51 +++++ src/Avalonia.Base/Size.cs | 15 +- src/Avalonia.Base/StyledElement.cs | 2 +- src/Avalonia.Base/Threading/Dispatcher.cs | 11 +- src/Avalonia.Base/Utilities/MathUtilities.cs | 25 ++- src/Avalonia.Base/Visual.cs | 16 +- .../DataGridColumn.cs | 2 +- src/Avalonia.Controls/Border.cs | 2 +- .../Presenters/ContentPresenter.cs | 18 +- .../Presenters/ScrollContentPresenter.cs | 2 +- .../Primitives/TemplatedControl.cs | 3 +- src/Avalonia.Controls/TextBlock.cs | 8 +- src/Avalonia.Controls/TopLevel.cs | 27 ++- tests/Avalonia.Benchmarks/Layout/Measure.cs | 14 +- .../Presenters/ScrollContentPresenterTests.cs | 5 +- .../TextBlockTests.cs | 6 +- .../WindowBaseTests.cs | 11 +- .../WindowTests.cs | 1 + 20 files changed, 293 insertions(+), 202 deletions(-) create mode 100644 src/Avalonia.Base/Layout/MinMax.cs diff --git a/src/Avalonia.Base/Layout/LayoutHelper.cs b/src/Avalonia.Base/Layout/LayoutHelper.cs index c6cbda62557..a342c654f9c 100644 --- a/src/Avalonia.Base/Layout/LayoutHelper.cs +++ b/src/Avalonia.Base/Layout/LayoutHelper.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Avalonia.Utilities; using Avalonia.VisualTree; @@ -25,21 +27,20 @@ public static class LayoutHelper /// The space available for the control. /// The control's size. public static Size ApplyLayoutConstraints(Layoutable control, Size constraints) - { - var minmax = new MinMax(control); + => ApplyLayoutConstraints(new MinMax(control), constraints); - return new Size( - MathUtilities.Clamp(constraints.Width, minmax.MinWidth, minmax.MaxWidth), - MathUtilities.Clamp(constraints.Height, minmax.MinHeight, minmax.MaxHeight)); - } + internal static Size ApplyLayoutConstraints(MinMax minMax, Size constraints) + => new( + MathUtilities.Clamp(constraints.Width, minMax.MinWidth, minMax.MaxWidth), + MathUtilities.Clamp(constraints.Height, minMax.MinHeight, minMax.MaxHeight)); public static Size MeasureChild(Layoutable? control, Size availableSize, Thickness padding, Thickness borderThickness) { if (IsParentLayoutRounded(control, out double scale)) { - padding = RoundLayoutThickness(padding, scale, scale); - borderThickness = RoundLayoutThickness(borderThickness, scale, scale); + padding = RoundLayoutThickness(padding, scale); + borderThickness = RoundLayoutThickness(borderThickness, scale); } if (control != null) @@ -55,7 +56,7 @@ public static Size MeasureChild(Layoutable? control, Size availableSize, Thickne { if (IsParentLayoutRounded(control, out double scale)) { - padding = RoundLayoutThickness(padding, scale, scale); + padding = RoundLayoutThickness(padding, scale); } if (control != null) @@ -71,8 +72,8 @@ public static Size ArrangeChild(Layoutable? child, Size availableSize, Thickness { if (IsParentLayoutRounded(child, out double scale)) { - padding = RoundLayoutThickness(padding, scale, scale); - borderThickness = RoundLayoutThickness(borderThickness, scale, scale); + padding = RoundLayoutThickness(padding, scale); + borderThickness = RoundLayoutThickness(borderThickness, scale); } return ArrangeChildInternal(child, availableSize, padding + borderThickness); @@ -81,7 +82,7 @@ public static Size ArrangeChild(Layoutable? child, Size availableSize, Thickness public static Size ArrangeChild(Layoutable? child, Size availableSize, Thickness padding) { if(IsParentLayoutRounded(child, out double scale)) - padding = RoundLayoutThickness(padding, scale, scale); + padding = RoundLayoutThickness(padding, scale); return ArrangeChildInternal(child, availableSize, padding); } @@ -140,18 +141,7 @@ void InnerInvalidateMeasure(Visual target) /// The control. /// Thrown when control has no root or returned layout scaling is invalid. public static double GetLayoutScale(Layoutable control) - { - var visualRoot = (control as Visual)?.VisualRoot; - - var result = (visualRoot as ILayoutRoot)?.LayoutScaling ?? 1.0; - - if (result == 0 || double.IsNaN(result) || double.IsInfinity(result)) - { - throw new Exception($"Invalid LayoutScaling returned from {visualRoot!.GetType()}"); - } - - return result; - } + => control.VisualRoot is ILayoutRoot layoutRoot ? layoutRoot.LayoutScaling : 1.0; /// /// Rounds a size to integer values for layout purposes, compensating for high DPI screen @@ -172,6 +162,20 @@ public static Size RoundLayoutSizeUp(Size size, double dpiScaleX, double dpiScal return new Size(RoundLayoutValueUp(size.Width, dpiScaleX), RoundLayoutValueUp(size.Height, dpiScaleY)); } + [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "The DPI scale should have been normalized.")] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Size RoundLayoutSizeUp(Size size, double dpiScale) + { + // If DPI == 1, don't use DPI-aware rounding. + return dpiScale == 1.0 ? + new Size( + Math.Ceiling(size.Width), + Math.Ceiling(size.Height)) : + new Size( + Math.Ceiling(RoundTo8Digits(size.Width) * dpiScale) / dpiScale, + Math.Ceiling(RoundTo8Digits(size.Height) * dpiScale) / dpiScale); + } + /// /// Rounds a thickness to integer values for layout purposes, compensating for high DPI screen /// coordinates. @@ -196,6 +200,38 @@ public static Thickness RoundLayoutThickness(Thickness thickness, double dpiScal ); } + [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "The DPI scale should have been normalized.")] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Thickness RoundLayoutThickness(Thickness thickness, double dpiScale) + { + // If DPI == 1, don't use DPI-aware rounding. + return dpiScale == 1.0 ? + new Thickness( + Math.Round(thickness.Left), + Math.Round(thickness.Top), + Math.Round(thickness.Right), + Math.Round(thickness.Bottom)) : + new Thickness( + Math.Round(thickness.Left * dpiScale) / dpiScale, + Math.Round(thickness.Top * dpiScale) / dpiScale, + Math.Round(thickness.Right * dpiScale) / dpiScale, + Math.Round(thickness.Bottom * dpiScale) / dpiScale); + } + + [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "The DPI scale should have been normalized.")] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Point RoundLayoutPoint(Point point, double dpiScale) + { + // If DPI == 1, don't use DPI-aware rounding. + return dpiScale == 1.0 ? + new Point( + Math.Round(point.X), + Math.Round(point.Y)) : + new Point( + Math.Round(point.X * dpiScale) / dpiScale, + Math.Round(point.Y * dpiScale) / dpiScale); + } + /// /// Calculates the value to be used for layout rounding at high DPI by rounding the value /// up or down to the nearest pixel. @@ -211,28 +247,10 @@ public static Thickness RoundLayoutThickness(Thickness thickness, double dpiScal /// public static double RoundLayoutValue(double value, double dpiScale) { - double newValue; - // If DPI == 1, don't use DPI-aware rounding. - if (!MathUtilities.IsOne(dpiScale)) - { - newValue = Math.Round(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.Round(value); - } - - return newValue; + return MathUtilities.IsOne(dpiScale) ? + Math.Round(value) : + Math.Round(value * dpiScale) / dpiScale; } /// @@ -250,73 +268,25 @@ public static double RoundLayoutValue(double value, double dpiScale) /// public static double RoundLayoutValueUp(double value, double dpiScale) { - double newValue; + // If DPI == 1, don't use DPI-aware rounding. + return MathUtilities.IsOne(dpiScale) ? + Math.Ceiling(value) : + Math.Ceiling(RoundTo8Digits(value) * dpiScale) / dpiScale; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double RoundTo8Digits(double value) + { // Round the value to avoid FP errors. This is needed because if `value` has a floating // point precision error (e.g. 79.333333333333343) then when it's multiplied by // `dpiScale` and rounded up, it will be rounded up to a value one greater than it // should be. #if NET6_0_OR_GREATER - value = Math.Round(value, 8, MidpointRounding.ToZero); + return Math.Round(value, 8, MidpointRounding.ToZero); #else // MidpointRounding.ToZero isn't available in netstandard2.0. - value = Math.Truncate(value * 1e8) / 1e8; + return Math.Truncate(value * 1e8) / 1e8; #endif - - // 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. - /// - private readonly struct MinMax - { - public MinMax(Layoutable e) - { - MaxHeight = e.MaxHeight; - MinHeight = e.MinHeight; - double l = e.Height; - - double height = (double.IsNaN(l) ? double.PositiveInfinity : l); - MaxHeight = Math.Max(Math.Min(height, MaxHeight), MinHeight); - - height = (double.IsNaN(l) ? 0 : l); - MinHeight = Math.Max(Math.Min(MaxHeight, height), MinHeight); - - MaxWidth = e.MaxWidth; - MinWidth = e.MinWidth; - l = e.Width; - - double width = (double.IsNaN(l) ? double.PositiveInfinity : l); - MaxWidth = Math.Max(Math.Min(width, MaxWidth), MinWidth); - - width = (double.IsNaN(l) ? 0 : l); - MinWidth = Math.Max(Math.Min(MaxWidth, width), MinWidth); - } - - public double MinWidth { get; } - public double MaxWidth { get; } - public double MinHeight { get; } - public double MaxHeight { get; } } } } diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index a71ceb76afa..ad39127cf48 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -2,6 +2,7 @@ using Avalonia.Diagnostics; using Avalonia.Logging; using Avalonia.Reactive; +using Avalonia.Utilities; using Avalonia.VisualTree; #nullable enable @@ -544,53 +545,43 @@ protected virtual Size MeasureCore(Size availableSize) if (useLayoutRounding) { scale = LayoutHelper.GetLayoutScale(this); - margin = LayoutHelper.RoundLayoutThickness(margin, scale, scale); + margin = LayoutHelper.RoundLayoutThickness(margin, scale); } ApplyStyling(); ApplyTemplate(); + var minMax = new MinMax(this); + var constrained = LayoutHelper.ApplyLayoutConstraints( - this, + minMax, availableSize.Deflate(margin)); var measured = MeasureOverride(constrained); - var width = measured.Width; - var height = measured.Height; + var width = MathUtilities.Clamp(measured.Width, minMax.MinWidth, minMax.MaxWidth); + var height = MathUtilities.Clamp(measured.Height, minMax.MinHeight, minMax.MaxHeight); + if (useLayoutRounding) { - double widthCache = Width; - - if (!double.IsNaN(widthCache)) - { - width = widthCache; - } + (width, height) = LayoutHelper.RoundLayoutSizeUp(new Size(width, height), scale); } - width = Math.Min(width, MaxWidth); - width = Math.Max(width, MinWidth); + if (width > availableSize.Width) + width = availableSize.Width; - { - double heightCache = Height; + if (height > availableSize.Height) + height = availableSize.Height; - if (!double.IsNaN(heightCache)) - { - height = heightCache; - } - } + width += margin.Left + margin.Right; + height += margin.Top + margin.Bottom; - height = Math.Min(height, MaxHeight); - height = Math.Max(height, MinHeight); + if (width < 0) + width = 0; - if (useLayoutRounding) - { - (width, height) = LayoutHelper.RoundLayoutSizeUp(new Size(width, height), scale, scale); - } - - width = Math.Min(width, availableSize.Width); - height = Math.Min(height, availableSize.Height); + if (height < 0) + height = 0; - return NonNegative(new Size(width, height).Inflate(margin)); + return new Size(width, height); } else { @@ -618,8 +609,13 @@ protected virtual Size MeasureOverride(Size availableSize) if (visual is Layoutable layoutable) { layoutable.Measure(availableSize); - width = Math.Max(width, layoutable.DesiredSize.Width); - height = Math.Max(height, layoutable.DesiredSize.Height); + var childSize = layoutable.DesiredSize; + + if (childSize.Width > width) + width = childSize.Width; + + if (childSize.Height > height) + height = childSize.Height; } } @@ -650,12 +646,19 @@ protected virtual void ArrangeCore(Rect finalRect) // If the margin isn't pre-rounded some sizes will be offset by 1 pixel in certain scales. if (useLayoutRounding) { - margin = LayoutHelper.RoundLayoutThickness(margin, scale, scale); + margin = LayoutHelper.RoundLayoutThickness(margin, scale); } - var availableSizeMinusMargins = new Size( - Math.Max(0, finalRect.Width - margin.Left - margin.Right), - Math.Max(0, finalRect.Height - margin.Top - margin.Bottom)); + + var availableWidthMinusMargins = finalRect.Width - margin.Left - margin.Right; + if (availableWidthMinusMargins < 0) + availableWidthMinusMargins = 0; + + var availableHeightMinusMargins = finalRect.Height - margin.Top - margin.Bottom; + if (availableHeightMinusMargins < 0) + availableHeightMinusMargins = 0; + + var availableSizeMinusMargins = new Size(availableWidthMinusMargins, availableHeightMinusMargins); var horizontalAlignment = HorizontalAlignment; var verticalAlignment = VerticalAlignment; var size = availableSizeMinusMargins; @@ -670,12 +673,12 @@ protected virtual void ArrangeCore(Rect finalRect) size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - margin.Top - margin.Bottom)); } - size = LayoutHelper.ApplyLayoutConstraints(this, size); + size = LayoutHelper.ApplyLayoutConstraints(new MinMax(this), size); if (useLayoutRounding) { - size = LayoutHelper.RoundLayoutSizeUp(size, scale, scale); - availableSizeMinusMargins = LayoutHelper.RoundLayoutSizeUp(availableSizeMinusMargins, scale, scale); + size = LayoutHelper.RoundLayoutSizeUp(size, scale); + availableSizeMinusMargins = LayoutHelper.RoundLayoutSizeUp(availableSizeMinusMargins, scale); } size = ArrangeOverride(size).Constrain(size); @@ -702,13 +705,14 @@ protected virtual void ArrangeCore(Rect finalRect) break; } + var origin = new Point(originX, originY); + if (useLayoutRounding) { - originX = LayoutHelper.RoundLayoutValue(originX, scale); - originY = LayoutHelper.RoundLayoutValue(originY, scale); + origin = LayoutHelper.RoundLayoutPoint(origin, scale); } - Bounds = new Rect(originX, originY, size.Width, size.Height); + Bounds = new Rect(origin, size); } } @@ -887,11 +891,10 @@ private void AncestorBecameVisible(ILayoutManager layoutManager) /// True if the rect is invalid; otherwise false. private static bool IsInvalidRect(Rect rect) { - return rect.Width < 0 || rect.Height < 0 || - double.IsInfinity(rect.X) || double.IsInfinity(rect.Y) || - double.IsInfinity(rect.Width) || double.IsInfinity(rect.Height) || - double.IsNaN(rect.X) || double.IsNaN(rect.Y) || - double.IsNaN(rect.Width) || double.IsNaN(rect.Height); + return MathUtilities.IsNegativeOrNonFinite(rect.Width) || + MathUtilities.IsNegativeOrNonFinite(rect.Height) || + !MathUtilities.IsFinite(rect.X) || + !MathUtilities.IsFinite(rect.Y); } /// @@ -902,9 +905,8 @@ private static bool IsInvalidRect(Rect rect) /// True if the size is invalid; otherwise false. private static bool IsInvalidSize(Size size) { - return size.Width < 0 || size.Height < 0 || - double.IsInfinity(size.Width) || double.IsInfinity(size.Height) || - double.IsNaN(size.Width) || double.IsNaN(size.Height); + return MathUtilities.IsNegativeOrNonFinite(size.Width) || + MathUtilities.IsNegativeOrNonFinite(size.Height); } /// diff --git a/src/Avalonia.Base/Layout/MinMax.cs b/src/Avalonia.Base/Layout/MinMax.cs new file mode 100644 index 00000000000..aa482442553 --- /dev/null +++ b/src/Avalonia.Base/Layout/MinMax.cs @@ -0,0 +1,51 @@ +using System.Runtime.CompilerServices; + +namespace Avalonia.Layout; + +internal struct MinMax +{ + public double MinWidth; + public double MaxWidth; + public double MinHeight; + public double MaxHeight; + + public MinMax(Layoutable e) + { + (MinWidth, MaxWidth) = CalcMinMax(e.Width, e.MinWidth, e.MaxWidth); + (MinHeight, MaxHeight) = CalcMinMax(e.Height, e.MinHeight, e.MaxHeight); + } + + private static (double Min, double Max) CalcMinMax(double value, double min, double max) + { + double v0, v1; + + if (double.IsNaN(value)) + { + v0 = 0.0; + v1 = double.PositiveInfinity; + } + else + { + v0 = v1 = value; + } + + max = ClampUnchecked(v1, min, max); + min = ClampUnchecked(v0, min, max); + + return (min, max); + } + + // Don't use Math.Clamp, it's possible for min to be greater than max here + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double ClampUnchecked(double value, double min, double max) + { + if (value > max) + value = max; + + if (value < min) + value = min; + + return value; + } + +} diff --git a/src/Avalonia.Base/Size.cs b/src/Avalonia.Base/Size.cs index 587d5a53e46..5ac9b7f9dec 100644 --- a/src/Avalonia.Base/Size.cs +++ b/src/Avalonia.Base/Size.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Runtime.CompilerServices; #if !BUILDTASK using Avalonia.Animation.Animators; #endif @@ -187,11 +188,18 @@ public Size Constrain(Size constraint) /// The thickness. /// The deflated size. /// The deflated size cannot be less than 0. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public Size Deflate(Thickness thickness) { - return new Size( - Math.Max(0, _width - thickness.Left - thickness.Right), - Math.Max(0, _height - thickness.Top - thickness.Bottom)); + var width = _width - thickness.Left - thickness.Right; + if (width < 0) + width = 0; + + var height = _height - thickness.Top - thickness.Bottom; + if (height < 0) + height = 0; + + return new Size(width, height); } /// @@ -247,6 +255,7 @@ public override int GetHashCode() /// /// The thickness. /// The inflated size. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public Size Inflate(Thickness thickness) { return new Size( diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index 1f0c00ef639..8d8a093ac4f 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -84,7 +84,7 @@ public class StyledElement : Animatable, private string? _name; private Classes? _classes; private ILogicalRoot? _logicalRoot; - private IAvaloniaList? _logicalChildren; + private AvaloniaList? _logicalChildren; private IResourceDictionary? _resources; private Styles? _styles; private bool _stylesApplied; diff --git a/src/Avalonia.Base/Threading/Dispatcher.cs b/src/Avalonia.Base/Threading/Dispatcher.cs index 07e09c3d588..8253c2fed25 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.cs @@ -44,9 +44,18 @@ internal Dispatcher(IDispatcherImpl impl) _exceptionFilterEventArgs = new DispatcherUnhandledExceptionFilterEventArgs(this); } - public static Dispatcher UIThread => s_uiThread ??= CreateUIThreadDispatcher(); + public static Dispatcher UIThread + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + return s_uiThread ??= CreateUIThreadDispatcher(); + } + } + public bool SupportsRunLoops => _controlledImpl != null; + [MethodImpl(MethodImplOptions.NoInlining)] private static Dispatcher CreateUIThreadDispatcher() { var impl = AvaloniaLocator.Current.GetService(); diff --git a/src/Avalonia.Base/Utilities/MathUtilities.cs b/src/Avalonia.Base/Utilities/MathUtilities.cs index 1c729593127..01f041c5eec 100644 --- a/src/Avalonia.Base/Utilities/MathUtilities.cs +++ b/src/Avalonia.Base/Utilities/MathUtilities.cs @@ -208,6 +208,7 @@ public static bool IsZero(float value) /// The minimum value. /// The maximum value. /// The clamped value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static double Clamp(double val, double min, double max) { if (min > max) @@ -363,6 +364,28 @@ public static (double min, double max) GetMinMaxFromDelta(double initialValue, d { return GetMinMax(initialValue, initialValue + delta); } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsNegativeOrNonFinite(double d) + { +#if NET6_0_OR_GREATER + ulong bits = BitConverter.DoubleToUInt64Bits(d); + return bits >= 0x7FF0_0000_0000_0000; +#else + return d < 0 || !IsFinite(d); +#endif + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsFinite(double d) + { +#if NET6_0_OR_GREATER + return double.IsFinite(d); +#else + long bits = BitConverter.DoubleToInt64Bits(d); + return (bits & 0x7FFF_FFFF_FFFF_FFFF) < 0x7FF0_0000_0000_0000; +#endif + } #if !BUILDTASK internal static int WhichPolygonSideIntersects( @@ -451,7 +474,7 @@ internal static bool IsEntirelyContained( return true; } #endif - + private static void ThrowCannotBeGreaterThanException(T min, T max) { throw new ArgumentException($"{min} cannot be greater than {max}."); diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 52e5251addc..6b10fa228c4 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -148,6 +148,8 @@ static Visual() /// public Visual() { + _visualRoot = this as IRenderRoot; + // Disable transitions until we're added to the visual tree. DisableTransitions(); @@ -317,16 +319,12 @@ public int ZIndex /// /// Gets the control's child visuals. /// - protected internal IAvaloniaList VisualChildren - { - get; - private set; - } + protected internal IAvaloniaList VisualChildren { get; } /// /// Gets the root of the visual tree, if the control is attached to a visual tree. /// - protected internal IRenderRoot? VisualRoot => _visualRoot ?? (this as IRenderRoot); + protected internal IRenderRoot? VisualRoot => _visualRoot; internal RenderOptions RenderOptions { get; set; } @@ -544,7 +542,7 @@ protected virtual void OnDetachedFromVisualTreeCore(VisualTreeAttachmentEventArg { Logger.TryGet(LogEventLevel.Verbose, LogArea.Visual)?.Log(this, "Detached from visual tree"); - _visualRoot = null; + _visualRoot = this as IRenderRoot; RootedVisualChildrenCount--; if (RenderTransform is IMutableTransform mutableTransform) @@ -683,9 +681,9 @@ private void SetVisualParent(Visual? value) var old = _visualParent; _visualParent = value; - if (_visualRoot != null) + if (_visualRoot is not null && old is not null) { - var e = new VisualTreeAttachmentEventArgs(old!, _visualRoot); + var e = new VisualTreeAttachmentEventArgs(old, _visualRoot); OnDetachedFromVisualTreeCore(e); } diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumn.cs b/src/Avalonia.Controls.DataGrid/DataGridColumn.cs index 6353f7ebb93..a1df0c41805 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumn.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumn.cs @@ -899,7 +899,7 @@ internal void ComputeLayoutRoundedWidth(double leftEdge) if (OwningGrid != null && OwningGrid.UseLayoutRounding) { var scale = LayoutHelper.GetLayoutScale(HeaderCell); - var roundSize = LayoutHelper.RoundLayoutSizeUp(new Size(leftEdge + ActualWidth, 1), scale, scale); + var roundSize = LayoutHelper.RoundLayoutSizeUp(new Size(leftEdge + ActualWidth, 1), scale); LayoutRoundedWidth = roundSize.Width - leftEdge; } else diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index 4eb0274ec72..dc82c50a392 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -156,7 +156,7 @@ private Thickness LayoutThickness var borderThickness = BorderThickness; if (UseLayoutRounding) - borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, _scale, _scale); + borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, _scale); _layoutThickness = borderThickness; } diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index e57acba5cfe..aacdb266674 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -525,7 +525,7 @@ private Thickness LayoutThickness var borderThickness = BorderThickness; if (UseLayoutRounding) - borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, _scale, _scale); + borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, _scale); _layoutThickness = borderThickness; } @@ -618,8 +618,8 @@ internal Size ArrangeOverrideImpl(Size finalSize, Vector offset) if (useLayoutRounding) { - padding = LayoutHelper.RoundLayoutThickness(padding, scale, scale); - borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, scale, scale); + padding = LayoutHelper.RoundLayoutThickness(padding, scale); + borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, scale); } padding += borderThickness; @@ -642,8 +642,8 @@ internal Size ArrangeOverrideImpl(Size finalSize, Vector offset) if (useLayoutRounding) { - sizeForChild = LayoutHelper.RoundLayoutSizeUp(sizeForChild, scale, scale); - availableSize = LayoutHelper.RoundLayoutSizeUp(availableSize, scale, scale); + sizeForChild = LayoutHelper.RoundLayoutSizeUp(sizeForChild, scale); + availableSize = LayoutHelper.RoundLayoutSizeUp(availableSize, scale); } switch (horizontalContentAlignment) @@ -666,14 +666,14 @@ internal Size ArrangeOverrideImpl(Size finalSize, Vector offset) break; } + var origin = new Point(originX, originY); + if (useLayoutRounding) { - originX = LayoutHelper.RoundLayoutValue(originX, scale); - originY = LayoutHelper.RoundLayoutValue(originY, scale); + origin = LayoutHelper.RoundLayoutPoint(origin, scale); } - var boundsForChild = - new Rect(originX, originY, sizeForChild.Width, sizeForChild.Height).Deflate(padding); + var boundsForChild = new Rect(origin, sizeForChild).Deflate(padding); Child.Arrange(boundsForChild); diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 88e10c3ba35..e8503886813 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs @@ -486,7 +486,7 @@ private Size ComputeExtent(Size viewportSize) if (Child.UseLayoutRounding) { var scale = LayoutHelper.GetLayoutScale(Child); - childMargin = LayoutHelper.RoundLayoutThickness(childMargin, scale, scale); + childMargin = LayoutHelper.RoundLayoutThickness(childMargin, scale); } var extent = Child!.Bounds.Size.Inflate(childMargin); diff --git a/src/Avalonia.Controls/Primitives/TemplatedControl.cs b/src/Avalonia.Controls/Primitives/TemplatedControl.cs index 12b631aeb16..9ae941f6d12 100644 --- a/src/Avalonia.Controls/Primitives/TemplatedControl.cs +++ b/src/Avalonia.Controls/Primitives/TemplatedControl.cs @@ -291,7 +291,6 @@ public static void SetIsTemplateFocusTarget(Control control, bool value) public sealed override void ApplyTemplate() { var template = Template; - var logical = (ILogical)this; // Apply the template if it is not the same as the template already applied - except // for in the case that the template is null and we're not attached to the logical @@ -299,7 +298,7 @@ public sealed override void ApplyTemplate() // the template has been detached, so we want to wait until it's re-attached to the // logical tree as if it's re-attached to the same tree the template will be the same // and we don't need to do anything. - if (_appliedTemplate != template && (template != null || logical.IsAttachedToLogicalTree)) + if (_appliedTemplate != template && (template != null || ((ILogical)this).IsAttachedToLogicalTree)) { if (VisualChildren.Count > 0) { diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index a8324b2a5a2..5de3d76ed1a 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -605,7 +605,7 @@ private protected virtual void RenderCore(DrawingContext context) } var scale = LayoutHelper.GetLayoutScale(this); - var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale); + var padding = LayoutHelper.RoundLayoutThickness(Padding, scale); var top = padding.Top; var textHeight = TextLayout.Height; @@ -709,7 +709,7 @@ protected override void OnMeasureInvalidated() protected override Size MeasureOverride(Size availableSize) { var scale = LayoutHelper.GetLayoutScale(this); - var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale); + var padding = LayoutHelper.RoundLayoutThickness(Padding, scale); var deflatedSize = availableSize.Deflate(padding); if (_constraint != deflatedSize) @@ -740,7 +740,7 @@ protected override Size MeasureOverride(Size availableSize) //This implicitly recreated the TextLayout with a new constraint if we previously reset it. var textLayout = TextLayout; - var size = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.MinTextWidth, textLayout.Height).Inflate(padding), 1, 1); + var size = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.MinTextWidth, textLayout.Height).Inflate(padding), 1); return size; } @@ -748,7 +748,7 @@ protected override Size MeasureOverride(Size availableSize) protected override Size ArrangeOverride(Size finalSize) { var scale = LayoutHelper.GetLayoutScale(this); - var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale); + var padding = LayoutHelper.RoundLayoutThickness(Padding, scale); var availableSize = finalSize.Deflate(padding); diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index fa03cc7be83..6331268f978 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -131,6 +131,7 @@ private static readonly WeakEvent private readonly IDisposable? _pointerOverPreProcessorSubscription; private readonly IDisposable? _backGestureSubscription; private readonly Dictionary _platformImplBindings = new(); + private double _scaling; private Size _clientSize; private Size? _frameSize; private WindowTransparencyLevel _actualTransparencyLevel; @@ -211,6 +212,7 @@ public TopLevel(ITopLevelImpl impl, IAvaloniaDependencyResolver? dependencyResol PlatformImpl = impl ?? throw new InvalidOperationException( "Could not create window implementation: maybe no windowing subsystem was initialized?"); + _scaling = ValidateScaling(impl.RenderScaling); _actualTransparencyLevel = PlatformImpl.TransparencyLevel; dependencyResolver ??= AvaloniaLocator.Current; @@ -536,11 +538,10 @@ public static bool GetAutoSafeAreaPadding(Control control) return control.GetValue(AutoSafeAreaPaddingProperty); } - /// - double ILayoutRoot.LayoutScaling => PlatformImpl?.RenderScaling ?? 1; + double ILayoutRoot.LayoutScaling => _scaling; /// - public double RenderScaling => PlatformImpl?.RenderScaling ?? 1; + public double RenderScaling => _scaling; IStyleHost IStyleHost.StylingParent => _globalStyles!; @@ -698,6 +699,7 @@ private protected virtual void HandleClosed() Debug.Assert(PlatformImpl != null); // The PlatformImpl is completely invalid at this point PlatformImpl = null; + _scaling = 1.0; if (_globalStyles is object) { @@ -749,6 +751,7 @@ internal virtual void HandleResized(Size clientSize, WindowResizeReason reason) /// The window scaling. private void HandleScalingChanged(double scaling) { + _scaling = ValidateScaling(scaling); LayoutHelper.InvalidateSelfAndChildrenMeasure(this); Dispatcher.UIThread.Send(_ => ScalingChanged?.Invoke(this, EventArgs.Empty)); } @@ -952,6 +955,24 @@ protected internal override void InvalidateMirrorTransform() ITextInputMethodImpl? ITextInputMethodRoot.InputMethod => PlatformImpl?.TryGetFeature(); + private double ValidateScaling(double scaling) + { + if (MathUtilities.IsNegativeOrNonFinite(scaling) || MathUtilities.IsZero(scaling)) + { + throw new InvalidOperationException( + $"Invalid {nameof(ITopLevelImpl.RenderScaling)} value {scaling} returned from {PlatformImpl?.GetType()}"); + } + + if (MathUtilities.IsOne(scaling)) + { + // Ensure we've got exactly 1.0 and not an approximation, + // so we don't have to use MathUtilities.IsOne in various layout hot paths. + return 1.0; + } + + return scaling; + } + /// /// Provides layout pass timing from the layout manager to the renderer, for diagnostics purposes. /// diff --git a/tests/Avalonia.Benchmarks/Layout/Measure.cs b/tests/Avalonia.Benchmarks/Layout/Measure.cs index fce2cddec9d..98e5538b5d4 100644 --- a/tests/Avalonia.Benchmarks/Layout/Measure.cs +++ b/tests/Avalonia.Benchmarks/Layout/Measure.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.UnitTests; using BenchmarkDotNet.Attributes; @@ -31,12 +32,23 @@ public Measure() [Benchmark, MethodImpl(MethodImplOptions.NoInlining)] public void Remeasure() { + _root.InvalidateMeasure(); + foreach (var control in _controls) { - control.InvalidateMeasure(); + // Use an unsafe accessor instead of InvalidateMeasure, otherwise a lot of time is spent invalidating + // controls, which we don't want: this benchmark is supposed to be focused on Measure/Arrange. + SetIsMeasureValid(control, false); + SetIsArrangeValid(control, false); } _root.LayoutManager.ExecuteLayoutPass(); } + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_" + nameof(Layoutable.IsMeasureValid))] + private static extern void SetIsMeasureValid(Layoutable layoutable, bool value); + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_" + nameof(Layoutable.IsArrangeValid))] + private static extern void SetIsArrangeValid(Layoutable layoutable, bool value); } } diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs index e70ab520439..27d55fb77d9 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs @@ -300,7 +300,10 @@ public void Extent_Should_Be_Rounded_To_Viewport_When_Close() target.Measure(new Size(1000, 1000)); target.Arrange(new Rect(0, 0, 1000, 1000)); - Assert.Equal(new Size(176.00000000000003, 176.00000000000003), target.Child!.DesiredSize); + var nonRoundedVieViewport = target.Child!.Bounds.Size.Inflate( + LayoutHelper.RoundLayoutThickness(target.Child.Margin, root.LayoutScaling)); + + Assert.Equal(new Size(176.00000000000003, 176.00000000000003), nonRoundedVieViewport); Assert.Equal(new Size(176, 176), target.Viewport); Assert.Equal(new Size(176, 176), target.Extent); } diff --git a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs index f1399d83bc5..1237d0f2f10 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs @@ -66,7 +66,7 @@ public void Should_Measure_MinTextWith() var textLayout = textBlock.TextLayout; - var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.MinTextWidth, textLayout.Height), 1, 1); + var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.MinTextWidth, textLayout.Height), 1); Assert.Equal(textBlock.DesiredSize, constraint); } @@ -83,7 +83,7 @@ public void Calling_Arrange_With_Different_Size_Should_Update_Constraint_And_Tex var textLayout = textBlock.TextLayout; - var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height), 1, 1); + var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height), 1); textBlock.Arrange(new Rect(constraint)); @@ -118,7 +118,7 @@ public void Calling_Measure_With_Infinite_Space_Should_Set_DesiredSize() var textLayout = textBlock.TextLayout; - var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height), 1, 1); + var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height), 1); Assert.Equal(constraint, textBlock.DesiredSize); } diff --git a/tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs b/tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs index 1c6495359a4..a4c12744ab8 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs @@ -239,6 +239,7 @@ private static Mock CreateMockWindowBaseImpl(bool setupAllPrope var renderer = new Mock(); if (setupAllProperties) renderer.SetupAllProperties(); + renderer.Setup(x => x.RenderScaling).Returns(1.0); renderer.Setup(x => x.Compositor).Returns(RendererMocks.CreateDummyCompositor()); return renderer; } @@ -248,18 +249,10 @@ private class TestWindowBase : WindowBase public bool IsClosed { get; private set; } public TestWindowBase() - : base(CreateWindowsBaseImplMock()) + : base(CreateMockWindowBaseImpl().Object) { } - private static IWindowBaseImpl CreateWindowsBaseImplMock() - { - var compositor = RendererMocks.CreateDummyCompositor(); - return Mock.Of(x => - x.RenderScaling == 1 && - x.Compositor == compositor); - } - public TestWindowBase(IWindowBaseImpl impl) : base(impl) { diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index cd43282dc78..55b9a6de66b 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -18,6 +18,7 @@ public class WindowTests public void Setting_Title_Should_Set_Impl_Title() { var windowImpl = new Mock(); + windowImpl.Setup(r => r.RenderScaling).Returns(1.0); windowImpl.Setup(r => r.Compositor).Returns(RendererMocks.CreateDummyCompositor()); var windowingPlatform = new MockWindowingPlatform(() => windowImpl.Object);