Browse Source

Create new Selector parser that does not have a dependency on Sprache.

pull/1668/head
Jeremy Koritzinsky 8 years ago
parent
commit
2f087c350c
  1. 10
      src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionParser.cs
  2. 2
      src/Markup/Avalonia.Markup/Markup/Parsers/IdentifierParser.cs
  3. 21
      src/Markup/Avalonia.Markup/Markup/Parsers/Reader.cs
  4. 377
      src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs
  5. 117
      src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs
  6. 40
      tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs
  7. 4
      tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs

10
src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionParser.cs

@ -81,7 +81,7 @@ namespace Avalonia.Markup.Parsers
}
else
{
var identifier = IdentifierParser.Parse(r);
var identifier = r.ParseIdentifier();
if (identifier != null)
{
@ -120,7 +120,7 @@ namespace Avalonia.Markup.Parsers
}
else
{
var identifier = IdentifierParser.Parse(r);
var identifier = r.ParseIdentifier();
if (identifier != null)
{
@ -136,12 +136,12 @@ namespace Avalonia.Markup.Parsers
{
string ns = string.Empty;
string owner;
var ownerOrNamespace = IdentifierParser.Parse(r);
var ownerOrNamespace = r.ParseIdentifier();
if (r.TakeIf(':'))
{
ns = ownerOrNamespace;
owner = IdentifierParser.Parse(r);
owner = r.ParseIdentifier();
}
else
{
@ -153,7 +153,7 @@ namespace Avalonia.Markup.Parsers
throw new ExpressionParseException(r.Position, "Invalid attached property name.");
}
var name = IdentifierParser.Parse(r);
var name = r.ParseIdentifier();
if (r.End || !r.TakeIf(')'))
{

2
src/Markup/Avalonia.Markup/Markup/Parsers/IdentifierParser.cs

@ -8,7 +8,7 @@ namespace Avalonia.Markup.Parsers
{
internal static class IdentifierParser
{
public static string Parse(Reader r)
public static string ParseIdentifier(this Reader r)
{
if (IsValidIdentifierStart(r.Peek))
{

21
src/Markup/Avalonia.Markup/Markup/Parsers/Reader.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.Text;
namespace Avalonia.Markup.Parsers
{
@ -40,5 +41,25 @@ namespace Avalonia.Markup.Parsers
return false;
}
}
public bool TakeIf(Func<char, bool> condition)
{
if (condition(Peek))
{
Take();
return true;
}
return false;
}
public string TakeUntil(char c)
{
var builder = new StringBuilder();
while (!End && Peek != c)
{
builder.Append(Take());
}
return builder.ToString();
}
}
}

377
src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs

@ -3,7 +3,7 @@
using System.Collections.Generic;
using System.Globalization;
using Sprache;
using Avalonia.Data.Core;
// Don't need to override GetHashCode as the ISyntax objects will not be stored in a hash; the
// only reason they have overridden Equals methods is for unit testing.
@ -13,117 +13,280 @@ namespace Avalonia.Markup.Parsers
{
internal class SelectorGrammar
{
public static readonly Parser<char> CombiningCharacter = Parse.Char(
c =>
{
var cat = CharUnicodeInfo.GetUnicodeCategory(c);
return cat == UnicodeCategory.NonSpacingMark ||
cat == UnicodeCategory.SpacingCombiningMark;
},
"Connecting Character");
public static readonly Parser<char> ConnectingCharacter = Parse.Char(
c => CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.ConnectorPunctuation,
"Connecting Character");
public static readonly Parser<char> FormattingCharacter = Parse.Char(
c => CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.Format,
"Connecting Character");
public static readonly Parser<char> IdentifierStart = Parse.Letter.Or(Parse.Char('_'));
public static readonly Parser<char> IdentifierChar = Parse
.LetterOrDigit
.Or(ConnectingCharacter)
.Or(CombiningCharacter)
.Or(FormattingCharacter);
public static readonly Parser<string> Identifier =
from start in IdentifierStart.Once().Text()
from @char in IdentifierChar.Many().Text()
select start + @char;
public static readonly Parser<string> Namespace =
from ns in Parse.Letter.Many().Text()
from bar in Parse.Char('|')
select ns;
public static readonly Parser<OfTypeSyntax> OfType =
from ns in Namespace.Optional()
from identifier in Identifier
select new OfTypeSyntax
{
TypeName = identifier,
Xmlns = ns.GetOrDefault(),
private enum State
{
Start,
Middle,
Colon,
Class,
Name,
CanHaveType,
Traversal,
TypeName,
Property,
Template,
End,
}
public static IEnumerable<ISyntax> Parse(string s)
{
var r = new Reader(s);
var state = State.Start;
var selector = new List<ISyntax>();
while (!r.End && state != State.End)
{
ISyntax syntax = null;
switch (state)
{
case State.Start:
(state, syntax) = ParseStart(r);
break;
case State.Middle:
(state, syntax) = ParseMiddle(r);
break;
case State.Colon:
(state, syntax) = ParseColon(r);
break;
case State.Class:
(state, syntax) = ParseClass(r);
break;
case State.Traversal:
(state, syntax) = ParseTraversal(r);
break;
case State.TypeName:
(state, syntax) = ParseTypeName(r);
break;
case State.CanHaveType:
(state, syntax) = ParseCanHaveType(r);
break;
case State.Property:
(state, syntax) = ParseProperty(r);
break;
case State.Template:
(state, syntax) = ParseTemplate(r);
break;
case State.Name:
(state, syntax) = ParseName(r);
break;
}
if (syntax != null)
{
selector.Add(syntax);
}
}
if (state != State.Start && state != State.Middle && state != State.End && state != State.CanHaveType)
{
throw new ExpressionParseException(r.Position, "Unexpected end of selector");
}
return selector;
}
private static (State, ISyntax) ParseStart(Reader r)
{
r.SkipWhitespace();
if (r.TakeIf(':'))
{
return (State.Colon, null);
}
else if (r.TakeIf('.'))
{
return (State.Class, null);
}
else if (r.TakeIf('#'))
{
return (State.Name, null);
}
return (State.TypeName, null);
}
private static (State, ISyntax) ParseMiddle(Reader r)
{
if (r.TakeIf(':'))
{
return (State.Colon, null);
}
else if (r.TakeIf('.'))
{
return (State.Class, null);
}
else if (r.TakeIf(char.IsWhiteSpace) || r.Peek == '>')
{
return (State.Traversal, null);
}
else if (r.TakeIf('/'))
{
return (State.Template, null);
}
else if (r.TakeIf('#'))
{
return (State.Name, null);
}
return (State.TypeName, null);
}
private static (State, ISyntax) ParseCanHaveType(Reader r)
{
if (r.TakeIf('['))
{
return (State.Property, null);
}
return (State.Middle, null);
}
private static (State, ISyntax) ParseColon(Reader r)
{
var identifier = r.ParseIdentifier();
if (string.IsNullOrEmpty(identifier))
{
throw new ExpressionParseException(r.Position, "Expected class name or is selector after ':'.");
}
if (identifier == "is" && r.TakeIf('('))
{
var syntax = ParseType<IsSyntax>(r);
if (r.End || !r.TakeIf(')'))
{
throw new ExpressionParseException(r.Position, $"Expected ')', got {r.Peek}");
}
return (State.CanHaveType, syntax);
}
else
{
return (
State.CanHaveType,
new ClassSyntax
{
Class = ":" + identifier
});
}
}
private static (State, ISyntax) ParseTraversal(Reader r)
{
r.SkipWhitespace();
if (r.TakeIf('>'))
{
r.SkipWhitespace();
return (State.Middle, new ChildSyntax());
}
else if (r.TakeIf('/'))
{
return (State.Template, null);
}
else if (!r.End)
{
return (State.Middle, new DescendantSyntax());
}
else
{
return (State.End, null);
}
}
private static (State, ISyntax) ParseClass(Reader r)
{
var @class = r.ParseIdentifier();
if (string.IsNullOrEmpty(@class))
{
throw new ExpressionParseException(r.Position, $"Expected a class name after '.'.");
}
return (State.CanHaveType, new ClassSyntax { Class = @class });
}
private static (State, ISyntax) ParseTemplate(Reader r)
{
var template = r.ParseIdentifier();
if (template != nameof(template))
{
throw new ExpressionParseException(r.Position, $"Expected 'template', got {template}");
}
else if (!r.TakeIf('/'))
{
throw new ExpressionParseException(r.Position, "Expected '/'");
}
return (State.Start, new TemplateSyntax());
}
private static (State, ISyntax) ParseName(Reader r)
{
var name = r.ParseIdentifier();
if (string.IsNullOrEmpty(name))
{
throw new ExpressionParseException(r.Position, $"Expected a name after '#'.");
}
return (State.CanHaveType, new NameSyntax { Name = name });
}
private static (State, ISyntax) ParseTypeName(Reader r)
{
return (State.CanHaveType, ParseType<OfTypeSyntax>(r));
}
private static (State, ISyntax) ParseProperty(Reader r)
{
var property = r.ParseIdentifier();
if (!r.TakeIf('='))
{
throw new ExpressionParseException(r.Position, $"Expected '=', got '{r.Peek}'");
}
var value = r.TakeUntil(']');
r.Take();
return (State.CanHaveType, new PropertySyntax { Property = property, Value = value });
}
private static TSyntax ParseType<TSyntax>(Reader r)
where TSyntax : ITypeSyntax, new()
{
string ns = null;
string type;
var namespaceOrTypeName = r.ParseIdentifier();
if (string.IsNullOrEmpty(namespaceOrTypeName))
{
throw new ExpressionParseException(r.Position, $"Expected an identifier, got '{r.Peek}");
}
if (!r.End && r.TakeIf('|'))
{
ns = namespaceOrTypeName;
if (r.End)
{
throw new ExpressionParseException(r.Position, $"Unexpected end of selector.");
}
type = r.ParseIdentifier();
}
else
{
type = namespaceOrTypeName;
}
return new TSyntax
{
Xmlns = ns,
TypeName = type
};
}
public static readonly Parser<NameSyntax> Name =
from hash in Parse.Char('#')
from identifier in Identifier
select new NameSyntax { Name = identifier };
public static readonly Parser<char> ClassStart = Parse.Char('_').Or(Parse.Letter);
public static readonly Parser<char> ClassChar = ClassStart.Or(Parse.Numeric);
public static readonly Parser<string> ClassIdentifier =
from start in ClassStart.Once().Text()
from @char in ClassChar.Many().Text()
select start + @char;
public static readonly Parser<ClassSyntax> StandardClass =
from dot in Parse.Char('.').Once()
from identifier in ClassIdentifier
select new ClassSyntax { Class = identifier };
public static readonly Parser<ClassSyntax> Pseduoclass =
from colon in Parse.Char(':').Once()
from identifier in ClassIdentifier
select new ClassSyntax { Class = ':' + identifier };
public static readonly Parser<ClassSyntax> Class = StandardClass.Or(Pseduoclass);
public static readonly Parser<PropertySyntax> Property =
from open in Parse.Char('[').Once()
from identifier in Identifier
from eq in Parse.Char('=').Once()
from value in Parse.CharExcept(']').Many().Text()
from close in Parse.Char(']').Once()
select new PropertySyntax { Property = identifier, Value = value };
public static readonly Parser<ChildSyntax> Child = Parse.Char('>').Token().Return(new ChildSyntax());
public static readonly Parser<DescendantSyntax> Descendant =
from child in Parse.WhiteSpace.Many()
select new DescendantSyntax();
public static readonly Parser<TemplateSyntax> Template =
from template in Parse.String("/template/").Token()
select new TemplateSyntax();
public static readonly Parser<IsSyntax> Is =
from function in Parse.String(":is(")
from type in OfType
from close in Parse.Char(')')
select new IsSyntax { TypeName = type.TypeName, Xmlns = type.Xmlns };
public static readonly Parser<ISyntax> SingleSelector =
OfType
.Or<ISyntax>(Is)
.Or<ISyntax>(Name)
.Or<ISyntax>(Class)
.Or<ISyntax>(Property)
.Or<ISyntax>(Child)
.Or<ISyntax>(Template)
.Or<ISyntax>(Descendant);
public static readonly Parser<IEnumerable<ISyntax>> Selector = SingleSelector.Many().End();
public interface ISyntax
{
}
public class OfTypeSyntax : ISyntax
public interface ITypeSyntax
{
string TypeName { get; set; }
string Xmlns { get; set; }
}
public class OfTypeSyntax : ISyntax, ITypeSyntax
{
public string TypeName { get; set; }
@ -136,7 +299,7 @@ namespace Avalonia.Markup.Parsers
}
}
public class IsSyntax : ISyntax
public class IsSyntax : ISyntax, ITypeSyntax
{
public string TypeName { get; set; }

117
src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs

@ -2,7 +2,9 @@
// 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.Data.Core;
using Avalonia.Styling;
using Avalonia.Utilities;
using Sprache;
@ -36,79 +38,68 @@ namespace Avalonia.Markup.Parsers
/// <returns>The parsed selector.</returns>
public Selector Parse(string s)
{
var syntax = SelectorGrammar.Selector.Parse(s);
var syntax = SelectorGrammar.Parse(s);
var result = default(Selector);
foreach (var i in syntax)
{
var ofType = i as SelectorGrammar.OfTypeSyntax;
var @is = i as SelectorGrammar.IsSyntax;
var @class = i as SelectorGrammar.ClassSyntax;
var name = i as SelectorGrammar.NameSyntax;
var property = i as SelectorGrammar.PropertySyntax;
var child = i as SelectorGrammar.ChildSyntax;
var descendant = i as SelectorGrammar.DescendantSyntax;
var template = i as SelectorGrammar.TemplateSyntax;
if (ofType != null)
{
result = result.OfType(_typeResolver(ofType.Xmlns, ofType.TypeName));
}
if (@is != null)
{
result = result.Is(_typeResolver(@is.Xmlns, @is.TypeName));
}
else if (@class != null)
{
result = result.Class(@class.Class);
}
else if (name != null)
{
result = result.Name(name.Name);
}
else if (property != null)
switch (i)
{
var type = result?.TargetType;
if (type == null)
{
throw new InvalidOperationException("Property selectors must be applied to a type.");
}
case SelectorGrammar.OfTypeSyntax ofType:
result = result.OfType(_typeResolver(ofType.Xmlns, ofType.TypeName));
break;
case SelectorGrammar.IsSyntax @is:
result = result.Is(_typeResolver(@is.Xmlns, @is.TypeName));
break;
case SelectorGrammar.ClassSyntax @class:
result = result.Class(@class.Class);
break;
case SelectorGrammar.NameSyntax name:
result = result.Name(name.Name);
break;
case SelectorGrammar.PropertySyntax property:
{
var type = result?.TargetType;
var targetProperty = AvaloniaPropertyRegistry.Instance.FindRegistered(type, property.Property);
if (type == null)
{
throw new InvalidOperationException("Property selectors must be applied to a type.");
}
if (targetProperty == null)
{
throw new InvalidOperationException($"Cannot find '{property.Property}' on '{type}");
}
var targetProperty = AvaloniaPropertyRegistry.Instance.FindRegistered(type, property.Property);
object typedValue;
if (targetProperty == null)
{
throw new InvalidOperationException($"Cannot find '{property.Property}' on '{type}");
}
if (TypeUtilities.TryConvert(
targetProperty.PropertyType,
property.Value,
CultureInfo.InvariantCulture,
out typedValue))
{
result = result.PropertyEquals(targetProperty, typedValue);
}
else
{
throw new InvalidOperationException(
$"Could not convert '{property.Value}' to '{targetProperty.PropertyType}");
}
}
else if (child != null)
{
result = result.Child();
}
else if (descendant != null)
{
result = result.Descendant();
}
else if (template != null)
{
result = result.Template();
object typedValue;
if (TypeUtilities.TryConvert(
targetProperty.PropertyType,
property.Value,
CultureInfo.InvariantCulture,
out typedValue))
{
result = result.PropertyEquals(targetProperty, typedValue);
}
else
{
throw new InvalidOperationException(
$"Could not convert '{property.Value}' to '{targetProperty.PropertyType}");
}
break;
}
case SelectorGrammar.ChildSyntax child:
result = result.Child();
break;
case SelectorGrammar.DescendantSyntax descendant:
result = result.Descendant();
break;
case SelectorGrammar.TemplateSyntax template:
result = result.Template();
break;
}
}

40
tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs

@ -2,8 +2,8 @@
// Licensed under the MIT license. See licence.md file in the project root for full license information.
using System.Linq;
using Avalonia.Data.Core;
using Avalonia.Markup.Parsers;
using Sprache;
using Xunit;
namespace Avalonia.Markup.UnitTests.Parsers
@ -13,7 +13,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void OfType()
{
var result = SelectorGrammar.Selector.Parse("Button").ToList();
var result = SelectorGrammar.Parse("Button");
Assert.Equal(
new[] { new SelectorGrammar.OfTypeSyntax { TypeName = "Button", Xmlns = null } },
@ -23,7 +23,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void NamespacedOfType()
{
var result = SelectorGrammar.Selector.Parse("x|Button").ToList();
var result = SelectorGrammar.Parse("x|Button");
Assert.Equal(
new[] { new SelectorGrammar.OfTypeSyntax { TypeName = "Button", Xmlns = "x" } },
@ -33,7 +33,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void Name()
{
var result = SelectorGrammar.Selector.Parse("#foo").ToList();
var result = SelectorGrammar.Parse("#foo");
Assert.Equal(
new[] { new SelectorGrammar.NameSyntax { Name = "foo" }, },
@ -43,7 +43,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void OfType_Name()
{
var result = SelectorGrammar.Selector.Parse("Button#foo").ToList();
var result = SelectorGrammar.Parse("Button#foo");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -57,7 +57,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void Is()
{
var result = SelectorGrammar.Selector.Parse(":is(Button)").ToList();
var result = SelectorGrammar.Parse(":is(Button)");
Assert.Equal(
new[] { new SelectorGrammar.IsSyntax { TypeName = "Button", Xmlns = null } },
@ -67,7 +67,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void Is_Name()
{
var result = SelectorGrammar.Selector.Parse(":is(Button)#foo").ToList();
var result = SelectorGrammar.Parse(":is(Button)#foo");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -81,7 +81,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void NamespacedIs_Name()
{
var result = SelectorGrammar.Selector.Parse(":is(x|Button)#foo").ToList();
var result = SelectorGrammar.Parse(":is(x|Button)#foo");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -95,7 +95,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void Class()
{
var result = SelectorGrammar.Selector.Parse(".foo").ToList();
var result = SelectorGrammar.Parse(".foo");
Assert.Equal(
new[] { new SelectorGrammar.ClassSyntax { Class = "foo" } },
@ -105,7 +105,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void Pseudoclass()
{
var result = SelectorGrammar.Selector.Parse(":foo").ToList();
var result = SelectorGrammar.Parse(":foo");
Assert.Equal(
new[] { new SelectorGrammar.ClassSyntax { Class = ":foo" } },
@ -115,7 +115,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void OfType_Class()
{
var result = SelectorGrammar.Selector.Parse("Button.foo").ToList();
var result = SelectorGrammar.Parse("Button.foo");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -129,7 +129,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void OfType_Child_Class()
{
var result = SelectorGrammar.Selector.Parse("Button > .foo").ToList();
var result = SelectorGrammar.Parse("Button > .foo");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -144,7 +144,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void OfType_Child_Class_No_Spaces()
{
var result = SelectorGrammar.Selector.Parse("Button>.foo").ToList();
var result = SelectorGrammar.Parse("Button>.foo");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -159,7 +159,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void OfType_Descendant_Class()
{
var result = SelectorGrammar.Selector.Parse("Button .foo").ToList();
var result = SelectorGrammar.Parse("Button .foo");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -174,7 +174,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void OfType_Template_Class()
{
var result = SelectorGrammar.Selector.Parse("Button /template/ .foo").ToList();
var result = SelectorGrammar.Parse("Button /template/ .foo");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -189,7 +189,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void OfType_Property()
{
var result = SelectorGrammar.Selector.Parse("Button[Foo=bar]").ToList();
var result = SelectorGrammar.Parse("Button[Foo=bar]");
Assert.Equal(
new SelectorGrammar.ISyntax[]
@ -203,25 +203,25 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void Namespace_Alone_Fails()
{
Assert.Throws<ParseException>(() => SelectorGrammar.Selector.Parse("ns|").ToList());
Assert.Throws<ExpressionParseException>(() => SelectorGrammar.Parse("ns|"));
}
[Fact]
public void Dot_Alone_Fails()
{
Assert.Throws<ParseException>(() => SelectorGrammar.Selector.Parse(". dot").ToList());
Assert.Throws<ExpressionParseException>(() => SelectorGrammar.Parse(". dot"));
}
[Fact]
public void Invalid_Identifier_Fails()
{
Assert.Throws<ParseException>(() => SelectorGrammar.Selector.Parse("%foo").ToList());
Assert.Throws<ExpressionParseException>(() => SelectorGrammar.Parse("%foo"));
}
[Fact]
public void Invalid_Class_Fails()
{
Assert.Throws<ParseException>(() => SelectorGrammar.Selector.Parse(".%foo").ToList());
Assert.Throws<ExpressionParseException>(() => SelectorGrammar.Parse(".%foo"));
}
}
}

4
tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs

@ -1,6 +1,8 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Markup.Parsers;
using Avalonia.Styling;
using Xunit;
namespace Avalonia.Markup.UnitTests.Parsers
@ -10,7 +12,7 @@ namespace Avalonia.Markup.UnitTests.Parsers
[Fact]
public void Parses_Boolean_Property_Selector()
{
var target = new SelectorParser((type, ns) => typeof(TextBlock));
var target = new SelectorParser((ns, type) => typeof(TextBlock));
var result = target.Parse("TextBlock[IsPointerOver=True]");
}
}

Loading…
Cancel
Save