Browse Source

Initial implementation of nested styles.

No XAML/parser support yet.
Steven Kirk 3 years ago
parent
commit
98ba0f529b

+ 2 - 2
src/Avalonia.Base/Styling/ChildSelector.cs

@@ -37,13 +37,13 @@ namespace Avalonia.Styling
             return _selectorString;
         }
 
-        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
             var controlParent = ((ILogical)control).LogicalParent;
 
             if (controlParent != null)
             {
-                var parentMatch = _parent.Match((IStyleable)controlParent, subscribe);
+                var parentMatch = _parent.Match((IStyleable)controlParent, parent, subscribe);
 
                 if (parentMatch.Result == SelectorMatchResult.Sometimes)
                 {

+ 2 - 2
src/Avalonia.Base/Styling/DescendentSelector.cs

@@ -35,7 +35,7 @@ namespace Avalonia.Styling
             return _selectorString;
         }
 
-        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
             var c = (ILogical)control;
             var descendantMatches = new OrActivatorBuilder();
@@ -46,7 +46,7 @@ namespace Avalonia.Styling
 
                 if (c is IStyleable)
                 {
-                    var match = _parent.Match((IStyleable)c, subscribe);
+                    var match = _parent.Match((IStyleable)c, parent, subscribe);
 
                     if (match.Result == SelectorMatchResult.Sometimes)
                     {

+ 29 - 0
src/Avalonia.Base/Styling/NestingSelector.cs

@@ -0,0 +1,29 @@
+using System;
+
+namespace Avalonia.Styling
+{
+    /// <summary>
+    /// The `&amp;` nesting style selector.
+    /// </summary>
+    internal class NestingSelector : Selector
+    {
+        public override bool InTemplate => false;
+        public override bool IsCombinator => false;
+        public override Type? TargetType => null;
+
+        public override string ToString() => "&";
+
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
+        {
+            if (parent is Style s && s.Selector is Selector selector)
+            {
+                return selector.Match(control, null, subscribe);
+            }
+
+            throw new InvalidOperationException(
+                "Nesting selector was specified but cannot determine parent selector.");
+        }
+
+        protected override Selector? MovePrevious() => null;
+    }
+}

+ 2 - 2
src/Avalonia.Base/Styling/NotSelector.cs

@@ -45,9 +45,9 @@ namespace Avalonia.Styling
             return _selectorString;
         }
 
-        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
-            var innerResult = _argument.Match(control, subscribe);
+            var innerResult = _argument.Match(control, parent, subscribe);
 
             switch (innerResult.Result)
             {

+ 1 - 1
src/Avalonia.Base/Styling/NthChildSelector.cs

@@ -48,7 +48,7 @@ namespace Avalonia.Styling
         public int Step { get; }
         public int Offset { get; }
 
-        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
             if (!(control is ILogical logical))
             {

+ 2 - 2
src/Avalonia.Base/Styling/OrSelector.cs

@@ -65,14 +65,14 @@ namespace Avalonia.Styling
             return _selectorString;
         }
 
-        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
             var activators = new OrActivatorBuilder();
             var neverThisInstance = false;
 
             foreach (var selector in _selectors)
             {
-                var match = selector.Match(control, subscribe);
+                var match = selector.Match(control, parent, subscribe);
 
                 switch (match.Result)
                 {

+ 1 - 1
src/Avalonia.Base/Styling/PropertyEqualsSelector.cs

@@ -74,7 +74,7 @@ namespace Avalonia.Styling
         }
 
         /// <inheritdoc/>
-        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
             if (subscribe)
             {

+ 23 - 8
src/Avalonia.Base/Styling/Selector.cs

@@ -33,22 +33,25 @@ namespace Avalonia.Styling
         /// Tries to match the selector with a control.
         /// </summary>
         /// <param name="control">The control.</param>
+        /// <param name="parent">
+        /// The parent style, if the style containing the selector is a nested style.
+        /// </param>
         /// <param name="subscribe">
         /// Whether the match should subscribe to changes in order to track the match over time,
         /// or simply return an immediate result.
         /// </param>
         /// <returns>A <see cref="SelectorMatch"/>.</returns>
-        public SelectorMatch Match(IStyleable control, bool subscribe = true)
+        public SelectorMatch Match(IStyleable control, IStyle? parent = null, bool subscribe = true)
         {
             // First match the selector until a combinator is found. Selectors are stored from 
             // right-to-left, so MatchUntilCombinator reverses this order because the type selector
             // will be on the left.
-            var match = MatchUntilCombinator(control, this, subscribe, out var combinator);
+            var match = MatchUntilCombinator(control, this, parent, subscribe, out var combinator);
             
             // If the pre-combinator selector matches, we can now match the combinator, if any.
             if (match.IsMatch && combinator is object)
             {
-                match = match.And(combinator.Match(control, subscribe));
+                match = match.And(combinator.Match(control, parent, subscribe));
 
                 // If we have a combinator then we can never say that we always match a control of
                 // this type, because by definition the combinator matches on things outside of the
@@ -68,12 +71,15 @@ namespace Avalonia.Styling
         /// Evaluates the selector for a match.
         /// </summary>
         /// <param name="control">The control.</param>
+        /// <param name="parent">
+        /// The parent style, if the style containing the selector is a nested style.
+        /// </param>
         /// <param name="subscribe">
         /// Whether the match should subscribe to changes in order to track the match over time,
         /// or simply return an immediate result.
         /// </param>
         /// <returns>A <see cref="SelectorMatch"/>.</returns>
-        protected abstract SelectorMatch Evaluate(IStyleable control, bool subscribe);
+        protected abstract SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe);
 
         /// <summary>
         /// Moves to the previous selector.
@@ -83,13 +89,18 @@ namespace Avalonia.Styling
         private static SelectorMatch MatchUntilCombinator(
             IStyleable control,
             Selector start,
+            IStyle? parent,
             bool subscribe,
             out Selector? combinator)
         {
             combinator = null;
 
             var activators = new AndActivatorBuilder();
-            var result = Match(control, start, subscribe, ref activators, ref combinator);
+            var foundNested = false;
+            var result = Match(control, start, parent, subscribe, ref activators, ref combinator, ref foundNested);
+
+            if (parent is not null && !foundNested)
+                throw new InvalidOperationException("Nesting selector '&' must appear in child selector.");
 
             return result == SelectorMatchResult.Sometimes ?
                 new SelectorMatch(activators.Get()) :
@@ -99,9 +110,11 @@ namespace Avalonia.Styling
         private static SelectorMatchResult Match(
             IStyleable control,
             Selector selector,
+            IStyle? parent,
             bool subscribe,
             ref AndActivatorBuilder activators,
-            ref Selector? combinator)
+            ref Selector? combinator,
+            ref bool foundNested)
         {
             var previous = selector.MovePrevious();
 
@@ -110,7 +123,7 @@ namespace Avalonia.Styling
             // opportunity to exit early.
             if (previous != null && !previous.IsCombinator)
             {
-                var previousMatch = Match(control, previous, subscribe, ref activators, ref combinator);
+                var previousMatch = Match(control, previous, parent, subscribe, ref activators, ref combinator, ref foundNested);
 
                 if (previousMatch < SelectorMatchResult.Sometimes)
                 {
@@ -118,8 +131,10 @@ namespace Avalonia.Styling
                 }
             }
 
+            foundNested |= selector is NestingSelector;
+
             // Match this selector.
-            var match = selector.Evaluate(control, subscribe);
+            var match = selector.Evaluate(control, parent, subscribe);
 
             if (!match.IsMatch)
             {

+ 9 - 0
src/Avalonia.Base/Styling/Selectors.cs

@@ -109,6 +109,15 @@ namespace Avalonia.Styling
             }
         }
 
+        public static Selector Nesting(this Selector? previous)
+        {
+            if (previous is not null)
+                throw new InvalidOperationException(
+                    "Nesting selector '&' must appear at the start of the style selector.");
+
+            return new NestingSelector();
+        }
+
         /// <summary>
         /// Returns a selector which inverts the results of selector argument.
         /// </summary>

+ 18 - 4
src/Avalonia.Base/Styling/Style.cs

@@ -4,8 +4,6 @@ using Avalonia.Animation;
 using Avalonia.Controls;
 using Avalonia.Metadata;
 
-#nullable enable
-
 namespace Avalonia.Styling
 {
     /// <summary>
@@ -14,6 +12,7 @@ namespace Avalonia.Styling
     public class Style : AvaloniaObject, IStyle, IResourceProvider
     {
         private IResourceHost? _owner;
+        private StyleChildren? _children;
         private IResourceDictionary? _resources;
         private List<ISetter>? _setters;
         private List<IAnimation>? _animations;
@@ -34,6 +33,14 @@ namespace Avalonia.Styling
             Selector = selector(null);
         }
 
+        /// <summary>
+        /// Gets the children of the style.
+        /// </summary>
+        public IList<IStyle> Children => _children ??= new(this);
+
+        /// <summary>
+        /// Gets the <see cref="StyledElement"/> or Application that hosts the style.
+        /// </summary>
         public IResourceHost? Owner
         {
             get => _owner;
@@ -47,6 +54,11 @@ namespace Avalonia.Styling
             }
         }
 
+        /// <summary>
+        /// Gets the parent style if this style is hosted in a <see cref="Style.Children"/> collection.
+        /// </summary>
+        public Style? Parent { get; private set; }
+
         /// <summary>
         /// Gets or sets a dictionary of style resources.
         /// </summary>
@@ -90,7 +102,7 @@ namespace Avalonia.Styling
         public IList<IAnimation> Animations => _animations ??= new List<IAnimation>();
 
         bool IResourceNode.HasResources => _resources?.Count > 0;
-        IReadOnlyList<IStyle> IStyle.Children => Array.Empty<IStyle>();
+        IReadOnlyList<IStyle> IStyle.Children => (IReadOnlyList<IStyle>?)_children ?? Array.Empty<IStyle>();
 
         public event EventHandler? OwnerChanged;
 
@@ -98,7 +110,7 @@ namespace Avalonia.Styling
         {
             target = target ?? throw new ArgumentNullException(nameof(target));
 
-            var match = Selector is object ? Selector.Match(target) :
+            var match = Selector is object ? Selector.Match(target, Parent) :
                 target == host ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance;
 
             if (match.IsMatch && (_setters is object || _animations is object))
@@ -156,5 +168,7 @@ namespace Avalonia.Styling
                 _resources?.RemoveOwner(owner);
             }
         }
+
+        internal void SetParent(Style? parent) => Parent = parent;
     }
 }

+ 29 - 0
src/Avalonia.Base/Styling/StyleChildren.cs

@@ -0,0 +1,29 @@
+using System.Collections.ObjectModel;
+
+namespace Avalonia.Styling
+{
+    internal class StyleChildren : Collection<IStyle>
+    {
+        private readonly Style _owner;
+
+        public StyleChildren(Style owner) => _owner = owner;
+
+        protected override void InsertItem(int index, IStyle item)
+        {
+            base.InsertItem(index, item);
+            (item as Style)?.SetParent(_owner);
+        }
+
+        protected override void RemoveItem(int index)
+        {
+            (Items[index] as Style)?.SetParent(null);
+            base.RemoveItem(index);
+        }
+
+        protected override void SetItem(int index, IStyle item)
+        {
+            base.SetItem(index, item);
+            (item as Style)?.SetParent(_owner);
+        }
+    }
+}

+ 2 - 2
src/Avalonia.Base/Styling/TemplateSelector.cs

@@ -36,7 +36,7 @@ namespace Avalonia.Styling
             return _selectorString;
         }
 
-        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
             var templatedParent = control.TemplatedParent as IStyleable;
 
@@ -45,7 +45,7 @@ namespace Avalonia.Styling
                 return SelectorMatch.NeverThisInstance;
             }
 
-            return _parent.Match(templatedParent, subscribe);
+            return _parent.Match(templatedParent, parent, subscribe);
         }
 
         protected override Selector? MovePrevious() => null;

+ 1 - 1
src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs

@@ -94,7 +94,7 @@ namespace Avalonia.Styling
         }
 
         /// <inheritdoc/>
-        protected override SelectorMatch Evaluate(IStyleable control, bool subscribe)
+        protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
             if (TargetType != null)
             {

+ 132 - 0
tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Nesting.cs

@@ -0,0 +1,132 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Styling;
+using Avalonia.Styling.Activators;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Styling
+{
+    public class SelectorTests_Nesting
+    {
+        [Fact]
+        public void Parent_Selector_Doesnt_Match_OfType()
+        {
+            var control = new Control2();
+            Style nested;
+            var parent = new Style(x => x.OfType<Control1>())
+            {
+                Children =
+                {
+                    (nested = new Style(x => x.Nesting().Class("foo"))),
+                }
+            };
+
+            var match = nested.Selector.Match(control, parent);
+            Assert.Equal(SelectorMatchResult.NeverThisType, match.Result);
+        }
+
+        [Fact]
+        public void Nested_Class_Selector()
+        {
+            var control = new Control1 { Classes = { "foo" } };
+            Style nested;
+            var parent = new Style(x => x.OfType<Control1>())
+            {
+                Children =
+                {
+                    (nested = new Style(x => x.Nesting().Class("foo"))),
+                }
+            };
+
+            var match = nested.Selector.Match(control, parent);
+            Assert.Equal(SelectorMatchResult.Sometimes, match.Result);
+
+            var sink = new ActivatorSink(match.Activator);
+
+            Assert.True(sink.Active);
+            control.Classes.Clear();
+            Assert.False(sink.Active);
+        }
+
+        [Fact]
+        public void Nesting_With_No_Parent_Style_Fails()
+        {
+            var control = new Control1();
+            var style = new Style(x => x.Nesting().OfType<Control1>());
+
+            Assert.Throws<InvalidOperationException>(() => style.Selector.Match(control, null));
+        }
+
+        [Fact]
+        public void Nesting_With_No_Parent_Selector_Fails()
+        {
+            var control = new Control1();
+            Style nested;
+            var parent = new Style
+            {
+                Children =
+                {
+                    (nested = new Style(x => x.Nesting().Class("foo"))),
+                }
+            };
+
+            Assert.Throws<InvalidOperationException>(() => nested.Selector.Match(control, parent));
+        }
+
+        [Fact]
+        public void Nesting_Must_Appear_At_Start_Of_Selector()
+        {
+            var control = new Control1();
+            Assert.Throws<InvalidOperationException>(() => new Style(x => x.OfType<Control1>().Nesting()));
+        }
+
+        [Fact]
+        public void Nesting_Must_Appear()
+        {
+            var control = new Control1();
+            Style nested;
+            var parent = new Style
+            {
+                Children =
+                {
+                    (nested = new Style(x => x.OfType<Control1>().Class("foo"))),
+                }
+            };
+
+            Assert.Throws<InvalidOperationException>(() => nested.Selector.Match(control, parent));
+        }
+
+        [Fact]
+        public void Nesting_Must_Appear_In_All_Or_Arguments()
+        {
+            var control = new Control1();
+            Style nested;
+            var parent = new Style(x => x.OfType<Control1>())
+            {
+                Children =
+                {
+                    (nested = new Style(x => Selectors.Or(
+                        x.Nesting().Class("foo"),
+                        x.Class("bar"))))
+                }
+            };
+
+            Assert.Throws<InvalidOperationException>(() => nested.Selector.Match(control, parent));
+        }
+
+        public class Control1 : Control
+        {
+        }
+
+        public class Control2 : Control
+        {
+        }
+
+        private class ActivatorSink : IStyleActivatorSink
+        {
+            public ActivatorSink(IStyleActivator source) => source.Subscribe(this);
+            public bool Active { get; private set; }
+            public void OnNext(bool value, int tag) => Active = value;
+        }
+    }
+}