From 89d969b367b4e1bd37982361602a74518e431ab9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 4 Mar 2019 22:45:47 +0100 Subject: [PATCH] Added selector comma operator. The comma selector can be used to separate a number different selectors, all of which will be applied to the control with an OR. Fixes #1742 --- src/Avalonia.Styling/Styling/OrSelector.cs | 131 ++++++++++++++++++ src/Avalonia.Styling/Styling/Selectors.cs | 22 +++ .../Markup/Parsers/SelectorGrammar.cs | 30 ++-- .../Markup/Parsers/SelectorParser.cs | 20 +++ .../Parsers/SelectorGrammarTests.cs | 16 +++ .../Parsers/SelectorParserTests.cs | 7 + .../SelectorTests_Or.cs | 106 ++++++++++++++ 7 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 src/Avalonia.Styling/Styling/OrSelector.cs create mode 100644 tests/Avalonia.Styling.UnitTests/SelectorTests_Or.cs diff --git a/src/Avalonia.Styling/Styling/OrSelector.cs b/src/Avalonia.Styling/Styling/OrSelector.cs new file mode 100644 index 0000000000..58c5c778fb --- /dev/null +++ b/src/Avalonia.Styling/Styling/OrSelector.cs @@ -0,0 +1,131 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; + +namespace Avalonia.Styling +{ + /// + /// The OR style selector. + /// + internal class OrSelector : Selector + { + private readonly IReadOnlyList _selectors; + private string _selectorString; + private Type _targetType; + + /// + /// Initializes a new instance of the class. + /// + /// The selectors to OR. + public OrSelector(IReadOnlyList selectors) + { + Contract.Requires(selectors != null); + Contract.Requires(selectors.Count > 1); + + _selectors = selectors; + } + + /// + public override bool InTemplate => false; + + /// + public override bool IsCombinator => false; + + /// + public override Type TargetType + { + get + { + if (_targetType == null) + { + _targetType = EvaluateTargetType(); + } + + return _targetType; + } + } + + /// + public override string ToString() + { + if (_selectorString == null) + { + _selectorString = string.Join(", ", _selectors); + } + + return _selectorString; + } + + protected override SelectorMatch Evaluate(IStyleable control, bool subscribe) + { + var activators = new List>(); + var neverThisInstance = false; + + foreach (var selector in _selectors) + { + var match = selector.Match(control, subscribe); + + switch (match.Result) + { + case SelectorMatchResult.AlwaysThisType: + case SelectorMatchResult.AlwaysThisInstance: + return match; + case SelectorMatchResult.NeverThisInstance: + neverThisInstance = true; + break; + case SelectorMatchResult.Sometimes: + activators.Add(match.Activator); + break; + } + } + + if (activators.Count > 1) + { + return new SelectorMatch(StyleActivator.Or(activators)); + } + else if (activators.Count == 1) + { + return new SelectorMatch(activators[0]); + } + else if (neverThisInstance) + { + return SelectorMatch.NeverThisInstance; + } + else + { + return SelectorMatch.NeverThisType; + } + } + + protected override Selector MovePrevious() => null; + + private Type EvaluateTargetType() + { + var result = default(Type); + + foreach (var selector in _selectors) + { + if (selector.TargetType == null) + { + return null; + } + else if (result == null) + { + result = selector.TargetType; + } + else + { + while (!result.IsAssignableFrom(selector.TargetType)) + { + result = result.BaseType; + } + } + } + + return result; + } + } +} + diff --git a/src/Avalonia.Styling/Styling/Selectors.cs b/src/Avalonia.Styling/Styling/Selectors.cs index deb677e04c..3e7a30d389 100644 --- a/src/Avalonia.Styling/Styling/Selectors.cs +++ b/src/Avalonia.Styling/Styling/Selectors.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.Collections.Generic; +using System.Linq; namespace Avalonia.Styling { @@ -137,6 +139,26 @@ namespace Avalonia.Styling return previous.OfType(typeof(T)); } + /// + /// Returns a selector which ORs selectors. + /// + /// The selectors to be OR'd. + /// The selector. + public static Selector Or(params Selector[] selectors) + { + return new OrSelector(selectors); + } + + /// + /// Returns a selector which ORs selectors. + /// + /// The selectors to be OR'd. + /// The selector. + public static Selector Or(IReadOnlyList selectors) + { + return new OrSelector(selectors); + } + /// /// Returns a selector which matches a control with the specified property value. /// diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs index 55c3aab81f..e11e333a49 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs @@ -49,7 +49,7 @@ namespace Avalonia.Markup.Parsers state = ParseStart(ref r); break; case State.Middle: - state = ParseMiddle(ref r, end); + (state, syntax) = ParseMiddle(ref r, end); break; case State.CanHaveType: state = ParseCanHaveType(ref r); @@ -113,33 +113,37 @@ namespace Avalonia.Markup.Parsers return State.TypeName; } - private static State ParseMiddle(ref CharacterReader r, char? end) + private static (State, ISyntax) ParseMiddle(ref CharacterReader r, char? end) { if (r.TakeIf(':')) { - return State.Colon; + return (State.Colon, null); } else if (r.TakeIf('.')) { - return State.Class; + return (State.Class, null); } else if (r.TakeIf(char.IsWhiteSpace) || r.Peek == '>') { - return State.Traversal; + return (State.Traversal, null); } else if (r.TakeIf('/')) { - return State.Template; + return (State.Template, null); } else if (r.TakeIf('#')) { - return State.Name; + return (State.Name, null); + } + else if (r.TakeIf(',')) + { + return (State.Start, new CommaSyntax()); } else if (end.HasValue && !r.End && r.Peek == end.Value) { - return State.End; + return (State.End, null); } - return State.TypeName; + return (State.TypeName, null); } private static State ParseCanHaveType(ref CharacterReader r) @@ -415,5 +419,13 @@ namespace Avalonia.Markup.Parsers return (obj is NotSyntax not) && Argument.SequenceEqual(not.Argument); } } + + public class CommaSyntax : ISyntax + { + public override bool Equals(object obj) + { + return obj is CommaSyntax or; + } + } } } diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs index 8137ac3f48..493579d676 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs @@ -43,6 +43,7 @@ namespace Avalonia.Markup.Parsers private Selector Create(IEnumerable syntax) { var result = default(Selector); + var results = default(List); foreach (var i in syntax) { @@ -106,11 +107,30 @@ namespace Avalonia.Markup.Parsers case SelectorGrammar.NotSyntax not: result = result.Not(x => Create(not.Argument)); break; + case SelectorGrammar.CommaSyntax comma: + if (results == null) + { + results = new List(); + } + + results.Add(result); + result = null; + break; default: throw new NotSupportedException($"Unsupported selector grammar '{i.GetType()}'."); } } + if (results != null) + { + if (result != null) + { + results.Add(result); + } + + result = results.Count > 1 ? Selectors.Or(results) : results[0]; + } + return result; } diff --git a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs index e3ce4b0968..216043aa20 100644 --- a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs @@ -261,6 +261,22 @@ namespace Avalonia.Markup.UnitTests.Parsers result); } + [Fact] + public void OfType_Comma_Is_Class() + { + var result = SelectorGrammar.Parse("TextBlock, :is(Button).foo"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "TextBlock" }, + new SelectorGrammar.CommaSyntax(), + new SelectorGrammar.IsSyntax { TypeName = "Button" }, + new SelectorGrammar.ClassSyntax { Class = "foo" }, + }, + result); + } + [Fact] public void Namespace_Alone_Fails() { diff --git a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs index 1b1a96a7e2..1c0cba56c9 100644 --- a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs @@ -14,6 +14,13 @@ namespace Avalonia.Markup.UnitTests.Parsers var result = target.Parse("TextBlock[IsPointerOver=True]"); } + [Fact] + public void Parses_Comma_Separated_Selectors() + { + var target = new SelectorParser((ns, type) => typeof(TextBlock)); + var result = target.Parse("TextBlock, TextBlock:foo"); + } + [Fact] public void Throws_If_OfType_Type_Not_Found() { diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_Or.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_Or.cs new file mode 100644 index 0000000000..521c73ce27 --- /dev/null +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_Or.cs @@ -0,0 +1,106 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Xunit; + +namespace Avalonia.Styling.UnitTests +{ + public class SelectorTests_Or + { + [Fact] + public void Or_Selector_Should_Have_Correct_String_Representation() + { + var target = Selectors.Or( + default(Selector).OfType().Class("foo"), + default(Selector).OfType().Class("bar")); + + Assert.Equal("Control1.foo, Control2.bar", target.ToString()); + } + + [Fact] + public void Or_Selector_Matches_Control_Of_Correct_Type() + { + var target = Selectors.Or( + default(Selector).OfType(), + default(Selector).OfType().Class("bar")); + var control = new Control1(); + + Assert.Equal(SelectorMatchResult.AlwaysThisType, target.Match(control).Result); + } + + [Fact] + public void Or_Selector_Matches_Control_Of_Correct_Type_With_Class() + { + var target = Selectors.Or( + default(Selector).OfType(), + default(Selector).OfType().Class("bar")); + var control = new Control2(); + + Assert.Equal(SelectorMatchResult.Sometimes, target.Match(control).Result); + } + + [Fact] + public void Or_Selector_Doesnt_Match_Control_Of_Incorrect_Type() + { + var target = Selectors.Or( + default(Selector).OfType(), + default(Selector).OfType().Class("bar")); + var control = new Control3(); + + Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(control).Result); + } + + [Fact] + public void Or_Selector_Doesnt_Match_Control_With_Incorrect_Name() + { + var target = Selectors.Or( + default(Selector).OfType().Name("foo"), + default(Selector).OfType().Name("foo")); + var control = new Control1 { Name = "bar" }; + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(control).Result); + } + + [Fact] + public void Returns_Correct_TargetType_When_Types_Same() + { + var target = Selectors.Or( + default(Selector).OfType().Class("foo"), + default(Selector).OfType().Class("bar")); + + Assert.Equal(typeof(Control1), target.TargetType); + } + + [Fact] + public void Returns_Common_TargetType() + { + var target = Selectors.Or( + default(Selector).OfType().Class("foo"), + default(Selector).OfType().Class("bar")); + + Assert.Equal(typeof(TestControlBase), target.TargetType); + } + + [Fact] + public void Returns_Null_TargetType_When_A_Selector_Has_No_TargetType() + { + var target = Selectors.Or( + default(Selector).OfType().Class("foo"), + default(Selector).Class("bar")); + + Assert.Equal(null, target.TargetType); + } + + public class Control1 : TestControlBase + { + } + + public class Control2 : TestControlBase + { + } + + public class Control3 : TestControlBase + { + } + } +}