/ src / modules / cmdpal / Microsoft.CmdPal.UI / Services / ResourceSwapper.cs
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  }