ResourceSwapper.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 Microsoft.UI.Xaml; 6 7 namespace Microsoft.CmdPal.UI.Services; 8 9 /// <summary> 10 /// Simple theme switcher that swaps application ResourceDictionaries at runtime. 11 /// Can also operate in event-only mode for consumers to apply resources themselves. 12 /// Exposes a dedicated override dictionary that stays merged and is cleared on theme changes. 13 /// </summary> 14 internal sealed partial class ResourceSwapper 15 { 16 private readonly Lock _resourceSwapGate = new(); 17 private readonly Dictionary<string, Uri> _themeUris = new(StringComparer.OrdinalIgnoreCase); 18 private ResourceDictionary? _activeDictionary; 19 private string? _currentThemeName; 20 private Uri? _currentThemeUri; 21 22 private ResourceDictionary? _overrideDictionary; 23 24 /// <summary> 25 /// Raised after a theme has been activated. 26 /// </summary> 27 public event EventHandler<ResourcesSwappedEventArgs>? ResourcesSwapped; 28 29 /// <summary> 30 /// Gets or sets a value indicating whether when true (default) ResourceSwapper updates Application.Current.Resources. When false, it only raises ResourcesSwapped. 31 /// </summary> 32 public bool ApplyToAppResources { get; set; } = true; 33 34 /// <summary> 35 /// Gets name of the currently selected theme (if any). 36 /// </summary> 37 public string? CurrentThemeName 38 { 39 get 40 { 41 lock (_resourceSwapGate) 42 { 43 return _currentThemeName; 44 } 45 } 46 } 47 48 /// <summary> 49 /// Initializes ResourceSwapper by checking Application resources for an already merged theme dictionary. 50 /// </summary> 51 public void Initialize() 52 { 53 // Find merged dictionary in Application resources that matches a registered theme by URI 54 // This allows ResourceSwapper to pick up an initial theme set in XAML 55 var app = Application.Current; 56 var resourcesMergedDictionaries = app?.Resources?.MergedDictionaries; 57 if (resourcesMergedDictionaries == null) 58 { 59 return; 60 } 61 62 foreach (var dict in resourcesMergedDictionaries) 63 { 64 var uri = dict.Source; 65 if (uri is null) 66 { 67 continue; 68 } 69 70 var name = GetNameForUri(uri); 71 if (name is null) 72 { 73 continue; 74 } 75 76 lock (_resourceSwapGate) 77 { 78 _currentThemeName = name; 79 _currentThemeUri = uri; 80 _activeDictionary = dict; 81 } 82 83 break; 84 } 85 } 86 87 /// <summary> 88 /// Gets uri of the currently selected theme dictionary (if any). 89 /// </summary> 90 public Uri? CurrentThemeUri 91 { 92 get 93 { 94 lock (_resourceSwapGate) 95 { 96 return _currentThemeUri; 97 } 98 } 99 } 100 101 public static ResourceDictionary GetOverrideDictionary(bool clear = false) 102 { 103 var app = Application.Current ?? throw new InvalidOperationException("App is null"); 104 105 if (app.Resources == null) 106 { 107 throw new InvalidOperationException("Application.Resources is null"); 108 } 109 110 // (Re)locate the slot – Hot Reload may rebuild Application.Resources. 111 var slot = app.Resources!.MergedDictionaries! 112 .OfType<MutableOverridesDictionary>() 113 .FirstOrDefault(); 114 115 if (slot is null) 116 { 117 // If the slot vanished (Hot Reload), create it again at the end so it wins precedence. 118 slot = new MutableOverridesDictionary(); 119 app.Resources.MergedDictionaries!.Add(slot); 120 } 121 122 // Ensure the slot has exactly one child RD we can swap safely. 123 if (slot.MergedDictionaries!.Count == 0) 124 { 125 slot.MergedDictionaries.Add(new ResourceDictionary()); 126 } 127 else if (slot.MergedDictionaries.Count > 1) 128 { 129 // Normalize to a single child to keep semantics predictable. 130 var keep = slot.MergedDictionaries[^1]; 131 slot.MergedDictionaries.Clear(); 132 slot.MergedDictionaries.Add(keep); 133 } 134 135 if (clear) 136 { 137 // Swap the child dictionary instead of Clear() to avoid reentrancy issues. 138 var fresh = new ResourceDictionary(); 139 slot.MergedDictionaries[0] = fresh; 140 return fresh; 141 } 142 143 return slot.MergedDictionaries[0]!; 144 } 145 146 /// <summary> 147 /// Registers a theme name mapped to a XAML ResourceDictionary URI (e.g. ms-appx:///Themes/Red.xaml) 148 /// </summary> 149 public void RegisterTheme(string name, Uri dictionaryUri) 150 { 151 if (string.IsNullOrWhiteSpace(name)) 152 { 153 throw new ArgumentException("Theme name is required", nameof(name)); 154 } 155 156 lock (_resourceSwapGate) 157 { 158 _themeUris[name] = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri)); 159 } 160 } 161 162 /// <summary> 163 /// Registers a theme with a string URI. 164 /// </summary> 165 public void RegisterTheme(string name, string dictionaryUri) 166 { 167 ArgumentNullException.ThrowIfNull(dictionaryUri); 168 RegisterTheme(name, new Uri(dictionaryUri)); 169 } 170 171 /// <summary> 172 /// Removes a previously registered theme. 173 /// </summary> 174 public bool UnregisterTheme(string name) 175 { 176 lock (_resourceSwapGate) 177 { 178 return _themeUris.Remove(name); 179 } 180 } 181 182 /// <summary> 183 /// Gets the names of all registered themes. 184 /// </summary> 185 public IEnumerable<string> GetRegisteredThemes() 186 { 187 lock (_resourceSwapGate) 188 { 189 // return a copy to avoid external mutation 190 return new List<string>(_themeUris.Keys); 191 } 192 } 193 194 /// <summary> 195 /// Activates a theme by name. The dictionary for the given name must be registered first. 196 /// </summary> 197 public void ActivateTheme(string theme) 198 { 199 if (string.IsNullOrWhiteSpace(theme)) 200 { 201 throw new ArgumentException("Theme name is required", nameof(theme)); 202 } 203 204 Uri uri; 205 lock (_resourceSwapGate) 206 { 207 if (!_themeUris.TryGetValue(theme, out uri!)) 208 { 209 throw new KeyNotFoundException($"Theme '{theme}' is not registered."); 210 } 211 } 212 213 ActivateThemeInternal(theme, uri); 214 } 215 216 /// <summary> 217 /// Tries to activate a theme by name without throwing. 218 /// </summary> 219 public bool TryActivateTheme(string theme) 220 { 221 if (string.IsNullOrWhiteSpace(theme)) 222 { 223 return false; 224 } 225 226 Uri uri; 227 lock (_resourceSwapGate) 228 { 229 if (!_themeUris.TryGetValue(theme, out uri!)) 230 { 231 return false; 232 } 233 } 234 235 ActivateThemeInternal(theme, uri); 236 return true; 237 } 238 239 /// <summary> 240 /// Activates a theme by URI to a ResourceDictionary. 241 /// </summary> 242 public void ActivateTheme(Uri dictionaryUri) 243 { 244 ArgumentNullException.ThrowIfNull(dictionaryUri); 245 246 ActivateThemeInternal(GetNameForUri(dictionaryUri), dictionaryUri); 247 } 248 249 /// <summary> 250 /// Clears the currently active theme ResourceDictionary. Also clears the override dictionary. 251 /// </summary> 252 public void ClearActiveTheme() 253 { 254 lock (_resourceSwapGate) 255 { 256 var app = Application.Current; 257 if (app is null) 258 { 259 return; 260 } 261 262 if (_activeDictionary is not null && ApplyToAppResources) 263 { 264 _ = app.Resources.MergedDictionaries.Remove(_activeDictionary); 265 _activeDictionary = null; 266 } 267 268 // Clear overrides but keep the override dictionary merged for future updates 269 _overrideDictionary?.Clear(); 270 271 _currentThemeName = null; 272 _currentThemeUri = null; 273 } 274 } 275 276 private void ActivateThemeInternal(string? name, Uri dictionaryUri) 277 { 278 lock (_resourceSwapGate) 279 { 280 _currentThemeName = name; 281 _currentThemeUri = dictionaryUri; 282 } 283 284 if (ApplyToAppResources) 285 { 286 ActivateThemeCore(dictionaryUri); 287 } 288 289 OnResourcesSwapped(new(name, dictionaryUri)); 290 } 291 292 private void ActivateThemeCore(Uri dictionaryUri) 293 { 294 var app = Application.Current ?? throw new InvalidOperationException("Application.Current is null"); 295 296 // Remove previously applied base theme dictionary 297 if (_activeDictionary is not null) 298 { 299 _ = app.Resources.MergedDictionaries.Remove(_activeDictionary); 300 _activeDictionary = null; 301 } 302 303 // Load and merge the new base theme dictionary 304 var newDict = new ResourceDictionary { Source = dictionaryUri }; 305 app.Resources.MergedDictionaries.Add(newDict); 306 _activeDictionary = newDict; 307 308 // Ensure override dictionary exists and is merged last, then clear it to avoid leaking stale overrides 309 _overrideDictionary = GetOverrideDictionary(clear: true); 310 } 311 312 private string? GetNameForUri(Uri dictionaryUri) 313 { 314 lock (_resourceSwapGate) 315 { 316 foreach (var (key, value) in _themeUris) 317 { 318 if (Uri.Compare(value, dictionaryUri, UriComponents.AbsoluteUri, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) 319 { 320 return key; 321 } 322 } 323 324 return null; 325 } 326 } 327 328 private void OnResourcesSwapped(ResourcesSwappedEventArgs e) 329 { 330 ResourcesSwapped?.Invoke(this, e); 331 } 332 }