From 253ecd028d58112fc04eb79317b0cc053807cb97 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 18 Jan 2023 02:05:34 -0500 Subject: [PATCH] Introduce ThemeVariant API --- .../Controls/IResourceDictionary.cs | 6 + src/Avalonia.Base/Controls/IResourceNode.cs | 7 +- .../Controls/ResourceDictionary.cs | 91 ++++++++++++- .../Controls/ResourceNodeExtensions.cs | 125 +++++++++++++++--- src/Avalonia.Base/StyledElement.cs | 41 +++++- .../Styling/IGlobalThemeVariantProvider.cs | 22 +++ src/Avalonia.Base/Styling/StyleBase.cs | 6 +- src/Avalonia.Base/Styling/Styles.cs | 6 +- src/Avalonia.Base/Styling/ThemeVariant.cs | 65 +++++++++ .../Styling/ThemeVariantTypeConverter.cs | 23 ++++ src/Avalonia.Controls/Application.cs | 62 ++++++++- src/Avalonia.Controls/ThemeVariantScope.cs | 23 ++++ src/Avalonia.Controls/TopLevel.cs | 52 ++++++-- .../Diagnostics/Controls/Application.cs | 46 ++++++- .../Diagnostics/DevToolsOptions.cs | 8 +- .../ViewModels/ControlDetailsViewModel.cs | 3 +- .../Diagnostics/Views/MainWindow.xaml | 4 +- .../Diagnostics/Views/MainWindow.xaml.cs | 8 +- .../Internal/ResourceSelectorConverter.cs | 2 +- .../StaticResourceExtension.cs | 21 ++- .../Styling/ResourceInclude.cs | 5 +- .../Styling/StyleInclude.cs | 4 +- 22 files changed, 552 insertions(+), 78 deletions(-) create mode 100644 src/Avalonia.Base/Styling/IGlobalThemeVariantProvider.cs create mode 100644 src/Avalonia.Base/Styling/ThemeVariant.cs create mode 100644 src/Avalonia.Base/Styling/ThemeVariantTypeConverter.cs create mode 100644 src/Avalonia.Controls/ThemeVariantScope.cs diff --git a/src/Avalonia.Base/Controls/IResourceDictionary.cs b/src/Avalonia.Base/Controls/IResourceDictionary.cs index 3a68dde31e..2bd1f65638 100644 --- a/src/Avalonia.Base/Controls/IResourceDictionary.cs +++ b/src/Avalonia.Base/Controls/IResourceDictionary.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Avalonia.Styling; #nullable enable @@ -13,5 +14,10 @@ namespace Avalonia.Controls /// Gets a collection of child resource dictionaries. /// IList MergedDictionaries { get; } + + /// + /// Gets a collection of merged resource dictionaries that are specifically keyed and composed to address theme scenarios. + /// + IDictionary ThemeDictionaries { get; } } } diff --git a/src/Avalonia.Base/Controls/IResourceNode.cs b/src/Avalonia.Base/Controls/IResourceNode.cs index d6c900f97f..d2fa3c7af3 100644 --- a/src/Avalonia.Base/Controls/IResourceNode.cs +++ b/src/Avalonia.Base/Controls/IResourceNode.cs @@ -1,5 +1,5 @@ -using System; -using Avalonia.Metadata; +using Avalonia.Metadata; +using Avalonia.Styling; namespace Avalonia.Controls { @@ -23,6 +23,7 @@ namespace Avalonia.Controls /// Tries to find a resource within the object. /// /// The resource key. + /// Theme used to select theme dictionary. /// /// When this method returns, contains the value associated with the specified key, /// if the key is found; otherwise, null. @@ -30,6 +31,6 @@ namespace Avalonia.Controls /// /// True if the resource if found, otherwise false. /// - bool TryGetResource(object key, out object? value); + bool TryGetResource(object key, ThemeVariant? theme, out object? value); } } diff --git a/src/Avalonia.Base/Controls/ResourceDictionary.cs b/src/Avalonia.Base/Controls/ResourceDictionary.cs index d6197c50c6..85e4487ba9 100644 --- a/src/Avalonia.Base/Controls/ResourceDictionary.cs +++ b/src/Avalonia.Base/Controls/ResourceDictionary.cs @@ -1,9 +1,12 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Templates; +using Avalonia.Media; +using Avalonia.Styling; namespace Avalonia.Controls { @@ -15,6 +18,7 @@ namespace Avalonia.Controls private Dictionary? _inner; private IResourceHost? _owner; private AvaloniaList? _mergedDictionaries; + private AvaloniaDictionary? _themeDictionary; /// /// Initializes a new instance of the class. @@ -69,14 +73,14 @@ namespace Avalonia.Controls _mergedDictionaries.ForEachItem( x => { - if (Owner is object) + if (Owner is not null) { x.AddOwner(Owner); } }, x => { - if (Owner is object) + if (Owner is not null) { x.RemoveOwner(Owner); } @@ -88,6 +92,34 @@ namespace Avalonia.Controls } } + public IDictionary ThemeDictionaries + { + get + { + if (_themeDictionary == null) + { + _themeDictionary = new AvaloniaDictionary(2); + _themeDictionary.ForEachItem( + (_, x) => + { + if (Owner is not null) + { + x.AddOwner(Owner); + } + }, + (_, x) => + { + if (Owner is not null) + { + x.RemoveOwner(Owner); + } + }, + () => throw new NotSupportedException("Dictionary reset not supported")); + } + return _themeDictionary; + } + } + bool IResourceNode.HasResources { get @@ -152,16 +184,47 @@ namespace Avalonia.Controls return false; } - public bool TryGetResource(object key, out object? value) + public bool TryGetResource(object key, ThemeVariant? theme, out object? value) { if (TryGetValue(key, out value)) return true; + if (_themeDictionary is not null) + { + IResourceProvider? themeResourceProvider; + if (theme is not null) + { + if (_themeDictionary.TryGetValue(theme, out themeResourceProvider) + && themeResourceProvider.TryGetResource(key, theme, out value)) + { + return true; + } + + var themeInherit = theme.InheritVariant; + while (themeInherit is not null) + { + if (_themeDictionary.TryGetValue(themeInherit, out themeResourceProvider) + && themeResourceProvider.TryGetResource(key, theme, out value)) + { + return true; + } + + themeInherit = themeInherit.InheritVariant; + } + } + + if (_themeDictionary.TryGetValue(ThemeVariant.Default, out themeResourceProvider) + && themeResourceProvider.TryGetResource(key, theme, out value)) + { + return true; + } + } + if (_mergedDictionaries != null) { for (var i = _mergedDictionaries.Count - 1; i >= 0; --i) { - if (_mergedDictionaries[i].TryGetResource(key, out value)) + if (_mergedDictionaries[i].TryGetResource(key, theme, out value)) { return true; } @@ -248,7 +311,7 @@ namespace Avalonia.Controls var hasResources = _inner?.Count > 0; - if (_mergedDictionaries is object) + if (_mergedDictionaries is not null) { foreach (var i in _mergedDictionaries) { @@ -256,6 +319,14 @@ namespace Avalonia.Controls hasResources |= i.HasResources; } } + if (_themeDictionary is not null) + { + foreach (var i in _themeDictionary.Values) + { + i.AddOwner(owner); + hasResources |= i.HasResources; + } + } if (hasResources) { @@ -273,7 +344,7 @@ namespace Avalonia.Controls var hasResources = _inner?.Count > 0; - if (_mergedDictionaries is object) + if (_mergedDictionaries is not null) { foreach (var i in _mergedDictionaries) { @@ -281,6 +352,14 @@ namespace Avalonia.Controls hasResources |= i.HasResources; } } + if (_themeDictionary is not null) + { + foreach (var i in _themeDictionary.Values) + { + i.RemoveOwner(owner); + hasResources |= i.HasResources; + } + } if (hasResources) { diff --git a/src/Avalonia.Base/Controls/ResourceNodeExtensions.cs b/src/Avalonia.Base/Controls/ResourceNodeExtensions.cs index 0bf1073098..4b0bab0c92 100644 --- a/src/Avalonia.Base/Controls/ResourceNodeExtensions.cs +++ b/src/Avalonia.Base/Controls/ResourceNodeExtensions.cs @@ -1,6 +1,4 @@ using System; -using Avalonia.Data.Converters; -using Avalonia.LogicalTree; using Avalonia.Reactive; using Avalonia.Styling; @@ -41,21 +39,66 @@ namespace Avalonia.Controls control = control ?? throw new ArgumentNullException(nameof(control)); key = key ?? throw new ArgumentNullException(nameof(key)); - IResourceNode? current = control; + return control.TryFindResource(key, null, out value); + } + + /// + /// Finds the specified resource by searching up the logical tree and then global styles. + /// + /// The control. + /// Theme used to select theme dictionary. + /// The resource key. + /// The resource, or if not found. + public static object? FindResource(this IResourceHost control, ThemeVariant? theme, object key) + { + control = control ?? throw new ArgumentNullException(nameof(control)); + key = key ?? throw new ArgumentNullException(nameof(key)); + + if (control.TryFindResource(key, theme, out var value)) + { + return value; + } + + return AvaloniaProperty.UnsetValue; + } + + /// + /// Tries to the specified resource by searching up the logical tree and then global styles. + /// + /// The control. + /// The resource key. + /// Theme used to select theme dictionary. + /// On return, contains the resource if found, otherwise null. + /// True if the resource was found; otherwise false. + public static bool TryFindResource(this IResourceHost control, object key, ThemeVariant? theme, out object? value) + { + control = control ?? throw new ArgumentNullException(nameof(control)); + key = key ?? throw new ArgumentNullException(nameof(key)); + + IResourceHost? current = control; while (current != null) { - if (current.TryGetResource(key, out value)) + if (current.TryGetResource(key, theme, out value)) { return true; } - current = (current as IStyleHost)?.StylingParent as IResourceNode; + current = (current as IStyleHost)?.StylingParent as IResourceHost; } value = null; return false; } + + /// + public static bool TryGetResource(this IResourceHost control, object key, out object? value) + { + control = control ?? throw new ArgumentNullException(nameof(control)); + key = key ?? throw new ArgumentNullException(nameof(key)); + + return control.TryGetResource(key, null, out value); + } public static IObservable GetResourceObservable( this IResourceHost control, @@ -95,24 +138,49 @@ namespace Avalonia.Controls protected override void Initialize() { _target.ResourcesChanged += ResourcesChanged; + if (_target is StyledElement themeStyleable) + { + themeStyleable.PropertyChanged += PropertyChanged; + } } protected override void Deinitialize() { _target.ResourcesChanged -= ResourcesChanged; + if (_target is StyledElement themeStyleable) + { + themeStyleable.PropertyChanged -= PropertyChanged; + } } protected override void Subscribed(IObserver observer, bool first) { - observer.OnNext(Convert(_target.FindResource(_key))); + observer.OnNext(GetValue()); } private void ResourcesChanged(object? sender, ResourcesChangedEventArgs e) { - PublishNext(Convert(_target.FindResource(_key))); + PublishNext(GetValue()); + } + + private void PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == StyledElement.ActualThemeVariantProperty) + { + PublishNext(GetValue()); + } } - private object? Convert(object? value) => _converter?.Invoke(value) ?? value; + private object? GetValue() + { + if (_target is not StyledElement themeStyleable + || !_target.TryFindResource(_key, themeStyleable.ActualThemeVariant, out var value)) + { + value = _target.FindResource(_key) ?? AvaloniaProperty.UnsetValue; + } + + return _converter?.Invoke(value) ?? value; + } } private class FloatingResourceObservable : LightweightObservableBase @@ -134,7 +202,7 @@ namespace Avalonia.Controls _target.OwnerChanged += OwnerChanged; _owner = _target.Owner; - if (_owner is object) + if (_owner is not null) { _owner.ResourcesChanged += ResourcesChanged; } @@ -148,43 +216,68 @@ namespace Avalonia.Controls protected override void Subscribed(IObserver observer, bool first) { - if (_target.Owner is object) + if (_target.Owner is not null) { - observer.OnNext(Convert(_target.Owner.FindResource(_key))); + observer.OnNext(GetValue()); } } private void PublishNext() { - if (_target.Owner is object) + if (_target.Owner is not null) { - PublishNext(Convert(_target.Owner.FindResource(_key))); + PublishNext(GetValue()); } } private void OwnerChanged(object? sender, EventArgs e) { - if (_owner is object) + if (_owner is not null) { _owner.ResourcesChanged -= ResourcesChanged; } + if (_owner is StyledElement styleable) + { + styleable.PropertyChanged += PropertyChanged; + } _owner = _target.Owner; - if (_owner is object) + if (_owner is not null) { _owner.ResourcesChanged += ResourcesChanged; } + if (_owner is StyledElement styleable2) + { + styleable2.PropertyChanged += PropertyChanged; + } PublishNext(); } + private void PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == StyledElement.ActualThemeVariantProperty) + { + PublishNext(); + } + } + private void ResourcesChanged(object? sender, ResourcesChangedEventArgs e) { PublishNext(); } - private object? Convert(object? value) => _converter?.Invoke(value) ?? value; + private object? GetValue() + { + if (!(_target.Owner is StyledElement themeStyleable) + || !_target.Owner.TryFindResource(_key, themeStyleable.ActualThemeVariant, out var value)) + { + value = _target.Owner?.FindResource(_key) ?? AvaloniaProperty.UnsetValue; + } + + return _converter?.Invoke(value) ?? value; + } } } } diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index 6043175eee..d23c585299 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -71,6 +71,23 @@ namespace Avalonia public static readonly StyledProperty ThemeProperty = AvaloniaProperty.Register(nameof(Theme)); + /// + /// Defines the property. + /// + public static readonly StyledProperty ActualThemeVariantProperty = + AvaloniaProperty.Register( + nameof(ThemeVariant), + inherits: true, + defaultValue: ThemeVariant.Light); + + /// + /// Defines the property. + /// + public static readonly StyledProperty RequestedThemeVariantProperty = + AvaloniaProperty.Register( + nameof(ThemeVariant), + defaultValue: ThemeVariant.Default); + private static readonly ControlTheme s_invalidTheme = new ControlTheme(); private int _initCount; private string? _name; @@ -257,6 +274,15 @@ namespace Avalonia set => SetValue(ThemeProperty, value); } + /// + /// Gets the UI theme that is currently used by the element, which might be different than the . + /// + /// + /// If current control is contained in the ThemeVariantScope, TopLevel or Application with non-default RequestedThemeVariant, that value will be returned. + /// Otherwise, current OS theme variant is returned. + /// + public ThemeVariant ActualThemeVariant => GetValue(ActualThemeVariantProperty); + /// /// Gets the styled element's logical children. /// @@ -439,11 +465,11 @@ namespace Avalonia void IResourceHost.NotifyHostedResourcesChanged(ResourcesChangedEventArgs e) => NotifyResourcesChanged(e); /// - bool IResourceNode.TryGetResource(object key, out object? value) + public bool TryGetResource(object key, ThemeVariant? theme, out object? value) { value = null; - return (_resources?.TryGetResource(key, out value) ?? false) || - (_styles?.TryGetResource(key, out value) ?? false); + return (_resources?.TryGetResource(key, theme, out value) ?? false) || + (_styles?.TryGetResource(key, theme, out value) ?? false); } /// @@ -621,6 +647,13 @@ namespace Avalonia if (change.Property == ThemeProperty) OnControlThemeChanged(); + else if (change.Property == RequestedThemeVariantProperty) + { + if (change.GetNewValue() is {} themeVariant && themeVariant != ThemeVariant.Default) + SetValue(ActualThemeVariantProperty, themeVariant); + else + ClearValue(ActualThemeVariantProperty); + } } private protected virtual void OnControlThemeChanged() @@ -658,7 +691,7 @@ namespace Avalonia { var theme = Theme; - // Explitly set Theme property takes precedence. + // Explicitly set Theme property takes precedence. if (theme is not null) return theme; diff --git a/src/Avalonia.Base/Styling/IGlobalThemeVariantProvider.cs b/src/Avalonia.Base/Styling/IGlobalThemeVariantProvider.cs new file mode 100644 index 0000000000..2467d99b3b --- /dev/null +++ b/src/Avalonia.Base/Styling/IGlobalThemeVariantProvider.cs @@ -0,0 +1,22 @@ +using System; +using Avalonia.Controls; +using Avalonia.Metadata; + +namespace Avalonia.Styling; + +/// +/// Interface for an application host element with a root theme variant. +/// +[Unstable] +public interface IGlobalThemeVariantProvider : IResourceHost +{ + /// + /// Gets the UI theme variant that is used by the control (and its child elements) for resource determination. + /// + ThemeVariant ActualThemeVariant { get; } + + /// + /// Raised when the theme variant is changed on the element or an ancestor of the element. + /// + event EventHandler? ActualThemeVariantChanged; +} diff --git a/src/Avalonia.Base/Styling/StyleBase.cs b/src/Avalonia.Base/Styling/StyleBase.cs index e8fc40ca4c..7dfa516bce 100644 --- a/src/Avalonia.Base/Styling/StyleBase.cs +++ b/src/Avalonia.Base/Styling/StyleBase.cs @@ -74,16 +74,16 @@ namespace Avalonia.Styling public event EventHandler? OwnerChanged; - public bool TryGetResource(object key, out object? result) + public bool TryGetResource(object key, ThemeVariant? themeVariant, out object? result) { - if (_resources is not null && _resources.TryGetResource(key, out result)) + if (_resources is not null && _resources.TryGetResource(key, themeVariant, out result)) return true; if (_children is not null) { for (var i = 0; i < _children.Count; ++i) { - if (_children[i].TryGetResource(key, out result)) + if (_children[i].TryGetResource(key, themeVariant, out result)) return true; } } diff --git a/src/Avalonia.Base/Styling/Styles.cs b/src/Avalonia.Base/Styling/Styles.cs index 1b1886335f..5d5b1617aa 100644 --- a/src/Avalonia.Base/Styling/Styles.cs +++ b/src/Avalonia.Base/Styling/Styles.cs @@ -115,16 +115,16 @@ namespace Avalonia.Styling } /// - public bool TryGetResource(object key, out object? value) + public bool TryGetResource(object key, ThemeVariant? theme, out object? value) { - if (_resources != null && _resources.TryGetResource(key, out value)) + if (_resources != null && _resources.TryGetResource(key, theme, out value)) { return true; } for (var i = Count - 1; i >= 0; --i) { - if (this[i].TryGetResource(key, out value)) + if (this[i].TryGetResource(key, theme, out value)) { return true; } diff --git a/src/Avalonia.Base/Styling/ThemeVariant.cs b/src/Avalonia.Base/Styling/ThemeVariant.cs new file mode 100644 index 0000000000..d9cb123925 --- /dev/null +++ b/src/Avalonia.Base/Styling/ThemeVariant.cs @@ -0,0 +1,65 @@ +using System; +using System.ComponentModel; +using System.Text; +using Avalonia.Platform; + +namespace Avalonia.Styling; + +[TypeConverter(typeof(ThemeVariantTypeConverter))] +public sealed record ThemeVariant(object Key) +{ + public ThemeVariant(object key, ThemeVariant? inheritVariant) + : this(key) + { + InheritVariant = inheritVariant; + } + + public static ThemeVariant Default { get; } = new(nameof(Default)); + public static ThemeVariant Light { get; } = new(nameof(Light)); + public static ThemeVariant Dark { get; } = new(nameof(Dark)); + + public ThemeVariant? InheritVariant { get; init; } + + public override string ToString() + { + return Key.ToString() ?? $"ThemeVariant {{ Key = {Key} }}"; + } + + public override int GetHashCode() + { + return Key.GetHashCode(); + } + + public bool Equals(ThemeVariant? other) + { + return Key == other?.Key; + } + + public static ThemeVariant FromPlatformThemeVariant(PlatformThemeVariant themeVariant) + { + return themeVariant switch + { + PlatformThemeVariant.Light => Light, + PlatformThemeVariant.Dark => Dark, + _ => throw new ArgumentOutOfRangeException(nameof(themeVariant), themeVariant, null) + }; + } + + public PlatformThemeVariant? ToPlatformThemeVariant() + { + if (this == Light) + { + return PlatformThemeVariant.Light; + } + else if (this == Dark) + { + return PlatformThemeVariant.Dark; + } + else if (InheritVariant is { } inheritVariant) + { + return inheritVariant.ToPlatformThemeVariant(); + } + + return null; + } +} diff --git a/src/Avalonia.Base/Styling/ThemeVariantTypeConverter.cs b/src/Avalonia.Base/Styling/ThemeVariantTypeConverter.cs new file mode 100644 index 0000000000..4da1b495f5 --- /dev/null +++ b/src/Avalonia.Base/Styling/ThemeVariantTypeConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.ComponentModel; +using System.Globalization; + +namespace Avalonia.Styling; + +public class ThemeVariantTypeConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + return value switch + { + nameof(ThemeVariant.Light) => ThemeVariant.Light, + nameof(ThemeVariant.Dark) => ThemeVariant.Dark, + _ => new ThemeVariant(value) + }; + } +} diff --git a/src/Avalonia.Controls/Application.cs b/src/Avalonia.Controls/Application.cs index 5b652cce19..58cc02e8c5 100644 --- a/src/Avalonia.Controls/Application.cs +++ b/src/Avalonia.Controls/Application.cs @@ -4,6 +4,7 @@ using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Templates; +using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Input.Raw; @@ -28,7 +29,7 @@ namespace Avalonia /// method. /// - Tracks the lifetime of the application. /// - public class Application : AvaloniaObject, IDataContextProvider, IGlobalDataTemplates, IGlobalStyles, IResourceHost, IApplicationPlatformEvents + public class Application : AvaloniaObject, IDataContextProvider, IGlobalDataTemplates, IGlobalStyles, IGlobalThemeVariantProvider, IApplicationPlatformEvents { /// /// The application-global data templates. @@ -49,10 +50,22 @@ namespace Avalonia public static readonly StyledProperty DataContextProperty = StyledElement.DataContextProperty.AddOwner(); + /// + public static readonly StyledProperty ActualThemeVariantProperty = + StyledElement.ActualThemeVariantProperty.AddOwner(); + + /// + public static readonly StyledProperty RequestedThemeVariantProperty = + StyledElement.RequestedThemeVariantProperty.AddOwner(); + /// public event EventHandler? ResourcesChanged; - public event EventHandler? UrlsOpened; + /// + public event EventHandler? UrlsOpened; + + /// + public event EventHandler? ActualThemeVariantChanged; /// /// Creates an instance of the class. @@ -75,6 +88,19 @@ namespace Avalonia set { SetValue(DataContextProperty, value); } } + /// + public ThemeVariant? RequestedThemeVariant + { + get => GetValue(RequestedThemeVariantProperty); + set => SetValue(RequestedThemeVariantProperty, value); + } + + /// + public ThemeVariant ActualThemeVariant + { + get => GetValue(ActualThemeVariantProperty); + } + /// /// Gets the current instance of the class. /// @@ -191,11 +217,11 @@ namespace Avalonia public virtual void Initialize() { } /// - bool IResourceNode.TryGetResource(object key, out object? value) + public bool TryGetResource(object key, ThemeVariant? theme, out object? value) { value = null; - return (_resources?.TryGetResource(key, out value) ?? false) || - Styles.TryGetResource(key, out value); + return (_resources?.TryGetResource(key, theme, out value) ?? false) || + Styles.TryGetResource(key, theme, out value); } void IResourceHost.NotifyHostedResourcesChanged(ResourcesChangedEventArgs e) @@ -222,10 +248,15 @@ namespace Avalonia FocusManager = new FocusManager(); InputManager = new InputManager(); + var settings = AvaloniaLocator.Current.GetRequiredService(); + settings.ColorValuesChanged += OnColorValuesChanged; + OnColorValuesChanged(settings, settings.GetColorValues()); + AvaloniaLocator.CurrentMutable .Bind().ToTransient() .Bind().ToConstant(this) .Bind().ToConstant(this) + .Bind().ToConstant(this) .Bind().ToConstant(FocusManager) .Bind().ToConstant(InputManager) .Bind().ToTransient() @@ -290,5 +321,26 @@ namespace Avalonia set => SetAndRaise(NameProperty, ref _name, value); } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == RequestedThemeVariantProperty) + { + if (change.GetNewValue() is {} themeVariant && themeVariant != ThemeVariant.Default) + SetValue(ActualThemeVariantProperty, themeVariant); + else + ClearValue(ActualThemeVariantProperty); + } + else if (change.Property == ActualThemeVariantProperty) + { + ActualThemeVariantChanged?.Invoke(this, EventArgs.Empty); + } + } + + private void OnColorValuesChanged(object? sender, PlatformColorValues e) + { + SetValue(ActualThemeVariantProperty, ThemeVariant.FromPlatformThemeVariant(e.ThemeVariant), BindingPriority.Template); + } } } diff --git a/src/Avalonia.Controls/ThemeVariantScope.cs b/src/Avalonia.Controls/ThemeVariantScope.cs new file mode 100644 index 0000000000..b9724251c7 --- /dev/null +++ b/src/Avalonia.Controls/ThemeVariantScope.cs @@ -0,0 +1,23 @@ +using Avalonia.Styling; + +namespace Avalonia.Controls +{ + /// + /// Decorator control that isolates controls subtree with locally defined . + /// + public class ThemeVariantScope : Decorator + { + /// + /// Gets or sets the UI theme variant that is used by the control (and its child elements) for resource determination. + /// The UI theme you specify with ThemeVariant can override the app-level ThemeVariant. + /// + /// + /// Setting RequestedThemeVariant to will apply parent's actual theme variant on the current scope. + /// + public ThemeVariant? RequestedThemeVariant + { + get => GetValue(RequestedThemeVariantProperty); + set => SetValue(RequestedThemeVariantProperty, value); + } + } +} diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index c0265e28b9..e92b310057 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -4,6 +4,7 @@ using Avalonia.Controls.Metadata; using Avalonia.Controls.Notifications; using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives; +using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.Input.TextInput; @@ -96,6 +97,7 @@ namespace Avalonia.Controls private readonly IKeyboardNavigationHandler? _keyboardNavigationHandler; private readonly IPlatformRenderInterface? _renderInterface; private readonly IGlobalStyles? _globalStyles; + private readonly IGlobalThemeVariantProvider? _applicationThemeHost; private readonly PointerOverPreProcessor? _pointerOverPreProcessor; private readonly IDisposable? _pointerOverPreProcessorSubscription; private readonly IDisposable? _backGestureSubscription; @@ -114,16 +116,6 @@ namespace Avalonia.Controls { KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Cycle); AffectsMeasure(ClientSizeProperty); - - TransparencyLevelHintProperty.Changed.AddClassHandler( - (tl, e) => - { - if (tl.PlatformImpl != null) - { - tl.PlatformImpl.SetTransparencyLevelHint((WindowTransparencyLevel)e.NewValue!); - tl.HandleTransparencyLevelChanged(tl.PlatformImpl.TransparencyLevel); - } - }); } /// @@ -161,6 +153,7 @@ namespace Avalonia.Controls _keyboardNavigationHandler = TryGetService(dependencyResolver); _renderInterface = TryGetService(dependencyResolver); _globalStyles = TryGetService(dependencyResolver); + _applicationThemeHost = TryGetService(dependencyResolver); Renderer = impl.CreateRenderer(this); @@ -191,6 +184,11 @@ namespace Avalonia.Controls _globalStyles.GlobalStylesAdded += ((IStyleHost)this).StylesAdded; _globalStyles.GlobalStylesRemoved += ((IStyleHost)this).StylesRemoved; } + if (_applicationThemeHost is { }) + { + SetValue(ActualThemeVariantProperty, _applicationThemeHost.ActualThemeVariant, BindingPriority.Template); + _applicationThemeHost.ActualThemeVariantChanged += GlobalActualThemeVariantChanged; + } ClientSize = impl.ClientSize; FrameSize = impl.FrameSize; @@ -315,6 +313,13 @@ namespace Avalonia.Controls set => SetValue(TransparencyBackgroundFallbackProperty, value); } + /// + public ThemeVariant? RequestedThemeVariant + { + get => GetValue(RequestedThemeVariantProperty); + set => SetValue(RequestedThemeVariantProperty, value); + } + /// /// Occurs when physical Back Button is pressed or a back navigation has been requested. /// @@ -413,6 +418,24 @@ namespace Avalonia.Controls return visual == null ? null : visual.VisualRoot as TopLevel; } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == TransparencyLevelHintProperty) + { + if (PlatformImpl != null) + { + PlatformImpl.SetTransparencyLevelHint(change.GetNewValue()); + HandleTransparencyLevelChanged(PlatformImpl.TransparencyLevel); + } + } + else if (change.Property == ActualThemeVariantProperty) + { + PlatformImpl?.SetFrameThemeVariant(change.GetNewValue().ToPlatformThemeVariant() ?? PlatformThemeVariant.Light); + } + } + /// /// Creates the layout manager for this . /// @@ -437,6 +460,10 @@ namespace Avalonia.Controls _globalStyles.GlobalStylesAdded -= ((IStyleHost)this).StylesAdded; _globalStyles.GlobalStylesRemoved -= ((IStyleHost)this).StylesRemoved; } + if (_applicationThemeHost is { }) + { + _applicationThemeHost.ActualThemeVariantChanged -= GlobalActualThemeVariantChanged; + } Renderer?.Dispose(); Renderer = null!; @@ -589,6 +616,11 @@ namespace Avalonia.Controls _inputManager?.ProcessInput(e); } + private void GlobalActualThemeVariantChanged(object? sender, EventArgs e) + { + SetValue(ActualThemeVariantProperty, ((IGlobalThemeVariantProvider)sender!).ActualThemeVariant, BindingPriority.Template); + } + private void SceneInvalidated(object? sender, SceneInvalidatedEventArgs e) { _pointerOverPreProcessor?.SceneInvalidated(e.DirtyRect); diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/Application.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/Application.cs index 00173dbb35..7426c4e2ed 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/Application.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/Application.cs @@ -1,19 +1,22 @@ using System; using Avalonia.Controls; +using Avalonia.Styling; using Lifetimes = Avalonia.Controls.ApplicationLifetimes; -using App = Avalonia.Application; namespace Avalonia.Diagnostics.Controls { class Application : AvaloniaObject - , Input.ICloseable + , Input.ICloseable, IDisposable { - private readonly App _application; + private readonly Avalonia.Application _application; public event EventHandler? Closed; - public Application(App application) + public static readonly StyledProperty RequestedThemeVariantProperty = + StyledElement.RequestedThemeVariantProperty.AddOwner(); + + public Application(Avalonia.Application application) { _application = application; @@ -33,9 +36,12 @@ namespace Avalonia.Diagnostics.Controls Lifetimes.ISingleViewApplicationLifetime single => (single.MainView as Visual)?.VisualRoot?.Renderer, _ => null }; + + RequestedThemeVariant = application.RequestedThemeVariant; + _application.PropertyChanged += ApplicationOnPropertyChanged; } - internal App Instance => _application; + internal Avalonia.Application Instance => _application; /// /// Defines the property. @@ -114,5 +120,35 @@ namespace Avalonia.Diagnostics.Controls /// Gets the root of the visual tree, if the control is attached to a visual tree. /// internal Rendering.IRenderer? RendererRoot { get; } + + /// + public ThemeVariant? RequestedThemeVariant + { + get => GetValue(RequestedThemeVariantProperty); + set => SetValue(RequestedThemeVariantProperty, value); + } + + public void Dispose() + { + _application.PropertyChanged -= ApplicationOnPropertyChanged; + } + + private void ApplicationOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == Avalonia.Application.RequestedThemeVariantProperty) + { + RequestedThemeVariant = e.GetNewValue(); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == RequestedThemeVariantProperty) + { + _application.RequestedThemeVariant = change.GetNewValue(); + } + } } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/DevToolsOptions.cs b/src/Avalonia.Diagnostics/Diagnostics/DevToolsOptions.cs index 5fc274a4e9..3cfb0246eb 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/DevToolsOptions.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/DevToolsOptions.cs @@ -1,4 +1,6 @@ -using Avalonia.Input; +using System; +using Avalonia.Input; +using Avalonia.Styling; namespace Avalonia.Diagnostics { @@ -42,8 +44,8 @@ namespace Avalonia.Diagnostics = Conventions.DefaultScreenshotHandler; /// - /// Gets or sets whether DevTools should use the dark mode theme + /// Gets or sets whether DevTools theme. /// - public bool UseDarkMode { get; set; } + public ThemeVariant? ThemeVariant { get; set; } } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs index 8bff9ccde0..1951914273 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs @@ -123,7 +123,8 @@ namespace Avalonia.Diagnostics.ViewModels private static (object resourceKey, bool isDynamic)? GetResourceInfo(object? value) { - if (value is StaticResourceExtension staticResource) + if (value is StaticResourceExtension staticResource + && staticResource.ResourceKey != null) { return (staticResource.ResourceKey, false); } diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml index 6c1da3ec00..748c2cc313 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml @@ -9,9 +9,9 @@ - + - + diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs index dbc4c98f78..4768c88f75 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs @@ -263,13 +263,9 @@ namespace Avalonia.Diagnostics.Views public void SetOptions(DevToolsOptions options) { (DataContext as MainViewModel)?.SetOptions(options); - - if (options.UseDarkMode) + if (options.ThemeVariant is { } themeVariant) { - if (Styles[0] is SimpleTheme st) - { - st.Mode = SimpleThemeMode.Dark; - } + RequestedThemeVariant = themeVariant; } } diff --git a/src/Avalonia.Dialogs/Internal/ResourceSelectorConverter.cs b/src/Avalonia.Dialogs/Internal/ResourceSelectorConverter.cs index a492dfed3a..e46b9276fc 100644 --- a/src/Avalonia.Dialogs/Internal/ResourceSelectorConverter.cs +++ b/src/Avalonia.Dialogs/Internal/ResourceSelectorConverter.cs @@ -9,7 +9,7 @@ namespace Avalonia.Dialogs.Internal { public object Convert(object key, Type targetType, object parameter, CultureInfo culture) { - TryGetResource((string)key, out var value); + TryGetResource((string)key, null, out var value); return value; } diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/StaticResourceExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/StaticResourceExtension.cs index 84b4f3bdba..cdd344becc 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/StaticResourceExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/StaticResourceExtension.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using Avalonia.Controls; using Avalonia.Markup.Data; @@ -7,6 +8,8 @@ using Avalonia.Markup.Xaml.Converters; using Avalonia.Markup.Xaml.XamlIl.Runtime; using Avalonia.Styling; +#nullable enable + namespace Avalonia.Markup.Xaml.MarkupExtensions { public class StaticResourceExtension @@ -20,12 +23,18 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions ResourceKey = resourceKey; } - public object ResourceKey { get; set; } + public object? ResourceKey { get; set; } public object ProvideValue(IServiceProvider serviceProvider) { + if (ResourceKey is not { } resourceKey) + { + throw new ArgumentException("StaticResourceExtension.ResourceKey must be set."); + } + var stack = serviceProvider.GetService(); var provideTarget = serviceProvider.GetService(); + var themeVariant = (provideTarget.TargetObject as StyledElement)?.ActualThemeVariant; var targetType = provideTarget.TargetProperty switch { @@ -36,14 +45,14 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions if (provideTarget.TargetObject is Setter { Property: not null } setter) { - targetType = setter.Property.PropertyType; + targetType = setter.Property?.PropertyType; } // Look upwards though the ambient context for IResourceNodes // which might be able to give us the resource. foreach (var parent in stack.Parents) { - if (parent is IResourceNode node && node.TryGetResource(ResourceKey, out var value)) + if (parent is IResourceNode node && node.TryGetResource(resourceKey, themeVariant, out var value)) { return ColorToBrushConverter.Convert(value, targetType); } @@ -60,12 +69,12 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions return AvaloniaProperty.UnsetValue; } - throw new KeyNotFoundException($"Static resource '{ResourceKey}' not found."); + throw new KeyNotFoundException($"Static resource '{resourceKey}' not found."); } - private object GetValue(StyledElement control, Type targetType) + private object GetValue(StyledElement control, Type? targetType) { - return ColorToBrushConverter.Convert(control.FindResource(ResourceKey), targetType); + return ColorToBrushConverter.Convert(control.FindResource(ResourceKey!), targetType); } } } diff --git a/src/Markup/Avalonia.Markup.Xaml/Styling/ResourceInclude.cs b/src/Markup/Avalonia.Markup.Xaml/Styling/ResourceInclude.cs index 595b37f7d1..4ff105cf1f 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Styling/ResourceInclude.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Styling/ResourceInclude.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using Avalonia.Controls; +using Avalonia.Styling; #nullable enable @@ -74,11 +75,11 @@ namespace Avalonia.Markup.Xaml.Styling remove => Loaded.OwnerChanged -= value; } - bool IResourceNode.TryGetResource(object key, out object? value) + public bool TryGetResource(object key, ThemeVariant? theme, out object? value) { if (!_isLoading) { - return Loaded.TryGetResource(key, out value); + return Loaded.TryGetResource(key, theme, out value); } value = null; diff --git a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs index b87aa64297..27367fce5e 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs @@ -91,11 +91,11 @@ namespace Avalonia.Markup.Xaml.Styling } } - public bool TryGetResource(object key, out object? value) + public bool TryGetResource(object key, ThemeVariant? theme, out object? value) { if (!_isLoading) { - return Loaded.TryGetResource(key, out value); + return Loaded.TryGetResource(key, theme, out value); } value = null;