From c02439aaaf5c1984f8aae75c777173c775c89257 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 30 May 2022 17:10:09 +0200 Subject: [PATCH 01/42] Refactored most of Style into StyleBase. Ready for `ControlTheme` class, which is a style without a selector. --- src/Avalonia.Base/Styling/Style.cs | 148 ++------------------- src/Avalonia.Base/Styling/StyleBase.cs | 137 +++++++++++++++++++ src/Avalonia.Base/Styling/StyleChildren.cs | 10 +- 3 files changed, 151 insertions(+), 144 deletions(-) create mode 100644 src/Avalonia.Base/Styling/StyleBase.cs diff --git a/src/Avalonia.Base/Styling/Style.cs b/src/Avalonia.Base/Styling/Style.cs index 000e588bad..c85c85fe21 100644 --- a/src/Avalonia.Base/Styling/Style.cs +++ b/src/Avalonia.Base/Styling/Style.cs @@ -1,23 +1,12 @@ using System; -using System.Collections.Generic; -using Avalonia.Animation; -using Avalonia.Controls; -using Avalonia.Metadata; namespace Avalonia.Styling { /// /// Defines a style. /// - public class Style : AvaloniaObject, IStyle, IResourceProvider + public class Style : StyleBase { - private IResourceHost? _owner; - private StyleChildren? _children; - private IResourceDictionary? _resources; - private List? _setters; - private List? _animations; - private StyleCache? _childCache; - /// /// Initializes a new instance of the class. /// @@ -34,114 +23,11 @@ namespace Avalonia.Styling Selector = selector(null); } - /// - /// Gets the children of the style. - /// - public IList Children => _children ??= new(this); - - /// - /// Gets the or Application that hosts the style. - /// - public IResourceHost? Owner - { - get => _owner; - private set - { - if (_owner != value) - { - _owner = value; - OwnerChanged?.Invoke(this, EventArgs.Empty); - } - } - } - - /// - /// Gets the parent style if this style is hosted in a collection. - /// - public Style? Parent { get; private set; } - - /// - /// Gets or sets a dictionary of style resources. - /// - public IResourceDictionary Resources - { - get => _resources ?? (Resources = new ResourceDictionary()); - set - { - value = value ?? throw new ArgumentNullException(nameof(value)); - - var hadResources = _resources?.HasResources ?? false; - - _resources = value; - - if (Owner is object) - { - _resources.AddOwner(Owner); - - if (hadResources || _resources.HasResources) - { - Owner.NotifyHostedResourcesChanged(ResourcesChangedEventArgs.Empty); - } - } - } - } - /// /// Gets or sets the style's selector. /// public Selector? Selector { get; set; } - /// - /// Gets the style's setters. - /// - public IList Setters => _setters ??= new List(); - - /// - /// Gets the style's animations. - /// - public IList Animations => _animations ??= new List(); - - bool IResourceNode.HasResources => _resources?.Count > 0; - IReadOnlyList IStyle.Children => (IReadOnlyList?)_children ?? Array.Empty(); - - public event EventHandler? OwnerChanged; - - public void Add(ISetter setter) => Setters.Add(setter); - public void Add(IStyle style) => Children.Add(style); - - public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) - { - target = target ?? throw new ArgumentNullException(nameof(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)) - { - var instance = new StyleInstance(this, target, _setters, _animations, match.Activator); - target.StyleApplied(instance); - instance.Start(); - } - - var result = match.Result; - - if (_children is not null) - { - _childCache ??= new StyleCache(); - var childResult = _childCache.TryAttach(_children, target, host); - if (childResult > result) - result = childResult; - } - - return result; - } - - public bool TryGetResource(object key, out object? result) - { - result = null; - return _resources?.TryGetResource(key, out result) ?? false; - } - /// /// Returns a string representation of the style. /// @@ -158,33 +44,17 @@ namespace Avalonia.Styling } } - void IResourceProvider.AddOwner(IResourceHost owner) - { - owner = owner ?? throw new ArgumentNullException(nameof(owner)); - - if (Owner != null) - { - throw new InvalidOperationException("The Style already has a parent."); - } - - Owner = owner; - _resources?.AddOwner(owner); - } - - void IResourceProvider.RemoveOwner(IResourceHost owner) + protected override SelectorMatch Matches(IStyleable target, IStyleHost? host) { - owner = owner ?? throw new ArgumentNullException(nameof(owner)); - - if (Owner == owner) - { - Owner = null; - _resources?.RemoveOwner(owner); - } + return Selector?.Match(target, Parent) ?? + (target == host ? + SelectorMatch.AlwaysThisInstance : + SelectorMatch.NeverThisInstance); } - internal void SetParent(Style? parent) + internal override void SetParent(StyleBase? parent) { - if (parent?.Selector is not null) + if (parent is Style parentStyle && parentStyle.Selector is not null) { if (Selector is null) throw new InvalidOperationException("Child styles must have a selector."); @@ -192,7 +62,7 @@ namespace Avalonia.Styling throw new InvalidOperationException("Child styles must have a nesting selector."); } - Parent = parent; + base.SetParent(parent); } } } diff --git a/src/Avalonia.Base/Styling/StyleBase.cs b/src/Avalonia.Base/Styling/StyleBase.cs new file mode 100644 index 0000000000..0fc57da728 --- /dev/null +++ b/src/Avalonia.Base/Styling/StyleBase.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Metadata; + +namespace Avalonia.Styling +{ + /// + /// Base class for and . + /// + public abstract class StyleBase : AvaloniaObject, IStyle, IResourceProvider + { + private IResourceHost? _owner; + private StyleChildren? _children; + private IResourceDictionary? _resources; + private List? _setters; + private List? _animations; + private StyleCache? _childCache; + + public IList Children => _children ??= new(this); + + public IResourceHost? Owner + { + get => _owner; + private set + { + if (_owner != value) + { + _owner = value; + OwnerChanged?.Invoke(this, EventArgs.Empty); + } + } + } + + public IStyle? Parent { get; private set; } + + public IResourceDictionary Resources + { + get => _resources ?? (Resources = new ResourceDictionary()); + set + { + value = value ?? throw new ArgumentNullException(nameof(value)); + + var hadResources = _resources?.HasResources ?? false; + + _resources = value; + + if (Owner is object) + { + _resources.AddOwner(Owner); + + if (hadResources || _resources.HasResources) + { + Owner.NotifyHostedResourcesChanged(ResourcesChangedEventArgs.Empty); + } + } + } + } + + public IList Setters => _setters ??= new List(); + public IList Animations => _animations ??= new List(); + + bool IResourceNode.HasResources => _resources?.Count > 0; + IReadOnlyList IStyle.Children => (IReadOnlyList?)_children ?? Array.Empty(); + + public void Add(ISetter setter) => Setters.Add(setter); + public void Add(IStyle style) => Children.Add(style); + + public event EventHandler? OwnerChanged; + + public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) + { + target = target ?? throw new ArgumentNullException(nameof(target)); + + var result = SelectorMatchResult.NeverThisType; + + if (_setters?.Count > 0 || _animations?.Count > 0) + { + var match = Matches(target, host); + + if (match.IsMatch) + { + var instance = new StyleInstance(this, target, _setters, _animations, match.Activator); + target.StyleApplied(instance); + instance.Start(); + } + + result = match.Result; + } + + if (_children is not null) + { + _childCache ??= new StyleCache(); + var childResult = _childCache.TryAttach(_children, target, host); + if (childResult > result) + result = childResult; + } + + return result; + } + + public bool TryGetResource(object key, out object? result) + { + result = null; + return _resources?.TryGetResource(key, out result) ?? false; + } + + protected abstract SelectorMatch Matches(IStyleable target, IStyleHost? host); + + internal virtual void SetParent(StyleBase? parent) => Parent = parent; + + void IResourceProvider.AddOwner(IResourceHost owner) + { + owner = owner ?? throw new ArgumentNullException(nameof(owner)); + + if (Owner != null) + { + throw new InvalidOperationException("The Style already has a parent."); + } + + Owner = owner; + _resources?.AddOwner(owner); + } + + void IResourceProvider.RemoveOwner(IResourceHost owner) + { + owner = owner ?? throw new ArgumentNullException(nameof(owner)); + + if (Owner == owner) + { + Owner = null; + _resources?.RemoveOwner(owner); + } + } + } +} diff --git a/src/Avalonia.Base/Styling/StyleChildren.cs b/src/Avalonia.Base/Styling/StyleChildren.cs index 5f8635f155..42b0a331ee 100644 --- a/src/Avalonia.Base/Styling/StyleChildren.cs +++ b/src/Avalonia.Base/Styling/StyleChildren.cs @@ -5,20 +5,20 @@ namespace Avalonia.Styling { internal class StyleChildren : Collection { - private readonly Style _owner; + private readonly StyleBase _owner; - public StyleChildren(Style owner) => _owner = owner; + public StyleChildren(StyleBase owner) => _owner = owner; protected override void InsertItem(int index, IStyle item) { - (item as Style)?.SetParent(_owner); + (item as StyleBase)?.SetParent(_owner); base.InsertItem(index, item); } protected override void RemoveItem(int index) { var item = Items[index]; - (item as Style)?.SetParent(null); + (item as StyleBase)?.SetParent(null); if (_owner.Owner is IResourceHost host) (item as IResourceProvider)?.RemoveOwner(host); base.RemoveItem(index); @@ -26,7 +26,7 @@ namespace Avalonia.Styling protected override void SetItem(int index, IStyle item) { - (item as Style)?.SetParent(_owner); + (item as StyleBase)?.SetParent(_owner); base.SetItem(index, item); if (_owner.Owner is IResourceHost host) (item as IResourceProvider)?.AddOwner(host); From 088d8cfc5c147da723e7641cf77c8dc67646e786 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 1 Jun 2022 13:21:52 +0200 Subject: [PATCH 02/42] Initial implementation of control themes. --- src/Avalonia.Base/Styling/ControlTheme.cs | 27 ++++ src/Avalonia.Base/Styling/IStyle.cs | 2 +- src/Avalonia.Base/Styling/IThemed.cs | 13 ++ src/Avalonia.Base/Styling/NestingSelector.cs | 4 +- src/Avalonia.Base/Styling/Style.cs | 8 +- src/Avalonia.Base/Styling/StyleBase.cs | 20 ++- src/Avalonia.Base/Styling/StyleCache.cs | 2 +- src/Avalonia.Base/Styling/Styler.cs | 14 +++ src/Avalonia.Base/Styling/Styles.cs | 2 +- .../Primitives/TemplatedControl.cs | 25 +++- src/Avalonia.Themes.Default/SimpleTheme.cs | 2 +- src/Avalonia.Themes.Fluent/FluentTheme.cs | 2 +- .../Styling/StyleInclude.cs | 2 +- .../TemplatedControlTests_Theming.cs | 119 ++++++++++++++++++ 14 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 src/Avalonia.Base/Styling/ControlTheme.cs create mode 100644 src/Avalonia.Base/Styling/IThemed.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs diff --git a/src/Avalonia.Base/Styling/ControlTheme.cs b/src/Avalonia.Base/Styling/ControlTheme.cs new file mode 100644 index 0000000000..54fc972c31 --- /dev/null +++ b/src/Avalonia.Base/Styling/ControlTheme.cs @@ -0,0 +1,27 @@ +using System; + +namespace Avalonia.Styling +{ + /// + /// Defines a switchable theme for a control. + /// + public class ControlTheme : StyleBase + { + /// + /// Gets or sets the type for which this control theme is intended. + /// + public Type? TargetType { get; set; } + + internal override bool HasSelector => TargetType is not null; + + internal override SelectorMatch Match(IStyleable control, object? host, bool subscribe) + { + if (TargetType is null) + throw new InvalidOperationException("ControlTheme has no TargetType."); + + return control.StyleKey == TargetType ? + SelectorMatch.AlwaysThisType : + SelectorMatch.NeverThisType; + } + } +} diff --git a/src/Avalonia.Base/Styling/IStyle.cs b/src/Avalonia.Base/Styling/IStyle.cs index e9faf82c07..417739fb28 100644 --- a/src/Avalonia.Base/Styling/IStyle.cs +++ b/src/Avalonia.Base/Styling/IStyle.cs @@ -23,6 +23,6 @@ namespace Avalonia.Styling /// /// A describing how the style matches the control. /// - SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host); + SelectorMatchResult TryAttach(IStyleable target, object? host); } } diff --git a/src/Avalonia.Base/Styling/IThemed.cs b/src/Avalonia.Base/Styling/IThemed.cs new file mode 100644 index 0000000000..32ae515bcb --- /dev/null +++ b/src/Avalonia.Base/Styling/IThemed.cs @@ -0,0 +1,13 @@ +namespace Avalonia.Styling +{ + /// + /// Represents a themed element. + /// + public interface IThemed + { + /// + /// Gets the theme style for the element. + /// + public ControlTheme? Theme { get; } + } +} diff --git a/src/Avalonia.Base/Styling/NestingSelector.cs b/src/Avalonia.Base/Styling/NestingSelector.cs index 481a937867..6d31f7cb18 100644 --- a/src/Avalonia.Base/Styling/NestingSelector.cs +++ b/src/Avalonia.Base/Styling/NestingSelector.cs @@ -15,9 +15,9 @@ namespace Avalonia.Styling protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe) { - if (parent is Style s && s.Selector is Selector selector) + if (parent is StyleBase s && s.HasSelector) { - return selector.Match(control, (parent as Style)?.Parent, subscribe); + return s.Match(control, null, subscribe); } throw new InvalidOperationException( diff --git a/src/Avalonia.Base/Styling/Style.cs b/src/Avalonia.Base/Styling/Style.cs index c85c85fe21..ca20ff2b4b 100644 --- a/src/Avalonia.Base/Styling/Style.cs +++ b/src/Avalonia.Base/Styling/Style.cs @@ -28,6 +28,8 @@ namespace Avalonia.Styling /// public Selector? Selector { get; set; } + internal override bool HasSelector => Selector is not null; + /// /// Returns a string representation of the style. /// @@ -44,10 +46,10 @@ namespace Avalonia.Styling } } - protected override SelectorMatch Matches(IStyleable target, IStyleHost? host) + internal override SelectorMatch Match(IStyleable control, object? host, bool subscribe) { - return Selector?.Match(target, Parent) ?? - (target == host ? + return Selector?.Match(control, Parent, subscribe) ?? + (control == host ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance); } diff --git a/src/Avalonia.Base/Styling/StyleBase.cs b/src/Avalonia.Base/Styling/StyleBase.cs index 0fc57da728..b6bfec62bd 100644 --- a/src/Avalonia.Base/Styling/StyleBase.cs +++ b/src/Avalonia.Base/Styling/StyleBase.cs @@ -64,12 +64,14 @@ namespace Avalonia.Styling bool IResourceNode.HasResources => _resources?.Count > 0; IReadOnlyList IStyle.Children => (IReadOnlyList?)_children ?? Array.Empty(); + internal abstract bool HasSelector { get; } + public void Add(ISetter setter) => Setters.Add(setter); public void Add(IStyle style) => Children.Add(style); public event EventHandler? OwnerChanged; - public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) + public SelectorMatchResult TryAttach(IStyleable target, object? host) { target = target ?? throw new ArgumentNullException(nameof(target)); @@ -77,7 +79,7 @@ namespace Avalonia.Styling if (_setters?.Count > 0 || _animations?.Count > 0) { - var match = Matches(target, host); + var match = Match(target, host, subscribe: true); if (match.IsMatch) { @@ -106,7 +108,19 @@ namespace Avalonia.Styling return _resources?.TryGetResource(key, out result) ?? false; } - protected abstract SelectorMatch Matches(IStyleable target, IStyleHost? host); + /// + /// Evaluates the style's selector against the specified target element. + /// + /// The control. + /// The element that hosts the style. + /// + /// Whether the match should subscribe to changes in order to track the match over time, + /// or simply return an immediate result. + /// + /// + /// A describing how the style matches the control. + /// + internal abstract SelectorMatch Match(IStyleable control, object? host, bool subscribe); internal virtual void SetParent(StyleBase? parent) => Parent = parent; diff --git a/src/Avalonia.Base/Styling/StyleCache.cs b/src/Avalonia.Base/Styling/StyleCache.cs index 3285476880..81196f6a27 100644 --- a/src/Avalonia.Base/Styling/StyleCache.cs +++ b/src/Avalonia.Base/Styling/StyleCache.cs @@ -12,7 +12,7 @@ namespace Avalonia.Styling /// internal class StyleCache : Dictionary?> { - public SelectorMatchResult TryAttach(IList styles, IStyleable target, IStyleHost? host) + public SelectorMatchResult TryAttach(IList styles, IStyleable target, object? host) { if (TryGetValue(target.StyleKey, out var cached)) { diff --git a/src/Avalonia.Base/Styling/Styler.cs b/src/Avalonia.Base/Styling/Styler.cs index 74cf77ea40..b9359b3329 100644 --- a/src/Avalonia.Base/Styling/Styler.cs +++ b/src/Avalonia.Base/Styling/Styler.cs @@ -10,6 +10,20 @@ namespace Avalonia.Styling { target = target ?? throw new ArgumentNullException(nameof(target)); + // If the control has a themed templated parent then first apply the styles from + // the templated parent theme. + if (target.TemplatedParent is IThemed themedTemplatedParent) + { + themedTemplatedParent.Theme?.TryAttach(target, themedTemplatedParent); + } + + // If the control itself is themed, then next apply the control theme. + if (target is IThemed themed) + { + themed.Theme?.TryAttach(target, target); + } + + // Apply styles from the rest of the tree. if (target is IStyleHost styleHost) { ApplyStyles(target, styleHost); diff --git a/src/Avalonia.Base/Styling/Styles.cs b/src/Avalonia.Base/Styling/Styles.cs index 7c0bc4ad7f..4c011f1b0d 100644 --- a/src/Avalonia.Base/Styling/Styles.cs +++ b/src/Avalonia.Base/Styling/Styles.cs @@ -109,7 +109,7 @@ namespace Avalonia.Styling set => _styles[index] = value; } - public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) + public SelectorMatchResult TryAttach(IStyleable target, object? host) { _cache ??= new StyleCache(); return _cache.TryAttach(this, target, host); diff --git a/src/Avalonia.Controls/Primitives/TemplatedControl.cs b/src/Avalonia.Controls/Primitives/TemplatedControl.cs index db029d38c0..e1f42b6eb0 100644 --- a/src/Avalonia.Controls/Primitives/TemplatedControl.cs +++ b/src/Avalonia.Controls/Primitives/TemplatedControl.cs @@ -12,7 +12,7 @@ namespace Avalonia.Controls.Primitives /// /// A lookless control whose visual appearance is defined by its . /// - public class TemplatedControl : Control, ITemplatedControl + public class TemplatedControl : Control, IThemed, ITemplatedControl { /// /// Defines the property. @@ -86,6 +86,12 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty TemplateProperty = AvaloniaProperty.Register(nameof(Template)); + /// + /// Defines the property. + /// + public static readonly StyledProperty ThemeProperty = + AvaloniaProperty.Register(nameof(Theme)); + /// /// Defines the IsTemplateFocusTarget attached property. /// @@ -228,6 +234,15 @@ namespace Avalonia.Controls.Primitives set { SetValue(TemplateProperty, value); } } + /// + /// Gets or sets the theme to be applied to the control. + /// + public ControlTheme? Theme + { + get { return GetValue(ThemeProperty); } + set { SetValue(ThemeProperty, value); } + } + /// /// Gets the value of the IsTemplateFocusTargetProperty attached property on a control. /// @@ -365,6 +380,14 @@ namespace Avalonia.Controls.Primitives { } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ThemeProperty) + InvalidateStyles(); + } + /// /// Called when the control's template is applied. /// diff --git a/src/Avalonia.Themes.Default/SimpleTheme.cs b/src/Avalonia.Themes.Default/SimpleTheme.cs index 6929660757..d7939a68c1 100644 --- a/src/Avalonia.Themes.Default/SimpleTheme.cs +++ b/src/Avalonia.Themes.Default/SimpleTheme.cs @@ -103,7 +103,7 @@ namespace Avalonia.Themes.Default void IResourceProvider.RemoveOwner(IResourceHost owner) => (Loaded as IResourceProvider)?.RemoveOwner(owner); - public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) => Loaded.TryAttach(target, host); + public SelectorMatchResult TryAttach(IStyleable target, object? host) => Loaded.TryAttach(target, host); public bool TryGetResource(object key, out object? value) { diff --git a/src/Avalonia.Themes.Fluent/FluentTheme.cs b/src/Avalonia.Themes.Fluent/FluentTheme.cs index f6b47a5466..befe669029 100644 --- a/src/Avalonia.Themes.Fluent/FluentTheme.cs +++ b/src/Avalonia.Themes.Fluent/FluentTheme.cs @@ -164,7 +164,7 @@ namespace Avalonia.Themes.Fluent } } - public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) => Loaded.TryAttach(target, host); + public SelectorMatchResult TryAttach(IStyleable target, object? host) => Loaded.TryAttach(target, host); public bool TryGetResource(object key, out object? value) { diff --git a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs index fa4a27fc50..109e85f1a4 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs @@ -82,7 +82,7 @@ namespace Avalonia.Markup.Xaml.Styling } } - public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) => Loaded.TryAttach(target, host); + public SelectorMatchResult TryAttach(IStyleable target, object? host) => Loaded.TryAttach(target, host); public bool TryGetResource(object key, out object? value) { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs new file mode 100644 index 0000000000..b24adfe7ab --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs @@ -0,0 +1,119 @@ +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Xunit; + +#nullable enable + +namespace Avalonia.Controls.UnitTests.Primitives +{ + public class TemplatedControlTests_Theming + { + [Fact] + public void Theme_Is_Applied_When_Attached_To_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + + Assert.Null(target.Template); + + var root = CreateRoot(target); + + Assert.NotNull(target.Template); + var border = Assert.IsType(target.VisualChild); + + Assert.Equal(border.Background, Brushes.Red); + + target.Classes.Add("foo"); + Assert.Equal(border.Background, Brushes.Green); + } + + [Fact] + public void Theme_Is_Detached_When_Theme_Property_Cleared() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + var root = CreateRoot(target); + + Assert.NotNull(target.Template); + + target.Theme = null; + Assert.Null(target.Template); + } + + [Fact] + public void Theme_Is_Applied_On_Layout_After_Theme_Property_Changes() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = new ThemedControl(); + var root = CreateRoot(target); + + Assert.Null(target.Template); + + target.Theme = CreateTheme(); + Assert.Null(target.Template); + + root.LayoutManager.ExecuteLayoutPass(); + + var border = Assert.IsType(target.VisualChild); + Assert.NotNull(target.Template); + Assert.Equal(border.Background, Brushes.Red); + } + + private static ThemedControl CreateTarget() + { + return new ThemedControl + { + Theme = CreateTheme(), + }; + } + + private static ControlTheme CreateTheme() + { + var template = new FuncControlTemplate((o, n) => + new Border { Name = "PART_Border" }); + + return new ControlTheme + { + TargetType = typeof(ThemedControl), + Setters = + { + new Setter(ThemedControl.TemplateProperty, template), + }, + Children = + { + new Style(x => x.Nesting().Template().OfType()) + { + Setters = + { + new Setter(Border.BackgroundProperty, Brushes.Red), + } + }, + new Style(x => x.Nesting().Class("foo").Template().OfType()) + { + Setters = + { + new Setter(Border.BackgroundProperty, Brushes.Green), + } + }, + } + }; + } + + private static TestRoot CreateRoot(IControl child) + { + var result = new TestRoot(child); + result.LayoutManager.ExecuteInitialLayoutPass(); + return result; + } + + private class ThemedControl : TemplatedControl + { + public IVisual? VisualChild => VisualChildren?.SingleOrDefault(); + } + } +} From dee353bb9640278ab2364b1d9b4624d5cbe7a215 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 1 Jun 2022 15:24:15 +0200 Subject: [PATCH 03/42] Support ControlTheme in XAML compiler. --- .../AvaloniaXamlIlCompiler.cs | 1 + .../AvaloniaXamlIlControlThemeTransformer.cs | 39 ++++++++++ .../AvaloniaXamlIlSetterTransformer.cs | 75 +++++++++++++------ .../Xaml/ControlThemeTests.cs | 53 +++++++++++++ .../Xaml/TestTemplatedControl.cs | 8 ++ 5 files changed, 155 insertions(+), 21 deletions(-) create mode 100644 src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlControlThemeTransformer.cs create mode 100644 tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs create mode 100644 tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestTemplatedControl.cs diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs index 1ca7be67a7..20e035f8ff 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs @@ -48,6 +48,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions InsertBefore( new AvaloniaXamlIlBindingPathParser(), + new AvaloniaXamlIlControlThemeTransformer(), new AvaloniaXamlIlSelectorTransformer(), new AvaloniaXamlIlControlTemplateTargetTypeMetadataTransformer(), new AvaloniaXamlIlPropertyPathTransformer(), diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlControlThemeTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlControlThemeTransformer.cs new file mode 100644 index 0000000000..1338dc7248 --- /dev/null +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlControlThemeTransformer.cs @@ -0,0 +1,39 @@ +using System.Linq; +using XamlX; +using XamlX.Ast; +using XamlX.Transform; +using XamlX.Transform.Transformers; +using XamlX.TypeSystem; + +namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers +{ + class AvaloniaXamlIlControlThemeTransformer : IXamlAstTransformer + { + public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node) + { + if (!(node is XamlAstObjectNode on && on.Type.GetClrType().FullName == "Avalonia.Styling.ControlTheme")) + return node; + + // Check if we've already transformed this node. + if (context.ParentNodes().FirstOrDefault() is AvaloniaXamlIlTargetTypeMetadataNode) + return node; + + var targetTypeNode = on.Children.OfType() + .FirstOrDefault(p => p.Property.GetClrProperty().Name == "TargetType") ?? + throw new XamlParseException("ControlTheme must have a TargetType.", node); + + IXamlType targetType; + + if (targetTypeNode.Values[0] is XamlTypeExtensionNode extension) + targetType = extension.Value.GetClrType(); + else if (targetTypeNode.Values[0] is XamlAstTextNode text) + targetType = TypeReferenceResolver.ResolveType(context, text.Text, false, text, true).GetClrType(); + else + throw new XamlParseException("Could not determine TargetType for ControlTheme.", targetTypeNode); + + return new AvaloniaXamlIlTargetTypeMetadataNode(on, + new XamlAstClrTypeReference(targetTypeNode, targetType, false), + AvaloniaXamlIlTargetTypeMetadataNode.ScopeTypes.Style); + } + } +} diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSetterTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSetterTransformer.cs index e816265422..06e34a85a2 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSetterTransformer.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSetterTransformer.cs @@ -1,19 +1,14 @@ -using System; using System.Collections.Generic; using System.Linq; -using Avalonia.Data.Core; -using XamlX; using XamlX.Ast; using XamlX.Emit; using XamlX.IL; using XamlX.Transform; -using XamlX.Transform.Transformers; using XamlX.TypeSystem; namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers { using XamlParseException = XamlX.XamlParseException; - using XamlLoadException = XamlX.XamlLoadException; class AvaloniaXamlIlSetterTransformer : IXamlAstTransformer { public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node) @@ -22,21 +17,10 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers && on.Type.GetClrType().FullName == "Avalonia.Styling.Setter")) return node; - var parent = context.ParentNodes().OfType() - .FirstOrDefault(p => p.Type.GetClrType().FullName == "Avalonia.Styling.Style"); - - if (parent == null) - throw new XamlParseException( - "Avalonia.Styling.Setter is only valid inside Avalonia.Styling.Style", node); - var selectorProperty = parent.Children.OfType() - .FirstOrDefault(p => p.Property.GetClrProperty().Name == "Selector"); - if (selectorProperty == null) - throw new XamlParseException( - "Can not find parent Style Selector", node); - var selector = selectorProperty.Values.FirstOrDefault() as XamlIlSelectorNode; - if (selector?.TargetType == null) - throw new XamlParseException( - "Can not resolve parent Style Selector type", node); + var targetTypeNode = context.ParentNodes() + .OfType() + .FirstOrDefault(x => x.ScopeType == AvaloniaXamlIlTargetTypeMetadataNode.ScopeTypes.Style) ?? + throw new XamlParseException("Can not find parent Style Selector or ControlTemplate TargetType", node); IXamlType propType = null; var property = @on.Children.OfType() @@ -50,7 +34,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers var avaloniaPropertyNode = XamlIlAvaloniaPropertyHelper.CreateNode(context, propertyName, - new XamlAstClrTypeReference(selector, selector.TargetType, false), property.Values[0]); + new XamlAstClrTypeReference(targetTypeNode, targetTypeNode.TargetType.GetClrType(), false), property.Values[0]); property.Values = new List {avaloniaPropertyNode}; propType = avaloniaPropertyNode.AvaloniaPropertyType; } @@ -84,6 +68,55 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers return node; } + private (IXamlLineInfo, IXamlType) GetTargetType(AstTransformationContext context, IXamlAstNode node) + { + foreach (var n in context.ParentNodes()) + { + if (n is XamlAstObjectNode parent) + { + switch (parent.Type.GetClrType().FullName) + { + case "Avalonia.Styling.Style": + var selectorProperty = parent.Children.OfType() + .FirstOrDefault(p => p.Property.GetClrProperty().Name == "Selector"); + if (selectorProperty == null) + throw new XamlParseException("Can not find parent Style Selector.", node); + var selector = selectorProperty.Values.FirstOrDefault() as XamlIlSelectorNode; + if (selector?.TargetType != null) + return (selector, selector.TargetType); + throw new XamlParseException( + "Can not resolve parent Style Selector type", node); + + case "Avalonia.Styling.ControlTheme": + var targetTypeProperty = parent.Children.OfType() + .FirstOrDefault(p => p.Property.GetClrProperty().Name == "TargetType"); + if (targetTypeProperty == null) + throw new XamlParseException("ControlTemplate has no TargetType.", parent); + break; + } + } + } + + throw new XamlParseException("'Setter' is only valid inside a 'Style' or 'ControlTheme'.", node); + //var parent = context.ParentNodes().OfType() + // .FirstOrDefault(p => p.Type.GetClrType().FullName == "Avalonia.Styling.Style" || + // p.Type.GetClrType().FullName == "Avalonia.Styling.ControlTheme"); + + //if (parent == null) + // throw new XamlParseException( + // "Avalonia.Styling.Setter is only valid inside Avalonia.Styling.Style", node); + //var selectorProperty = parent.Children.OfType() + // .FirstOrDefault(p => p.Property.GetClrProperty().Name == "Selector" || + // p.Property.GetClrProperty().Name == "TargetType"); + //if (selectorProperty == null) + // throw new XamlParseException( + // "Can not find parent Style Selector or ControlTemplate TargetType", node); + //var selector = selectorProperty.Values.FirstOrDefault() as XamlIlSelectorNode; + //if (selector?.TargetType == null) + // throw new XamlParseException( + // "Can not resolve parent Style Selector type", node); + } + class SetterValueProperty : XamlAstClrProperty { public SetterValueProperty(IXamlLineInfo line, IXamlType setterType, IXamlType targetType, diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs new file mode 100644 index 0000000000..05083537cd --- /dev/null +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs @@ -0,0 +1,53 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Xunit; + +namespace Avalonia.Markup.Xaml.UnitTests.Xaml +{ + public class ControlThemeTests : XamlTestBase + { + [Fact] + public void ControlTheme_Can_Be_StaticResource() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = $@" + + + {ControlThemeXaml} + + + +"; + + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + var button = Assert.IsType(window.Content); + + window.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.NotNull(button.Template); + + var child = Assert.Single(button.GetVisualChildren()); + var border = Assert.IsType(child); + + Assert.Equal(Brushes.Red, border.Background); + } + } + + private const string ControlThemeXaml = @" + + + + + + + +"; + } +} diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestTemplatedControl.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestTemplatedControl.cs new file mode 100644 index 0000000000..0c862bb66a --- /dev/null +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestTemplatedControl.cs @@ -0,0 +1,8 @@ +using Avalonia.Controls.Primitives; + +namespace Avalonia.Markup.Xaml.UnitTests.Xaml +{ + public class TestTemplatedControl : TemplatedControl + { + } +} From a6dc6b1c887c8a5139be7bf1abba1315c26af0d7 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 1 Jun 2022 21:45:45 +0200 Subject: [PATCH 04/42] Prevent ControlTheme as a nested style. --- src/Avalonia.Base/Styling/ControlTheme.cs | 16 +++++++++++ .../Styling/ControlThemeTests.cs | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/Avalonia.Base.UnitTests/Styling/ControlThemeTests.cs diff --git a/src/Avalonia.Base/Styling/ControlTheme.cs b/src/Avalonia.Base/Styling/ControlTheme.cs index 54fc972c31..9dcbd7d2c4 100644 --- a/src/Avalonia.Base/Styling/ControlTheme.cs +++ b/src/Avalonia.Base/Styling/ControlTheme.cs @@ -7,6 +7,17 @@ namespace Avalonia.Styling /// public class ControlTheme : StyleBase { + /// + /// Initializes a new instance of the class. + /// + public ControlTheme() { } + + /// + /// Initializes a new instance of the class. + /// + /// The value for . + public ControlTheme(Type targetType) => TargetType = targetType; + /// /// Gets or sets the type for which this control theme is intended. /// @@ -23,5 +34,10 @@ namespace Avalonia.Styling SelectorMatch.AlwaysThisType : SelectorMatch.NeverThisType; } + + internal override void SetParent(StyleBase? parent) + { + throw new InvalidOperationException("ControlThemes cannot be added as a nested style."); + } } } diff --git a/tests/Avalonia.Base.UnitTests/Styling/ControlThemeTests.cs b/tests/Avalonia.Base.UnitTests/Styling/ControlThemeTests.cs new file mode 100644 index 0000000000..93a0e6c2fd --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Styling/ControlThemeTests.cs @@ -0,0 +1,28 @@ +using System; +using Avalonia.Controls; +using Avalonia.Styling; +using Xunit; + +namespace Avalonia.Base.UnitTests.Styling +{ + public class ControlThemeTests + { + [Fact] + public void ControlTheme_Cannot_Be_Added_To_Style_Children() + { + var target = new ControlTheme(typeof(Button)); + var style = new Style(); + + Assert.Throws(() => style.Children.Add(target)); + } + + [Fact] + public void ControlTheme_Cannot_Be_Added_To_ControlTheme_Children() + { + var target = new ControlTheme(typeof(Button)); + var other = new ControlTheme(typeof(CheckBox)); + + Assert.Throws(() => other.Children.Add(target)); + } + } +} From fc3c036b02afce41d8faca7e4c1e8219fb0c4ceb Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 1 Jun 2022 22:48:34 +0200 Subject: [PATCH 05/42] Move Theme to StyledElement. The WPF equivalent (`Style`) is in `FrameworkElement` so it would make sense. Will also make stuff a lot easier and removes the need for an `IThemed` interface. --- src/Avalonia.Base/StyledElement.cs | 25 +++++++++++++++++-- src/Avalonia.Base/Styling/IStyleable.cs | 7 ++++-- src/Avalonia.Base/Styling/IThemed.cs | 13 ---------- src/Avalonia.Base/Styling/Styler.cs | 23 ++++------------- .../Primitives/TemplatedControl.cs | 25 +------------------ .../AvaloniaPropertyConverterTest.cs | 5 ++++ 6 files changed, 39 insertions(+), 59 deletions(-) delete mode 100644 src/Avalonia.Base/Styling/IThemed.cs diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index f98d2cdbcc..4ead2470d7 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -12,8 +12,6 @@ using Avalonia.Logging; using Avalonia.LogicalTree; using Avalonia.Styling; -#nullable enable - namespace Avalonia { /// @@ -55,6 +53,12 @@ namespace Avalonia nameof(TemplatedParent), o => o.TemplatedParent, (o ,v) => o.TemplatedParent = v); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ThemeProperty = + AvaloniaProperty.Register(nameof(Theme)); private int _initCount; private string? _name; @@ -230,6 +234,15 @@ namespace Avalonia internal set => SetAndRaise(TemplatedParentProperty, ref _templatedParent, value); } + /// + /// Gets or sets the theme to be applied to the element. + /// + public ControlTheme? Theme + { + get { return GetValue(ThemeProperty); } + set { SetValue(ThemeProperty, value); } + } + /// /// Gets the styled element's logical children. /// @@ -590,6 +603,14 @@ namespace Avalonia { } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ThemeProperty) + InvalidateStyles(); + } + private static void DataContextNotifying(IAvaloniaObject o, bool updateStarted) { if (o is StyledElement element) diff --git a/src/Avalonia.Base/Styling/IStyleable.cs b/src/Avalonia.Base/Styling/IStyleable.cs index 5bc972e7ab..61fcbdf850 100644 --- a/src/Avalonia.Base/Styling/IStyleable.cs +++ b/src/Avalonia.Base/Styling/IStyleable.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using Avalonia.Collections; using Avalonia.Metadata; -#nullable enable - namespace Avalonia.Styling { /// @@ -28,6 +26,11 @@ namespace Avalonia.Styling /// ITemplatedControl? TemplatedParent { get; } + /// + /// Gets the theme to be applied to the control. + /// + public ControlTheme? Theme { get; } + /// /// Notifies the element that a style has been applied. /// diff --git a/src/Avalonia.Base/Styling/IThemed.cs b/src/Avalonia.Base/Styling/IThemed.cs deleted file mode 100644 index 32ae515bcb..0000000000 --- a/src/Avalonia.Base/Styling/IThemed.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Avalonia.Styling -{ - /// - /// Represents a themed element. - /// - public interface IThemed - { - /// - /// Gets the theme style for the element. - /// - public ControlTheme? Theme { get; } - } -} diff --git a/src/Avalonia.Base/Styling/Styler.cs b/src/Avalonia.Base/Styling/Styler.cs index b9359b3329..c9ea123bdc 100644 --- a/src/Avalonia.Base/Styling/Styler.cs +++ b/src/Avalonia.Base/Styling/Styler.cs @@ -1,33 +1,24 @@ using System; -#nullable enable - namespace Avalonia.Styling { public class Styler : IStyler { public void ApplyStyles(IStyleable target) { - target = target ?? throw new ArgumentNullException(nameof(target)); + _ = target ?? throw new ArgumentNullException(nameof(target)); // If the control has a themed templated parent then first apply the styles from // the templated parent theme. - if (target.TemplatedParent is IThemed themedTemplatedParent) - { - themedTemplatedParent.Theme?.TryAttach(target, themedTemplatedParent); - } + if (target.TemplatedParent is IStyleable styleableParent) + styleableParent.Theme?.TryAttach(target, styleableParent); - // If the control itself is themed, then next apply the control theme. - if (target is IThemed themed) - { - themed.Theme?.TryAttach(target, target); - } + // Next apply the control theme. + target.Theme?.TryAttach(target, target); // Apply styles from the rest of the tree. if (target is IStyleHost styleHost) - { ApplyStyles(target, styleHost); - } } private void ApplyStyles(IStyleable target, IStyleHost host) @@ -35,14 +26,10 @@ namespace Avalonia.Styling var parent = host.StylingParent; if (parent != null) - { ApplyStyles(target, parent); - } if (host.IsStylesInitialized) - { host.Styles.TryAttach(target, host); - } } } } diff --git a/src/Avalonia.Controls/Primitives/TemplatedControl.cs b/src/Avalonia.Controls/Primitives/TemplatedControl.cs index e1f42b6eb0..db029d38c0 100644 --- a/src/Avalonia.Controls/Primitives/TemplatedControl.cs +++ b/src/Avalonia.Controls/Primitives/TemplatedControl.cs @@ -12,7 +12,7 @@ namespace Avalonia.Controls.Primitives /// /// A lookless control whose visual appearance is defined by its . /// - public class TemplatedControl : Control, IThemed, ITemplatedControl + public class TemplatedControl : Control, ITemplatedControl { /// /// Defines the property. @@ -86,12 +86,6 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty TemplateProperty = AvaloniaProperty.Register(nameof(Template)); - /// - /// Defines the property. - /// - public static readonly StyledProperty ThemeProperty = - AvaloniaProperty.Register(nameof(Theme)); - /// /// Defines the IsTemplateFocusTarget attached property. /// @@ -234,15 +228,6 @@ namespace Avalonia.Controls.Primitives set { SetValue(TemplateProperty, value); } } - /// - /// Gets or sets the theme to be applied to the control. - /// - public ControlTheme? Theme - { - get { return GetValue(ThemeProperty); } - set { SetValue(ThemeProperty, value); } - } - /// /// Gets the value of the IsTemplateFocusTargetProperty attached property on a control. /// @@ -380,14 +365,6 @@ namespace Avalonia.Controls.Primitives { } - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == ThemeProperty) - InvalidateStyles(); - } - /// /// Called when the control's template is applied. /// diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs index 33bf72014c..ca59fe8480 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs @@ -137,6 +137,11 @@ namespace Avalonia.Markup.Xaml.UnitTests.Converters get { throw new NotImplementedException(); } } + public ControlTheme Theme + { + get { throw new NotImplementedException(); } + } + public void DetachStyles() { throw new NotImplementedException(); From 8c61f25188afe50b0785150e2e1fbf605c4652c5 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 2 Jun 2022 09:23:17 +0200 Subject: [PATCH 06/42] Promote theme to LocalValue if applied from style. --- src/Avalonia.Base/StyledElement.cs | 25 ++- .../TemplatedControlTests_Theming.cs | 146 ++++++++++++------ .../Xaml/ControlThemeTests.cs | 36 +++++ 3 files changed, 157 insertions(+), 50 deletions(-) diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index 4ead2470d7..75c4b94174 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -71,6 +71,7 @@ namespace Avalonia private List? _appliedStyles; private ITemplatedControl? _templatedParent; private bool _dataContextUpdating; + private bool _hasPromotedTheme; /// /// Initializes static members of the class. @@ -239,8 +240,8 @@ namespace Avalonia /// public ControlTheme? Theme { - get { return GetValue(ThemeProperty); } - set { SetValue(ThemeProperty, value); } + get => GetValue(ThemeProperty); + set => SetValue(ThemeProperty, value); } /// @@ -315,6 +316,7 @@ namespace Avalonia /// IStyleHost? IStyleHost.StylingParent => (IStyleHost?)InheritanceParent; + /// public virtual void BeginInit() { @@ -354,10 +356,15 @@ namespace Avalonia } finally { + _styled = true; EndBatchUpdate(); } - _styled = true; + if (_hasPromotedTheme) + { + _hasPromotedTheme = false; + ClearValue(ThemeProperty); + } } return _styled; @@ -608,7 +615,19 @@ namespace Avalonia base.OnPropertyChanged(change); if (change.Property == ThemeProperty) + { + // Changing the theme detaches all styles, meaning that if the theme property was + // set via a style, it will get cleared! To work around this, if the value was + // applied at less than local value priority then promote the value to local value + // priority until styling is re-applied. + if (change.Priority > BindingPriority.LocalValue) + { + Theme = change.GetNewValue(); + _hasPromotedTheme = true; + } + InvalidateStyles(); + } } private static void DataContextNotifying(IAvaloniaObject o, bool updateStarted) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs index b24adfe7ab..74d75ff056 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs @@ -13,63 +13,122 @@ namespace Avalonia.Controls.UnitTests.Primitives { public class TemplatedControlTests_Theming { - [Fact] - public void Theme_Is_Applied_When_Attached_To_Logical_Tree() + public class InlineTheme { - using var app = UnitTestApplication.Start(TestServices.RealStyler); - var target = CreateTarget(); + [Fact] + public void Theme_Is_Applied_When_Attached_To_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); - Assert.Null(target.Template); + Assert.Null(target.Template); - var root = CreateRoot(target); + var root = CreateRoot(target); + Assert.NotNull(target.Template); - Assert.NotNull(target.Template); - var border = Assert.IsType(target.VisualChild); - - Assert.Equal(border.Background, Brushes.Red); + var border = Assert.IsType(target.VisualChild); + Assert.Equal(border.Background, Brushes.Red); - target.Classes.Add("foo"); - Assert.Equal(border.Background, Brushes.Green); - } + target.Classes.Add("foo"); + Assert.Equal(border.Background, Brushes.Green); + } - [Fact] - public void Theme_Is_Detached_When_Theme_Property_Cleared() - { - using var app = UnitTestApplication.Start(TestServices.RealStyler); - var target = CreateTarget(); - var root = CreateRoot(target); + [Fact] + public void Theme_Is_Detached_When_Theme_Property_Cleared() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + var root = CreateRoot(target); - Assert.NotNull(target.Template); + Assert.NotNull(target.Template); - target.Theme = null; - Assert.Null(target.Template); - } + target.Theme = null; + Assert.Null(target.Template); + } - [Fact] - public void Theme_Is_Applied_On_Layout_After_Theme_Property_Changes() - { - using var app = UnitTestApplication.Start(TestServices.RealStyler); - var target = new ThemedControl(); - var root = CreateRoot(target); + [Fact] + public void Theme_Is_Applied_On_Layout_After_Theme_Property_Changes() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = new ThemedControl(); + var root = CreateRoot(target); + + Assert.Null(target.Template); - Assert.Null(target.Template); + target.Theme = CreateTheme(); + Assert.Null(target.Template); - target.Theme = CreateTheme(); - Assert.Null(target.Template); + root.LayoutManager.ExecuteLayoutPass(); - root.LayoutManager.ExecuteLayoutPass(); + var border = Assert.IsType(target.VisualChild); + Assert.NotNull(target.Template); + Assert.Equal(border.Background, Brushes.Red); + } - var border = Assert.IsType(target.VisualChild); - Assert.NotNull(target.Template); - Assert.Equal(border.Background, Brushes.Red); + private static ThemedControl CreateTarget() + { + return new ThemedControl + { + Theme = CreateTheme(), + }; + } + + private static TestRoot CreateRoot(IControl child) + { + var result = new TestRoot(child); + result.LayoutManager.ExecuteInitialLayoutPass(); + return result; + } } - private static ThemedControl CreateTarget() + public class ThemeFromStyle { - return new ThemedControl + [Fact] + public void Theme_Is_Applied_When_Attached_To_Logical_Tree() { - Theme = CreateTheme(), - }; + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + + Assert.Null(target.Theme); + Assert.Null(target.Template); + + var root = CreateRoot(target); + + Assert.NotNull(target.Theme); + Assert.NotNull(target.Template); + + var border = Assert.IsType(target.VisualChild); + Assert.Equal(border.Background, Brushes.Red); + + target.Classes.Add("foo"); + Assert.Equal(border.Background, Brushes.Green); + } + + private static ThemedControl CreateTarget() + { + return new ThemedControl(); + } + + private static TestRoot CreateRoot(IControl child) + { + var result = new TestRoot() + { + Styles = + { + new Style(x => x.OfType()) + { + Setters = + { + new Setter(TemplatedControl.ThemeProperty, CreateTheme()) + } + } + } + }; + + result.Child = child; + result.LayoutManager.ExecuteInitialLayoutPass(); + return result; + } } private static ControlTheme CreateTheme() @@ -104,13 +163,6 @@ namespace Avalonia.Controls.UnitTests.Primitives }; } - private static TestRoot CreateRoot(IControl child) - { - var result = new TestRoot(child); - result.LayoutManager.ExecuteInitialLayoutPass(); - return result; - } - private class ThemedControl : TemplatedControl { public IVisual? VisualChild => VisualChildren?.SingleOrDefault(); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs index 05083537cd..9eb48311df 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/ControlThemeTests.cs @@ -38,6 +38,42 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml } } + [Fact] + public void ControlTheme_Can_Be_Set_In_Style() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = $@" + + + {ControlThemeXaml} + + + + + + + +"; + + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + var button = Assert.IsType(window.Content); + + window.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.NotNull(button.Template); + + var child = Assert.Single(button.GetVisualChildren()); + var border = Assert.IsType(child); + + Assert.Equal(Brushes.Red, border.Background); + } + } + private const string ControlThemeXaml = @" From 5cd95320128fa97fff7af8223cf55b9d043086f8 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 2 Jun 2022 09:36:53 +0200 Subject: [PATCH 07/42] Move tests to correct place. --- .../Styling/StyledElementTests_Theming.cs | 169 +++++++++++++++++ .../TemplatedControlTests_Theming.cs | 171 ------------------ 2 files changed, 169 insertions(+), 171 deletions(-) create mode 100644 tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs delete mode 100644 tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs diff --git a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs new file mode 100644 index 0000000000..539f9e6576 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs @@ -0,0 +1,169 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Xunit; + +namespace Avalonia.Base.UnitTests.Styling; + +public class StyledElementTests_Theming +{ + public class InlineTheme + { + [Fact] + public void Theme_Is_Applied_When_Attached_To_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + + Assert.Null(target.Template); + + var root = CreateRoot(target); + Assert.NotNull(target.Template); + + var border = Assert.IsType(target.VisualChild); + Assert.Equal(border.Background, Brushes.Red); + + target.Classes.Add("foo"); + Assert.Equal(border.Background, Brushes.Green); + } + + [Fact] + public void Theme_Is_Detached_When_Theme_Property_Cleared() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + var root = CreateRoot(target); + + Assert.NotNull(target.Template); + + target.Theme = null; + Assert.Null(target.Template); + } + + [Fact] + public void Theme_Is_Applied_On_Layout_After_Theme_Property_Changes() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = new ThemedControl(); + var root = CreateRoot(target); + + Assert.Null(target.Template); + + target.Theme = CreateTheme(); + Assert.Null(target.Template); + + root.LayoutManager.ExecuteLayoutPass(); + + var border = Assert.IsType(target.VisualChild); + Assert.NotNull(target.Template); + Assert.Equal(border.Background, Brushes.Red); + } + + private static ThemedControl CreateTarget() + { + return new ThemedControl + { + Theme = CreateTheme(), + }; + } + + private static TestRoot CreateRoot(IControl child) + { + var result = new TestRoot(child); + result.LayoutManager.ExecuteInitialLayoutPass(); + return result; + } + } + + public class ThemeFromStyle + { + [Fact] + public void Theme_Is_Applied_When_Attached_To_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + + Assert.Null(target.Theme); + Assert.Null(target.Template); + + var root = CreateRoot(target); + + Assert.NotNull(target.Theme); + Assert.NotNull(target.Template); + + var border = Assert.IsType(target.VisualChild); + Assert.Equal(border.Background, Brushes.Red); + + target.Classes.Add("foo"); + Assert.Equal(border.Background, Brushes.Green); + } + + private static ThemedControl CreateTarget() + { + return new ThemedControl(); + } + + private static TestRoot CreateRoot(IControl child) + { + var result = new TestRoot() + { + Styles = + { + new Style(x => x.OfType()) + { + Setters = + { + new Setter(TemplatedControl.ThemeProperty, CreateTheme()) + } + } + } + }; + + result.Child = child; + result.LayoutManager.ExecuteInitialLayoutPass(); + return result; + } + } + + private static ControlTheme CreateTheme() + { + var template = new FuncControlTemplate((o, n) => + new Border { Name = "PART_Border" }); + + return new ControlTheme + { + TargetType = typeof(ThemedControl), + Setters = + { + new Setter(ThemedControl.TemplateProperty, template), + }, + Children = + { + new Style(x => x.Nesting().Template().OfType()) + { + Setters = + { + new Setter(Border.BackgroundProperty, Brushes.Red), + } + }, + new Style(x => x.Nesting().Class("foo").Template().OfType()) + { + Setters = + { + new Setter(Border.BackgroundProperty, Brushes.Green), + } + }, + } + }; + } + + private class ThemedControl : TemplatedControl + { + public IVisual? VisualChild => VisualChildren?.SingleOrDefault(); + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs deleted file mode 100644 index 74d75ff056..0000000000 --- a/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Linq; -using Avalonia.Controls.Primitives; -using Avalonia.Controls.Templates; -using Avalonia.Media; -using Avalonia.Styling; -using Avalonia.UnitTests; -using Avalonia.VisualTree; -using Xunit; - -#nullable enable - -namespace Avalonia.Controls.UnitTests.Primitives -{ - public class TemplatedControlTests_Theming - { - public class InlineTheme - { - [Fact] - public void Theme_Is_Applied_When_Attached_To_Logical_Tree() - { - using var app = UnitTestApplication.Start(TestServices.RealStyler); - var target = CreateTarget(); - - Assert.Null(target.Template); - - var root = CreateRoot(target); - Assert.NotNull(target.Template); - - var border = Assert.IsType(target.VisualChild); - Assert.Equal(border.Background, Brushes.Red); - - target.Classes.Add("foo"); - Assert.Equal(border.Background, Brushes.Green); - } - - [Fact] - public void Theme_Is_Detached_When_Theme_Property_Cleared() - { - using var app = UnitTestApplication.Start(TestServices.RealStyler); - var target = CreateTarget(); - var root = CreateRoot(target); - - Assert.NotNull(target.Template); - - target.Theme = null; - Assert.Null(target.Template); - } - - [Fact] - public void Theme_Is_Applied_On_Layout_After_Theme_Property_Changes() - { - using var app = UnitTestApplication.Start(TestServices.RealStyler); - var target = new ThemedControl(); - var root = CreateRoot(target); - - Assert.Null(target.Template); - - target.Theme = CreateTheme(); - Assert.Null(target.Template); - - root.LayoutManager.ExecuteLayoutPass(); - - var border = Assert.IsType(target.VisualChild); - Assert.NotNull(target.Template); - Assert.Equal(border.Background, Brushes.Red); - } - - private static ThemedControl CreateTarget() - { - return new ThemedControl - { - Theme = CreateTheme(), - }; - } - - private static TestRoot CreateRoot(IControl child) - { - var result = new TestRoot(child); - result.LayoutManager.ExecuteInitialLayoutPass(); - return result; - } - } - - public class ThemeFromStyle - { - [Fact] - public void Theme_Is_Applied_When_Attached_To_Logical_Tree() - { - using var app = UnitTestApplication.Start(TestServices.RealStyler); - var target = CreateTarget(); - - Assert.Null(target.Theme); - Assert.Null(target.Template); - - var root = CreateRoot(target); - - Assert.NotNull(target.Theme); - Assert.NotNull(target.Template); - - var border = Assert.IsType(target.VisualChild); - Assert.Equal(border.Background, Brushes.Red); - - target.Classes.Add("foo"); - Assert.Equal(border.Background, Brushes.Green); - } - - private static ThemedControl CreateTarget() - { - return new ThemedControl(); - } - - private static TestRoot CreateRoot(IControl child) - { - var result = new TestRoot() - { - Styles = - { - new Style(x => x.OfType()) - { - Setters = - { - new Setter(TemplatedControl.ThemeProperty, CreateTheme()) - } - } - } - }; - - result.Child = child; - result.LayoutManager.ExecuteInitialLayoutPass(); - return result; - } - } - - private static ControlTheme CreateTheme() - { - var template = new FuncControlTemplate((o, n) => - new Border { Name = "PART_Border" }); - - return new ControlTheme - { - TargetType = typeof(ThemedControl), - Setters = - { - new Setter(ThemedControl.TemplateProperty, template), - }, - Children = - { - new Style(x => x.Nesting().Template().OfType()) - { - Setters = - { - new Setter(Border.BackgroundProperty, Brushes.Red), - } - }, - new Style(x => x.Nesting().Class("foo").Template().OfType()) - { - Setters = - { - new Setter(Border.BackgroundProperty, Brushes.Green), - } - }, - } - }; - } - - private class ThemedControl : TemplatedControl - { - public IVisual? VisualChild => VisualChildren?.SingleOrDefault(); - } - } -} From 4bdcb8eeeaab2f790245ddb70a5cfca3df7886f8 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 2 Jun 2022 10:34:34 +0200 Subject: [PATCH 08/42] Invalidate template control styles when Theme changes. --- .../Primitives/TemplatedControl.cs | 11 +++ .../Styling/StyledElementTests_Theming.cs | 75 ++++++++++++++----- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/TemplatedControl.cs b/src/Avalonia.Controls/Primitives/TemplatedControl.cs index db029d38c0..a07dd9ae27 100644 --- a/src/Avalonia.Controls/Primitives/TemplatedControl.cs +++ b/src/Avalonia.Controls/Primitives/TemplatedControl.cs @@ -365,6 +365,17 @@ namespace Avalonia.Controls.Primitives { } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ThemeProperty) + { + foreach (var child in this.GetTemplateChildren()) + child.InvalidateStyles(); + } + } + /// /// Called when the control's template is applied. /// diff --git a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs index 539f9e6576..0c0808987a 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs @@ -8,6 +8,8 @@ using Avalonia.UnitTests; using Avalonia.VisualTree; using Xunit; +#nullable enable + namespace Avalonia.Base.UnitTests.Styling; public class StyledElementTests_Theming @@ -45,6 +47,40 @@ public class StyledElementTests_Theming Assert.Null(target.Template); } + [Fact] + public void Theme_Is_Detached_From_Template_Controls_When_Theme_Property_Cleared() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + + var theme = new ControlTheme + { + TargetType = typeof(ThemedControl), + Children = + { + new Style(x => x.Nesting().Template().OfType()) + { + Setters = + { + new Setter(Canvas.BackgroundProperty, Brushes.Red), + } + }, + } + }; + + var target = CreateTarget(theme); + target.Template = new FuncControlTemplate((o, n) => new Canvas()); + + var root = CreateRoot(target); + + var canvas = Assert.IsType(target.VisualChild); + Assert.Equal(canvas.Background, Brushes.Red); + + target.Theme = null; + + Assert.IsType(target.VisualChild); + Assert.Null(canvas.Background); + } + [Fact] public void Theme_Is_Applied_On_Layout_After_Theme_Property_Changes() { @@ -64,11 +100,11 @@ public class StyledElementTests_Theming Assert.Equal(border.Background, Brushes.Red); } - private static ThemedControl CreateTarget() + private static ThemedControl CreateTarget(ControlTheme? theme = null) { return new ThemedControl { - Theme = CreateTheme(), + Theme = theme ?? CreateTheme(), }; } @@ -132,33 +168,32 @@ public class StyledElementTests_Theming private static ControlTheme CreateTheme() { - var template = new FuncControlTemplate((o, n) => - new Border { Name = "PART_Border" }); + var template = new FuncControlTemplate((o, n) => new Border()); return new ControlTheme { TargetType = typeof(ThemedControl), Setters = - { - new Setter(ThemedControl.TemplateProperty, template), - }, - Children = - { - new Style(x => x.Nesting().Template().OfType()) { - Setters = - { - new Setter(Border.BackgroundProperty, Brushes.Red), - } + new Setter(ThemedControl.TemplateProperty, template), }, - new Style(x => x.Nesting().Class("foo").Template().OfType()) + Children = { - Setters = + new Style(x => x.Nesting().Template().OfType()) { - new Setter(Border.BackgroundProperty, Brushes.Green), - } - }, - } + Setters = + { + new Setter(Border.BackgroundProperty, Brushes.Red), + } + }, + new Style(x => x.Nesting().Class("foo").Template().OfType()) + { + Setters = + { + new Setter(Border.BackgroundProperty, Brushes.Green), + } + }, + } }; } From 49613c7bceb5244444df97862b59285915a8e4ee Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 3 Jun 2022 12:04:37 +0200 Subject: [PATCH 09/42] Add accent button to control catalog. --- samples/ControlCatalog/Pages/ButtonsPage.xaml | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/ControlCatalog/Pages/ButtonsPage.xaml b/samples/ControlCatalog/Pages/ButtonsPage.xaml index 059b4d9788..8a474a203d 100644 --- a/samples/ControlCatalog/Pages/ButtonsPage.xaml +++ b/samples/ControlCatalog/Pages/ButtonsPage.xaml @@ -90,6 +90,7 @@ + Date: Fri, 3 Jun 2022 14:32:19 +0200 Subject: [PATCH 10/42] Fix nested :not selector. --- src/Avalonia.Base/Styling/NotSelector.cs | 2 +- .../Styling/SelectorTests_Nesting.cs | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Styling/NotSelector.cs b/src/Avalonia.Base/Styling/NotSelector.cs index cdc3254d38..76a0690e96 100644 --- a/src/Avalonia.Base/Styling/NotSelector.cs +++ b/src/Avalonia.Base/Styling/NotSelector.cs @@ -67,6 +67,6 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => _previous; - internal override bool HasValidNestingSelector() => _argument.HasValidNestingSelector(); + internal override bool HasValidNestingSelector() => _previous?.HasValidNestingSelector() ?? false; } } diff --git a/tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Nesting.cs b/tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Nesting.cs index d49fcf03a2..1520dc329d 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Nesting.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Nesting.cs @@ -257,6 +257,30 @@ namespace Avalonia.Base.UnitTests.Styling parent.Children.Add(child); } + + [Fact] + public void Nesting_Not_Class_Matches() + { + var control = new Control1 { Classes = { "foo" } }; + Style nested; + var parent = new Style(x => x.OfType()) + { + Children = + { + (nested = new Style(x => x.Nesting().Not(y => y.Class("foo")))), + } + }; + + var match = nested.Selector.Match(control, parent); + Assert.Equal(SelectorMatchResult.Sometimes, match.Result); + + var sink = new ActivatorSink(match.Activator); + + Assert.False(sink.Active); + control.Classes.Clear(); + Assert.True(sink.Active); + } + public class Control1 : Control { } From 05fdc0446416a285c9067b56d858c018e4f7104d Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 3 Jun 2022 23:43:55 +0200 Subject: [PATCH 11/42] Add ControlTheme.BasedOn. --- src/Avalonia.Base/Styling/ControlTheme.cs | 26 ++++++-- src/Avalonia.Base/Styling/NestingSelector.cs | 12 +++- src/Avalonia.Base/Styling/Style.cs | 35 ++++++++--- src/Avalonia.Base/Styling/StyleBase.cs | 61 ++++++------------- .../Styling/StyledElementTests_Theming.cs | 59 ++++++++++++++++-- 5 files changed, 129 insertions(+), 64 deletions(-) diff --git a/src/Avalonia.Base/Styling/ControlTheme.cs b/src/Avalonia.Base/Styling/ControlTheme.cs index 9dcbd7d2c4..aff6fad990 100644 --- a/src/Avalonia.Base/Styling/ControlTheme.cs +++ b/src/Avalonia.Base/Styling/ControlTheme.cs @@ -23,16 +23,32 @@ namespace Avalonia.Styling /// public Type? TargetType { get; set; } - internal override bool HasSelector => TargetType is not null; + /// + /// Gets or sets a control theme that is the basis of the current theme. + /// + public ControlTheme? BasedOn { get; set; } - internal override SelectorMatch Match(IStyleable control, object? host, bool subscribe) + public override SelectorMatchResult TryAttach(IStyleable target, object? host) { + _ = target ?? throw new ArgumentNullException(nameof(target)); + if (TargetType is null) throw new InvalidOperationException("ControlTheme has no TargetType."); - return control.StyleKey == TargetType ? - SelectorMatch.AlwaysThisType : - SelectorMatch.NeverThisType; + var result = BasedOn?.TryAttach(target, host) ?? SelectorMatchResult.NeverThisType; + + if (HasSettersOrAnimations && target.StyleKey == TargetType) + { + Attach(target, null); + result = SelectorMatchResult.AlwaysThisType; + } + + var childResult = TryAttachChildren(target, host); + + if (childResult > result) + result = childResult; + + return result; } internal override void SetParent(StyleBase? parent) diff --git a/src/Avalonia.Base/Styling/NestingSelector.cs b/src/Avalonia.Base/Styling/NestingSelector.cs index 6d31f7cb18..c8945a713d 100644 --- a/src/Avalonia.Base/Styling/NestingSelector.cs +++ b/src/Avalonia.Base/Styling/NestingSelector.cs @@ -15,9 +15,17 @@ namespace Avalonia.Styling protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe) { - if (parent is StyleBase s && s.HasSelector) + if (parent is Style s && s.Selector is not null) { - return s.Match(control, null, subscribe); + return s.Selector.Match(control, s.Parent, subscribe); + } + else if (parent is ControlTheme theme) + { + if (theme.TargetType is null) + throw new InvalidOperationException("ControlTheme has no TargetType."); + return control.StyleKey == theme.TargetType ? + SelectorMatch.AlwaysThisType : + SelectorMatch.NeverThisType; } throw new InvalidOperationException( diff --git a/src/Avalonia.Base/Styling/Style.cs b/src/Avalonia.Base/Styling/Style.cs index ca20ff2b4b..7a6b746488 100644 --- a/src/Avalonia.Base/Styling/Style.cs +++ b/src/Avalonia.Base/Styling/Style.cs @@ -28,7 +28,32 @@ namespace Avalonia.Styling /// public Selector? Selector { get; set; } - internal override bool HasSelector => Selector is not null; + public override SelectorMatchResult TryAttach(IStyleable target, object? host) + { + _ = target ?? throw new ArgumentNullException(nameof(target)); + + var result = SelectorMatchResult.NeverThisType; + + if (HasSettersOrAnimations) + { + var match = Selector?.Match(target, Parent, true) ?? + (target == host ? + SelectorMatch.AlwaysThisInstance : + SelectorMatch.NeverThisInstance); + + if (match.IsMatch) + Attach(target, match.Activator); + + result = match.Result; + } + + var childResult = TryAttachChildren(target, host); + + if (childResult > result) + result = childResult; + + return result; + } /// /// Returns a string representation of the style. @@ -46,14 +71,6 @@ namespace Avalonia.Styling } } - internal override SelectorMatch Match(IStyleable control, object? host, bool subscribe) - { - return Selector?.Match(control, Parent, subscribe) ?? - (control == host ? - SelectorMatch.AlwaysThisInstance : - SelectorMatch.NeverThisInstance); - } - internal override void SetParent(StyleBase? parent) { if (parent is Style parentStyle && parentStyle.Selector is not null) diff --git a/src/Avalonia.Base/Styling/StyleBase.cs b/src/Avalonia.Base/Styling/StyleBase.cs index b6bfec62bd..306a4cf010 100644 --- a/src/Avalonia.Base/Styling/StyleBase.cs +++ b/src/Avalonia.Base/Styling/StyleBase.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Metadata; +using Avalonia.Styling.Activators; namespace Avalonia.Styling { @@ -64,43 +65,14 @@ namespace Avalonia.Styling bool IResourceNode.HasResources => _resources?.Count > 0; IReadOnlyList IStyle.Children => (IReadOnlyList?)_children ?? Array.Empty(); - internal abstract bool HasSelector { get; } + internal bool HasSettersOrAnimations => _setters?.Count > 0 || _animations?.Count > 0; public void Add(ISetter setter) => Setters.Add(setter); public void Add(IStyle style) => Children.Add(style); public event EventHandler? OwnerChanged; - public SelectorMatchResult TryAttach(IStyleable target, object? host) - { - target = target ?? throw new ArgumentNullException(nameof(target)); - - var result = SelectorMatchResult.NeverThisType; - - if (_setters?.Count > 0 || _animations?.Count > 0) - { - var match = Match(target, host, subscribe: true); - - if (match.IsMatch) - { - var instance = new StyleInstance(this, target, _setters, _animations, match.Activator); - target.StyleApplied(instance); - instance.Start(); - } - - result = match.Result; - } - - if (_children is not null) - { - _childCache ??= new StyleCache(); - var childResult = _childCache.TryAttach(_children, target, host); - if (childResult > result) - result = childResult; - } - - return result; - } + public abstract SelectorMatchResult TryAttach(IStyleable target, object? host); public bool TryGetResource(object key, out object? result) { @@ -108,19 +80,20 @@ namespace Avalonia.Styling return _resources?.TryGetResource(key, out result) ?? false; } - /// - /// Evaluates the style's selector against the specified target element. - /// - /// The control. - /// The element that hosts the style. - /// - /// Whether the match should subscribe to changes in order to track the match over time, - /// or simply return an immediate result. - /// - /// - /// A describing how the style matches the control. - /// - internal abstract SelectorMatch Match(IStyleable control, object? host, bool subscribe); + internal void Attach(IStyleable target, IStyleActivator? activator) + { + var instance = new StyleInstance(this, target, _setters, _animations, activator); + target.StyleApplied(instance); + instance.Start(); + } + + internal SelectorMatchResult TryAttachChildren(IStyleable target, object? host) + { + if (_children is null || _children.Count == 0) + return SelectorMatchResult.NeverThisType; + _childCache ??= new StyleCache(); + return _childCache.TryAttach(_children, target, host); + } internal virtual void SetParent(StyleBase? parent) => Parent = parent; diff --git a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs index 0c0808987a..737cf1e048 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs @@ -28,10 +28,10 @@ public class StyledElementTests_Theming Assert.NotNull(target.Template); var border = Assert.IsType(target.VisualChild); - Assert.Equal(border.Background, Brushes.Red); + Assert.Equal(Brushes.Red, border.Background); target.Classes.Add("foo"); - Assert.Equal(border.Background, Brushes.Green); + Assert.Equal(Brushes.Green, border.Background); } [Fact] @@ -73,7 +73,7 @@ public class StyledElementTests_Theming var root = CreateRoot(target); var canvas = Assert.IsType(target.VisualChild); - Assert.Equal(canvas.Background, Brushes.Red); + Assert.Equal(Brushes.Red, canvas.Background); target.Theme = null; @@ -97,7 +97,28 @@ public class StyledElementTests_Theming var border = Assert.IsType(target.VisualChild); Assert.NotNull(target.Template); - Assert.Equal(border.Background, Brushes.Red); + Assert.Equal(Brushes.Red, border.Background); + } + + [Fact] + public void BasedOn_Theme_Is_Applied_When_Attached_To_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(CreateDerivedTheme()); + + Assert.Null(target.Template); + + var root = CreateRoot(target); + Assert.NotNull(target.Template); + Assert.Equal(Brushes.Blue, target.BorderBrush); + + var border = Assert.IsType(target.VisualChild); + Assert.Equal(Brushes.Red, border.Background); + Assert.Equal(Brushes.Yellow, border.BorderBrush); + + target.Classes.Add("foo"); + Assert.Equal(Brushes.Green, border.Background); + Assert.Equal(Brushes.Cyan, border.BorderBrush); } private static ThemedControl CreateTarget(ControlTheme? theme = null) @@ -197,6 +218,36 @@ public class StyledElementTests_Theming }; } + private static ControlTheme CreateDerivedTheme() + { + return new ControlTheme + { + TargetType = typeof(ThemedControl), + BasedOn = CreateTheme(), + Setters = + { + new Setter(Border.BorderBrushProperty, Brushes.Blue), + }, + Children = + { + new Style(x => x.Nesting().Template().OfType()) + { + Setters = + { + new Setter(Border.BorderBrushProperty, Brushes.Yellow), + } + }, + new Style(x => x.Nesting().Class("foo").Template().OfType()) + { + Setters = + { + new Setter(Border.BorderBrushProperty, Brushes.Cyan), + } + }, + } + }; + } + private class ThemedControl : TemplatedControl { public IVisual? VisualChild => VisualChildren?.SingleOrDefault(); From 1d1ef5ca9fdb42d2fbf029f122530dcca9b51a59 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 4 Jun 2022 12:45:19 +0200 Subject: [PATCH 12/42] Display control themes in devtools. --- src/Avalonia.Base/Styling/ControlTheme.cs | 8 ++++++++ .../Diagnostics/ViewModels/ControlDetailsViewModel.cs | 11 +++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Base/Styling/ControlTheme.cs b/src/Avalonia.Base/Styling/ControlTheme.cs index aff6fad990..399eb9ae59 100644 --- a/src/Avalonia.Base/Styling/ControlTheme.cs +++ b/src/Avalonia.Base/Styling/ControlTheme.cs @@ -51,6 +51,14 @@ namespace Avalonia.Styling return result; } + public override string ToString() + { + if (TargetType is not null) + return "ControlTheme: " + TargetType.Name; + else + return "ControlTheme"; + } + internal override void SetParent(StyleBase? parent) { throw new InvalidOperationException("ControlThemes cannot be added as a nested style."); diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs index e383c160e3..795826e4f6 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs @@ -67,8 +67,15 @@ namespace Avalonia.Diagnostics.ViewModels var setters = new List(); - if (styleSource is Style style) + if (styleSource is StyleBase style) { + var selector = style switch + { + Style s => s.Selector?.ToString(), + ControlTheme t => t.TargetType?.Name.ToString(), + _ => null, + }; + foreach (var setter in style.Setters) { if (setter is Setter regularSetter @@ -105,7 +112,7 @@ namespace Avalonia.Diagnostics.ViewModels } } - AppliedStyles.Add(new StyleViewModel(appliedStyle, style.Selector?.ToString() ?? "No selector", setters)); + AppliedStyles.Add(new StyleViewModel(appliedStyle, selector ?? "No selector", setters)); } } From 95f70143ca319e5e6f49601d1e2de60bdbb39759 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 4 Jun 2022 14:53:31 +0200 Subject: [PATCH 13/42] Can apply control theme to derived types. --- src/Avalonia.Base/Styling/ControlTheme.cs | 2 +- src/Avalonia.Base/Styling/NestingSelector.cs | 2 +- .../Styling/StyledElementTests_Theming.cs | 25 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Base/Styling/ControlTheme.cs b/src/Avalonia.Base/Styling/ControlTheme.cs index 399eb9ae59..644e8b32d4 100644 --- a/src/Avalonia.Base/Styling/ControlTheme.cs +++ b/src/Avalonia.Base/Styling/ControlTheme.cs @@ -37,7 +37,7 @@ namespace Avalonia.Styling var result = BasedOn?.TryAttach(target, host) ?? SelectorMatchResult.NeverThisType; - if (HasSettersOrAnimations && target.StyleKey == TargetType) + if (HasSettersOrAnimations && TargetType.IsAssignableFrom(target.StyleKey)) { Attach(target, null); result = SelectorMatchResult.AlwaysThisType; diff --git a/src/Avalonia.Base/Styling/NestingSelector.cs b/src/Avalonia.Base/Styling/NestingSelector.cs index c8945a713d..4393d3239f 100644 --- a/src/Avalonia.Base/Styling/NestingSelector.cs +++ b/src/Avalonia.Base/Styling/NestingSelector.cs @@ -23,7 +23,7 @@ namespace Avalonia.Styling { if (theme.TargetType is null) throw new InvalidOperationException("ControlTheme has no TargetType."); - return control.StyleKey == theme.TargetType ? + return theme.TargetType.IsAssignableFrom(control.StyleKey) ? SelectorMatch.AlwaysThisType : SelectorMatch.NeverThisType; } diff --git a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs index 737cf1e048..ab6c239393 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs @@ -34,6 +34,27 @@ public class StyledElementTests_Theming Assert.Equal(Brushes.Green, border.Background); } + [Fact] + public void Theme_Is_Applied_To_Derived_Class_When_Attached_To_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = new DerivedThemedControl + { + Theme = CreateTheme(), + }; + + Assert.Null(target.Template); + + var root = CreateRoot(target); + Assert.NotNull(target.Template); + + var border = Assert.IsType(target.VisualChild); + Assert.Equal(Brushes.Red, border.Background); + + target.Classes.Add("foo"); + Assert.Equal(Brushes.Green, border.Background); + } + [Fact] public void Theme_Is_Detached_When_Theme_Property_Cleared() { @@ -252,4 +273,8 @@ public class StyledElementTests_Theming { public IVisual? VisualChild => VisualChildren?.SingleOrDefault(); } + + private class DerivedThemedControl : ThemedControl + { + } } From d21e634ab308c1b63d1e2f2105de29c8af236b2a Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 8 Jun 2022 12:00:14 +0200 Subject: [PATCH 14/42] Added support for implicit themes. If no `Theme` property is provided, try to look up a resource keyed with the control's `StyleKey`. --- src/Avalonia.Base/StyledElement.cs | 28 ++++++++++++ src/Avalonia.Base/Styling/IStyleable.cs | 4 +- src/Avalonia.Base/Styling/Styler.cs | 4 +- .../Styling/StyledElementTests_Theming.cs | 43 +++++++++++++++++++ .../AvaloniaPropertyConverterTest.cs | 4 +- .../DynamicResourceExtensionTests.cs | 6 ++- 6 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index 75c4b94174..f377eb848c 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -60,6 +60,7 @@ namespace Avalonia public static readonly StyledProperty ThemeProperty = AvaloniaProperty.Register(nameof(Theme)); + private static readonly ControlTheme s_invalidTheme = new ControlTheme(); private int _initCount; private string? _name; private readonly Classes _classes = new Classes(); @@ -72,6 +73,7 @@ namespace Avalonia private ITemplatedControl? _templatedParent; private bool _dataContextUpdating; private bool _hasPromotedTheme; + private ControlTheme? _implicitTheme; /// /// Initializes static members of the class. @@ -495,6 +497,31 @@ namespace Avalonia }; } + ControlTheme? IStyleable.GetEffectiveTheme() + { + var theme = Theme; + + // Explitly set Theme property takes precedence. + if (theme is not null) + return theme; + + // If the Theme property is not set, try to find a ControlTheme resource with our StyleKey. + if (_implicitTheme is null) + { + var key = ((IStyleable)this).StyleKey; + + if (this.TryFindResource(key, out var value) && value is ControlTheme t) + _implicitTheme = t; + else + _implicitTheme = s_invalidTheme; + } + + if (_implicitTheme != s_invalidTheme) + return _implicitTheme; + + return null; + } + void IStyleable.StyleApplied(IStyleInstance instance) { instance = instance ?? throw new ArgumentNullException(nameof(instance)); @@ -736,6 +763,7 @@ namespace Avalonia if (_logicalRoot != null) { _logicalRoot = null; + _implicitTheme = null; DetachStyles(); OnDetachedFromLogicalTree(e); DetachedFromLogicalTree?.Invoke(this, e); diff --git a/src/Avalonia.Base/Styling/IStyleable.cs b/src/Avalonia.Base/Styling/IStyleable.cs index 61fcbdf850..254da4d85c 100644 --- a/src/Avalonia.Base/Styling/IStyleable.cs +++ b/src/Avalonia.Base/Styling/IStyleable.cs @@ -27,9 +27,9 @@ namespace Avalonia.Styling ITemplatedControl? TemplatedParent { get; } /// - /// Gets the theme to be applied to the control. + /// Gets the effective theme for the control as used by the syling system. /// - public ControlTheme? Theme { get; } + ControlTheme? GetEffectiveTheme(); /// /// Notifies the element that a style has been applied. diff --git a/src/Avalonia.Base/Styling/Styler.cs b/src/Avalonia.Base/Styling/Styler.cs index c9ea123bdc..6ac2e8d372 100644 --- a/src/Avalonia.Base/Styling/Styler.cs +++ b/src/Avalonia.Base/Styling/Styler.cs @@ -11,10 +11,10 @@ namespace Avalonia.Styling // If the control has a themed templated parent then first apply the styles from // the templated parent theme. if (target.TemplatedParent is IStyleable styleableParent) - styleableParent.Theme?.TryAttach(target, styleableParent); + styleableParent.GetEffectiveTheme()?.TryAttach(target, styleableParent); // Next apply the control theme. - target.Theme?.TryAttach(target, target); + target.GetEffectiveTheme()?.TryAttach(target, target); // Apply styles from the rest of the tree. if (target is IStyleHost styleHost) diff --git a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs index ab6c239393..522937b669 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs @@ -158,6 +158,49 @@ public class StyledElementTests_Theming } } + public class ImplicitTheme + { + [Fact] + public void Implicit_Theme_Is_Applied_When_Attached_To_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + var root = CreateRoot(target); + Assert.NotNull(target.Template); + + var border = Assert.IsType(target.VisualChild); + Assert.Equal(Brushes.Red, border.Background); + + target.Classes.Add("foo"); + Assert.Equal(Brushes.Green, border.Background); + } + + [Fact] + public void Implicit_Theme_Is_Cleared_When_Removed_From_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = CreateTarget(); + var root = CreateRoot(target); + + Assert.NotNull(((IStyleable)target).GetEffectiveTheme()); + + root.Child = null; + + Assert.Null(((IStyleable)target).GetEffectiveTheme()); + } + + private static ThemedControl CreateTarget() => new ThemedControl(); + + private static TestRoot CreateRoot(IControl child) + { + var result = new TestRoot(); + result.Resources.Add(typeof(ThemedControl), CreateTheme()); + result.Child = child; + result.LayoutManager.ExecuteInitialLayoutPass(); + return result; + } + } + public class ThemeFromStyle { [Fact] diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs index ca59fe8480..75e21a7138 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs @@ -137,9 +137,9 @@ namespace Avalonia.Markup.Xaml.UnitTests.Converters get { throw new NotImplementedException(); } } - public ControlTheme Theme + public ControlTheme GetEffectiveTheme() { - get { throw new NotImplementedException(); } + throw new NotImplementedException(); } public void DetachStyles() diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs index 592dbfc0d1..987725c314 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs @@ -845,7 +845,8 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions Assert.Equal("bar", border.Tag); var resourceProvider = (TrackingResourceProvider)window.Resources.MergedDictionaries[0]; - Assert.Equal(new[] { "bar" }, resourceProvider.RequestedResources); + Assert.Contains("bar", resourceProvider.RequestedResources); + Assert.DoesNotContain("foo", resourceProvider.RequestedResources); } [Fact] @@ -883,7 +884,8 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions Assert.Equal("bar", border.Tag); var resourceProvider = (TrackingResourceProvider)window.Resources.MergedDictionaries[0]; - Assert.Equal(new[] { "bar" }, resourceProvider.RequestedResources); + Assert.Contains("bar", resourceProvider.RequestedResources); + Assert.DoesNotContain("foo", resourceProvider.RequestedResources); } private IDisposable StyledWindow(params (string, string)[] assets) From 8b4cf63be3ffbf29427bb16d15ff514c595d348b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 10 Jun 2022 11:18:22 +0200 Subject: [PATCH 15/42] Additional validation for ControlTheme children. --- src/Avalonia.Base/Styling/ChildSelector.cs | 2 +- .../Styling/DescendentSelector.cs | 2 +- src/Avalonia.Base/Styling/NestingSelector.cs | 2 +- src/Avalonia.Base/Styling/NotSelector.cs | 2 +- src/Avalonia.Base/Styling/NthChildSelector.cs | 2 +- src/Avalonia.Base/Styling/OrSelector.cs | 12 +--- .../Styling/PropertyEqualsSelector.cs | 2 +- src/Avalonia.Base/Styling/Selector.cs | 31 ++++++++- src/Avalonia.Base/Styling/Style.cs | 9 ++- src/Avalonia.Base/Styling/Styles.cs | 5 ++ src/Avalonia.Base/Styling/TemplateSelector.cs | 2 +- .../Styling/TypeNameAndClassSelector.cs | 2 +- .../Styling/ControlThemeTests.cs | 64 +++++++++++++++++++ 13 files changed, 117 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Base/Styling/ChildSelector.cs b/src/Avalonia.Base/Styling/ChildSelector.cs index 34f3a76b61..9512dc34df 100644 --- a/src/Avalonia.Base/Styling/ChildSelector.cs +++ b/src/Avalonia.Base/Styling/ChildSelector.cs @@ -65,6 +65,6 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => null; - internal override bool HasValidNestingSelector() => _parent.HasValidNestingSelector(); + protected override Selector? MovePreviousOrParent() => _parent; } } diff --git a/src/Avalonia.Base/Styling/DescendentSelector.cs b/src/Avalonia.Base/Styling/DescendentSelector.cs index 4ffaff6861..677a924189 100644 --- a/src/Avalonia.Base/Styling/DescendentSelector.cs +++ b/src/Avalonia.Base/Styling/DescendentSelector.cs @@ -70,6 +70,6 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => null; - internal override bool HasValidNestingSelector() => _parent.HasValidNestingSelector(); + protected override Selector? MovePreviousOrParent() => _parent; } } diff --git a/src/Avalonia.Base/Styling/NestingSelector.cs b/src/Avalonia.Base/Styling/NestingSelector.cs index 4393d3239f..77c5b719c6 100644 --- a/src/Avalonia.Base/Styling/NestingSelector.cs +++ b/src/Avalonia.Base/Styling/NestingSelector.cs @@ -33,6 +33,6 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => null; - internal override bool HasValidNestingSelector() => true; + protected override Selector? MovePreviousOrParent() => null; } } diff --git a/src/Avalonia.Base/Styling/NotSelector.cs b/src/Avalonia.Base/Styling/NotSelector.cs index 76a0690e96..c7727bb6b8 100644 --- a/src/Avalonia.Base/Styling/NotSelector.cs +++ b/src/Avalonia.Base/Styling/NotSelector.cs @@ -67,6 +67,6 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => _previous; - internal override bool HasValidNestingSelector() => _previous?.HasValidNestingSelector() ?? false; + protected override Selector? MovePreviousOrParent() => _previous; } } diff --git a/src/Avalonia.Base/Styling/NthChildSelector.cs b/src/Avalonia.Base/Styling/NthChildSelector.cs index 047bf434da..f473791664 100644 --- a/src/Avalonia.Base/Styling/NthChildSelector.cs +++ b/src/Avalonia.Base/Styling/NthChildSelector.cs @@ -105,7 +105,7 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => _previous; - internal override bool HasValidNestingSelector() => _previous?.HasValidNestingSelector() ?? false; + protected override Selector? MovePreviousOrParent() => _previous; public override string ToString() { diff --git a/src/Avalonia.Base/Styling/OrSelector.cs b/src/Avalonia.Base/Styling/OrSelector.cs index 913c27bf0c..af9249864f 100644 --- a/src/Avalonia.Base/Styling/OrSelector.cs +++ b/src/Avalonia.Base/Styling/OrSelector.cs @@ -103,18 +103,12 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => null; + protected override Selector? MovePreviousOrParent() => null; - internal override bool HasValidNestingSelector() + internal override void ValidateNestingSelector(bool inControlTheme) { foreach (var selector in _selectors) - { - if (!selector.HasValidNestingSelector()) - { - return false; - } - } - - return true; + selector.ValidateNestingSelector(inControlTheme); } private Type? EvaluateTargetType() diff --git a/src/Avalonia.Base/Styling/PropertyEqualsSelector.cs b/src/Avalonia.Base/Styling/PropertyEqualsSelector.cs index 7a37daf087..48136ba2de 100644 --- a/src/Avalonia.Base/Styling/PropertyEqualsSelector.cs +++ b/src/Avalonia.Base/Styling/PropertyEqualsSelector.cs @@ -90,7 +90,7 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => _previous; - internal override bool HasValidNestingSelector() => _previous?.HasValidNestingSelector() ?? false; + protected override Selector? MovePreviousOrParent() => _previous; internal static bool Compare(Type propertyType, object? propertyValue, object? value) { diff --git a/src/Avalonia.Base/Styling/Selector.cs b/src/Avalonia.Base/Styling/Selector.cs index 1e06f3d375..7ce17518dd 100644 --- a/src/Avalonia.Base/Styling/Selector.cs +++ b/src/Avalonia.Base/Styling/Selector.cs @@ -86,7 +86,36 @@ namespace Avalonia.Styling /// protected abstract Selector? MovePrevious(); - internal abstract bool HasValidNestingSelector(); + /// + /// Moves to the previous selector or the parent selector. + /// + protected abstract Selector? MovePreviousOrParent(); + + internal virtual void ValidateNestingSelector(bool inControlTheme) + { + var s = this; + var templateCount = 0; + + do + { + if (inControlTheme) + { + if (!s.InTemplate && s.IsCombinator) + throw new InvalidOperationException( + "ControlTheme style may not directly contain a child or descendent selector."); + if (s is TemplateSelector && templateCount++ > 0) + throw new InvalidOperationException( + "ControlTemplate styles cannot contain multiple template selectors."); + } + + var previous = s.MovePreviousOrParent(); + + if (previous is null && s is not NestingSelector) + throw new InvalidOperationException("Child styles must have a nesting selector."); + + s = previous; + } while (s is not null); + } private static SelectorMatch MatchUntilCombinator( IStyleable control, diff --git a/src/Avalonia.Base/Styling/Style.cs b/src/Avalonia.Base/Styling/Style.cs index 7a6b746488..77c4e62d29 100644 --- a/src/Avalonia.Base/Styling/Style.cs +++ b/src/Avalonia.Base/Styling/Style.cs @@ -77,8 +77,13 @@ namespace Avalonia.Styling { if (Selector is null) throw new InvalidOperationException("Child styles must have a selector."); - if (!Selector.HasValidNestingSelector()) - throw new InvalidOperationException("Child styles must have a nesting selector."); + Selector.ValidateNestingSelector(false); + } + else if (parent is ControlTheme) + { + if (Selector is null) + throw new InvalidOperationException("Child styles must have a selector."); + Selector.ValidateNestingSelector(true); } base.SetParent(parent); diff --git a/src/Avalonia.Base/Styling/Styles.cs b/src/Avalonia.Base/Styling/Styles.cs index 4c011f1b0d..3a27275438 100644 --- a/src/Avalonia.Base/Styling/Styles.cs +++ b/src/Avalonia.Base/Styling/Styles.cs @@ -26,6 +26,11 @@ namespace Avalonia.Styling { _styles.ResetBehavior = ResetBehavior.Remove; _styles.CollectionChanged += OnCollectionChanged; + _styles.Validate = i => + { + if (i is ControlTheme) + throw new InvalidOperationException("ControlThemes cannot be added to a Styles collection."); + }; } public Styles(IResourceHost owner) diff --git a/src/Avalonia.Base/Styling/TemplateSelector.cs b/src/Avalonia.Base/Styling/TemplateSelector.cs index b0a2dae8d6..278e24a203 100644 --- a/src/Avalonia.Base/Styling/TemplateSelector.cs +++ b/src/Avalonia.Base/Styling/TemplateSelector.cs @@ -49,6 +49,6 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => null; - internal override bool HasValidNestingSelector() => _parent?.HasValidNestingSelector() ?? false; + protected override Selector? MovePreviousOrParent() => _parent; } } diff --git a/src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs b/src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs index 24d5d6bbbf..6681a7da36 100644 --- a/src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs +++ b/src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs @@ -140,7 +140,7 @@ namespace Avalonia.Styling } protected override Selector? MovePrevious() => _previous; - internal override bool HasValidNestingSelector() => _previous?.HasValidNestingSelector() ?? false; + protected override Selector? MovePreviousOrParent() => _previous; private string BuildSelectorString() { diff --git a/tests/Avalonia.Base.UnitTests/Styling/ControlThemeTests.cs b/tests/Avalonia.Base.UnitTests/Styling/ControlThemeTests.cs index 93a0e6c2fd..7a27a02fc4 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/ControlThemeTests.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/ControlThemeTests.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Styling; using Xunit; @@ -7,6 +8,15 @@ namespace Avalonia.Base.UnitTests.Styling { public class ControlThemeTests { + [Fact] + public void ControlTheme_Cannot_Be_Added_To_Styles() + { + var target = new ControlTheme(typeof(Button)); + var styles = new Styles(); + + Assert.Throws(() => styles.Add(target)); + } + [Fact] public void ControlTheme_Cannot_Be_Added_To_Style_Children() { @@ -24,5 +34,59 @@ namespace Avalonia.Base.UnitTests.Styling Assert.Throws(() => other.Children.Add(target)); } + + [Fact] + public void Style_Without_Selector_Cannot_Be_Added_To_Children() + { + var target = new ControlTheme(typeof(Button)); + var child = new Style(); + + Assert.Throws(() => target.Children.Add(child)); + } + + [Fact] + public void Style_Without_Nesting_Selector_Cannot_Be_Added_To_Children() + { + var target = new ControlTheme(typeof(Button)); + var child = new Style(x => x.OfType IEnumerable Containers { get; } + /// + /// Gets or sets the theme to be applied to the items in the control. + /// + ControlTheme? ItemContainerTheme { get; set; } + /// /// Gets or sets the data template used to display the items in the control. /// diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs index a76dcbe9c8..8b36b07cec 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs @@ -4,6 +4,7 @@ using System.Linq; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Styling; namespace Avalonia.Controls.Generators { @@ -35,6 +36,11 @@ namespace Avalonia.Controls.Generators /// public event EventHandler? Recycled; + /// + /// Gets or sets the theme to be applied to the items in the control. + /// + public ControlTheme? ItemContainerTheme { get; set; } + /// /// Gets or sets the data template used to display the items in the control. /// @@ -190,10 +196,18 @@ namespace Avalonia.Controls.Generators result.SetValue( ContentPresenter.ContentTemplateProperty, ItemTemplate, - BindingPriority.TemplatedParent); + BindingPriority.Style); } } + if (ItemContainerTheme != null) + { + result.SetValue( + StyledElement.ThemeProperty, + ItemContainerTheme, + BindingPriority.TemplatedParent); + } + return result; } diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs index 635f3a7d37..3ff1b0702d 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs @@ -44,28 +44,29 @@ namespace Avalonia.Controls.Generators { var container = item as T; - if (container != null) + if (container is null) { - return container; - } - else - { - var result = new T(); + container = new T(); if (ContentTemplateProperty != null) { - result.SetValue(ContentTemplateProperty, ItemTemplate, BindingPriority.Style); + container.SetValue(ContentTemplateProperty, ItemTemplate, BindingPriority.Style); } - result.SetValue(ContentProperty, item, BindingPriority.Style); + container.SetValue(ContentProperty, item, BindingPriority.Style); if (!(item is IControl)) { - result.DataContext = item; + container.DataContext = item; } + } - return result; + if (ItemContainerTheme != null) + { + container.SetValue(StyledElement.ThemeProperty, ItemContainerTheme, BindingPriority.Style); } + + return container; } /// diff --git a/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs index 536a5fdd06..4e3deb5552 100644 --- a/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs @@ -71,6 +71,11 @@ namespace Avalonia.Controls.Generators var template = GetTreeDataTemplate(item, ItemTemplate); var result = new T(); + if (ItemContainerTheme != null) + { + result.SetValue(Control.ThemeProperty, ItemContainerTheme, BindingPriority.Style); + } + result.SetValue(ContentProperty, template.Build(item), BindingPriority.Style); var itemsSelector = template.ItemsSelector(item); diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 56b0014c05..427f5c8a9b 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -15,6 +15,7 @@ using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Metadata; using Avalonia.VisualTree; +using Avalonia.Styling; namespace Avalonia.Controls { @@ -36,6 +37,12 @@ namespace Avalonia.Controls public static readonly DirectProperty ItemsProperty = AvaloniaProperty.RegisterDirect(nameof(Items), o => o.Items, (o, v) => o.Items = v); + /// + /// Defines the property. + /// + public static readonly StyledProperty ItemContainerThemeProperty = + AvaloniaProperty.Register(nameof(ItemContainerTheme)); + /// /// Defines the property. /// @@ -88,6 +95,7 @@ namespace Avalonia.Controls { _itemContainerGenerator = CreateItemContainerGenerator(); + _itemContainerGenerator.ItemContainerTheme = ItemContainerTheme; _itemContainerGenerator.ItemTemplate = ItemTemplate; _itemContainerGenerator.Materialized += (_, e) => OnContainersMaterialized(e); _itemContainerGenerator.Dematerialized += (_, e) => OnContainersDematerialized(e); @@ -108,6 +116,15 @@ namespace Avalonia.Controls set { SetAndRaise(ItemsProperty, ref _items, value); } } + /// + /// Gets or sets the that is applied to the container element generated for each item. + /// + public ControlTheme? ItemContainerTheme + { + get { return GetValue(ItemContainerThemeProperty); } + set { SetValue(ItemContainerThemeProperty, value); } + } + /// /// Gets the number of items in . /// @@ -349,6 +366,10 @@ namespace Avalonia.Controls { UpdatePseudoClasses(change.GetNewValue()); } + else if (change.Property == ItemContainerThemeProperty && _itemContainerGenerator is not null) + { + _itemContainerGenerator.ItemContainerTheme = change.GetNewValue(); + } } /// diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index 490b0b3ce3..2e3aa037c2 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -121,6 +121,11 @@ namespace Avalonia.Controls { ItemTemplate = _treeView.ItemTemplate; } + + if (ItemContainerTheme == null && _treeView?.ItemContainerTheme != null) + { + ItemContainerTheme = _treeView.ItemContainerTheme; + } } protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index bfece7871c..f08653a4f8 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -1,15 +1,14 @@ +using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; +using Avalonia.Input; using Avalonia.LogicalTree; -using Avalonia.VisualTree; -using Xunit; -using System.Collections.ObjectModel; +using Avalonia.Styling; using Avalonia.UnitTests; -using Avalonia.Input; -using System.Collections.Generic; +using Xunit; namespace Avalonia.Controls.UnitTests { @@ -62,6 +61,25 @@ namespace Avalonia.Controls.UnitTests Assert.Null(container.TemplatedParent); } + [Fact] + public void Container_Should_Have_Theme_Set_To_ItemContainerTheme() + { + var theme = new ControlTheme(); + var target = new ItemsControl + { + ItemContainerTheme = theme, + }; + + target.Template = GetTemplate(); + target.Items = new[] { "Foo" }; + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var container = (ContentPresenter)target.Presenter.Panel.Children[0]; + + Assert.Same(container.Theme, theme); + } + [Fact] public void Container_Should_Have_LogicalParent_Set_To_ItemsControl() { diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index e87990ebb1..afa153a593 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -94,6 +94,50 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Container_Should_Have_Theme_Set_To_ItemContainerTheme() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var items = new[] { "Foo", "Bar", "Baz " }; + var theme = new ControlTheme(); + var target = new ListBox + { + Template = ListBoxTemplate(), + Items = items, + ItemContainerTheme = theme, + }; + + Prepare(target); + + var container = (ListBoxItem)target.Presenter.Panel.Children[0]; + + Assert.Same(container.Theme, theme); + } + } + + [Fact] + public void Inline_Item_Should_Have_Theme_Set_To_ItemContainerTheme() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var items = new[] { "Foo", "Bar", "Baz " }; + var theme = new ControlTheme(); + var target = new ListBox + { + Template = ListBoxTemplate(), + Items = new[] { new ListBoxItem() }, + ItemContainerTheme = theme, + }; + + Prepare(target); + + var container = (ListBoxItem)target.Presenter.Panel.Children[0]; + + Assert.Same(container.Theme, theme); + } + } + [Fact] public void LogicalChildren_Should_Be_Set_For_DataTemplate_Generated_Items() { diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 2169b15cad..d784caf2db 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -72,6 +72,35 @@ namespace Avalonia.Controls.UnitTests Assert.All(items, x => Assert.IsType(x.HeaderPresenter.Child)); } + [Fact] + public void Items_Should_Be_Created_Using_ItemConatinerTheme_If_Present() + { + TreeView target; + var theme = new ControlTheme(); + + var root = new TestRoot + { + Child = target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = CreateTestTreeData(), + ItemContainerTheme = theme, + ItemTemplate = new FuncTreeDataTemplate( + (_, __) => new Canvas(), + x => x.Children), + } + }; + + ApplyTemplates(target); + + var items = target.ItemContainerGenerator.Index.Containers + .OfType() + .ToList(); + + Assert.Equal(5, items.Count); + Assert.All(items, x => Assert.Same(theme, x.ItemContainerTheme)); + } + [Fact] public void Root_ItemContainerGenerator_Containers_Should_Be_Root_Containers() { From fb49040fbed8c25f10649056193a41f14ba97008 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 13 Jul 2022 12:04:03 +0200 Subject: [PATCH 31/42] Added some ControlTheme benchmarks. --- .../Styling/ControlTheme_Apply.cs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/Avalonia.Benchmarks/Styling/ControlTheme_Apply.cs diff --git a/tests/Avalonia.Benchmarks/Styling/ControlTheme_Apply.cs b/tests/Avalonia.Benchmarks/Styling/ControlTheme_Apply.cs new file mode 100644 index 0000000000..0c9bcf412f --- /dev/null +++ b/tests/Avalonia.Benchmarks/Styling/ControlTheme_Apply.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Media; +using Avalonia.Styling; +using BenchmarkDotNet.Attributes; + +#nullable enable + +namespace Avalonia.Benchmarks.Styling +{ + [MemoryDiagnoser] + public class ControlTheme_Apply + { + private ControlTheme _theme; + private ControlTheme _otherTheme; + private List