Browse Source

Overhauled layout system.

From lessons learnt from porting moonlight's Grid tests. Instead of
making a child invalidate its parents directly, send a message from the
child to the parent when its DesiredSize changes. LayoutManager also
needed rewriting to allow this.
Steven Kirk 9 years ago
parent
commit
0e2cee8810

+ 6 - 0
src/Perspex.Layout/ILayoutable.cs

@@ -112,5 +112,11 @@ namespace Perspex.Layout
         /// Invalidates the arrangement of the control and queues a new layout pass.
         /// </summary>
         void InvalidateArrange();
+
+        /// <summary>
+        /// Called when a child control's desired size changes.
+        /// </summary>
+        /// <param name="control">The child control.</param>
+        void ChildDesiredSizeChanged(ILayoutable control);
     }
 }

+ 92 - 214
src/Perspex.Layout/LayoutManager.cs

@@ -3,10 +3,10 @@
 
 using System;
 using System.Collections.Generic;
+using System.Linq;
 using System.Reactive;
-using System.Reactive.Disposables;
 using System.Reactive.Subjects;
-using Perspex.VisualTree;
+using Perspex.Threading;
 using Serilog;
 using Serilog.Core.Enrichers;
 
@@ -15,53 +15,15 @@ namespace Perspex.Layout
     /// <summary>
     /// Manages measuring and arranging of controls.
     /// </summary>
-    /// <remarks>
-    /// Each layout root element such as a window has its own LayoutManager that is responsible
-    /// for laying out its child controls. When a layout is required the <see cref="LayoutNeeded"/>
-    /// observable will fire and the root element should respond by calling
-    /// <see cref="ExecuteLayoutPass"/> at the earliest opportunity to carry out the layout.
-    /// </remarks>
     public class LayoutManager : ILayoutManager
     {
-        /// <summary>
-        /// The maximum number of times a measure/arrange loop can be retried.
-        /// </summary>
-        private const int MaxTries = 3;
-
-        /// <summary>
-        /// Called when a layout is needed.
-        /// </summary>
-        private readonly Subject<Unit> _layoutNeeded;
-
-        /// <summary>
-        /// Called when a layout is completed.
-        /// </summary>
-        private readonly Subject<Unit> _layoutCompleted;
-
-        /// <summary>
-        /// Whether a measure is needed on the next layout pass.
-        /// </summary>
-        private bool _measureNeeded = true;
-
-        /// <summary>
-        /// The controls that need to be measured.
-        /// </summary>
-        private List<Item> _toMeasure = new List<Item>();
-
-        /// <summary>
-        /// The controls that need to be arranged.
-        /// </summary>
-        private List<Item> _toArrange = new List<Item>();
-
-        /// <summary>
-        /// Prevents re-entrancy.
-        /// </summary>
-        private bool _running;
-
-        /// <summary>
-        /// The logger to use.
-        /// </summary>
+        private readonly Queue<ILayoutable> _toMeasure = new Queue<ILayoutable>();
+        private readonly Queue<ILayoutable> _toArrange = new Queue<ILayoutable>();
+        private readonly Subject<Unit> _layoutNeeded = new Subject<Unit>();
+        private readonly Subject<Unit> _layoutCompleted = new Subject<Unit>();
         private readonly ILogger _log;
+        private bool _first = true;
+        private bool _running;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="LayoutManager"/> class.
@@ -74,9 +36,6 @@ namespace Perspex.Layout
                 new PropertyEnricher("SourceContext", GetType()),
                 new PropertyEnricher("Id", GetHashCode()),
             });
-
-            _layoutNeeded = new Subject<Unit>();
-            _layoutCompleted = new Subject<Unit>();
         }
 
         /// <summary>
@@ -114,20 +73,52 @@ namespace Perspex.Layout
             private set;
         }
 
+        /// <summary>
+        /// Notifies the layout manager that a control requires a measure.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        /// <param name="distance">The control's distance from the layout root.</param>
+        public void InvalidateMeasure(ILayoutable control, int distance)
+        {
+            Contract.Requires<ArgumentNullException>(control != null);
+            Dispatcher.UIThread.VerifyAccess();
+
+            _toMeasure.Enqueue(control);
+            _toArrange.Enqueue(control);
+            FireLayoutNeeded();
+        }
+
+        /// <summary>
+        /// Notifies the layout manager that a control requires an arrange.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        /// <param name="distance">The control's distance from the layout root.</param>
+        public void InvalidateArrange(ILayoutable control, int distance)
+        {
+            Contract.Requires<ArgumentNullException>(control != null);
+            Dispatcher.UIThread.VerifyAccess();
+
+            _toArrange.Enqueue(control);
+            FireLayoutNeeded();
+        }
+
         /// <summary>
         /// Executes a layout pass.
         /// </summary>
         public void ExecuteLayoutPass()
         {
-            if (_running)
+            const int MaxPasses = 3;
+
+            Dispatcher.UIThread.VerifyAccess();
+
+            if (Root == null)
             {
-                return;
+                throw new InvalidOperationException("Root must be set before executing layout pass.");
             }
 
-            using (Disposable.Create(() => _running = false))
+            if (!_running)
             {
                 _running = true;
-                LayoutQueued = false;
 
                 _log.Information(
                     "Started layout pass. To measure: {Measure} To arrange: {Arrange}",
@@ -137,21 +128,31 @@ namespace Perspex.Layout
                 var stopwatch = new System.Diagnostics.Stopwatch();
                 stopwatch.Start();
 
-                for (int i = 0; i < MaxTries; ++i)
+                try
                 {
-                    if (_measureNeeded)
+                    if (_first)
                     {
-                        ExecuteMeasure();
-                        _measureNeeded = false;
+                        Measure(Root);
+                        Arrange(Root);
+                        _first = false;
                     }
 
-                    ExecuteArrange();
-
-                    if (_toMeasure.Count == 0)
+                    for (var pass = 0; pass < MaxPasses; ++pass)
                     {
-                        break;
+                        ExecuteMeasurePass();
+                        ExecuteArrangePass();
+
+                        if (_toMeasure.Count == 0)
+                        {
+                            break;
+                        }
                     }
                 }
+                finally
+                {
+                    _running = false;
+                    LayoutQueued = false;
+                }
 
                 stopwatch.Stop();
                 _log.Information("Layout pass finised in {Time}", stopwatch.Elapsed);
@@ -160,181 +161,58 @@ namespace Perspex.Layout
             }
         }
 
-        /// <summary>
-        /// Notifies the layout manager that a control requires a measure.
-        /// </summary>
-        /// <param name="control">The control.</param>
-        /// <param name="distance">The control's distance from the layout root.</param>
-        public void InvalidateMeasure(ILayoutable control, int distance)
+        private void ExecuteMeasurePass()
         {
-            var item = new Item(control, distance);
-            _toMeasure.Add(item);
-            _toArrange.Add(item);
-
-            _measureNeeded = true;
-
-            if (!LayoutQueued)
+            while (_toMeasure.Count > 0)
             {
-                IVisual visual = control as IVisual;
-                _layoutNeeded.OnNext(Unit.Default);
-                LayoutQueued = true;
+                var next = _toMeasure.Dequeue();
+                Measure(next);
             }
         }
 
-        /// <summary>
-        /// Notifies the layout manager that a control requires an arrange.
-        /// </summary>
-        /// <param name="control">The control.</param>
-        /// <param name="distance">The control's distance from the layout root.</param>
-        public void InvalidateArrange(ILayoutable control, int distance)
+        private void ExecuteArrangePass()
         {
-            _toArrange.Add(new Item(control, distance));
-
-            if (!LayoutQueued)
+            while (_toArrange.Count > 0 && _toMeasure.Count == 0)
             {
-                IVisual visual = control as IVisual;
-                _layoutNeeded.OnNext(Unit.Default);
-                LayoutQueued = true;
+                var next = _toArrange.Dequeue();
+                Arrange(next);
             }
         }
 
-        /// <summary>
-        /// Executes the measure part of the layout pass.
-        /// </summary>
-        private void ExecuteMeasure()
+        private void Measure(ILayoutable control)
         {
-            for (int i = 0; i < MaxTries; ++i)
-            {
-                var measure = _toMeasure;
-
-                _toMeasure = new List<Item>();
-                measure.Sort();
+            var root = control as ILayoutRoot;
 
-                if (!Root.IsMeasureValid)
-                {
-                    var size = new Size(
-                        double.IsNaN(Root.Width) ? double.PositiveInfinity : Root.Width,
-                        double.IsNaN(Root.Height) ? double.PositiveInfinity : Root.Height);
-                    Root.Measure(size);
-                }
-
-                foreach (var item in measure)
-                {
-                    if (!item.Control.IsMeasureValid)
-                    {
-                        if (item.Control != Root)
-                        {
-                            var parent = item.Control.GetVisualParent<ILayoutable>();
-
-                            while (parent != null && parent.PreviousMeasure == null)
-                            {
-                                parent = parent.GetVisualParent<ILayoutable>();
-                            }
-
-                            if (parent != null && parent.GetVisualRoot() == Root)
-                            {
-                                parent.Measure(parent.PreviousMeasure.Value, true);
-                            }
-                        }
-                    }
-                }
-
-                if (_toMeasure.Count == 0)
-                {
-                    break;
-                }
+            if (root != null)
+            {
+                root.Measure(Size.Infinity);
             }
-        }
-
-        /// <summary>
-        /// Executes the arrange part of the layout pass.
-        /// </summary>
-        private void ExecuteArrange()
-        {
-            for (int i = 0; i < MaxTries; ++i)
+            else if (control.PreviousMeasure.HasValue)
             {
-                var arrange = _toArrange;
-
-                _toArrange = new List<Item>();
-                arrange.Sort();
-
-                if (!Root.IsArrangeValid && Root.IsMeasureValid)
-                {
-                    Root.Arrange(new Rect(Root.DesiredSize));
-                }
-
-                if (_toMeasure.Count > 0)
-                {
-                    return;
-                }
-
-                foreach (var item in arrange)
-                {
-                    if (!item.Control.IsArrangeValid)
-                    {
-                        if (item.Control != Root)
-                        {
-                            var control = item.Control;
-
-                            while (control != null && control.PreviousArrange == null)
-                            {
-                                control = control.GetVisualParent<ILayoutable>();
-                            }
-
-                            if (control != null && control.GetVisualRoot() == Root)
-                            {
-                                control.Arrange(control.PreviousArrange.Value, true);
-                            }
-
-                            if (_toMeasure.Count > 0)
-                            {
-                                return;
-                            }
-                        }
-                    }
-                }
-
-                if (_toArrange.Count == 0)
-                {
-                    break;
-                }
+                control.Measure(control.PreviousMeasure.Value);
             }
         }
 
-        /// <summary>
-        /// An item to be layed-out.
-        /// </summary>
-        private class Item : IComparable<Item>
+        private void Arrange(ILayoutable control)
         {
-            /// <summary>
-            /// Initializes a new instance of the <see cref="Item"/> class.
-            /// </summary>
-            /// <param name="control">The control.</param>
-            /// <param name="distance">The control's distance from the layout root.</param>
-            public Item(ILayoutable control, int distance)
+            var root = control as ILayoutRoot;
+
+            if (root != null)
             {
-                Control = control;
-                Distance = distance;
+                root.Arrange(new Rect(root.DesiredSize));
             }
+            else if (control.PreviousArrange.HasValue)
+            {
+                control.Arrange(control.PreviousArrange.Value);
+            }
+        }
 
-            /// <summary>
-            /// Gets the control.
-            /// </summary>
-            public ILayoutable Control { get; }
-
-            /// <summary>
-            /// Gets the control's distance from the layout root.
-            /// </summary>
-            public int Distance { get; }
-
-            /// <summary>
-            /// Compares the distance of two items.
-            /// </summary>
-            /// <param name="other">The other item/</param>
-            /// <returns>The comparison.</returns>
-            public int CompareTo(Item other)
+        private void FireLayoutNeeded()
+        {
+            if (!LayoutQueued)
             {
-                return Distance - other.Distance;
+                _layoutNeeded.OnNext(Unit.Default);
+                LayoutQueued = true;
             }
         }
     }

+ 34 - 32
src/Perspex.Layout/Layoutable.cs

@@ -132,12 +132,11 @@ namespace Perspex.Layout
         public static readonly StyledProperty<bool> UseLayoutRoundingProperty =
             PerspexProperty.Register<Layoutable, bool>(nameof(UseLayoutRounding), defaultValue: true, inherits: true);
 
+        private readonly ILogger _layoutLog;
+        private bool _measuring;
         private Size? _previousMeasure;
-
         private Rect? _previousArrange;
 
-        private readonly ILogger _layoutLog;
-
         /// <summary>
         /// Initializes static members of the <see cref="Layoutable"/> class.
         /// </summary>
@@ -320,9 +319,20 @@ namespace Perspex.Layout
 
             if (force || !IsMeasureValid || _previousMeasure != availableSize)
             {
+                var previousDesiredSize = DesiredSize;
+                var desiredSize = default(Size);
+
                 IsMeasureValid = true;
 
-                var desiredSize = MeasureCore(availableSize).Constrain(availableSize);
+                try
+                {
+                    _measuring = true;
+                    desiredSize = MeasureCore(availableSize).Constrain(availableSize);
+                }
+                finally
+                {
+                    _measuring = false;
+                }
 
                 if (IsInvalidSize(desiredSize))
                 {
@@ -333,6 +343,11 @@ namespace Perspex.Layout
                 _previousMeasure = availableSize;
 
                 _layoutLog.Verbose("Measure requested {DesiredSize}", DesiredSize);
+
+                if (DesiredSize != previousDesiredSize)
+                {
+                    this.GetVisualParent<ILayoutable>()?.ChildDesiredSizeChanged(this);
+                }
             }
         }
 
@@ -373,24 +388,13 @@ namespace Perspex.Layout
         /// </summary>
         public void InvalidateMeasure()
         {
-            var parent = this.GetVisualParent<ILayoutable>();
-
             if (IsMeasureValid)
             {
                 _layoutLog.Verbose("Invalidated measure");
-            }
 
-            IsMeasureValid = false;
-            IsArrangeValid = false;
-            _previousMeasure = null;
-            _previousArrange = null;
+                IsMeasureValid = false;
+                IsArrangeValid = false;
 
-            if (parent != null && IsResizable(parent))
-            {
-                parent.InvalidateMeasure();
-            }
-            else
-            {
                 var root = GetLayoutRoot();
                 root?.Item1.LayoutManager?.InvalidateMeasure(this, root.Item2);
             }
@@ -401,16 +405,24 @@ namespace Perspex.Layout
         /// </summary>
         public void InvalidateArrange()
         {
-            var root = GetLayoutRoot();
-
             if (IsArrangeValid)
             {
                 _layoutLog.Verbose("Arrange measure");
+
+                IsArrangeValid = false;
+
+                var root = GetLayoutRoot();
+                root?.Item1.LayoutManager?.InvalidateArrange(this, root.Item2);
             }
+        }
 
-            IsArrangeValid = false;
-            _previousArrange = null;
-            root?.Item1.LayoutManager?.InvalidateArrange(this, root.Item2);
+        /// <inheritdoc/>
+        void ILayoutable.ChildDesiredSizeChanged(ILayoutable control)
+        {
+            if (!_measuring)
+            {
+                InvalidateMeasure();
+            }
         }
 
         /// <summary>
@@ -624,16 +636,6 @@ namespace Perspex.Layout
             control?.InvalidateArrange();
         }
 
-        /// <summary>
-        /// Tests whether a control's size can be changed by a layout pass.
-        /// </summary>
-        /// <param name="control">The control.</param>
-        /// <returns>True if the control's size can change; otherwise false.</returns>
-        private static bool IsResizable(ILayoutable control)
-        {
-            return double.IsNaN(control.Width) || double.IsNaN(control.Height);
-        }
-
         /// <summary>
         /// Tests whether any of a <see cref="Rect"/>'s properties incude nagative values,
         /// a NaN or Infinity.

+ 38 - 0
tests/Perspex.Layout.UnitTests/LayoutManagerTests.cs

@@ -0,0 +1,38 @@
+// Copyright (c) The Perspex Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using Perspex.Controls;
+using Xunit;
+
+namespace Perspex.Layout.UnitTests
+{
+    public class LayoutManagerTests
+    {
+        [Fact]
+        public void Invalidating_Child_Should_Remeasure_Parent()
+        {
+            Border border;
+            StackPanel panel;
+
+            var root = new TestLayoutRoot
+            {
+                Child = panel = new StackPanel
+                {
+                    Children = new Controls.Controls
+                    {
+                        (border = new Border())
+                    }
+                }
+            };
+
+            root.LayoutManager.ExecuteLayoutPass();
+            Assert.Equal(new Size(0, 0), root.DesiredSize);
+
+            border.Width = 100;
+            border.Height = 100;
+
+            root.LayoutManager.ExecuteLayoutPass();
+            Assert.Equal(new Size(100, 100), panel.DesiredSize);
+        }
+    }
+}

+ 21 - 0
tests/Perspex.Layout.UnitTests/MeasureTests.cs

@@ -8,6 +8,27 @@ namespace Perspex.Layout.UnitTests
 {
     public class MeasureTests
     {
+        [Fact]
+        public void Invalidating_Child_Should_Not_Invalidate_Parent()
+        {
+            var panel = new StackPanel();
+            var child = new Border();
+            panel.Children.Add(child);
+
+            panel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+            Assert.Equal(new Size(0, 0), panel.DesiredSize);
+
+            child.Width = 100;
+            child.Height = 100;
+
+            Assert.True(panel.IsMeasureValid);
+            Assert.False(child.IsMeasureValid);
+
+            panel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+            Assert.Equal(new Size(0, 0), panel.DesiredSize);
+        }
+
         [Fact]
         public void Negative_Margin_Larger_Than_Constraint_Should_Request_Width_0()
         {

+ 2 - 0
tests/Perspex.Layout.UnitTests/Perspex.Layout.UnitTests.csproj

@@ -86,8 +86,10 @@
   </Choose>
   <ItemGroup>
     <Compile Include="FullLayoutTests.cs" />
+    <Compile Include="LayoutManagerTests.cs" />
     <Compile Include="MeasureTests.cs" />
     <Compile Include="Properties\AssemblyInfo.cs" />
+    <Compile Include="TestLayoutRoot.cs" />
   </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\..\src\Perspex.Animation\Perspex.Animation.csproj">

+ 27 - 0
tests/Perspex.Layout.UnitTests/TestLayoutRoot.cs

@@ -0,0 +1,27 @@
+// Copyright (c) The Perspex Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using Perspex.Controls;
+
+namespace Perspex.Layout.UnitTests
+{
+    internal class TestLayoutRoot : Decorator, ILayoutRoot
+    {
+        public TestLayoutRoot()
+        {
+            ClientSize = new Size(500, 500);
+            LayoutManager = new LayoutManager { Root = this };
+        }
+
+        public Size ClientSize
+        {
+            get;
+            set;
+        }
+
+        public ILayoutManager LayoutManager
+        {
+            get;
+        }
+    }
+}

+ 2 - 2
tests/Perspex.SceneGraph.UnitTests/VisualTree/BoundsTrackerTests.cs

@@ -40,13 +40,13 @@ namespace Perspex.SceneGraph.UnitTests.VisualTree
             var results = new List<TransformedBounds>();
             track.Subscribe(results.Add);
 
-            Assert.Equal(new Rect(42, 42, 15, 15), results.Last().Bounds);
+            Assert.Equal(new Rect(42, 42, 15, 15), results[0].Bounds);
 
             tree.Padding = new Thickness(15);
             tree.Measure(Size.Infinity);
             tree.Arrange(new Rect(0, 0, 100, 100), true);
 
-            Assert.Equal(new Rect(42, 42, 15, 15), results.Last().Bounds);
+            Assert.Equal(new Rect(47, 47, 15, 15), results[1].Bounds);
         }
     }
 }