mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-06 03:07:04 +02:00
CmdPal: Light, dark, pink, and unicorns (#43505)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
This PR introduces user settings for app mode themes (dark, light, or
system) and background customization options, including custom colors,
system accent colors, or custom images.
- Adds a new page to the Settings window with new appearance settings
and moves some existing settings there as well.
- Introduces a new core-level service abstraction, `IThemeService`, that
holds the state for the current theme.
- Uses the helper class `ResourceSwapper` to update application-level
XAML resources. The way WinUI / XAML handles these is painful, and XAML
Hot Reload is pain². Initialization must be lazy, as XAML resources can
only be accessed after the window is activated.
- `ThemeService` takes app and system settings and selects one of the
registered `IThemeProvider`s to calculate visuals and choose the
appropriate XAML resources.
- At the moment, there are two:
- `NormalThemeProvider`
- Provides the current uncolorized light and dark styles
- `ms-appx:///Styles/Theme.Normal.xaml`
- `ColorfulThemeProvider`
- Style that matches the Windows 11 visual style (based on the Start
menu) and colors
- `ms-appx:///Styles/Theme.Colorful.xaml`
- Applied when the background is colorized or a background image is
selected
- The app theme is applied only on the main window
(`WindowThemeSynchronizer` helper class can be used to synchronize other
windows if needed).
- Adds a new dependency on `Microsoft.Graphics.Win2D`.
- Adds a custom color picker popup; the one from the Community Toolkit
occasionally loses the selected color.
- Flyby: separates the keyword tag and localizable label for pages in
the Settings window navigation.
## Pictures? Pictures!
<img width="2027" height="1276" alt="image"
src="https://github.com/user-attachments/assets/e3485c71-7faa-495b-b455-b313ea6046ee"
/>
<img width="3776" height="2025" alt="image"
src="https://github.com/user-attachments/assets/820fa823-34d4-426d-b066-b1049dc3266f"
/>
Matching Windows accent color and tint:
<img width="3840" height="2160" alt="image"
src="https://github.com/user-attachments/assets/65f3b608-e282-4894-b7c8-e014a194f11f"
/>
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist
- [x] Closes: #38444
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx
<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments
<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
---------
Co-authored-by: Niels Laute <niels.laute@live.nl>
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides theme appropriate for colorful (accented) appearance.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Blends a semitransparent tint color over an opaque base color using alpha compositing.
|
||||
/// </summary>
|
||||
/// <param name="baseColor">The opaque base color (background)</param>
|
||||
/// <param name="tintColor">The semitransparent tint color (foreground)</param>
|
||||
/// <param name="intensity">The intensity of the tint (0.0 - 1.0)</param>
|
||||
/// <returns>The resulting blended color</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides theme identification, resource path resolution, and creation of acrylic
|
||||
/// backdrop parameters based on the current <see cref="ThemeContext"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations should expose a stable <see cref="ThemeKey"/> and a valid XAML resource
|
||||
/// dictionary path via <see cref="ResourcePath"/>. The
|
||||
/// <see cref="GetAcrylicBackdrop(ThemeContext)"/> method computes
|
||||
/// <see cref="AcrylicBackdropParameters"/> using the supplied theme context.
|
||||
/// </remarks>
|
||||
internal interface IThemeProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique key identifying this theme provider.
|
||||
/// </summary>
|
||||
string ThemeKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resource dictionary path for this theme.
|
||||
/// </summary>
|
||||
string ResourcePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates acrylic backdrop parameters based on the provided theme context.
|
||||
/// </summary>
|
||||
/// <param name="context">The current theme context, including theme, tint, and optional background details.</param>
|
||||
/// <returns>The computed <see cref="AcrylicBackdropParameters"/> for the backdrop.</returns>
|
||||
AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed partial class MutableOverridesDictionary : ResourceDictionary;
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides theme resources and acrylic backdrop parameters matching the default Command Palette theme.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed partial class ResourceSwapper
|
||||
{
|
||||
private readonly Lock _resourceSwapGate = new();
|
||||
private readonly Dictionary<string, Uri> _themeUris = new(StringComparer.OrdinalIgnoreCase);
|
||||
private ResourceDictionary? _activeDictionary;
|
||||
private string? _currentThemeName;
|
||||
private Uri? _currentThemeUri;
|
||||
|
||||
private ResourceDictionary? _overrideDictionary;
|
||||
|
||||
/// <summary>
|
||||
/// Raised after a theme has been activated.
|
||||
/// </summary>
|
||||
public event EventHandler<ResourcesSwappedEventArgs>? ResourcesSwapped;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether when true (default) ResourceSwapper updates Application.Current.Resources. When false, it only raises ResourcesSwapped.
|
||||
/// </summary>
|
||||
public bool ApplyToAppResources { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets name of the currently selected theme (if any).
|
||||
/// </summary>
|
||||
public string? CurrentThemeName
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_resourceSwapGate)
|
||||
{
|
||||
return _currentThemeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes ResourceSwapper by checking Application resources for an already merged theme dictionary.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets uri of the currently selected theme dictionary (if any).
|
||||
/// </summary>
|
||||
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<MutableOverridesDictionary>()
|
||||
.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]!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a theme name mapped to a XAML ResourceDictionary URI (e.g. ms-appx:///Themes/Red.xaml)
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a theme with a string URI.
|
||||
/// </summary>
|
||||
public void RegisterTheme(string name, string dictionaryUri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dictionaryUri);
|
||||
RegisterTheme(name, new Uri(dictionaryUri));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously registered theme.
|
||||
/// </summary>
|
||||
public bool UnregisterTheme(string name)
|
||||
{
|
||||
lock (_resourceSwapGate)
|
||||
{
|
||||
return _themeUris.Remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the names of all registered themes.
|
||||
/// </summary>
|
||||
public IEnumerable<string> GetRegisteredThemes()
|
||||
{
|
||||
lock (_resourceSwapGate)
|
||||
{
|
||||
// return a copy to avoid external mutation
|
||||
return new List<string>(_themeUris.Keys);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates a theme by name. The dictionary for the given name must be registered first.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to activate a theme by name without throwing.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates a theme by URI to a ResourceDictionary.
|
||||
/// </summary>
|
||||
public void ActivateTheme(Uri dictionaryUri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dictionaryUri);
|
||||
|
||||
ActivateThemeInternal(GetNameForUri(dictionaryUri), dictionaryUri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the currently active theme ResourceDictionary. Also clears the override dictionary.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
261
src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs
Normal file
261
src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// ThemeService is a hub that translates user settings and system preferences into concrete
|
||||
/// theme resources and notifies listeners of changes.
|
||||
/// </summary>
|
||||
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<ThemeChangedEventArgs>? ThemeChanged;
|
||||
|
||||
public ThemeSnapshot Current => Volatile.Read(ref _currentState).Snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the theme service. Must be called after the application window is activated and on UI thread.
|
||||
/// </summary>
|
||||
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<IThemeProvider> 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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes a window's theme with <see cref="IThemeService"/>.
|
||||
/// </summary>
|
||||
internal sealed partial class WindowThemeSynchronizer : IDisposable
|
||||
{
|
||||
private readonly IThemeService _themeService;
|
||||
private readonly Window _window;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WindowThemeSynchronizer"/> class and subscribes to theme changes.
|
||||
/// </summary>
|
||||
/// <param name="themeService">The theme service to monitor for changes.</param>
|
||||
/// <param name="window">The window to synchronize.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="themeService"/> or <paramref name="window"/> is null.</exception>
|
||||
public WindowThemeSynchronizer(IThemeService themeService, Window window)
|
||||
{
|
||||
_themeService = themeService ?? throw new ArgumentNullException(nameof(themeService));
|
||||
_window = window ?? throw new ArgumentNullException(nameof(window));
|
||||
_themeService.ThemeChanged += ThemeServiceOnThemeChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from theme change events.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the current theme to the window when theme changes occur.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user