[Run] Fix dark mode detection code, plus refactor (#37324)

* Fix risky int cast in dark mode detection.

* Refactored Helper and Manager classes. New unit tests and changes to support Registry access mocking.

* Spelling update.

* Improve documentation for the registry-related classes.

* Fix issue with UpdateTheme raised in review. Enhance documentation. Rewrite tests to use parameterised unit tests, and expand to cover more cases.
This commit is contained in:
Dave Rayment
2025-02-20 10:47:30 +00:00
committed by GitHub
parent c6f9701818
commit 727de3e1fc
7 changed files with 426 additions and 104 deletions

View File

@@ -1,72 +0,0 @@
// 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;
using System.Globalization;
using System.Linq;
using ManagedCommon;
using Microsoft.Win32;
namespace PowerLauncher.Helper
{
public static class ThemeExtensions
{
public static Theme GetCurrentTheme()
{
// Check for high-contrast mode
Theme highContrastTheme = GetHighContrastBaseType();
if (highContrastTheme != Theme.Light)
{
return highContrastTheme;
}
// Check if the system is using dark or light mode
return IsSystemDarkMode() ? Theme.Dark : Theme.Light;
}
private static bool IsSystemDarkMode()
{
const string registryKey = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
const string registryValue = "AppsUseLightTheme";
// Retrieve the registry value, which is a DWORD (0 or 1)
object registryValueObj = Registry.GetValue(registryKey, registryValue, null);
if (registryValueObj != null)
{
// 0 = Dark mode, 1 = Light mode
bool isLightMode = Convert.ToBoolean((int)registryValueObj, CultureInfo.InvariantCulture);
return !isLightMode; // Invert because 0 = Dark
}
else
{
// Default to Light theme if the registry key is missing
return false; // Default to dark mode assumption
}
}
public static Theme GetHighContrastBaseType()
{
const string registryKey = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes";
const string registryValue = "CurrentTheme";
string themePath = (string)Registry.GetValue(registryKey, registryValue, string.Empty);
if (string.IsNullOrEmpty(themePath))
{
return Theme.Light; // Default to light theme if missing
}
string theme = themePath.Split('\\').Last().Split('.').First().ToLowerInvariant();
return theme switch
{
"hc1" => Theme.HighContrastOne,
"hc2" => Theme.HighContrastTwo,
"hcwhite" => Theme.HighContrastWhite,
"hcblack" => Theme.HighContrastBlack,
_ => Theme.Light,
};
}
}
}

View File

@@ -0,0 +1,137 @@
// 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;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using ManagedCommon;
using PowerLauncher.Services;
[assembly: InternalsVisibleTo("Wox.Test")]
namespace PowerLauncher.Helper;
/// <summary>
/// Provides functionality for determining the application's theme based on system settings, user
/// preferences, and High Contrast mode detection.
/// </summary>
public class ThemeHelper
{
private const string ThemesKey = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes";
private const string PersonalizeKey = ThemesKey + "\\Personalize";
internal const int AppsUseLightThemeLight = 1;
internal const int AppsUseLightThemeDark = 0;
/// <summary>
/// Default value for the "AppsUseLightTheme" registry setting. This value represents Light
/// mode and will be used if the registry value is invalid or cannot be read.
/// </summary>
internal const int AppsUseLightThemeDefault = AppsUseLightThemeLight;
private readonly IRegistryService _registryService;
private readonly Dictionary<string, Theme> _highContrastThemeMap =
new(StringComparer.InvariantCultureIgnoreCase)
{
{ "hc1", Theme.HighContrastOne },
{ "hc2", Theme.HighContrastTwo },
{ "hcwhite", Theme.HighContrastWhite },
{ "hcblack", Theme.HighContrastBlack },
};
/// <summary>
/// Initializes a new instance of the <see cref="ThemeHelper"/> class.
/// </summary>
/// <param name="registryService">The service used to query registry values. If <c>null</c>, a
/// default implementation is used, which queries the Windows registry. This allows for
/// dependency injection and unit testing.</param>
public ThemeHelper(IRegistryService registryService = null)
{
_registryService = registryService ?? RegistryServiceFactory.Create();
}
/// <summary>
/// Determines the theme to apply, prioritizing an active High Contrast theme.
/// </summary>
/// <param name="settingsTheme">The theme selected in application settings.</param>
/// <returns>The resolved <see cref="Theme"/> based on the following priority order:
/// 1. If a default High Contrast Windows theme is active, return the corresponding High
/// Contrast <see cref="Theme"/>.
/// 2. If "Windows default" is selected in application settings, return the Windows app theme
/// (<see cref="Theme.Dark"/> or <see cref="Theme.Light"/>).
/// 3. If the user explicitly selected "Light" or "Dark", return their chosen theme.
/// 4. If the theme cannot be determined, return <see cref="Theme.Light"/>.
/// </returns>
public Theme DetermineTheme(Theme settingsTheme) =>
GetHighContrastTheme() ??
(settingsTheme == Theme.System ? GetAppsTheme() : ValidateTheme(settingsTheme));
/// <summary>
/// Ensures the provided <see cref="Theme"/> value is valid.
/// </summary>
/// <param name="theme">The <see cref="Theme"/> value to validate.</param>
/// <returns>The provided theme if it is a defined enum value; otherwise, defaults to
/// <see cref="Theme.Light"/>.
private Theme ValidateTheme(Theme theme) => Enum.IsDefined(theme) ? theme : Theme.Light;
/// <summary>
/// Determines if a High Contrast theme is currently active and returns the corresponding
/// <see cref="Theme"/>.
/// </summary>
/// <returns>The detected High Contrast <see cref="Theme"/> (e.g.
/// <see cref="Theme.HighContrastOne"/>, or <c>null</c> if no recognized High Contrast theme
/// is active.
/// </returns>
internal Theme? GetHighContrastTheme()
{
try
{
var themePath = Convert.ToString(
_registryService.GetValue(ThemesKey, "CurrentTheme", string.Empty),
CultureInfo.InvariantCulture);
if (!string.IsNullOrEmpty(themePath) && _highContrastThemeMap.TryGetValue(
Path.GetFileNameWithoutExtension(themePath), out var theme))
{
return theme;
}
}
catch
{
// Fall through to return null. Ignore exception.
}
return null;
}
/// <summary>
/// Retrieves the Windows app theme preference from the registry.
/// </summary>
/// <returns><see cref="Theme.Dark"/> if the user has selected Dark mode for apps,
/// <see cref="Theme.Light"/> otherwise. If the registry value cannot be read or is invalid,
/// the default value (<see cref="Theme.Light"/>) is used.
/// </returns>
internal Theme GetAppsTheme()
{
try
{
// "AppsUseLightTheme" registry value:
// - 0 = Dark mode
// - 1 (or missing/invalid) = Light mode
var regValue = _registryService.GetValue(
PersonalizeKey,
"AppsUseLightTheme",
AppsUseLightThemeDefault);
return regValue is int intValue && intValue == 0 ? Theme.Dark : Theme.Light;
}
catch
{
return Theme.Light;
}
}
}

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media;
using ManagedCommon;
@@ -17,10 +17,11 @@ namespace PowerLauncher.Helper
{
private readonly PowerToysRunSettings _settings;
private readonly MainWindow _mainWindow;
private ManagedCommon.Theme _currentTheme;
private readonly ThemeHelper _themeHelper = new();
private bool _disposed;
public ManagedCommon.Theme CurrentTheme => _currentTheme;
public Theme CurrentTheme { get; private set; }
public event Common.UI.ThemeChangedHandler ThemeChanged;
@@ -40,23 +41,25 @@ namespace PowerLauncher.Helper
}
}
private void SetSystemTheme(ManagedCommon.Theme theme)
private void SetSystemTheme(Theme theme)
{
_mainWindow.Background = OSVersionHelper.IsWindows11() is false ? SystemColors.WindowBrush : null;
_mainWindow.Background = !OSVersionHelper.IsWindows11() ? SystemColors.WindowBrush : null;
// Need to disable WPF0001 since setting Application.Current.ThemeMode is experimental
// https://learn.microsoft.com/en-us/dotnet/desktop/wpf/whats-new/net90#set-in-code
#pragma warning disable WPF0001
Application.Current.ThemeMode = theme is ManagedCommon.Theme.Light ? ThemeMode.Light : ThemeMode.Dark;
if (theme is ManagedCommon.Theme.Dark or ManagedCommon.Theme.Light)
Application.Current.ThemeMode = theme == Theme.Light ? ThemeMode.Light : ThemeMode.Dark;
#pragma warning restore WPF0001
if (theme is Theme.Dark or Theme.Light)
{
if (!OSVersionHelper.IsWindows11())
{
// Apply background only on Windows 10
// Windows theme does not work properly for dark and light mode so right now set the background color manual.
// Windows theme does not work properly for dark and light mode so right now set the background color manually.
_mainWindow.Background = new SolidColorBrush
{
Color = theme is ManagedCommon.Theme.Dark ? (Color)ColorConverter.ConvertFromString("#202020") : (Color)ColorConverter.ConvertFromString("#fafafa"),
Color = (Color)ColorConverter.ConvertFromString(theme == Theme.Dark ? "#202020" : "#fafafa"),
};
}
}
@@ -64,49 +67,46 @@ namespace PowerLauncher.Helper
{
string styleThemeString = theme switch
{
ManagedCommon.Theme.Light => "Themes/Light.xaml",
ManagedCommon.Theme.Dark => "Themes/Dark.xaml",
ManagedCommon.Theme.HighContrastOne => "Themes/HighContrast1.xaml",
ManagedCommon.Theme.HighContrastTwo => "Themes/HighContrast2.xaml",
ManagedCommon.Theme.HighContrastWhite => "Themes/HighContrastWhite.xaml",
_ => "Themes/HighContrastBlack.xaml",
Theme.HighContrastOne => "Themes/HighContrast1.xaml",
Theme.HighContrastTwo => "Themes/HighContrast2.xaml",
Theme.HighContrastWhite => "Themes/HighContrastWhite.xaml",
Theme.HighContrastBlack => "Themes/HighContrastBlack.xaml",
_ => "Themes/Light.xaml",
};
_mainWindow.Resources.MergedDictionaries.Clear();
_mainWindow.Resources.MergedDictionaries.Add(new ResourceDictionary
{
Source = new Uri(styleThemeString, UriKind.Relative),
});
ResourceDictionary test = new ResourceDictionary
{
Source = new Uri(styleThemeString, UriKind.Relative),
};
if (OSVersionHelper.IsWindows11())
{
// Apply background only on Windows 11 to keep the same style as WPFUI
_mainWindow.Background = new SolidColorBrush
{
Color = (Color)_mainWindow.FindResource("LauncherBackgroundColor"), // Use your DynamicResource key here
Color = (Color)_mainWindow.FindResource("LauncherBackgroundColor"),
};
}
}
ImageLoader.UpdateIconPath(theme);
ThemeChanged?.Invoke(_currentTheme, theme);
_currentTheme = theme;
ThemeChanged?.Invoke(CurrentTheme, theme);
CurrentTheme = theme;
}
/// <summary>
/// Updates the application's theme based on system settings and user preferences.
/// </summary>
/// <remarks>
/// This considers:
/// - Whether a High Contrast theme is active in Windows.
/// - The system-wide app mode preference (Light or Dark).
/// - The user's preference override for Light or Dark mode in the application settings.
/// </remarks>
public void UpdateTheme()
{
ManagedCommon.Theme newTheme = _settings.Theme;
ManagedCommon.Theme theme = ThemeExtensions.GetHighContrastBaseType();
if (theme != ManagedCommon.Theme.Light)
{
newTheme = theme;
}
else if (_settings.Theme == ManagedCommon.Theme.System)
{
newTheme = ThemeExtensions.GetCurrentTheme();
}
Theme newTheme = _themeHelper.DetermineTheme(_settings.Theme);
_mainWindow.Dispatcher.Invoke(() =>
{