ThemeManager.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System; 6 using System.Runtime.InteropServices; 7 using System.Threading; 8 using System.Threading.Tasks; 9 using System.Windows; 10 using System.Windows.Media; 11 using ManagedCommon; 12 using Microsoft.Win32; 13 using Wox.Infrastructure.Image; 14 using Wox.Infrastructure.UserSettings; 15 using Wox.Plugin.Logger; 16 17 namespace PowerLauncher.Helper 18 { 19 public class ThemeManager : IDisposable 20 { 21 private readonly PowerToysRunSettings _settings; 22 private readonly MainWindow _mainWindow; 23 private readonly ThemeHelper _themeHelper = new(); 24 25 private bool _disposed; 26 private CancellationTokenSource _themeUpdateTokenSource; 27 private const int MaxRetries = 5; 28 private const int InitialDelayMs = 2000; 29 30 public Theme CurrentTheme { get; private set; } 31 32 public event Common.UI.ThemeChangedHandler ThemeChanged; 33 34 public ThemeManager(PowerToysRunSettings settings, MainWindow mainWindow) 35 { 36 _settings = settings; 37 _mainWindow = mainWindow; 38 UpdateTheme(); 39 SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; 40 } 41 42 private void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) 43 { 44 if (e.Category == UserPreferenceCategory.General) 45 { 46 UpdateTheme(); 47 } 48 } 49 50 private void SetSystemTheme(Theme theme) 51 { 52 _mainWindow.Background = !OSVersionHelper.IsWindows11() ? SystemColors.WindowBrush : null; 53 54 // Need to disable WPF0001 since setting Application.Current.ThemeMode is experimental 55 // https://learn.microsoft.com/en-us/dotnet/desktop/wpf/whats-new/net90#set-in-code 56 #pragma warning disable WPF0001 57 Application.Current.ThemeMode = theme == Theme.Light ? ThemeMode.Light : ThemeMode.Dark; 58 #pragma warning restore WPF0001 59 60 if (theme is Theme.Dark or Theme.Light) 61 { 62 if (!OSVersionHelper.IsWindows11()) 63 { 64 // Apply background only on Windows 10 65 // Windows theme does not work properly for dark and light mode so right now set the background color manually. 66 _mainWindow.Background = new SolidColorBrush 67 { 68 Color = (Color)ColorConverter.ConvertFromString(theme == Theme.Dark ? "#202020" : "#fafafa"), 69 }; 70 } 71 } 72 else 73 { 74 string styleThemeString = theme switch 75 { 76 Theme.HighContrastOne => "Themes/HighContrast1.xaml", 77 Theme.HighContrastTwo => "Themes/HighContrast2.xaml", 78 Theme.HighContrastWhite => "Themes/HighContrastWhite.xaml", 79 Theme.HighContrastBlack => "Themes/HighContrastBlack.xaml", 80 _ => "Themes/Light.xaml", 81 }; 82 83 _mainWindow.Resources.MergedDictionaries.Clear(); 84 _mainWindow.Resources.MergedDictionaries.Add(new ResourceDictionary 85 { 86 Source = new Uri(styleThemeString, UriKind.Relative), 87 }); 88 89 if (OSVersionHelper.IsWindows11()) 90 { 91 // Apply background only on Windows 11 to keep the same style as WPFUI 92 _mainWindow.Background = new SolidColorBrush 93 { 94 Color = (Color)_mainWindow.FindResource("LauncherBackgroundColor"), 95 }; 96 } 97 } 98 99 ImageLoader.UpdateIconPath(theme); 100 ThemeChanged?.Invoke(CurrentTheme, theme); 101 CurrentTheme = theme; 102 } 103 104 /// <summary> 105 /// Updates the application's theme based on system settings and user preferences. 106 /// </summary> 107 /// <remarks> 108 /// This considers: 109 /// - Whether a High Contrast theme is active in Windows. 110 /// - The system-wide app mode preference (Light or Dark). 111 /// - The user's preference override for Light or Dark mode in the application settings. 112 /// </remarks> 113 public void UpdateTheme() 114 { 115 Theme newTheme = _themeHelper.DetermineTheme(_settings.Theme); 116 117 // Cancel any existing theme update operation 118 _themeUpdateTokenSource?.Cancel(); 119 _themeUpdateTokenSource?.Dispose(); 120 _themeUpdateTokenSource = new CancellationTokenSource(); 121 122 // Start theme update with retry logic in the background 123 _ = UpdateThemeWithRetryAsync(newTheme, _themeUpdateTokenSource.Token); 124 } 125 126 /// <summary> 127 /// Applies the theme with retry logic for desktop composition errors. 128 /// </summary> 129 /// <param name="theme">The theme to apply.</param> 130 /// <param name="cancellationToken">Token to cancel the operation.</param> 131 private async Task UpdateThemeWithRetryAsync(Theme theme, CancellationToken cancellationToken) 132 { 133 var delayMs = 0; 134 const int maxAttempts = MaxRetries + 1; 135 136 for (var attempt = 1; attempt <= maxAttempts; attempt++) 137 { 138 try 139 { 140 if (delayMs > 0) 141 { 142 await Task.Delay(delayMs, cancellationToken); 143 } 144 145 if (cancellationToken.IsCancellationRequested) 146 { 147 Log.Debug("Theme update operation was cancelled.", typeof(ThemeManager)); 148 return; 149 } 150 151 await _mainWindow.Dispatcher.InvokeAsync(() => 152 { 153 SetSystemTheme(theme); 154 }); 155 156 if (attempt > 1) 157 { 158 Log.Info($"Successfully applied theme after {attempt - 1} retry attempt(s).", typeof(ThemeManager)); 159 } 160 161 return; 162 } 163 catch (COMException ex) when (ExceptionHelper.IsRecoverableDwmCompositionException(ex)) 164 { 165 switch (attempt) 166 { 167 case 1: 168 Log.Warn($"Desktop composition is disabled (HRESULT: 0x{ex.HResult:X}). Scheduling retries for theme update.", typeof(ThemeManager)); 169 delayMs = InitialDelayMs; 170 break; 171 case < maxAttempts: 172 Log.Warn($"Retry {attempt - 1}/{MaxRetries} failed: Desktop composition still disabled. Retrying in {delayMs * 2}ms...", typeof(ThemeManager)); 173 delayMs *= 2; 174 break; 175 default: 176 Log.Exception($"Failed to set theme after {MaxRetries} retry attempts. Desktop composition remains disabled.", ex, typeof(ThemeManager)); 177 break; 178 } 179 } 180 catch (OperationCanceledException) 181 { 182 Log.Debug("Theme update operation was cancelled.", typeof(ThemeManager)); 183 return; 184 } 185 catch (Exception ex) 186 { 187 Log.Exception($"Unexpected error during theme update (attempt {attempt}/{maxAttempts}): {ex.Message}", ex, typeof(ThemeManager)); 188 throw; 189 } 190 } 191 } 192 193 public void Dispose() 194 { 195 Dispose(true); 196 GC.SuppressFinalize(this); 197 } 198 199 protected virtual void Dispose(bool disposing) 200 { 201 if (_disposed) 202 { 203 return; 204 } 205 206 if (disposing) 207 { 208 SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; 209 _themeUpdateTokenSource?.Cancel(); 210 _themeUpdateTokenSource?.Dispose(); 211 } 212 213 _disposed = true; 214 } 215 } 216 }