diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 672616c8e7..4a3305217e 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -141,6 +141,7 @@ BITSPIXEL bla BLACKFRAME BLENDFUNCTION +blittable Blockquotes blt BLURBEHIND @@ -250,6 +251,7 @@ colorformat colorhistory colorhistorylimit COLORKEY +colorref comctl comdlg comexp @@ -1860,8 +1862,10 @@ Uniquifies unitconverter unittests UNLEN +Uninitializes UNORM unremapped +Unsubscribes unvirtualized unwide unzoom diff --git a/Directory.Packages.props b/Directory.Packages.props index 3d64052a21..eb04903b7e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,6 +40,7 @@ + diff --git a/src/common/ManagedCsWin32/CLSID.cs b/src/common/ManagedCsWin32/CLSID.cs index 6087ba575b..00315fe737 100644 --- a/src/common/ManagedCsWin32/CLSID.cs +++ b/src/common/ManagedCsWin32/CLSID.cs @@ -16,4 +16,5 @@ public static partial class CLSID public static readonly Guid CollatorDataSource = new Guid("9E175B8B-F52A-11D8-B9A5-505054503030"); public static readonly Guid ApplicationActivationManager = new Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C"); public static readonly Guid VirtualDesktopManager = new("aa509086-5ca9-4c25-8f95-589d3c07b48a"); + public static readonly Guid DesktopWallpaper = new("C2CF3110-460E-4FC1-B9D0-8A1C0C9CC4BD"); } diff --git a/src/common/ManagedCsWin32/Ole32.cs b/src/common/ManagedCsWin32/Ole32.cs index 20181f3626..cf56c80373 100644 --- a/src/common/ManagedCsWin32/Ole32.cs +++ b/src/common/ManagedCsWin32/Ole32.cs @@ -16,6 +16,12 @@ public static partial class Ole32 CLSCTX dwClsContext, ref Guid riid, out IntPtr rReturnedComObject); + + [LibraryImport("ole32.dll")] + internal static partial int CoInitializeEx(nint pvReserved, uint dwCoInit); + + [LibraryImport("ole32.dll")] + internal static partial void CoUninitialize(); } [Flags] diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs index 2abbd83d3e..16ca5b1fca 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs @@ -14,6 +14,7 @@ using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.Core.ViewModels; public partial class ShellViewModel : ObservableObject, + IDisposable, IRecipient, IRecipient { @@ -460,4 +461,12 @@ public partial class ShellViewModel : ObservableObject, { _navigationCts?.Cancel(); } + + public void Dispose() + { + _handleInvokeTask?.Dispose(); + _navigationCts?.Dispose(); + + GC.SuppressFinalize(this); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs new file mode 100644 index 0000000000..71e150a7d2 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.WinUI; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable +{ + private static readonly ObservableCollection WindowsColorSwatches = [ + + // row 0 + Color.FromArgb(255, 255, 185, 0), // #ffb900 + Color.FromArgb(255, 255, 140, 0), // #ff8c00 + Color.FromArgb(255, 247, 99, 12), // #f7630c + Color.FromArgb(255, 202, 80, 16), // #ca5010 + Color.FromArgb(255, 218, 59, 1), // #da3b01 + Color.FromArgb(255, 239, 105, 80), // #ef6950 + + // row 1 + Color.FromArgb(255, 209, 52, 56), // #d13438 + Color.FromArgb(255, 255, 67, 67), // #ff4343 + Color.FromArgb(255, 231, 72, 86), // #e74856 + Color.FromArgb(255, 232, 17, 35), // #e81123 + Color.FromArgb(255, 234, 0, 94), // #ea005e + Color.FromArgb(255, 195, 0, 82), // #c30052 + + // row 2 + Color.FromArgb(255, 227, 0, 140), // #e3008c + Color.FromArgb(255, 191, 0, 119), // #bf0077 + Color.FromArgb(255, 194, 57, 179), // #c239b3 + Color.FromArgb(255, 154, 0, 137), // #9a0089 + Color.FromArgb(255, 0, 120, 212), // #0078d4 + Color.FromArgb(255, 0, 99, 177), // #0063b1 + + // row 3 + Color.FromArgb(255, 142, 140, 216), // #8e8cd8 + Color.FromArgb(255, 107, 105, 214), // #6b69d6 + Color.FromArgb(255, 135, 100, 184), // #8764b8 + Color.FromArgb(255, 116, 77, 169), // #744da9 + Color.FromArgb(255, 177, 70, 194), // #b146c2 + Color.FromArgb(255, 136, 23, 152), // #881798 + + // row 4 + Color.FromArgb(255, 0, 153, 188), // #0099bc + Color.FromArgb(255, 45, 125, 154), // #2d7d9a + Color.FromArgb(255, 0, 183, 195), // #00b7c3 + Color.FromArgb(255, 3, 131, 135), // #038387 + Color.FromArgb(255, 0, 178, 148), // #00b294 + Color.FromArgb(255, 1, 133, 116), // #018574 + + // row 5 + Color.FromArgb(255, 0, 204, 106), // #00cc6a + Color.FromArgb(255, 16, 137, 62), // #10893e + Color.FromArgb(255, 122, 117, 116), // #7a7574 + Color.FromArgb(255, 93, 90, 88), // #5d5a58 + Color.FromArgb(255, 104, 118, 138), // #68768a + Color.FromArgb(255, 81, 92, 107), // #515c6b + + // row 6 + Color.FromArgb(255, 86, 124, 115), // #567c73 + Color.FromArgb(255, 72, 104, 96), // #486860 + Color.FromArgb(255, 73, 130, 5), // #498205 + Color.FromArgb(255, 16, 124, 16), // #107c10 + Color.FromArgb(255, 118, 118, 118), // #767676 + Color.FromArgb(255, 76, 74, 72), // #4c4a48 + + // row 7 + Color.FromArgb(255, 105, 121, 126), // #69797e + Color.FromArgb(255, 74, 84, 89), // #4a5459 + Color.FromArgb(255, 100, 124, 100), // #647c64 + Color.FromArgb(255, 82, 94, 84), // #525e54 + Color.FromArgb(255, 132, 117, 69), // #847545 + Color.FromArgb(255, 126, 115, 95), // #7e735f + ]; + + private readonly SettingsModel _settings; + private readonly UISettings _uiSettings; + private readonly IThemeService _themeService; + private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread(); + + private ElementTheme? _elementThemeOverride; + private Color _currentSystemAccentColor; + + public ObservableCollection Swatches => WindowsColorSwatches; + + public int ThemeIndex + { + get => (int)_settings.Theme; + set => Theme = (UserTheme)value; + } + + public UserTheme Theme + { + get => _settings.Theme; + set + { + if (_settings.Theme != value) + { + _settings.Theme = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ThemeIndex)); + Save(); + } + } + } + + public ColorizationMode ColorizationMode + { + get => _settings.ColorizationMode; + set + { + if (_settings.ColorizationMode != value) + { + _settings.ColorizationMode = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ColorizationModeIndex)); + OnPropertyChanged(nameof(IsCustomTintVisible)); + OnPropertyChanged(nameof(IsCustomTintIntensityVisible)); + OnPropertyChanged(nameof(IsBackgroundControlsVisible)); + OnPropertyChanged(nameof(IsNoBackgroundVisible)); + OnPropertyChanged(nameof(IsAccentColorControlsVisible)); + + if (value == ColorizationMode.WindowsAccentColor) + { + ThemeColor = _currentSystemAccentColor; + } + + IsColorizationDetailsExpanded = value != ColorizationMode.None; + + Save(); + } + } + } + + public int ColorizationModeIndex + { + get => (int)_settings.ColorizationMode; + set => ColorizationMode = (ColorizationMode)value; + } + + public Color ThemeColor + { + get => _settings.CustomThemeColor; + set + { + if (_settings.CustomThemeColor != value) + { + _settings.CustomThemeColor = value; + + OnPropertyChanged(); + + if (ColorIntensity == 0) + { + ColorIntensity = 100; + } + + Save(); + } + } + } + + public int ColorIntensity + { + get => _settings.CustomThemeColorIntensity; + set + { + _settings.CustomThemeColorIntensity = value; + OnPropertyChanged(); + Save(); + } + } + + public string BackgroundImagePath + { + get => _settings.BackgroundImagePath ?? string.Empty; + set + { + if (_settings.BackgroundImagePath != value) + { + _settings.BackgroundImagePath = value; + OnPropertyChanged(); + + if (BackgroundImageOpacity == 0) + { + BackgroundImageOpacity = 100; + } + + Save(); + } + } + } + + public int BackgroundImageOpacity + { + get => _settings.BackgroundImageOpacity; + set + { + if (_settings.BackgroundImageOpacity != value) + { + _settings.BackgroundImageOpacity = value; + OnPropertyChanged(); + Save(); + } + } + } + + public int BackgroundImageBrightness + { + get => _settings.BackgroundImageBrightness; + set + { + if (_settings.BackgroundImageBrightness != value) + { + _settings.BackgroundImageBrightness = value; + OnPropertyChanged(); + Save(); + } + } + } + + public int BackgroundImageBlurAmount + { + get => _settings.BackgroundImageBlurAmount; + set + { + if (_settings.BackgroundImageBlurAmount != value) + { + _settings.BackgroundImageBlurAmount = value; + OnPropertyChanged(); + Save(); + } + } + } + + public BackgroundImageFit BackgroundImageFit + { + get => _settings.BackgroundImageFit; + set + { + if (_settings.BackgroundImageFit != value) + { + _settings.BackgroundImageFit = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(BackgroundImageFitIndex)); + Save(); + } + } + } + + public int BackgroundImageFitIndex + { + // Naming between UI facing string and enum is a bit confusing, but the enum fields + // are based on XAML Stretch enum values. So I'm choosing to keep the confusion here, close + // to the UI. + // - BackgroundImageFit.Fill corresponds to "Stretch" + // - BackgroundImageFit.UniformToFill corresponds to "Fill" + get => BackgroundImageFit switch + { + BackgroundImageFit.Fill => 1, + _ => 0, + }; + set => BackgroundImageFit = value switch + { + 1 => BackgroundImageFit.Fill, + _ => BackgroundImageFit.UniformToFill, + }; + } + + [ObservableProperty] + public partial bool IsColorizationDetailsExpanded { get; set; } + + public bool IsCustomTintVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image; + + public bool IsCustomTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image; + + public bool IsBackgroundControlsVisible => _settings.ColorizationMode is ColorizationMode.Image; + + public bool IsNoBackgroundVisible => _settings.ColorizationMode is ColorizationMode.None; + + public bool IsAccentColorControlsVisible => _settings.ColorizationMode is ColorizationMode.WindowsAccentColor; + + public AcrylicBackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f); + + public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme; + + public Color EffectiveThemeColor => ColorizationMode switch + { + ColorizationMode.WindowsAccentColor => _currentSystemAccentColor, + ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor, + _ => Colors.Transparent, + }; + + // Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen). + public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f); + + public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0; + + public ImageSource? EffectiveBackgroundImageSource => + ColorizationMode is ColorizationMode.Image + && !string.IsNullOrWhiteSpace(BackgroundImagePath) + && Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri) + ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri) + : null; + + public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings) + { + _themeService = themeService; + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + _settings = settings; + + _uiSettings = new UISettings(); + _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged; + UpdateAccentColor(_uiSettings); + + Reapply(); + + IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None; + } + + private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender)); + + private void UpdateAccentColor(UISettings sender) + { + _currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent); + if (ColorizationMode == ColorizationMode.WindowsAccentColor) + { + ThemeColor = _currentSystemAccentColor; + } + } + + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); + } + + private void Save() + { + SettingsModel.SaveSettings(_settings); + _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); + } + + private void Reapply() + { + // Theme services recalculates effective color and opacity based on current settings. + EffectiveBackdrop = _themeService.Current.BackdropParameters; + OnPropertyChanged(nameof(EffectiveBackdrop)); + OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness)); + OnPropertyChanged(nameof(EffectiveBackgroundImageSource)); + OnPropertyChanged(nameof(EffectiveThemeColor)); + OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount)); + + // LOAD BEARING: + // We need to cycle through the EffectiveTheme property to force reload of resources. + _elementThemeOverride = ElementTheme.Light; + OnPropertyChanged(nameof(EffectiveTheme)); + _elementThemeOverride = ElementTheme.Dark; + OnPropertyChanged(nameof(EffectiveTheme)); + _elementThemeOverride = null; + OnPropertyChanged(nameof(EffectiveTheme)); + } + + [RelayCommand] + private void ResetBackgroundImageProperties() + { + BackgroundImageBrightness = 0; + BackgroundImageBlurAmount = 0; + BackgroundImageFit = BackgroundImageFit.UniformToFill; + BackgroundImageOpacity = 100; + ColorIntensity = 0; + } + + public void Dispose() + { + _uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged; + _themeService.ThemeChanged -= ThemeServiceOnThemeChanged; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs new file mode 100644 index 0000000000..52102df30a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public enum BackgroundImageFit +{ + Fill, + UniformToFill, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs new file mode 100644 index 0000000000..57a65f1882 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public enum ColorizationMode +{ + None, + WindowsAccentColor, + CustomColor, + Image, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000000..140811c784 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class MainWindowViewModel : ObservableObject, IDisposable +{ + private readonly IThemeService _themeService; + private readonly DispatcherQueue _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread()!; + + [ObservableProperty] + public partial ImageSource? BackgroundImageSource { get; private set; } + + [ObservableProperty] + public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill; + + [ObservableProperty] + public partial double BackgroundImageOpacity { get; private set; } + + [ObservableProperty] + public partial Color BackgroundImageTint { get; private set; } + + [ObservableProperty] + public partial double BackgroundImageTintIntensity { get; private set; } + + [ObservableProperty] + public partial int BackgroundImageBlurAmount { get; private set; } + + [ObservableProperty] + public partial double BackgroundImageBrightness { get; private set; } + + [ObservableProperty] + public partial bool ShowBackgroundImage { get; private set; } + + public MainWindowViewModel(IThemeService themeService) + { + _themeService = themeService; + _themeService.ThemeChanged += ThemeService_ThemeChanged; + } + + private void ThemeService_ThemeChanged(object? sender, ThemeChangedEventArgs e) + { + _uiDispatcherQueue.TryEnqueue(() => + { + BackgroundImageSource = _themeService.Current.BackgroundImageSource; + BackgroundImageStretch = _themeService.Current.BackgroundImageStretch; + BackgroundImageOpacity = _themeService.Current.BackgroundImageOpacity; + + BackgroundImageBrightness = _themeService.Current.BackgroundBrightness; + BackgroundImageTint = _themeService.Current.Tint; + BackgroundImageTintIntensity = _themeService.Current.TintIntensity; + BackgroundImageBlurAmount = _themeService.Current.BlurAmount; + + ShowBackgroundImage = BackgroundImageSource != null; + }); + } + + public void Dispose() + { + _themeService.ThemeChanged -= ThemeService_ThemeChanged; + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj index 6b1b018273..1c85aa939b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj @@ -23,11 +23,12 @@ + compile - + compile diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs index be9d103b2d..8bc2a42a92 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -411,6 +411,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } + /// + /// Looks up a localized string similar to Pick background image. + /// + public static string builtin_settings_appearance_pick_background_image_title { + get { + return ResourceManager.GetString("builtin_settings_appearance_pick_background_image_title", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} extensions found. /// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx index 9a658e38f1..bb7637e133 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx @@ -239,4 +239,7 @@ {0} extensions installed + + Pick background image + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs new file mode 100644 index 0000000000..efb7ca1fa1 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +public sealed record AcrylicBackdropParameters(Color TintColor, Color FallbackColor, float TintOpacity, float LuminosityOpacity); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs new file mode 100644 index 0000000000..546742b8f4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Provides theme-related values for the Command Palette and notifies listeners about +/// changes that affect visual appearance (theme, tint, background image, and backdrop). +/// +/// +/// Implementations are expected to monitor system/app theme changes and raise +/// accordingly. Consumers should call +/// once to hook required sources and then query properties/methods for the current visuals. +/// +public interface IThemeService +{ + /// + /// Occurs when the effective theme or any visual-affecting setting changes. + /// + /// + /// Triggered for changes such as app theme (light/dark/default), background image, + /// tint/accent, or backdrop parameters that would require UI to refresh styling. + /// + event EventHandler? ThemeChanged; + + /// + /// Initializes the theme service and starts listening for theme-related changes. + /// + /// + /// Safe to call once during application startup before consuming the service. + /// + void Initialize(); + + /// + /// Gets the current theme settings. + /// + ThemeSnapshot Current { get; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs new file mode 100644 index 0000000000..96197dc376 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Event arguments for theme-related changes. +public class ThemeChangedEventArgs : EventArgs; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs new file mode 100644 index 0000000000..244fd41fba --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Represents a snapshot of theme-related visual settings, including accent color, theme preference, and background +/// image configuration, for use in rendering the Command Palette UI. +/// +public sealed class ThemeSnapshot +{ + /// + /// Gets the accent tint color used by the Command Palette visuals. + /// + public required Color Tint { get; init; } + + /// + /// Gets the accent tint color used by the Command Palette visuals. + /// + public required float TintIntensity { get; init; } + + /// + /// Gets the configured application theme preference. + /// + public required ElementTheme Theme { get; init; } + + /// + /// Gets the image source to render as the background, if any. + /// + /// + /// Returns when no background image is configured. + /// + public required ImageSource? BackgroundImageSource { get; init; } + + /// + /// Gets the stretch mode used to lay out the background image. + /// + public required Stretch BackgroundImageStretch { get; init; } + + /// + /// Gets the opacity applied to the background image. + /// + /// + /// A value in the range [0, 1], where 0 is fully transparent and 1 is fully opaque. + /// + public required double BackgroundImageOpacity { get; init; } + + /// + /// Gets the effective acrylic backdrop parameters based on current settings and theme. + /// + /// The resolved AcrylicBackdropParameters to apply. + public required AcrylicBackdropParameters BackdropParameters { get; init; } + + public required int BlurAmount { get; init; } + + public required float BackgroundBrightness { get; init; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index dae50b3f3e..e210359f76 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -11,7 +11,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.UI; using Windows.Foundation; +using Windows.UI; namespace Microsoft.CmdPal.UI.ViewModels; @@ -62,6 +64,24 @@ public partial class SettingsModel : ObservableObject public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack; + public UserTheme Theme { get; set; } = UserTheme.Default; + + public ColorizationMode ColorizationMode { get; set; } + + public Color CustomThemeColor { get; set; } = Colors.Transparent; + + public int CustomThemeColorIntensity { get; set; } = 100; + + public int BackgroundImageOpacity { get; set; } = 20; + + public int BackgroundImageBlurAmount { get; set; } + + public int BackgroundImageBrightness { get; set; } + + public BackgroundImageFit BackgroundImageFit { get; set; } + + public string? BackgroundImagePath { get; set; } + // END SETTINGS /////////////////////////////////////////////////////////////////////////// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 586670bff7..6ac9acacc4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -4,6 +4,8 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.Extensions.DependencyInjection; @@ -29,6 +31,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged public event PropertyChangedEventHandler? PropertyChanged; + public AppearanceSettingsViewModel Appearance { get; } + public HotkeySettings? Hotkey { get => _settings.Hotkey; @@ -179,6 +183,9 @@ public partial class SettingsViewModel : INotifyPropertyChanged _settings = settings; _serviceProvider = serviceProvider; + var themeService = serviceProvider.GetRequiredService(); + Appearance = new AppearanceSettingsViewModel(themeService, _settings); + var activeProviders = GetCommandProviders(); var allProviderSettings = _settings.ProviderSettings; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs new file mode 100644 index 0000000000..290668f3f5 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public enum UserTheme +{ + Default, + Light, + Dark, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml index f9a9e37ea1..d8d4655291 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml @@ -4,19 +4,23 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.CmdPal.UI.Controls" - xmlns:local="using:Microsoft.CmdPal.UI"> + xmlns:local="using:Microsoft.CmdPal.UI" + xmlns:services="using:Microsoft.CmdPal.UI.Services"> - - - - - - - + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 53f47286b2..a44682218f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -24,9 +24,11 @@ using Microsoft.CmdPal.Ext.WindowsTerminal; using Microsoft.CmdPal.Ext.WindowWalker; using Microsoft.CmdPal.Ext.WinGet; using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Services; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; @@ -112,6 +114,17 @@ public partial class App : Application // Root services services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext()); + AddBuiltInCommands(services); + + AddCoreServices(services); + + AddUIServices(services); + + return services.BuildServiceProvider(); + } + + private static void AddBuiltInCommands(ServiceCollection services) + { // Built-in Commands. Order matters - this is the order they'll be presented by default. var allApps = new AllAppsCommandProvider(); var files = new IndexerCommandsProvider(); @@ -154,17 +167,32 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + } + private static void AddUIServices(ServiceCollection services) + { // Models - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); var sm = SettingsModel.LoadSettings(); services.AddSingleton(sm); var state = AppStateModel.LoadState(); services.AddSingleton(state); - services.AddSingleton(); + + // Services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + } + + private static void AddCoreServices(ServiceCollection services) + { + // Core services + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -174,7 +202,5 @@ public partial class App : Application // ViewModels services.AddSingleton(); services.AddSingleton(); - - return services.BuildServiceProvider(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs new file mode 100644 index 0000000000..743e68d690 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; +using ManagedCommon; +using Microsoft.Graphics.Canvas.Effects; +using Microsoft.UI; +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +internal sealed partial class BlurImageControl : Control +{ + private const string ImageSourceParameterName = "ImageSource"; + + private const string BrightnessEffectName = "Brightness"; + private const string BrightnessOverlayEffectName = "BrightnessOverlay"; + private const string BlurEffectName = "Blur"; + private const string TintBlendEffectName = "TintBlend"; + private const string TintEffectName = "Tint"; + +#pragma warning disable CA1507 // Use nameof to express symbol names ... some of these refer to effect properties that are separate from the class properties + private static readonly string BrightnessSource1AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source1Amount"); + private static readonly string BrightnessSource2AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source2Amount"); + private static readonly string BrightnessOverlayColorEffectProperty = GetPropertyName(BrightnessOverlayEffectName, "Color"); + private static readonly string BlurBlurAmountEffectProperty = GetPropertyName(BlurEffectName, "BlurAmount"); + private static readonly string TintColorEffectProperty = GetPropertyName(TintEffectName, "Color"); +#pragma warning restore CA1507 + + private static readonly string[] AnimatableProperties = [ + BrightnessSource1AmountEffectProperty, + BrightnessSource2AmountEffectProperty, + BrightnessOverlayColorEffectProperty, + BlurBlurAmountEffectProperty, + TintColorEffectProperty + ]; + + public static readonly DependencyProperty ImageSourceProperty = + DependencyProperty.Register( + nameof(ImageSource), + typeof(ImageSource), + typeof(BlurImageControl), + new PropertyMetadata(null, OnImageChanged)); + + public static readonly DependencyProperty ImageStretchProperty = + DependencyProperty.Register( + nameof(ImageStretch), + typeof(Stretch), + typeof(BlurImageControl), + new PropertyMetadata(Stretch.UniformToFill, OnImageStretchChanged)); + + public static readonly DependencyProperty ImageOpacityProperty = + DependencyProperty.Register( + nameof(ImageOpacity), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(1.0, OnOpacityChanged)); + + public static readonly DependencyProperty ImageBrightnessProperty = + DependencyProperty.Register( + nameof(ImageBrightness), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(1.0, OnBrightnessChanged)); + + public static readonly DependencyProperty BlurAmountProperty = + DependencyProperty.Register( + nameof(BlurAmount), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(0.0, OnBlurAmountChanged)); + + public static readonly DependencyProperty TintColorProperty = + DependencyProperty.Register( + nameof(TintColor), + typeof(Color), + typeof(BlurImageControl), + new PropertyMetadata(Colors.Transparent, OnVisualPropertyChanged)); + + public static readonly DependencyProperty TintIntensityProperty = + DependencyProperty.Register( + nameof(TintIntensity), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(0.0, OnVisualPropertyChanged)); + + private Compositor? _compositor; + private SpriteVisual? _effectVisual; + private CompositionEffectBrush? _effectBrush; + private CompositionSurfaceBrush? _imageBrush; + + public BlurImageControl() + { + this.DefaultStyleKey = typeof(BlurImageControl); + this.Loaded += OnLoaded; + this.SizeChanged += OnSizeChanged; + } + + public ImageSource ImageSource + { + get => (ImageSource)GetValue(ImageSourceProperty); + set => SetValue(ImageSourceProperty, value); + } + + public Stretch ImageStretch + { + get => (Stretch)GetValue(ImageStretchProperty); + set => SetValue(ImageStretchProperty, value); + } + + public double ImageOpacity + { + get => (double)GetValue(ImageOpacityProperty); + set => SetValue(ImageOpacityProperty, value); + } + + public double ImageBrightness + { + get => (double)GetValue(ImageBrightnessProperty); + set => SetValue(ImageBrightnessProperty, Math.Clamp(value, -1, 1)); + } + + public double BlurAmount + { + get => (double)GetValue(BlurAmountProperty); + set => SetValue(BlurAmountProperty, value); + } + + public Color TintColor + { + get => (Color)GetValue(TintColorProperty); + set => SetValue(TintColorProperty, value); + } + + public double TintIntensity + { + get => (double)GetValue(TintIntensityProperty); + set => SetValue(TintIntensityProperty, value); + } + + private static void OnImageStretchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._imageBrush != null) + { + control._imageBrush.Stretch = ConvertStretch((Stretch)e.NewValue); + } + } + + private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._compositor != null) + { + control.UpdateEffect(); + } + } + + private static void OnOpacityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectVisual != null) + { + control._effectVisual.Opacity = (float)(double)e.NewValue; + } + } + + private static void OnBlurAmountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectBrush != null) + { + control.UpdateEffect(); + } + } + + private static void OnBrightnessChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectBrush != null) + { + control.UpdateEffect(); + } + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + InitializeComposition(); + } + + private void OnSizeChanged(object sender, SizeChangedEventArgs e) + { + if (_effectVisual != null) + { + _effectVisual.Size = new Vector2( + (float)Math.Max(1, e.NewSize.Width), + (float)Math.Max(1, e.NewSize.Height)); + } + } + + private static void OnImageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not BlurImageControl control) + { + return; + } + + control.EnsureEffect(force: true); + control.UpdateEffect(); + } + + private void InitializeComposition() + { + var visual = ElementCompositionPreview.GetElementVisual(this); + _compositor = visual.Compositor; + + _effectVisual = _compositor.CreateSpriteVisual(); + _effectVisual.Size = new Vector2( + (float)Math.Max(1, ActualWidth), + (float)Math.Max(1, ActualHeight)); + _effectVisual.Opacity = (float)ImageOpacity; + + ElementCompositionPreview.SetElementChildVisual(this, _effectVisual); + + UpdateEffect(); + } + + private void EnsureEffect(bool force = false) + { + if (_compositor is null) + { + return; + } + + if (_effectBrush is not null && !force) + { + return; + } + + var imageSource = new CompositionEffectSourceParameter(ImageSourceParameterName); + + // 1) Brightness via ArithmeticCompositeEffect + // We blend between the original image and either black or white, + // depending on whether we want to darken or brighten. BrightnessEffect isn't supported + // in the composition graph. + var brightnessEffect = new ArithmeticCompositeEffect + { + Name = BrightnessEffectName, + Source1 = imageSource, // original image + Source2 = new ColorSourceEffect + { + Name = BrightnessOverlayEffectName, + Color = Colors.Black, // we'll swap black/white via properties + }, + + MultiplyAmount = 0.0f, + Source1Amount = 1.0f, // original + Source2Amount = 0.0f, // overlay + Offset = 0.0f, + }; + + // 2) Blur + var blurEffect = new GaussianBlurEffect + { + Name = BlurEffectName, + BlurAmount = 0.0f, + BorderMode = EffectBorderMode.Hard, + Optimization = EffectOptimization.Balanced, + Source = brightnessEffect, + }; + + // 3) Tint (always in the chain; intensity via alpha) + var tintEffect = new BlendEffect + { + Name = TintBlendEffectName, + Background = blurEffect, + Foreground = new ColorSourceEffect + { + Name = TintEffectName, + Color = Colors.Transparent, + }, + Mode = BlendEffectMode.Multiply, + }; + + var effectFactory = _compositor.CreateEffectFactory(tintEffect, AnimatableProperties); + + _effectBrush?.Dispose(); + _effectBrush = effectFactory.CreateBrush(); + + // Set initial source + if (ImageSource is not null) + { + _imageBrush ??= _compositor.CreateSurfaceBrush(); + LoadImageAsync(ImageSource); + _effectBrush.SetSourceParameter(ImageSourceParameterName, _imageBrush); + } + else + { + _effectBrush.SetSourceParameter(ImageSourceParameterName, _compositor.CreateBackdropBrush()); + } + + if (_effectVisual is not null) + { + _effectVisual.Brush = _effectBrush; + } + } + + private void UpdateEffect() + { + if (_compositor is null) + { + return; + } + + EnsureEffect(); + if (_effectBrush is null) + { + return; + } + + var props = _effectBrush.Properties; + + // Brightness + var b = (float)Math.Clamp(ImageBrightness, -1.0, 1.0); + + float source1Amount; + float source2Amount; + Color overlayColor; + + if (b >= 0) + { + // Brighten: blend towards white + overlayColor = Colors.White; + source1Amount = 1.0f - b; // original image contribution + source2Amount = b; // white overlay contribution + } + else + { + // Darken: blend towards black + overlayColor = Colors.Black; + var t = -b; // 0..1 + source1Amount = 1.0f - t; // original image + source2Amount = t; // black overlay + } + + props.InsertScalar(BrightnessSource1AmountEffectProperty, source1Amount); + props.InsertScalar(BrightnessSource2AmountEffectProperty, source2Amount); + props.InsertColor(BrightnessOverlayColorEffectProperty, overlayColor); + + // Blur + props.InsertScalar(BlurBlurAmountEffectProperty, (float)BlurAmount); + + // Tint + var tintColor = TintColor; + var clampedIntensity = (float)Math.Clamp(TintIntensity, 0.0, 1.0); + + var adjustedColor = Color.FromArgb( + (byte)(clampedIntensity * 255), + tintColor.R, + tintColor.G, + tintColor.B); + + props.InsertColor(TintColorEffectProperty, adjustedColor); + } + + private void LoadImageAsync(ImageSource imageSource) + { + try + { + if (imageSource is Microsoft.UI.Xaml.Media.Imaging.BitmapImage bitmapImage) + { + _imageBrush ??= _compositor?.CreateSurfaceBrush(); + if (_imageBrush is null) + { + return; + } + + var loadedSurface = LoadedImageSurface.StartLoadFromUri(bitmapImage.UriSource); + loadedSurface.LoadCompleted += (_, _) => + { + if (_imageBrush is not null) + { + _imageBrush.Surface = loadedSurface; + _imageBrush.Stretch = ConvertStretch(ImageStretch); + _imageBrush.BitmapInterpolationMode = CompositionBitmapInterpolationMode.Linear; + } + }; + + _effectBrush?.SetSourceParameter(ImageSourceParameterName, _imageBrush); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to load image for BlurImageControl: {0}", ex); + } + } + + private static CompositionStretch ConvertStretch(Stretch stretch) + { + return stretch switch + { + Stretch.None => CompositionStretch.None, + Stretch.Fill => CompositionStretch.Fill, + Stretch.Uniform => CompositionStretch.Uniform, + Stretch.UniformToFill => CompositionStretch.UniformToFill, + _ => CompositionStretch.UniformToFill, + }; + } + + private static string GetPropertyName(string effectName, string propertyName) => $"{effectName}.{propertyName}"; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml new file mode 100644 index 0000000000..105010bbd2 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs new file mode 100644 index 0000000000..7267e894fa --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ColorPalette : UserControl +{ + public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection), typeof(ColorPalette), null!)!; + + public static readonly DependencyProperty CustomPaletteColumnCountProperty = DependencyProperty.Register(nameof(CustomPaletteColumnCount), typeof(int), typeof(ColorPalette), new PropertyMetadata(10))!; + + public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPalette), new PropertyMetadata(null!))!; + + public event EventHandler? SelectedColorChanged; + + private Color? _selectedColor; + + public Color? SelectedColor + { + get => _selectedColor; + + set + { + if (_selectedColor != value) + { + _selectedColor = value; + if (value is not null) + { + SetValue(SelectedColorProperty, value); + } + else + { + ClearValue(SelectedColorProperty); + } + } + } + } + + public ObservableCollection PaletteColors + { + get => (ObservableCollection)GetValue(PaletteColorsProperty)!; + set => SetValue(PaletteColorsProperty, value); + } + + public int CustomPaletteColumnCount + { + get => (int)GetValue(CustomPaletteColumnCountProperty); + set => SetValue(CustomPaletteColumnCountProperty, value); + } + + public ColorPalette() + { + PaletteColors = []; + InitializeComponent(); + } + + private void ListViewBase_OnItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is Color color) + { + SelectedColor = color; + SelectedColorChanged?.Invoke(this, color); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml new file mode 100644 index 0000000000..92a556f7a7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs new file mode 100644 index 0000000000..ff82fffd4e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using ManagedCommon; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ColorPickerButton : UserControl +{ + public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection), typeof(ColorPickerButton), new PropertyMetadata(new ObservableCollection()))!; + + public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPickerButton), new PropertyMetadata(Colors.Black))!; + + public static readonly DependencyProperty IsAlphaEnabledProperty = DependencyProperty.Register(nameof(IsAlphaEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(defaultValue: false))!; + + public static readonly DependencyProperty IsValueEditorEnabledProperty = DependencyProperty.Register(nameof(IsValueEditorEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!; + + public static readonly DependencyProperty HasSelectedColorProperty = DependencyProperty.Register(nameof(HasSelectedColor), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!; + + private Color _selectedColor; + + public Color SelectedColor + { + get + { + return _selectedColor; + } + + set + { + if (_selectedColor != value) + { + _selectedColor = value; + SetValue(SelectedColorProperty, value); + HasSelectedColor = true; + } + } + } + + public bool HasSelectedColor + { + get { return (bool)GetValue(HasSelectedColorProperty); } + set { SetValue(HasSelectedColorProperty, value); } + } + + public bool IsAlphaEnabled + { + get => (bool)GetValue(IsAlphaEnabledProperty); + set => SetValue(IsAlphaEnabledProperty, value); + } + + public bool IsValueEditorEnabled + { + get { return (bool)GetValue(IsValueEditorEnabledProperty); } + set { SetValue(IsValueEditorEnabledProperty, value); } + } + + public ObservableCollection PaletteColors + { + get { return (ObservableCollection)GetValue(PaletteColorsProperty); } + set { SetValue(PaletteColorsProperty, value); } + } + + public ColorPickerButton() + { + this.InitializeComponent(); + + IsEnabledChanged -= ColorPickerButton_IsEnabledChanged; + SetEnabledState(); + IsEnabledChanged += ColorPickerButton_IsEnabledChanged; + } + + private void ColorPickerButton_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + SetEnabledState(); + } + + private void SetEnabledState() + { + if (this.IsEnabled) + { + ColorPreviewBorder.Opacity = 1; + } + else + { + ColorPreviewBorder.Opacity = 0.2; + } + } + + private void ColorPalette_OnSelectedColorChanged(object? sender, Color? e) + { + if (e.HasValue) + { + HasSelectedColor = true; + SelectedColor = e.Value; + } + } + + private void FlyoutBase_OnOpened(object? sender, object e) + { + if (sender is not Flyout flyout || (flyout.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter) + { + return; + } + + FlyoutRoot!.UpdateLayout(); + flyoutPresenter.UpdateLayout(); + + // Logger.LogInfo($"FlyoutBase_OnOpened: {flyoutPresenter}, {FlyoutRoot!.ActualWidth}"); + flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth; + flyoutPresenter.MinWidth = 660; + flyoutPresenter.Width = FlyoutRoot!.ActualWidth; + } + + private void FlyoutRoot_OnSizeChanged(object sender, SizeChangedEventArgs e) + { + if ((ColorPickerFlyout!.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter) + { + return; + } + + FlyoutRoot!.UpdateLayout(); + flyoutPresenter.UpdateLayout(); + + flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth; + flyoutPresenter.MinWidth = 660; + flyoutPresenter.Width = FlyoutRoot!.ActualWidth; + } + + private Thickness ToDropDownPadding(bool hasColor) + { + return hasColor ? new Thickness(3, 3, 8, 3) : new Thickness(8, 4, 8, 4); + } + + private void ResetButton_Click(object sender, RoutedEventArgs e) + { + HasSelectedColor = false; + ColorPickerFlyout?.Hide(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml new file mode 100644 index 0000000000..a30d1fafdf --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs new file mode 100644 index 0000000000..96cd5d6aac --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class CommandPalettePreview : UserControl +{ + public static readonly DependencyProperty PreviewBackgroundOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d)); + + public static readonly DependencyProperty PreviewBackgroundColorProperty = DependencyProperty.Register(nameof(PreviewBackgroundColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color))); + + public static readonly DependencyProperty PreviewBackgroundImageSourceProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageSource), typeof(ImageSource), typeof(CommandPalettePreview), new PropertyMetadata(null, PropertyChangedCallback)); + + public static readonly DependencyProperty PreviewBackgroundImageOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageOpacity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0)); + + public static readonly DependencyProperty PreviewBackgroundImageFitProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageFit), typeof(BackgroundImageFit), typeof(CommandPalettePreview), new PropertyMetadata(default(BackgroundImageFit))); + + public static readonly DependencyProperty PreviewBackgroundImageBrightnessProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBrightness), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d)); + + public static readonly DependencyProperty PreviewBackgroundImageBlurAmountProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBlurAmount), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d)); + + public static readonly DependencyProperty PreviewBackgroundImageTintProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTint), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color))); + + public static readonly DependencyProperty PreviewBackgroundImageTintIntensityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTintIntensity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0)); + + public static readonly DependencyProperty ShowBackgroundImageProperty = DependencyProperty.Register(nameof(ShowBackgroundImage), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed)); + + public BackgroundImageFit PreviewBackgroundImageFit + { + get { return (BackgroundImageFit)GetValue(PreviewBackgroundImageFitProperty); } + set { SetValue(PreviewBackgroundImageFitProperty, value); } + } + + public double PreviewBackgroundOpacity + { + get { return (double)GetValue(PreviewBackgroundOpacityProperty); } + set { SetValue(PreviewBackgroundOpacityProperty, value); } + } + + public Color PreviewBackgroundColor + { + get { return (Color)GetValue(PreviewBackgroundColorProperty); } + set { SetValue(PreviewBackgroundColorProperty, value); } + } + + public ImageSource PreviewBackgroundImageSource + { + get { return (ImageSource)GetValue(PreviewBackgroundImageSourceProperty); } + set { SetValue(PreviewBackgroundImageSourceProperty, value); } + } + + public int PreviewBackgroundImageOpacity + { + get { return (int)GetValue(PreviewBackgroundImageOpacityProperty); } + set { SetValue(PreviewBackgroundImageOpacityProperty, value); } + } + + public double PreviewBackgroundImageBrightness + { + get => (double)GetValue(PreviewBackgroundImageBrightnessProperty); + set => SetValue(PreviewBackgroundImageBrightnessProperty, value); + } + + public double PreviewBackgroundImageBlurAmount + { + get => (double)GetValue(PreviewBackgroundImageBlurAmountProperty); + set => SetValue(PreviewBackgroundImageBlurAmountProperty, value); + } + + public Color PreviewBackgroundImageTint + { + get => (Color)GetValue(PreviewBackgroundImageTintProperty); + set => SetValue(PreviewBackgroundImageTintProperty, value); + } + + public int PreviewBackgroundImageTintIntensity + { + get => (int)GetValue(PreviewBackgroundImageTintIntensityProperty); + set => SetValue(PreviewBackgroundImageTintIntensityProperty, value); + } + + public Visibility ShowBackgroundImage + { + get => (Visibility)GetValue(ShowBackgroundImageProperty); + set => SetValue(ShowBackgroundImageProperty, value); + } + + public CommandPalettePreview() + { + InitializeComponent(); + } + + private static void PropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not CommandPalettePreview preview) + { + return; + } + + preview.ShowBackgroundImage = e.NewValue is ImageSource ? Visibility.Visible : Visibility.Collapsed; + } + + private double ToOpacity(int value) => value / 100.0; + + private double ToTintIntensity(int value) => value / 100.0; + + private Stretch ToStretch(BackgroundImageFit fit) + { + return fit switch + { + BackgroundImageFit.Fill => Stretch.Fill, + BackgroundImageFit.UniformToFill => Stretch.UniformToFill, + _ => Stretch.None, + }; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml new file mode 100644 index 0000000000..58c4e890a6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs new file mode 100644 index 0000000000..828fa76c74 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; + +namespace Microsoft.CmdPal.UI.Controls; + +[ContentProperty(Name = nameof(PreviewContent))] +public sealed partial class ScreenPreview : UserControl +{ + public static readonly DependencyProperty PreviewContentProperty = + DependencyProperty.Register(nameof(PreviewContent), typeof(object), typeof(ScreenPreview), new PropertyMetadata(null!))!; + + public object PreviewContent + { + get => GetValue(PreviewContentProperty)!; + set => SetValue(PreviewContentProperty, value); + } + + public ScreenPreview() + { + InitializeComponent(); + + var wallpaperHelper = new WallpaperHelper(); + WallpaperImage!.Source = wallpaperHelper.GetWallpaperImage()!; + ScreenBorder!.Background = new SolidColorBrush(wallpaperHelper.GetWallpaperColor()); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml index 80eb1a3ad6..d248c24f89 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml @@ -4,9 +4,8 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:cmdpalUi="using:Microsoft.CmdPal.UI" - xmlns:converters="using:CommunityToolkit.WinUI.Converters" - xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:h="using:Microsoft.CmdPal.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> @@ -22,6 +21,7 @@ MinHeight="32" VerticalAlignment="Stretch" VerticalContentAlignment="Stretch" + h:TextBoxCaretColor.SyncWithForeground="True" AutomationProperties.AutomationId="MainSearchBox" KeyDown="FilterBox_KeyDown" PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs new file mode 100644 index 0000000000..5f54682aaf --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Converters; + +/// +/// Gets a color, either black or white, depending on the brightness of the supplied color. +/// +public sealed partial class ContrastBrushConverter : IValueConverter +{ + /// + /// Gets or sets the alpha channel threshold below which a default color is used instead of black/white. + /// + public byte AlphaThreshold { get; set; } = 128; + + /// + public object Convert( + object value, + Type targetType, + object parameter, + string language) + { + Color comparisonColor; + Color? defaultColor = null; + + // Get the changing color to compare against + if (value is Color valueColor) + { + comparisonColor = valueColor; + } + else if (value is SolidColorBrush valueBrush) + { + comparisonColor = valueBrush.Color; + } + else + { + // Invalid color value provided + return DependencyProperty.UnsetValue; + } + + // Get the default color when transparency is high + if (parameter is Color parameterColor) + { + defaultColor = parameterColor; + } + else if (parameter is SolidColorBrush parameterBrush) + { + defaultColor = parameterBrush.Color; + } + + if (comparisonColor.A < AlphaThreshold && + defaultColor.HasValue) + { + // If the transparency is less than 50 %, just use the default brush + // This can commonly be something like the TextControlForeground brush + return new SolidColorBrush(defaultColor.Value); + } + else + { + // Chose a white/black brush based on contrast to the base color + return UseLightContrastColor(comparisonColor) + ? new SolidColorBrush(Colors.White) + : new SolidColorBrush(Colors.Black); + } + } + + /// + public object ConvertBack( + object value, + Type targetType, + object parameter, + string language) + { + return DependencyProperty.UnsetValue; + } + + /// + /// Determines whether a light or dark contrast color should be used with the given displayed color. + /// + /// + /// This code is using the WinUI algorithm. + /// + private bool UseLightContrastColor(Color displayedColor) + { + // The selection ellipse should be light if and only if the chosen color + // contrasts more with black than it does with white. + // To find how much something contrasts with white, we use the equation + // for relative luminance, which is given by + // + // L = 0.2126 * Rg + 0.7152 * Gg + 0.0722 * Bg + // + // where Xg = { X/3294 if X <= 10, (R/269 + 0.0513)^2.4 otherwise } + // + // If L is closer to 1, then the color is closer to white; if it is closer to 0, + // then the color is closer to black. This is based on the fact that the human + // eye perceives green to be much brighter than red, which in turn is perceived to be + // brighter than blue. + // + // If the third dimension is value, then we won't be updating the spectrum's displayed colors, + // so in that case we should use a value of 1 when considering the backdrop + // for the selection ellipse. + var rg = displayedColor.R <= 10 + ? displayedColor.R / 3294.0 + : Math.Pow((displayedColor.R / 269.0) + 0.0513, 2.4); + var gg = displayedColor.G <= 10 + ? displayedColor.G / 3294.0 + : Math.Pow((displayedColor.G / 269.0) + 0.0513, 2.4); + var bg = displayedColor.B <= 10 + ? displayedColor.B / 3294.0 + : Math.Pow((displayedColor.B / 269.0) + 0.0513, 2.4); + + return (0.2126 * rg) + (0.7152 * gg) + (0.0722 * bg) <= 0.5; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs index 012e8dc789..f00c230da5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs @@ -10,6 +10,8 @@ internal static class BindTransformers { public static bool Negate(bool value) => !value; + public static Visibility NegateVisibility(Visibility value) => value == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; + public static Visibility EmptyToCollapsed(string? input) => string.IsNullOrEmpty(input) ? Visibility.Collapsed : Visibility.Visible; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs new file mode 100644 index 0000000000..2492f7f7c9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Helpers; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// +/// Extension methods for . +/// +internal static class ColorExtensions +{ + /// Input color. + public static double CalculateBrightness(this Color color) + { + return color.ToHsv().V; + } + + /// + /// Allows to change the brightness by a factor based on the HSV color space. + /// + /// Input color. + /// The brightness adjustment factor, ranging from -1 to 1. + /// Updated color. + public static Color UpdateBrightness(this Color color, double brightnessFactor) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1); + + var hsvColor = color.ToHsv(); + return ColorHelper.FromHsv(hsvColor.H, hsvColor.S, Math.Clamp(hsvColor.V + brightnessFactor, 0, 1), hsvColor.A); + } + + /// + /// Updates the color by adjusting brightness, saturation, and luminance factors. + /// + /// Input color. + /// The brightness adjustment factor, ranging from -1 to 1. + /// The saturation adjustment factor, ranging from -1 to 1. Defaults to 0. + /// The luminance adjustment factor, ranging from -1 to 1. Defaults to 0. + /// Updated color. + public static Color Update(this Color color, double brightnessFactor, double saturationFactor = 0, double luminanceFactor = 0) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(saturationFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(saturationFactor, -1); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(luminanceFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(luminanceFactor, -1); + + var hsv = color.ToHsv(); + + var rgb = ColorHelper.FromHsv( + hsv.H, + Clamp01(hsv.S + saturationFactor), + Clamp01(hsv.V + brightnessFactor)); + + if (luminanceFactor == 0) + { + return rgb; + } + + var hsl = rgb.ToHsl(); + var lightness = Clamp01(hsl.L + luminanceFactor); + return ColorHelper.FromHsl(hsl.H, hsl.S, lightness); + } + + /// + /// Linearly interpolates between two colors in HSV space. + /// Hue is blended along the shortest arc on the color wheel (wrap-aware). + /// Saturation, Value, and Alpha are blended linearly. + /// + /// Start color. + /// End color. + /// Interpolation factor in [0,1]. + /// Interpolated color. + public static Color LerpHsv(this Color a, Color b, double t) + { + t = Clamp01(t); + + // Convert to HSV + var hslA = a.ToHsv(); + var hslB = b.ToHsv(); + + var h1 = hslA.H; + var h2 = hslB.H; + + // Handle near-gray hues (undefined hue) by inheriting the other's hue + const double satEps = 1e-4f; + if (hslA.S < satEps && hslB.S >= satEps) + { + h1 = h2; + } + else if (hslB.S < satEps && hslA.S >= satEps) + { + h2 = h1; + } + + return ColorHelper.FromHsv( + hue: LerpHueDegrees(h1, h2, t), + saturation: Lerp(hslA.S, hslB.S, t), + value: Lerp(hslA.V, hslB.V, t), + alpha: (byte)Math.Round(Lerp(hslA.A, hslB.A, t))); + } + + private static double LerpHueDegrees(double a, double b, double t) + { + a = Mod360(a); + b = Mod360(b); + var delta = ((b - a + 540f) % 360f) - 180f; + return Mod360(a + (delta * t)); + } + + private static double Mod360(double angle) + { + angle %= 360f; + if (angle < 0f) + { + angle += 360f; + } + + return angle; + } + + private static double Lerp(double a, double b, double t) => a + ((b - a) * t); + + private static double Clamp01(double x) => Math.Clamp(x, 0, 1); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs new file mode 100644 index 0000000000..f5103b9efc --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using CommunityToolkit.WinUI; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Rectangle = Microsoft.UI.Xaml.Shapes.Rectangle; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// +/// Attached property to color internal caret/overlay rectangles inside a TextBox +/// so they follow the TextBox's actual Foreground brush. +/// +public static class TextBoxCaretColor +{ + public static readonly DependencyProperty SyncWithForegroundProperty = + DependencyProperty.RegisterAttached("SyncWithForeground", typeof(bool), typeof(TextBoxCaretColor), new PropertyMetadata(false, OnSyncCaretRectanglesChanged))!; + + private static readonly ConditionalWeakTable States = []; + + public static void SetSyncWithForeground(DependencyObject obj, bool value) + { + obj.SetValue(SyncWithForegroundProperty, value); + } + + public static bool GetSyncWithForeground(DependencyObject obj) + { + return (bool)obj.GetValue(SyncWithForegroundProperty); + } + + private static void OnSyncCaretRectanglesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not TextBox tb) + { + return; + } + + if ((bool)e.NewValue) + { + Attach(tb); + } + else + { + Detach(tb); + } + } + + private static void Attach(TextBox tb) + { + if (States.TryGetValue(tb, out var st) && st.IsHooked) + { + return; + } + + st ??= new State(); + st.IsHooked = true; + States.Remove(tb); + States.Add(tb, st); + + tb.Loaded += TbOnLoaded; + tb.Unloaded += TbOnUnloaded; + tb.GotFocus += TbOnGotFocus; + + st.ForegroundToken = tb.RegisterPropertyChangedCallback(Control.ForegroundProperty!, (_, _) => Apply(tb)); + + if (tb.IsLoaded) + { + Apply(tb); + } + } + + private static void Detach(TextBox tb) + { + if (!States.TryGetValue(tb, out var st)) + { + return; + } + + tb.Loaded -= TbOnLoaded; + tb.Unloaded -= TbOnUnloaded; + tb.GotFocus -= TbOnGotFocus; + + if (st.ForegroundToken != 0) + { + tb.UnregisterPropertyChangedCallback(Control.ForegroundProperty!, st.ForegroundToken); + st.ForegroundToken = 0; + } + + st.IsHooked = false; + } + + private static void TbOnLoaded(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Apply(tb); + } + } + + private static void TbOnUnloaded(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Detach(tb); + } + } + + private static void TbOnGotFocus(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Apply(tb); + } + } + + private static void Apply(TextBox tb) + { + try + { + ApplyCore(tb); + } + catch (COMException) + { + // ignore + } + } + + private static void ApplyCore(TextBox tb) + { + // Ensure template is realized + tb.ApplyTemplate(); + + // Find the internal ScrollContentPresenter within the TextBox template + var scp = tb.FindDescendant(s => s.Name == "ScrollContentPresenter"); + if (scp is null) + { + return; + } + + var brush = tb.Foreground; // use the actual current foreground brush + if (brush == null) + { + brush = new SolidColorBrush(Colors.Black); + } + + foreach (var rect in scp.FindDescendants().OfType()) + { + try + { + rect.Fill = brush; + rect.CompositeMode = ElementCompositeMode.SourceOver; + rect.Opacity = 0.9; + } + catch + { + // best-effort; some rectangles might be template-owned + } + } + } + + private sealed class State + { + public long ForegroundToken { get; set; } + + public bool IsHooked { get; set; } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs new file mode 100644 index 0000000000..9772d33b1d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using ManagedCommon; +using ManagedCsWin32; +using Microsoft.UI; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// +/// Lightweight helper to access wallpaper information. +/// +internal sealed partial class WallpaperHelper +{ + private readonly IDesktopWallpaper? _desktopWallpaper; + + public WallpaperHelper() + { + try + { + var desktopWallpaper = ComHelper.CreateComInstance( + ref Unsafe.AsRef(in CLSID.DesktopWallpaper), + CLSCTX.ALL); + + _desktopWallpaper = desktopWallpaper; + } + catch (Exception ex) + { + // If COM initialization fails, keep helper usable with safe fallbacks + Logger.LogError("Failed to initialize DesktopWallpaper COM interface", ex); + _desktopWallpaper = null; + } + } + + private string? GetWallpaperPathForFirstMonitor() + { + try + { + if (_desktopWallpaper is null) + { + return null; + } + + _desktopWallpaper.GetMonitorDevicePathCount(out var monitorCount); + + for (uint i = 0; monitorCount != 0 && i < monitorCount; i++) + { + _desktopWallpaper.GetMonitorDevicePathAt(i, out var monitorId); + if (string.IsNullOrEmpty(monitorId)) + { + continue; + } + + _desktopWallpaper.GetWallpaper(monitorId, out var wallpaperPath); + + if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath)) + { + return wallpaperPath; + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to query wallpaper path", ex); + } + + return null; + } + + /// + /// Gets the wallpaper background color. + /// + /// The wallpaper background color, or black if it cannot be determined. + public Color GetWallpaperColor() + { + try + { + if (_desktopWallpaper is null) + { + return Colors.Black; + } + + _desktopWallpaper.GetBackgroundColor(out var colorref); + var r = (byte)(colorref.Value & 0x000000FF); + var g = (byte)((colorref.Value & 0x0000FF00) >> 8); + var b = (byte)((colorref.Value & 0x00FF0000) >> 16); + return Color.FromArgb(255, r, g, b); + } + catch (Exception ex) + { + Logger.LogError("Failed to load wallpaper color", ex); + return Colors.Black; + } + } + + /// + /// Gets the wallpaper image for the primary monitor. + /// + /// The wallpaper image, or null if it cannot be determined. + public BitmapImage? GetWallpaperImage() + { + try + { + var path = GetWallpaperPathForFirstMonitor(); + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var image = new BitmapImage(); + using var stream = File.OpenRead(path); + var randomAccessStream = stream.AsRandomAccessStream(); + if (randomAccessStream == null) + { + Logger.LogError("Failed to convert file stream to RandomAccessStream for wallpaper image."); + return null; + } + + image.SetSource(randomAccessStream); + return image; + } + catch (Exception ex) + { + Logger.LogError("Failed to load wallpaper image", ex); + return null; + } + } + + // blittable type for COM interop + [StructLayout(LayoutKind.Sequential)] + internal readonly partial struct COLORREF + { + internal readonly uint Value; + } + + // blittable type for COM interop + [StructLayout(LayoutKind.Sequential)] + internal readonly partial struct RECT + { + internal readonly int Left; + internal readonly int Top; + internal readonly int Right; + internal readonly int Bottom; + } + + // COM interface for IDesktopWallpaper, GeneratedComInterface to be AOT compatible + [GeneratedComInterface] + [Guid("B92B56A9-8B55-4E14-9A89-0199BBB6F93B")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal partial interface IDesktopWallpaper + { + void SetWallpaper( + [MarshalAs(UnmanagedType.LPWStr)] string? monitorId, + [MarshalAs(UnmanagedType.LPWStr)] string wallpaper); + + void GetWallpaper( + [MarshalAs(UnmanagedType.LPWStr)] string? monitorId, + [MarshalAs(UnmanagedType.LPWStr)] out string wallpaper); + + void GetMonitorDevicePathAt(uint monitorIndex, [MarshalAs(UnmanagedType.LPWStr)] out string monitorId); + + void GetMonitorDevicePathCount(out uint count); + + void GetMonitorRECT([MarshalAs(UnmanagedType.LPWStr)] string? monitorId, out RECT rect); + + void SetBackgroundColor(COLORREF color); + + void GetBackgroundColor(out COLORREF color); + + // Other methods omitted for brevity + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml index c0c0ab811f..32329e17a0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml @@ -2,6 +2,7 @@ x:Class="Microsoft.CmdPal.UI.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pages="using:Microsoft.CmdPal.UI.Pages" @@ -15,6 +16,21 @@ Closed="MainWindow_Closed" mc:Ignorable="d"> + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index 1655626714..d9acdb48d9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -15,8 +15,10 @@ using Microsoft.CmdPal.UI.Controls; using Microsoft.CmdPal.UI.Events; using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.Messages; +using Microsoft.CmdPal.UI.Services; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; using Microsoft.UI; @@ -66,7 +68,10 @@ public sealed partial class MainWindow : WindowEx, private readonly KeyboardListener _keyboardListener; private readonly LocalKeyboardListener _localKeyboardListener; private readonly HiddenOwnerWindowBehavior _hiddenOwnerBehavior = new(); + private readonly IThemeService _themeService; + private readonly WindowThemeSynchronizer _windowThemeSynchronizer; private bool _ignoreHotKeyWhenFullScreen = true; + private bool _themeServiceInitialized; private DesktopAcrylicController? _acrylicController; private SystemBackdropConfiguration? _configurationSource; @@ -74,13 +79,21 @@ public sealed partial class MainWindow : WindowEx, private WindowPosition _currentWindowPosition = new(); + private MainWindowViewModel ViewModel { get; } + public MainWindow() { InitializeComponent(); + ViewModel = App.Current.Services.GetService()!; + _autoGoHomeTimer = new DispatcherTimer(); _autoGoHomeTimer.Tick += OnAutoGoHomeTimerOnTick; + _themeService = App.Current.Services.GetRequiredService(); + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + _windowThemeSynchronizer = new WindowThemeSynchronizer(_themeService, this); + _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); unsafe @@ -88,6 +101,8 @@ public sealed partial class MainWindow : WindowEx, CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value); } + SetAcrylic(); + _hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached); _keyboardListener = new KeyboardListener(); @@ -100,8 +115,6 @@ public sealed partial class MainWindow : WindowEx, RestoreWindowPosition(); UpdateWindowPositionInMemory(); - SetAcrylic(); - WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -156,6 +169,11 @@ public sealed partial class MainWindow : WindowEx, WeakReferenceMessenger.Default.Send(new GoHomeMessage(WithAnimation: false, FocusSearch: false)); } + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + UpdateAcrylic(); + } + private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) { if (e.Key == VirtualKey.GoBack) @@ -247,8 +265,6 @@ public sealed partial class MainWindow : WindowEx, _autoGoHomeTimer.Interval = _autoGoHomeInterval; } - // We want to use DesktopAcrylicKind.Thin and custom colors as this is the default material - // other Shell surfaces are using, this cannot be set in XAML however. private void SetAcrylic() { if (DesktopAcrylicController.IsSupported()) @@ -265,41 +281,32 @@ public sealed partial class MainWindow : WindowEx, private void UpdateAcrylic() { - if (_acrylicController != null) + try { - _acrylicController.RemoveAllSystemBackdropTargets(); - _acrylicController.Dispose(); - } - - _acrylicController = GetAcrylicConfig(Content); - - // Enable the system backdrop. - // Note: Be sure to have "using WinRT;" to support the Window.As<...>() call. - _acrylicController.AddSystemBackdropTarget(this.As()); - _acrylicController.SetSystemBackdropConfiguration(_configurationSource); - } - - private static DesktopAcrylicController GetAcrylicConfig(UIElement content) - { - var feContent = content as FrameworkElement; - - return feContent?.ActualTheme == ElementTheme.Light - ? new DesktopAcrylicController() + if (_acrylicController != null) { - Kind = DesktopAcrylicKind.Thin, - TintColor = Color.FromArgb(255, 243, 243, 243), - LuminosityOpacity = 0.90f, - TintOpacity = 0.0f, - FallbackColor = Color.FromArgb(255, 238, 238, 238), + _acrylicController.RemoveAllSystemBackdropTargets(); + _acrylicController.Dispose(); } - : new DesktopAcrylicController() + + var backdrop = _themeService.Current.BackdropParameters; + _acrylicController = new DesktopAcrylicController { - Kind = DesktopAcrylicKind.Thin, - TintColor = Color.FromArgb(255, 32, 32, 32), - LuminosityOpacity = 0.96f, - TintOpacity = 0.5f, - FallbackColor = Color.FromArgb(255, 28, 28, 28), + TintColor = backdrop.TintColor, + TintOpacity = backdrop.TintOpacity, + FallbackColor = backdrop.FallbackColor, + LuminosityOpacity = backdrop.LuminosityOpacity, }; + + // Enable the system backdrop. + // Note: Be sure to have "using WinRT;" to support the Window.As<...>() call. + _acrylicController.AddSystemBackdropTarget(this.As()); + _acrylicController.SetSystemBackdropConfiguration(_configurationSource); + } + catch (Exception ex) + { + Logger.LogError("Failed to update backdrop", ex); + } } private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target) @@ -711,6 +718,19 @@ public sealed partial class MainWindow : WindowEx, internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args) { + if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated) + { + try + { + _themeService.Initialize(); + _themeServiceInitialized = true; + } + catch (Exception ex) + { + Logger.LogError("Failed to initialize ThemeService", ex); + } + } + if (args.WindowActivationState == WindowActivationState.Deactivated) { // Save the current window position before hiding the window @@ -1004,6 +1024,7 @@ public sealed partial class MainWindow : WindowEx, public void Dispose() { _localKeyboardListener.Dispose(); + _windowThemeSynchronizer.Dispose(); DisposeAcrylic(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj index 8397ffc767..54961a5828 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj @@ -68,8 +68,11 @@ + + + @@ -78,10 +81,12 @@ + + @@ -93,6 +98,7 @@ + @@ -207,6 +213,39 @@ + + + MSBuild:Compile + + + + + + MSBuild:Compile + + + + + + MSBuild:Compile + + + + + + Designer + + + MSBuild:Compile + + + + + + MSBuild:Compile + + + MSBuild:Compile diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index fe1a29dd97..04b4ca6c16 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -11,7 +11,6 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:h="using:Microsoft.CmdPal.UI.Helpers" xmlns:help="using:Microsoft.CmdPal.UI.Helpers" - xmlns:labToolkit="using:CommunityToolkit.Labs.WinUI.MarkdownTextBlock" xmlns:markdownImageProviders="using:Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" @@ -177,7 +176,7 @@ - + @@ -190,7 +189,7 @@ Padding="0,12,0,12" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderBrush="{ThemeResource CmdPal.TopBarBorderBrush}" BorderThickness="0,0,0,1"> @@ -390,7 +389,7 @@ HorizontalAlignment="Stretch" ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderBrush="{ThemeResource CmdPal.DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="{StaticResource ControlCornerRadius}" Visibility="Collapsed"> @@ -518,7 +517,7 @@ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs new file mode 100644 index 0000000000..fe6c8e48e0 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Helpers; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Provides theme appropriate for colorful (accented) appearance. +/// +internal sealed class ColorfulThemeProvider : IThemeProvider +{ + // Fluent dark: #202020 + private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32); + + // Fluent light: #F3F3F3 + private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243); + + private readonly UISettings _uiSettings; + + public string ThemeKey => "colorful"; + + public string ResourcePath => "ms-appx:///Styles/Theme.Colorful.xaml"; + + public ColorfulThemeProvider(UISettings uiSettings) + { + ArgumentNullException.ThrowIfNull(uiSettings); + _uiSettings = uiSettings; + } + + public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context) + { + var isLight = context.Theme == ElementTheme.Light || + (context.Theme == ElementTheme.Default && + _uiSettings.GetColorValue(UIColorType.Background).R > 128); + + var baseColor = isLight ? LightBaseColor : DarkBaseColor; + + // Windows is warping the hue of accent colors and running it through some curves to produce their accent shades. + // This will attempt to mimic that behavior. + var accentShades = AccentShades.Compute(context.Tint.LerpHsv(WindowsAccentHueWarpTransform.Transform(context.Tint), 0.5f)); + var blended = isLight ? accentShades.Light3 : accentShades.Dark2; + var colorIntensityUser = (context.ColorIntensity ?? 100) / 100f; + + // For light theme, we want to reduce intensity a bit, and also we need to keep the color fairly light, + // to avoid issues with text box caret. + var colorIntensity = isLight ? 0.6f * colorIntensityUser : colorIntensityUser; + var effectiveBgColor = ColorBlender.Blend(baseColor, blended, colorIntensity); + + return new AcrylicBackdropParameters(effectiveBgColor, effectiveBgColor, 0.8f, 0.8f); + } + + private static class ColorBlender + { + /// + /// Blends a semitransparent tint color over an opaque base color using alpha compositing. + /// + /// The opaque base color (background) + /// The semitransparent tint color (foreground) + /// The intensity of the tint (0.0 - 1.0) + /// The resulting blended color + public static Color Blend(Color baseColor, Color tintColor, float intensity) + { + // Normalize alpha to 0.0 - 1.0 range + intensity = Math.Clamp(intensity, 0f, 1f); + + // Alpha compositing formula: result = tint * alpha + base * (1 - alpha) + var r = (byte)((tintColor.R * intensity) + (baseColor.R * (1 - intensity))); + var g = (byte)((tintColor.G * intensity) + (baseColor.G * (1 - intensity))); + var b = (byte)((tintColor.B * intensity) + (baseColor.B * (1 - intensity))); + + // Result is fully opaque since base is opaque + return Color.FromArgb(255, r, g, b); + } + } + + private static class WindowsAccentHueWarpTransform + { + private static readonly (double HIn, double HOut)[] HueMap = + [ + (0, 0), + (10, 1), + (20, 6), + (30, 10), + (40, 14), + (50, 19), + (60, 36), + (70, 94), + (80, 112), + (90, 120), + (100, 120), + (110, 120), + (120, 120), + (130, 120), + (140, 120), + (150, 125), + (160, 135), + (170, 142), + (180, 178), + (190, 205), + (200, 220), + (210, 229), + (220, 237), + (230, 241), + (240, 243), + (250, 244), + (260, 245), + (270, 248), + (280, 252), + (290, 276), + (300, 293), + (310, 313), + (320, 330), + (330, 349), + (340, 353), + (350, 357) + ]; + + public static Color Transform(Color input, Options? opt = null) + { + opt ??= new Options(); + var hsv = input.ToHsv(); + return ColorHelper.FromHsv( + RemapHueLut(hsv.H), + Clamp01(Math.Pow(hsv.S, opt.SaturationGamma) * opt.SaturationGain), + Clamp01((opt.ValueScaleA * hsv.V) + opt.ValueBiasB), + input.A); + } + + // Hue LUT remap (piecewise-linear with cyclic wrap) + private static double RemapHueLut(double hDeg) + { + // Normalize to [0,360) + hDeg = Mod(hDeg, 360.0); + + // Handle wrap-around case: hDeg is between last entry (350°) and 360° + var last = HueMap[^1]; + var first = HueMap[0]; + if (hDeg >= last.HIn) + { + // Interpolate between last entry and first entry (wrapped by 360°) + var t = (hDeg - last.HIn) / (first.HIn + 360.0 - last.HIn + 1e-12); + var ho = Lerp(last.HOut, first.HOut + 360.0, t); + return Mod(ho, 360.0); + } + + // Find segment [i, i+1] where HueMap[i].HIn <= hDeg < HueMap[i+1].HIn + for (var i = 0; i < HueMap.Length - 1; i++) + { + var a = HueMap[i]; + var b = HueMap[i + 1]; + + if (hDeg >= a.HIn && hDeg < b.HIn) + { + var t = (hDeg - a.HIn) / (b.HIn - a.HIn + 1e-12); + return Lerp(a.HOut, b.HOut, t); + } + } + + // Fallback (shouldn't happen) + return hDeg; + } + + private static double Lerp(double a, double b, double t) => a + ((b - a) * t); + + private static double Mod(double x, double m) => ((x % m) + m) % m; + + private static double Clamp01(double x) => x < 0 ? 0 : (x > 1 ? 1 : x); + + public sealed class Options + { + // Saturation boost (1.0 = no change). Typical: 1.3–1.8 + public double SaturationGain { get; init; } = 1.0; + + // Optional saturation gamma (1.0 = linear). <1.0 raises low S a bit; >1.0 preserves low S. + public double SaturationGamma { get; init; } = 1.0; + + // Value (V) remap: V' = a*V + b (tone curve; clamp applied) + // Example that lifts blacks & compresses whites slightly: a=0.50, b=0.08 + public double ValueScaleA { get; init; } = 0.6; + + public double ValueBiasB { get; init; } = 0.01; + } + } + + private static class AccentShades + { + public static (Color Light3, Color Light2, Color Light1, Color Dark1, Color Dark2, Color Dark3) Compute(Color accent) + { + var light1 = accent.Update(brightnessFactor: 0.15, saturationFactor: -0.12); + var light2 = accent.Update(brightnessFactor: 0.30, saturationFactor: -0.24); + var light3 = accent.Update(brightnessFactor: 0.45, saturationFactor: -0.36); + + var dark1 = accent.UpdateBrightness(brightnessFactor: -0.05f); + var dark2 = accent.UpdateBrightness(brightnessFactor: -0.01f); + var dark3 = accent.UpdateBrightness(brightnessFactor: -0.015f); + + return (light3, light2, light1, dark1, dark2, dark3); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs new file mode 100644 index 0000000000..a9411c3656 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Services; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Provides theme identification, resource path resolution, and creation of acrylic +/// backdrop parameters based on the current . +/// +/// +/// Implementations should expose a stable and a valid XAML resource +/// dictionary path via . The +/// method computes +/// using the supplied theme context. +/// +internal interface IThemeProvider +{ + /// + /// Gets the unique key identifying this theme provider. + /// + string ThemeKey { get; } + + /// + /// Gets the resource dictionary path for this theme. + /// + string ResourcePath { get; } + + /// + /// Creates acrylic backdrop parameters based on the provided theme context. + /// + /// The current theme context, including theme, tint, and optional background details. + /// The computed for the backdrop. + AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs new file mode 100644 index 0000000000..8177326259 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Dedicated ResourceDictionary for dynamic overrides that win over base theme resources. Since +/// we can't use a key or name to identify the dictionary in Application resources, we use a dedicated type. +/// +internal sealed partial class MutableOverridesDictionary : ResourceDictionary; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs new file mode 100644 index 0000000000..c393894346 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Provides theme resources and acrylic backdrop parameters matching the default Command Palette theme. +/// +internal sealed class NormalThemeProvider : IThemeProvider +{ + private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32); + private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243); + private readonly UISettings _uiSettings; + + public NormalThemeProvider(UISettings uiSettings) + { + ArgumentNullException.ThrowIfNull(uiSettings); + _uiSettings = uiSettings; + } + + public string ThemeKey => "normal"; + + public string ResourcePath => "ms-appx:///Styles/Theme.Normal.xaml"; + + public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context) + { + var isLight = context.Theme == ElementTheme.Light || + (context.Theme == ElementTheme.Default && + _uiSettings.GetColorValue(UIColorType.Background).R > 128); + + return new AcrylicBackdropParameters( + TintColor: isLight ? LightBaseColor : DarkBaseColor, + FallbackColor: isLight ? LightBaseColor : DarkBaseColor, + TintOpacity: 0.5f, + LuminosityOpacity: isLight ? 0.9f : 0.96f); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs new file mode 100644 index 0000000000..6d0a6f01dd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Simple theme switcher that swaps application ResourceDictionaries at runtime. +/// Can also operate in event-only mode for consumers to apply resources themselves. +/// Exposes a dedicated override dictionary that stays merged and is cleared on theme changes. +/// +internal sealed partial class ResourceSwapper +{ + private readonly Lock _resourceSwapGate = new(); + private readonly Dictionary _themeUris = new(StringComparer.OrdinalIgnoreCase); + private ResourceDictionary? _activeDictionary; + private string? _currentThemeName; + private Uri? _currentThemeUri; + + private ResourceDictionary? _overrideDictionary; + + /// + /// Raised after a theme has been activated. + /// + public event EventHandler? ResourcesSwapped; + + /// + /// Gets or sets a value indicating whether when true (default) ResourceSwapper updates Application.Current.Resources. When false, it only raises ResourcesSwapped. + /// + public bool ApplyToAppResources { get; set; } = true; + + /// + /// Gets name of the currently selected theme (if any). + /// + public string? CurrentThemeName + { + get + { + lock (_resourceSwapGate) + { + return _currentThemeName; + } + } + } + + /// + /// Initializes ResourceSwapper by checking Application resources for an already merged theme dictionary. + /// + public void Initialize() + { + // Find merged dictionary in Application resources that matches a registered theme by URI + // This allows ResourceSwapper to pick up an initial theme set in XAML + var app = Application.Current; + var resourcesMergedDictionaries = app?.Resources?.MergedDictionaries; + if (resourcesMergedDictionaries == null) + { + return; + } + + foreach (var dict in resourcesMergedDictionaries) + { + var uri = dict.Source; + if (uri is null) + { + continue; + } + + var name = GetNameForUri(uri); + if (name is null) + { + continue; + } + + lock (_resourceSwapGate) + { + _currentThemeName = name; + _currentThemeUri = uri; + _activeDictionary = dict; + } + + break; + } + } + + /// + /// Gets uri of the currently selected theme dictionary (if any). + /// + public Uri? CurrentThemeUri + { + get + { + lock (_resourceSwapGate) + { + return _currentThemeUri; + } + } + } + + public static ResourceDictionary GetOverrideDictionary(bool clear = false) + { + var app = Application.Current ?? throw new InvalidOperationException("App is null"); + + if (app.Resources == null) + { + throw new InvalidOperationException("Application.Resources is null"); + } + + // (Re)locate the slot – Hot Reload may rebuild Application.Resources. + var slot = app.Resources!.MergedDictionaries! + .OfType() + .FirstOrDefault(); + + if (slot is null) + { + // If the slot vanished (Hot Reload), create it again at the end so it wins precedence. + slot = new MutableOverridesDictionary(); + app.Resources.MergedDictionaries!.Add(slot); + } + + // Ensure the slot has exactly one child RD we can swap safely. + if (slot.MergedDictionaries!.Count == 0) + { + slot.MergedDictionaries.Add(new ResourceDictionary()); + } + else if (slot.MergedDictionaries.Count > 1) + { + // Normalize to a single child to keep semantics predictable. + var keep = slot.MergedDictionaries[^1]; + slot.MergedDictionaries.Clear(); + slot.MergedDictionaries.Add(keep); + } + + if (clear) + { + // Swap the child dictionary instead of Clear() to avoid reentrancy issues. + var fresh = new ResourceDictionary(); + slot.MergedDictionaries[0] = fresh; + return fresh; + } + + return slot.MergedDictionaries[0]!; + } + + /// + /// Registers a theme name mapped to a XAML ResourceDictionary URI (e.g. ms-appx:///Themes/Red.xaml) + /// + public void RegisterTheme(string name, Uri dictionaryUri) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Theme name is required", nameof(name)); + } + + lock (_resourceSwapGate) + { + _themeUris[name] = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri)); + } + } + + /// + /// Registers a theme with a string URI. + /// + public void RegisterTheme(string name, string dictionaryUri) + { + ArgumentNullException.ThrowIfNull(dictionaryUri); + RegisterTheme(name, new Uri(dictionaryUri)); + } + + /// + /// Removes a previously registered theme. + /// + public bool UnregisterTheme(string name) + { + lock (_resourceSwapGate) + { + return _themeUris.Remove(name); + } + } + + /// + /// Gets the names of all registered themes. + /// + public IEnumerable GetRegisteredThemes() + { + lock (_resourceSwapGate) + { + // return a copy to avoid external mutation + return new List(_themeUris.Keys); + } + } + + /// + /// Activates a theme by name. The dictionary for the given name must be registered first. + /// + public void ActivateTheme(string theme) + { + if (string.IsNullOrWhiteSpace(theme)) + { + throw new ArgumentException("Theme name is required", nameof(theme)); + } + + Uri uri; + lock (_resourceSwapGate) + { + if (!_themeUris.TryGetValue(theme, out uri!)) + { + throw new KeyNotFoundException($"Theme '{theme}' is not registered."); + } + } + + ActivateThemeInternal(theme, uri); + } + + /// + /// Tries to activate a theme by name without throwing. + /// + public bool TryActivateTheme(string theme) + { + if (string.IsNullOrWhiteSpace(theme)) + { + return false; + } + + Uri uri; + lock (_resourceSwapGate) + { + if (!_themeUris.TryGetValue(theme, out uri!)) + { + return false; + } + } + + ActivateThemeInternal(theme, uri); + return true; + } + + /// + /// Activates a theme by URI to a ResourceDictionary. + /// + public void ActivateTheme(Uri dictionaryUri) + { + ArgumentNullException.ThrowIfNull(dictionaryUri); + + ActivateThemeInternal(GetNameForUri(dictionaryUri), dictionaryUri); + } + + /// + /// Clears the currently active theme ResourceDictionary. Also clears the override dictionary. + /// + public void ClearActiveTheme() + { + lock (_resourceSwapGate) + { + var app = Application.Current; + if (app is null) + { + return; + } + + if (_activeDictionary is not null && ApplyToAppResources) + { + _ = app.Resources.MergedDictionaries.Remove(_activeDictionary); + _activeDictionary = null; + } + + // Clear overrides but keep the override dictionary merged for future updates + _overrideDictionary?.Clear(); + + _currentThemeName = null; + _currentThemeUri = null; + } + } + + private void ActivateThemeInternal(string? name, Uri dictionaryUri) + { + lock (_resourceSwapGate) + { + _currentThemeName = name; + _currentThemeUri = dictionaryUri; + } + + if (ApplyToAppResources) + { + ActivateThemeCore(dictionaryUri); + } + + OnResourcesSwapped(new(name, dictionaryUri)); + } + + private void ActivateThemeCore(Uri dictionaryUri) + { + var app = Application.Current ?? throw new InvalidOperationException("Application.Current is null"); + + // Remove previously applied base theme dictionary + if (_activeDictionary is not null) + { + _ = app.Resources.MergedDictionaries.Remove(_activeDictionary); + _activeDictionary = null; + } + + // Load and merge the new base theme dictionary + var newDict = new ResourceDictionary { Source = dictionaryUri }; + app.Resources.MergedDictionaries.Add(newDict); + _activeDictionary = newDict; + + // Ensure override dictionary exists and is merged last, then clear it to avoid leaking stale overrides + _overrideDictionary = GetOverrideDictionary(clear: true); + } + + private string? GetNameForUri(Uri dictionaryUri) + { + lock (_resourceSwapGate) + { + foreach (var (key, value) in _themeUris) + { + if (Uri.Compare(value, dictionaryUri, UriComponents.AbsoluteUri, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) + { + return key; + } + } + + return null; + } + } + + private void OnResourcesSwapped(ResourcesSwappedEventArgs e) + { + ResourcesSwapped?.Invoke(this, e); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs new file mode 100644 index 0000000000..0a5cc15de6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Services; + +public sealed class ResourcesSwappedEventArgs(string? name, Uri dictionaryUri) : EventArgs +{ + public string? Name { get; } = name; + + public Uri DictionaryUri { get; } = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri)); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs new file mode 100644 index 0000000000..67432c8748 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Services; + +internal sealed record ThemeContext +{ + public ElementTheme Theme { get; init; } + + public Color Tint { get; init; } + + public ImageSource? BackgroundImageSource { get; init; } + + public Stretch BackgroundImageStretch { get; init; } + + public double BackgroundImageOpacity { get; init; } + + public int? ColorIntensity { get; init; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs new file mode 100644 index 0000000000..65fbfb24d7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI; +using ManagedCommon; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.UI.ViewManagement; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// ThemeService is a hub that translates user settings and system preferences into concrete +/// theme resources and notifies listeners of changes. +/// +internal sealed partial class ThemeService : IThemeService, IDisposable +{ + private static readonly TimeSpan ReloadDebounceInterval = TimeSpan.FromMilliseconds(500); + + private readonly UISettings _uiSettings; + private readonly SettingsModel _settings; + private readonly ResourceSwapper _resourceSwapper; + private readonly NormalThemeProvider _normalThemeProvider; + private readonly ColorfulThemeProvider _colorfulThemeProvider; + + private DispatcherQueue? _dispatcherQueue; + private DispatcherQueueTimer? _dispatcherQueueTimer; + private bool _isInitialized; + private bool _disposed; + private InternalThemeState _currentState; + + public event EventHandler? ThemeChanged; + + public ThemeSnapshot Current => Volatile.Read(ref _currentState).Snapshot; + + /// + /// Initializes the theme service. Must be called after the application window is activated and on UI thread. + /// + public void Initialize() + { + if (_isInitialized) + { + return; + } + + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + if (_dispatcherQueue is null) + { + throw new InvalidOperationException("Failed to get DispatcherQueue for the current thread. Ensure Initialize is called on the UI thread after window activation."); + } + + _dispatcherQueueTimer = _dispatcherQueue.CreateTimer(); + + _resourceSwapper.Initialize(); + _isInitialized = true; + Reload(); + } + + private void Reload() + { + if (!_isInitialized) + { + return; + } + + // provider selection + var intensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100); + IThemeProvider provider = intensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image + ? _colorfulThemeProvider + : _normalThemeProvider; + + // Calculate values + var tint = _settings.ColorizationMode switch + { + ColorizationMode.CustomColor => _settings.CustomThemeColor, + ColorizationMode.WindowsAccentColor => _uiSettings.GetColorValue(UIColorType.Accent), + ColorizationMode.Image => _settings.CustomThemeColor, + _ => Colors.Transparent, + }; + var effectiveTheme = GetElementTheme((ElementTheme)_settings.Theme); + var imageSource = _settings.ColorizationMode == ColorizationMode.Image + ? LoadImageSafe(_settings.BackgroundImagePath) + : null; + var stretch = _settings.BackgroundImageFit switch + { + BackgroundImageFit.Fill => Stretch.Fill, + _ => Stretch.UniformToFill, + }; + var opacity = Math.Clamp(_settings.BackgroundImageOpacity, 0, 100) / 100.0; + + // create context and offload to actual theme provider + var context = new ThemeContext + { + Tint = tint, + ColorIntensity = intensity, + Theme = effectiveTheme, + BackgroundImageSource = imageSource, + BackgroundImageStretch = stretch, + BackgroundImageOpacity = opacity, + }; + var backdrop = provider.GetAcrylicBackdrop(context); + var blur = _settings.BackgroundImageBlurAmount; + var brightness = _settings.BackgroundImageBrightness; + + // Create public snapshot (no provider!) + var snapshot = new ThemeSnapshot + { + Tint = tint, + TintIntensity = intensity / 100f, + Theme = effectiveTheme, + BackgroundImageSource = imageSource, + BackgroundImageStretch = stretch, + BackgroundImageOpacity = opacity, + BackdropParameters = backdrop, + BlurAmount = blur, + BackgroundBrightness = brightness / 100f, + }; + + // Bundle with provider for internal use + var newState = new InternalThemeState + { + Snapshot = snapshot, + Provider = provider, + }; + + // Atomic swap + Interlocked.Exchange(ref _currentState, newState); + + _resourceSwapper.TryActivateTheme(provider.ThemeKey); + ThemeChanged?.Invoke(this, new ThemeChangedEventArgs()); + } + + private static BitmapImage? LoadImageSafe(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + // If it looks like a file path and exists, prefer absolute file URI + if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri)) + { + return null; + } + + if (!uri.IsAbsoluteUri && File.Exists(path)) + { + uri = new Uri(Path.GetFullPath(path)); + } + + return new BitmapImage(uri); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to load background image '{path}'. {ex.Message}"); + return null; + } + } + + public ThemeService(SettingsModel settings, ResourceSwapper resourceSwapper) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(resourceSwapper); + + _settings = settings; + _settings.SettingsChanged += SettingsOnSettingsChanged; + + _resourceSwapper = resourceSwapper; + + _uiSettings = new UISettings(); + _uiSettings.ColorValuesChanged += UiSettings_ColorValuesChanged; + + _normalThemeProvider = new NormalThemeProvider(_uiSettings); + _colorfulThemeProvider = new ColorfulThemeProvider(_uiSettings); + List providers = [_normalThemeProvider, _colorfulThemeProvider]; + + foreach (var provider in providers) + { + _resourceSwapper.RegisterTheme(provider.ThemeKey, provider.ResourcePath); + } + + _currentState = new InternalThemeState + { + Snapshot = new ThemeSnapshot + { + Tint = Colors.Transparent, + Theme = ElementTheme.Light, + BackdropParameters = new AcrylicBackdropParameters(Colors.Black, Colors.Black, 0.5f, 0.5f), + BackgroundImageOpacity = 1, + BackgroundImageSource = null, + BackgroundImageStretch = Stretch.Fill, + BlurAmount = 0, + TintIntensity = 1.0f, + BackgroundBrightness = 0, + }, + Provider = _normalThemeProvider, + }; + } + + private void RequestReload() + { + if (!_isInitialized || _dispatcherQueueTimer is null) + { + return; + } + + _dispatcherQueueTimer.Debounce(Reload, ReloadDebounceInterval); + } + + private ElementTheme GetElementTheme(ElementTheme theme) + { + return theme switch + { + ElementTheme.Light => ElementTheme.Light, + ElementTheme.Dark => ElementTheme.Dark, + _ => _uiSettings.GetColorValue(UIColorType.Background).CalculateBrightness() < 0.5 + ? ElementTheme.Dark + : ElementTheme.Light, + }; + } + + private void SettingsOnSettingsChanged(SettingsModel sender, object? args) + { + RequestReload(); + } + + private void UiSettings_ColorValuesChanged(UISettings sender, object args) + { + RequestReload(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _dispatcherQueueTimer?.Stop(); + _uiSettings.ColorValuesChanged -= UiSettings_ColorValuesChanged; + _settings.SettingsChanged -= SettingsOnSettingsChanged; + } + + private sealed class InternalThemeState + { + public required ThemeSnapshot Snapshot { get; init; } + + public required IThemeProvider Provider { get; init; } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs new file mode 100644 index 0000000000..5c250b94ef --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Synchronizes a window's theme with . +/// +internal sealed partial class WindowThemeSynchronizer : IDisposable +{ + private readonly IThemeService _themeService; + private readonly Window _window; + + /// + /// Initializes a new instance of the class and subscribes to theme changes. + /// + /// The theme service to monitor for changes. + /// The window to synchronize. + /// Thrown when or is null. + public WindowThemeSynchronizer(IThemeService themeService, Window window) + { + _themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); + _window = window ?? throw new ArgumentNullException(nameof(window)); + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + } + + /// + /// Unsubscribes from theme change events. + /// + public void Dispose() + { + _themeService.ThemeChanged -= ThemeServiceOnThemeChanged; + } + + /// + /// Applies the current theme to the window when theme changes occur. + /// + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + if (_window.Content is not FrameworkElement fe) + { + return; + } + + var dispatcherQueue = fe.DispatcherQueue; + + if (dispatcherQueue is not null && dispatcherQueue.HasThreadAccess) + { + ApplyRequestedTheme(fe); + } + else + { + dispatcherQueue?.TryEnqueue(() => ApplyRequestedTheme(fe)); + } + } + + private void ApplyRequestedTheme(FrameworkElement fe) + { + // LOAD BEARING: Changing the RequestedTheme to Dark then Light then target forces + // a refresh of the theme. + fe.RequestedTheme = ElementTheme.Dark; + fe.RequestedTheme = ElementTheme.Light; + fe.RequestedTheme = _themeService.Current.Theme; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml new file mode 100644 index 0000000000..b9f31d8443 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml @@ -0,0 +1,209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +