From 088d8cfc5c147da723e7641cf77c8dc67646e786 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 1 Jun 2022 13:21:52 +0200 Subject: [PATCH] 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(); + } + } +}