From 233adc9ca5253e16ea5bc76f8984d922e5770e09 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 25 Jan 2019 17:51:48 +0100 Subject: [PATCH] Added `:not()` style selector. --- src/Avalonia.Styling/Styling/NotSelector.cs | 72 +++++++++++ src/Avalonia.Styling/Styling/Selectors.cs | 11 ++ .../Markup/Parsers/SelectorGrammar.cs | 51 +++++++- .../Markup/Parsers/SelectorParser.cs | 13 +- .../Parsers/SelectorGrammarTests.cs | 73 +++++++++++ .../Xaml/StyleTests.cs | 28 +++++ .../SelectorTests_Not.cs | 114 ++++++++++++++++++ 7 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 src/Avalonia.Styling/Styling/NotSelector.cs create mode 100644 tests/Avalonia.Styling.UnitTests/SelectorTests_Not.cs diff --git a/src/Avalonia.Styling/Styling/NotSelector.cs b/src/Avalonia.Styling/Styling/NotSelector.cs new file mode 100644 index 0000000000..bcf76620be --- /dev/null +++ b/src/Avalonia.Styling/Styling/NotSelector.cs @@ -0,0 +1,72 @@ +// 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.Reactive.Linq; + +namespace Avalonia.Styling +{ + /// + /// The `:not()` style selector. + /// + internal class NotSelector : Selector + { + private readonly Selector _previous; + private readonly Selector _argument; + private string _selectorString; + + /// + /// Initializes a new instance of the class. + /// + /// The previous selector. + /// The selector to be not-ed. + public NotSelector(Selector previous, Selector argument) + { + _previous = previous; + _argument = argument ?? throw new InvalidOperationException("Not selector must have a selector argument."); + } + + /// + public override bool InTemplate => _argument.InTemplate; + + /// + public override bool IsCombinator => false; + + /// + public override Type TargetType => _previous?.TargetType; + + /// + public override string ToString() + { + if (_selectorString == null) + { + _selectorString = ":not(" + _argument.ToString() + ")"; + } + + return _selectorString; + } + + protected override SelectorMatch Evaluate(IStyleable control, bool subscribe) + { + var innerResult = _argument.Match(control, subscribe); + + switch (innerResult.Result) + { + case SelectorMatchResult.AlwaysThisInstance: + return SelectorMatch.NeverThisInstance; + case SelectorMatchResult.AlwaysThisType: + return SelectorMatch.NeverThisType; + case SelectorMatchResult.NeverThisInstance: + return SelectorMatch.AlwaysThisInstance; + case SelectorMatchResult.NeverThisType: + return SelectorMatch.AlwaysThisType; + case SelectorMatchResult.Sometimes: + return new SelectorMatch(innerResult.Activator.Select(x => !x)); + default: + throw new InvalidOperationException("Invalid SelectorMatchResult."); + } + } + + protected override Selector MovePrevious() => _previous; + } +} diff --git a/src/Avalonia.Styling/Styling/Selectors.cs b/src/Avalonia.Styling/Styling/Selectors.cs index c91cc7af04..4284c7e798 100644 --- a/src/Avalonia.Styling/Styling/Selectors.cs +++ b/src/Avalonia.Styling/Styling/Selectors.cs @@ -94,6 +94,17 @@ namespace Avalonia.Styling } } + /// + /// Returns a selector which inverts the results of selector argument. + /// + /// The previous selector. + /// The selector to be not-ed. + /// The selector. + public static Selector Not(this Selector previous, Func argument) + { + return new NotSelector(previous, argument(null)); + } + /// /// Returns a selector which matches a type. /// diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs index f66d3e51fc..55c3aab81f 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Avalonia.Data.Core; using Avalonia.Utilities; @@ -32,6 +33,11 @@ namespace Avalonia.Markup.Parsers public static IEnumerable Parse(string s) { var r = new CharacterReader(s.AsSpan()); + return Parse(ref r, null); + } + + private static IEnumerable Parse(ref CharacterReader r, char? end) + { var state = State.Start; var selector = new List(); while (!r.End && state != State.End) @@ -43,7 +49,7 @@ namespace Avalonia.Markup.Parsers state = ParseStart(ref r); break; case State.Middle: - state = ParseMiddle(ref r); + state = ParseMiddle(ref r, end); break; case State.CanHaveType: state = ParseCanHaveType(ref r); @@ -107,7 +113,7 @@ namespace Avalonia.Markup.Parsers return State.TypeName; } - private static State ParseMiddle(ref CharacterReader r) + private static State ParseMiddle(ref CharacterReader r, char? end) { if (r.TakeIf(':')) { @@ -129,6 +135,10 @@ namespace Avalonia.Markup.Parsers { return State.Name; } + else if (end.HasValue && !r.End && r.Peek == end.Value) + { + return State.End; + } return State.TypeName; } @@ -151,16 +161,23 @@ namespace Avalonia.Markup.Parsers } const string IsKeyword = "is"; + const string NotKeyword = "not"; + if (identifier.SequenceEqual(IsKeyword.AsSpan()) && r.TakeIf('(')) { var syntax = ParseType(ref r, new IsSyntax()); - if (r.End || !r.TakeIf(')')) - { - throw new ExpressionParseException(r.Position, $"Expected ')', got {r.Peek}"); - } + Expect(ref r, ')'); return (State.CanHaveType, syntax); } + if (identifier.SequenceEqual(NotKeyword.AsSpan()) && r.TakeIf('(')) + { + var argument = Parse(ref r, ')'); + Expect(ref r, ')'); + + var syntax = new NotSyntax { Argument = argument }; + return (State.Middle, syntax); + } else { return ( @@ -282,6 +299,18 @@ namespace Avalonia.Markup.Parsers return syntax; } + private static void Expect(ref CharacterReader r, char c) + { + if (r.End) + { + throw new ExpressionParseException(r.Position, $"Expected '{c}', got end of selector."); + } + else if (!r.TakeIf(')')) + { + throw new ExpressionParseException(r.Position, $"Expected '{c}', got '{r.Peek}'."); + } + } + public interface ISyntax { } @@ -376,5 +405,15 @@ namespace Avalonia.Markup.Parsers return obj is TemplateSyntax; } } + + public class NotSyntax : ISyntax + { + public IEnumerable Argument { get; set; } + + public override bool Equals(object obj) + { + return (obj is NotSyntax not) && Argument.SequenceEqual(not.Argument); + } + } } } diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs index bf5b396bec..8d1216e1dc 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs @@ -2,6 +2,7 @@ // 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.Globalization; using Avalonia.Styling; using Avalonia.Utilities; @@ -25,7 +26,7 @@ namespace Avalonia.Markup.Parsers /// public SelectorParser(Func typeResolver) { - this._typeResolver = typeResolver; + _typeResolver = typeResolver; } /// @@ -36,6 +37,11 @@ namespace Avalonia.Markup.Parsers public Selector Parse(string s) { var syntax = SelectorGrammar.Parse(s); + return Create(syntax); + } + + private Selector Create(IEnumerable syntax) + { var result = default(Selector); foreach (var i in syntax) @@ -97,6 +103,11 @@ namespace Avalonia.Markup.Parsers case SelectorGrammar.TemplateSyntax template: result = result.Template(); break; + case SelectorGrammar.NotSyntax not: + result = result.Not(x => Create(not.Argument)); + break; + default: + throw new NotSupportedException($"Unsupported selector grammar '{i.GetType()}'."); } } diff --git a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs index 88fe5a2a12..e3ce4b0968 100644 --- a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs @@ -200,6 +200,67 @@ namespace Avalonia.Markup.UnitTests.Parsers result); } + [Fact] + public void Not_OfType() + { + var result = SelectorGrammar.Parse(":not(Button)"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.NotSyntax + { + Argument = new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "Button" }, + }, + } + }, + result); + } + + [Fact] + public void OfType_Not_Class() + { + var result = SelectorGrammar.Parse("Button:not(.foo)"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "Button" }, + new SelectorGrammar.NotSyntax + { + Argument = new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.ClassSyntax { Class = "foo" }, + }, + } + }, + result); + } + + [Fact] + public void Is_Descendent_Not_OfType_Class() + { + var result = SelectorGrammar.Parse(":is(Control) :not(Button.foo)"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.IsSyntax { TypeName = "Control" }, + new SelectorGrammar.DescendantSyntax { }, + new SelectorGrammar.NotSyntax + { + Argument = new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "Button" }, + new SelectorGrammar.ClassSyntax { Class = "foo" }, + }, + } + }, + result); + } + [Fact] public void Namespace_Alone_Fails() { @@ -223,5 +284,17 @@ namespace Avalonia.Markup.UnitTests.Parsers { Assert.Throws(() => SelectorGrammar.Parse(".%foo")); } + + [Fact] + public void Not_Without_Argument_Fails() + { + Assert.Throws(() => SelectorGrammar.Parse(":not()")); + } + + [Fact] + public void Not_Without_Closing_Parenthesis_Fails() + { + Assert.Throws(() => SelectorGrammar.Parse(":not(Button")); + } } } diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs index beaf7477d0..a84ce74a88 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs @@ -198,5 +198,33 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml ex.InnerException.Message); } } + + [Fact] + public void Style_Can_Use_Not_Selector() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = @" + + + + + + + + +"; + var loader = new AvaloniaXamlLoader(); + var window = (Window)loader.Load(xaml); + var foo = window.FindControl("foo"); + var notFoo = window.FindControl("notFoo"); + + Assert.Null(foo.Background); + Assert.Equal(Colors.Red, ((ISolidColorBrush)notFoo.Background).Color); + } + } } } diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_Not.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_Not.cs new file mode 100644 index 0000000000..2f3e2b8f34 --- /dev/null +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_Not.cs @@ -0,0 +1,114 @@ +// 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.Reactive.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using Xunit; + +namespace Avalonia.Styling.UnitTests +{ + public class SelectorTests_Not + { + [Fact] + public void Not_Selector_Should_Have_Correct_String_Representation() + { + var target = default(Selector).Not(x => x.Class("foo")); + + Assert.Equal(":not(.foo)", target.ToString()); + } + + [Fact] + public void Not_OfType_Matches_Control_Of_Incorrect_Type() + { + var control = new Control1(); + var target = default(Selector).Not(x => x.OfType()); + + Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(control).Result); + } + + [Fact] + public void Not_OfType_Doesnt_Match_Control_Of_Correct_Type() + { + var control = new Control2(); + var target = default(Selector).Not(x => x.OfType()); + + Assert.Equal(SelectorMatchResult.AlwaysThisType, target.Match(control).Result); + } + + [Fact] + public async Task Not_Class_Doesnt_Match_Control_With_Class() + { + var control = new Control1 + { + Classes = new Classes { "foo" }, + }; + + var target = default(Selector).Not(x => x.Class("foo")); + var match = target.Match(control); + + Assert.Equal(SelectorMatchResult.Sometimes, match.Result); + Assert.False(await match.Activator.Take(1)); + } + + [Fact] + public async Task Not_Class_Matches_Control_Without_Class() + { + var control = new Control1 + { + Classes = new Classes { "bar" }, + }; + + var target = default(Selector).Not(x => x.Class("foo")); + var match = target.Match(control); + + Assert.Equal(SelectorMatchResult.Sometimes, match.Result); + Assert.True(await match.Activator.Take(1)); + } + + [Fact] + public async Task OfType_Not_Class_Matches_Control_Without_Class() + { + var control = new Control1 + { + Classes = new Classes { "bar" }, + }; + + var target = default(Selector).OfType().Not(x => x.Class("foo")); + var match = target.Match(control); + + Assert.Equal(SelectorMatchResult.Sometimes, match.Result); + Assert.True(await match.Activator.Take(1)); + } + + [Fact] + public void OfType_Not_Class_Doesnt_Match_Control_Of_Wrong_Type() + { + var control = new Control2 + { + Classes = new Classes { "foo" }, + }; + + var target = default(Selector).OfType().Not(x => x.Class("foo")); + var match = target.Match(control); + + Assert.Equal(SelectorMatchResult.NeverThisType, match.Result); + } + + [Fact] + public void Returns_Correct_TargetType() + { + var target = default(Selector).OfType().Not(x => x.Class("foo")); + + Assert.Equal(typeof(Control1), target.TargetType); + } + + public class Control1 : TestControlBase + { + } + + public class Control2 : TestControlBase + { + } + } +}