Sfoglia il codice sorgente

feat: Allow use of (Classes.`classname`) syntax on Style and ControlThe… (#16938)

* feat: Allow use of (Classes.`classname`) syntax on Style and ControlTheme Setter elements

* feat: Add Selector validation

* test: Check binding Classes in Setter
workgroupengineering 1 anno fa
parent
commit
2d288847f7

+ 25 - 5
src/Avalonia.Base/ClassBindingManager.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 using Avalonia.Data;
 using Avalonia.Reactive;
 
@@ -7,13 +8,13 @@ namespace Avalonia
 {
     internal static class ClassBindingManager
     {
+        private const string ClassPropertyPrefix = "__AvaloniaReserved::Classes::";
         private static readonly Dictionary<string, AvaloniaProperty> s_RegisteredProperties =
             new Dictionary<string, AvaloniaProperty>();
-        
+
         public static IDisposable Bind(StyledElement target, string className, IBinding source, object anchor)
         {
-            if (!s_RegisteredProperties.TryGetValue(className, out var prop))
-                s_RegisteredProperties[className] = prop = RegisterClassProxyProperty(className);
+            var prop = GetClassProperty(className);
             return target.Bind(prop, source);
         }
 
@@ -21,14 +22,33 @@ namespace Avalonia
             Justification = "Classes.attr binding feature is implemented using intermediate avalonia properties for each class")]
         private static AvaloniaProperty RegisterClassProxyProperty(string className)
         {
-            var prop = AvaloniaProperty.Register<StyledElement, bool>("__AvaloniaReserved::Classes::" + className);
+            var prop = AvaloniaProperty.Register<StyledElement, bool>(ClassPropertyPrefix + className);
             prop.Changed.Subscribe(args =>
             {
                 var classes = ((StyledElement)args.Sender).Classes;
                 classes.Set(className, args.NewValue.GetValueOrDefault());
             });
-            
+
             return prop;
         }
+
+        [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
+        public static AvaloniaProperty GetClassProperty(string className) =>
+            s_RegisteredProperties.TryGetValue(ClassPropertyPrefix + className, out var property)
+                ? property 
+                : s_RegisteredProperties[className] = RegisterClassProxyProperty(className);
+
+        [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
+        public static bool IsClassesBindingProperty(AvaloniaProperty property, [NotNullWhen(true)] out string? classPropertyName)
+        {
+            
+            classPropertyName = default;
+            if(property.Name?.StartsWith(ClassPropertyPrefix, StringComparison.OrdinalIgnoreCase) == true)
+            {
+                classPropertyName = property.Name.Substring(ClassPropertyPrefix.Length + 1);
+                return true;
+            }
+            return false;
+        }
     }
 }

+ 7 - 0
src/Avalonia.Base/StyledElementExtensions.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Diagnostics.CodeAnalysis;
 using Avalonia.Data;
 
 namespace Avalonia
@@ -7,5 +8,11 @@ namespace Avalonia
     {
         public static IDisposable BindClass(this StyledElement target, string className, IBinding source, object anchor) =>
             ClassBindingManager.Bind(target, className, source, anchor);
+
+        public static AvaloniaProperty GetClassProperty(string className) =>
+            ClassBindingManager.GetClassProperty(className);
+
+        internal static bool IsClassesBindingProperty(this AvaloniaProperty property, [NotNullWhen(true)] out string? classPropertyName) =>
+            ClassBindingManager.IsClassesBindingProperty(property, out classPropertyName);
     }
 }

+ 3 - 0
src/Avalonia.Base/Styling/Setter.cs

@@ -75,6 +75,9 @@ namespace Avalonia.Styling
             if (Property.IsDirect && instance.HasActivator)
                 throw new InvalidOperationException(
                     $"Cannot set direct property '{Property}' in '{instance.Source}' because the style has an activator.");
+            if (Property.IsClassesBindingProperty(out var classPropertyName) && instance.HasActivator)
+                throw new InvalidOperationException(
+                        $"Cannot set Class Binding property '(Classes.{classPropertyName})' in '{instance.Source}' because the style has an activator.");
 
             if (Value is IBinding2 binding)
                 return SetBinding((StyleInstance)instance, ao, binding);

+ 24 - 0
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSetterTransformer.cs

@@ -60,6 +60,12 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
                     avaloniaPropertyNode = XamlIlAvaloniaPropertyHelper.CreateNode(context, propertyName,
                         new XamlAstClrTypeReference(on, targetType, false), property.Values[0]);
 
+                    if (avaloniaPropertyNode is IXamlIlAvaloniaClassPropertyNode && HasComplexActivator(styleParent!))
+                    {
+                        throw new XamlStyleTransformException($"Cannot set Classes Binding property '{propertyName}' because the style has an activator."
+                            , node);
+                    }
+
                     property.Values = new List<IXamlAstValueNode> {avaloniaPropertyNode};
                 }
 
@@ -137,6 +143,24 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
             }
 
             return node;
+
+            // Check that the style has selector activator and complexity
+            bool HasComplexActivator(AvaloniaXamlIlTargetTypeMetadataNode style)
+            {
+                if (style.Value is XamlAstObjectNode valueNode &&
+                    valueNode.Children
+                        .FirstOrDefault(n => n is XamlAstXamlPropertyValueNode
+                            { 
+                                Property: XamlAstClrProperty{ Name : "Selector" }
+                            }) is XamlAstXamlPropertyValueNode { Values.Count : >= 1  } selectorNone
+                    )
+                {
+                    return selectorNone.Values.Count > 1 ||
+                        (selectorNone.Values[0] is not XamlIlTypeSelector);
+                }
+                return false;
+            }
+
         }
 
         class SetterValueProperty : XamlAstClrProperty

+ 8 - 0
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs

@@ -130,6 +130,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
         public IXamlType IReadOnlyListOfT { get; }
         public IXamlType ControlTemplate { get; }
         public IXamlType EventHandlerT {  get; }
+        public IXamlMethod GetClassProperty { get; }
 
         sealed internal class InteractivityWellKnownTypes
         {
@@ -327,6 +328,13 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
             IReadOnlyListOfT = cfg.TypeSystem.GetType("System.Collections.Generic.IReadOnlyList`1");
             EventHandlerT = cfg.TypeSystem.GetType("System.EventHandler`1");
             Interactivity = new InteractivityWellKnownTypes(cfg);
+
+            GetClassProperty = cfg.TypeSystem.GetType("Avalonia.StyledElementExtensions")
+                .GetMethod(name: "GetClassProperty",
+                returnType: AvaloniaProperty,
+                allowDowncast:false,
+                cfg.WellKnownTypes.String
+                );
         }
     }
 

+ 60 - 5
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlAvaloniaPropertyHelper.cs

@@ -5,14 +5,13 @@ using Avalonia.Markup.Xaml.Parsers;
 using Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers;
 using Avalonia.Utilities;
 using XamlX.Ast;
+using XamlX.Emit;
+using XamlX.IL;
 using XamlX.Transform;
 using XamlX.Transform.Transformers;
 using XamlX.TypeSystem;
-using XamlX.Emit;
-using XamlX.IL;
-
-using XamlIlEmitContext = XamlX.Emit.XamlEmitContext<XamlX.IL.IXamlILEmitter, XamlX.IL.XamlILNodeEmitResult>;
 using IXamlIlAstEmitableNode = XamlX.Emit.IXamlAstEmitableNode<XamlX.IL.IXamlILEmitter, XamlX.IL.XamlILNodeEmitResult>;
+using XamlIlEmitContext = XamlX.Emit.XamlEmitContext<XamlX.IL.IXamlILEmitter, XamlX.IL.XamlILNodeEmitResult>;
 
 namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
 {
@@ -69,6 +68,13 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
             if(parsedPropertyName.owner == null)
                 forgedReference = new XamlAstNamePropertyReference(lineInfo, selectorTypeReference,
                     propertyName, selectorTypeReference);
+            else if (string.IsNullOrWhiteSpace(parsedPropertyName.ns)
+                && string.Equals(parsedPropertyName.owner, "Classes", StringComparison.OrdinalIgnoreCase)
+                && !string.IsNullOrWhiteSpace(parsedPropertyName.name)
+                )
+            {
+                return new XamlIlAvaloniaClassProperty(context.GetAvaloniaTypes(), parsedPropertyName.name, lineInfo);
+            }
             else
             {
                 var xmlOwner = parsedPropertyName.ns;
@@ -124,7 +130,13 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
     {
         IXamlType AvaloniaPropertyType { get; }
     }
-    
+
+    // Marker interface, used to identify whether the Avalonia property represents Classes
+    interface IXamlIlAvaloniaClassPropertyNode : IXamlIlAvaloniaPropertyNode
+    {
+
+    }
+
     class XamlIlAvaloniaPropertyNode : XamlAstNode, IXamlAstValueNode, IXamlIlAstEmitableNode, IXamlIlAvaloniaPropertyNode
     {
         public XamlIlAvaloniaPropertyNode(IXamlLineInfo lineInfo, IXamlType type, XamlAstClrProperty property, IXamlType propertyType) : base(lineInfo)
@@ -433,4 +445,47 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
             }
         }
     }
+
+    sealed class XamlIlAvaloniaClassProperty : XamlAstClrProperty,
+        IXamlIlAvaloniaClassPropertyNode,
+        IXamlAstValueNode,
+        IXamlAstLocalsEmitableNode<IXamlILEmitter, XamlILNodeEmitResult>
+    {
+        private readonly IXamlMethod _method;
+        private readonly AvaloniaXamlIlWellKnownTypes _types;
+        private readonly string _className;
+        private readonly IXamlAstTypeReference _type;
+        private readonly IXamlType _returnType;
+
+        public XamlIlAvaloniaClassProperty(AvaloniaXamlIlWellKnownTypes types,
+            string className,
+            IXamlLineInfo lineInfo) : base(lineInfo, className, types.Classes, null, null, null)
+        {
+            Parameters = [types.XamlIlTypes.String];
+            _method = types.GetClassProperty;
+            AvaloniaPropertyType = types.XamlIlTypes.Boolean;
+            _types = types;
+            _returnType = _types.AvaloniaPropertyT.MakeGenericType(types.XamlIlTypes.Boolean);
+            _type = new XamlAstClrTypeReference(this, _returnType, false);
+            _className = className;
+            Setters = [];
+        }
+
+        public IXamlType AvaloniaPropertyType { get; }
+        public IReadOnlyList<IXamlType> Parameters { get; }
+        public IXamlAstTypeReference Type => _type;
+
+        public PropertySetterBinderParameters BinderParameters { get; } = new PropertySetterBinderParameters();
+
+        public XamlILNodeEmitResult Emit(XamlEmitContextWithLocals<IXamlILEmitter, XamlILNodeEmitResult> context, IXamlILEmitter emitter)
+        {
+            using (var loc = emitter.LocalsPool.GetLocal(_types.XamlIlTypes.String))
+            {
+                emitter
+                    .Ldstr(_className);
+                emitter.EmitCall(_method, false);
+            }
+            return XamlILNodeEmitResult.Type(0, _returnType);
+        }
+    }
 }

+ 12 - 1
tests/Avalonia.Markup.Xaml.UnitTests/TestViewModel.cs

@@ -7,6 +7,7 @@ namespace Avalonia.Markup.Xaml.UnitTests
         private string _string;
         private int _integer;
         private TestViewModel _child;
+        private bool _boolean;
 
         public int Integer
         {
@@ -37,5 +38,15 @@ namespace Avalonia.Markup.Xaml.UnitTests
                 RaisePropertyChanged();
             }
         }
+
+        public bool Boolean
+        {
+            get => _boolean;
+            set
+            {
+                _boolean = value;
+                RaisePropertyChanged();
+            }
+        }
     }
-}
+}

+ 90 - 0
tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs

@@ -1,3 +1,4 @@
+using System.Xml;
 using Avalonia.Controls;
 using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Primitives;
@@ -175,6 +176,95 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml
             }
         }
 
+        [Fact]
+        public void Can_Use_Classes_In_Setter()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = $$$"""
+                            <Window xmlns='https://github.com/avaloniaui'
+                                    xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                                    xmlns:u='using:Avalonia.Markup.Xaml.UnitTests.Xaml'>
+                                <Window.Resources>
+                                    <ControlTheme x:Key='MyTheme' TargetType='ContentControl'>
+                                        <Setter Property='CornerRadius' Value='10, 0, 0, 10' />
+                                        <Setter Property='(Classes.Banned)' Value='true'/>
+                                        <Setter Property='Content'>
+                                            <Template>
+                                                <Border CornerRadius='{TemplateBinding CornerRadius}'/>
+                                            </Template>
+                                        </Setter>
+                                        <Setter Property='Template'>
+                                            <ControlTemplate>
+                                                <Button Content='{TemplateBinding Content}'
+                                                        ContentTemplate='{TemplateBinding ContentTemplate}' />
+                                            </ControlTemplate>
+                                        </Setter>
+
+                                        <Style Selector='^.Banned'>
+                                            <Setter Property="TextBlock.TextDecorations" Value="Strikethrough"/>
+                                        </Style>
+                                    </ControlTheme>
+                                </Window.Resources>
+                                <ContentControl Theme='{StaticResource MyTheme}' />
+                            </Window>
+                            """;
+
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+                var control = Assert.IsType<ContentControl>(window.Content);
+                Assert.Same(TextDecorations.Strikethrough,control.GetValue(TextBlock.TextDecorationsProperty));
+            }
+        }
+
+        [Fact]
+        public void Can_Binding_Classes_In_Setter()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = $$$"""
+                            <Window xmlns='https://github.com/avaloniaui'
+                                    xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                                    xmlns:u='using:Avalonia.Markup.Xaml.UnitTests.Xaml'
+                                    xmlns:vm='using:Avalonia.Markup.Xaml.UnitTests'>
+                                <Window.Resources>
+                                    <ControlTheme x:Key='MyTheme' TargetType='ContentControl' x:DataType='vm:TestViewModel'>
+                                        <Setter Property='CornerRadius' Value='10, 0, 0, 10' />
+                                        <Setter Property='(Classes.Banned)' Value='{Binding Boolean}'/>
+                                        <Setter Property='Content'>
+                                            <Template>
+                                                <Border CornerRadius='{TemplateBinding CornerRadius}'/>
+                                            </Template>
+                                        </Setter>
+                                        <Setter Property='Template'>
+                                            <ControlTemplate>
+                                                <Button Content='{TemplateBinding Content}'
+                                                        ContentTemplate='{TemplateBinding ContentTemplate}' />
+                                            </ControlTemplate>
+                                        </Setter>
+
+                                        <Style Selector='^.Banned'>
+                                            <Setter Property="TextBlock.TextDecorations" Value="Strikethrough"/>
+                                        </Style>
+                                    </ControlTheme>
+                                </Window.Resources>
+                                <Window.DataContext>
+                                   <vm:TestViewModel/>
+                                </Window.DataContext>
+                                <ContentControl Theme='{StaticResource MyTheme}' />
+                            </Window>
+                            """;
+
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+                window.ApplyTemplate();
+                var vm = window.DataContext as TestViewModel;
+                Assert.NotNull(vm);
+                var control = Assert.IsType<ContentControl>(window.Content);
+                Assert.Null(control.GetValue(TextBlock.TextDecorationsProperty));
+                vm.Boolean = true;
+                Assert.Same(TextDecorations.Strikethrough, control.GetValue(TextBlock.TextDecorationsProperty));
+            }
+        }
+
         private const string ControlThemeXaml = @"
 <ControlTheme x:Key='MyTheme' TargetType='u:TestTemplatedControl'>
     <Setter Property='Template'>

+ 96 - 2
tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs

@@ -2,11 +2,9 @@ using System;
 using System.Collections.Generic;
 using System.Collections.ObjectModel;
 using System.Linq;
-using System.Runtime.CompilerServices;
 using System.Xml;
 using Avalonia.Controls;
 using Avalonia.Data;
-using Avalonia.Markup.Xaml.Styling;
 using Avalonia.Markup.Xaml.Templates;
 using Avalonia.Media;
 using Avalonia.Styling;
@@ -640,5 +638,101 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml
                 Assert.Contains("ControlTemplate", exception.Message);
             }
         }
+
+        [Fact]
+        public void Can_Use_Classes_In_Setter()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = $"""
+                            <Window xmlns='https://github.com/avaloniaui'
+                                         xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                                         xmlns:u='using:Avalonia.Markup.Xaml.UnitTests.Xaml'>
+                                <Window.Styles>
+                                    <Style Selector="Border">
+                                        <Setter Property="(Classes.Banned)" Value='true'/>
+
+                                        <Style Selector="^.Banned">
+                                           <Setter Property='Background' Value='Red'/>
+                                        </Style>
+                                    </Style>
+                                </Window.Styles>
+                                <Border/>
+                            </Window>
+                            """;
+
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+                var border = window.Content as Border;
+                Assert.NotNull(border);
+                Assert.Equal(Brushes.Red, border.Background);
+            }
+        }
+
+        [Fact]
+        public void Can_Binding_Classes_In_Setter()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = $$"""
+                            <Window xmlns='https://github.com/avaloniaui'
+                                         xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                                         xmlns:u='using:Avalonia.Markup.Xaml.UnitTests.Xaml'
+                                         xmlns:vm='using:Avalonia.Markup.Xaml.UnitTests'
+                                         >
+                                <Window.Styles>
+                                    <Style Selector="Border" x:DataType='vm:TestViewModel'>
+                                        <Setter Property="(Classes.Banned)" Value='{Binding Boolean}'/>
+
+                                        <Style Selector="^.Banned">
+                                           <Setter Property='Background' Value='Red'/>
+                                        </Style>
+                                    </Style>
+                                </Window.Styles>
+                                <Window.DataContext>
+                                   <vm:TestViewModel/>
+                                </Window.DataContext>
+                                <Border/>
+                            </Window>
+                            """;
+
+                var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+                window.ApplyTemplate();
+                var vm = window.DataContext as TestViewModel;
+                Assert.NotNull(vm);
+
+                var border = window.Content as Border;
+                Assert.NotNull(border);
+                Assert.Null(border.Background);
+                vm.Boolean = true;
+                Assert.Equal(Brushes.Red, border.Background);
+            }
+        }
+
+        [Fact]
+        public void Fails_Use_Classes_In_Setter_When_Selector_Is_Complex()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = $"""
+                            <Window xmlns='https://github.com/avaloniaui'
+                                         xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                                         xmlns:u='using:Avalonia.Markup.Xaml.UnitTests.Xaml'>
+                                <Window.Styles>
+                                    <Style Selector="Border:pointover">
+                                        <Setter Property="(Classes.Banned)" Value='true'/>
+
+                                        <Style Selector="^.Banned">
+                                           <Setter Property='Background' Value='Red'/>
+                                        </Style>
+                                    </Style>
+                                </Window.Styles>
+                                <Border/>
+                            </Window>
+                            """;
+
+                var exception = Assert.ThrowsAny<XmlException>(() => AvaloniaRuntimeXamlLoader.Load(xaml));
+                Assert.Equal ("Cannot set Classes Binding property '(Classes.Banned)' because the style has an activator. Line 6, position 14.", exception.Message);
+            }
+        }
     }
 }