IconBox.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 CommunityToolkit.WinUI.Deferred;
  6  using ManagedCommon;
  7  using Microsoft.UI.Xaml;
  8  using Microsoft.UI.Xaml.Controls;
  9  using Microsoft.UI.Xaml.Input;
 10  using Windows.Foundation;
 11  
 12  namespace Microsoft.CmdPal.UI.Controls;
 13  
 14  /// <summary>
 15  /// A helper control which takes an <see cref="IconSource"/> and creates the corresponding <see cref="IconElement"/>.
 16  /// </summary>
 17  public partial class IconBox : ContentControl
 18  {
 19      private double _lastScale;
 20      private ElementTheme _lastTheme;
 21      private double _lastFontSize;
 22  
 23      private const double DefaultIconFontSize = 16.0;
 24  
 25      /// <summary>
 26      /// Gets or sets the <see cref="IconSource"/> to display within the <see cref="IconBox"/>. Overwritten, if <see cref="SourceKey"/> is used instead.
 27      /// </summary>
 28      public IconSource? Source
 29      {
 30          get => (IconSource?)GetValue(SourceProperty);
 31          set => SetValue(SourceProperty, value);
 32      }
 33  
 34      // Using a DependencyProperty as the backing store for Source.  This enables animation, styling, binding, etc...
 35      public static readonly DependencyProperty SourceProperty =
 36          DependencyProperty.Register(nameof(Source), typeof(IconSource), typeof(IconBox), new PropertyMetadata(null, OnSourcePropertyChanged));
 37  
 38      /// <summary>
 39      /// Gets or sets a value to use as the <see cref="SourceKey"/> to retrieve an <see cref="IconSource"/> to set as the <see cref="Source"/>.
 40      /// </summary>
 41      public object? SourceKey
 42      {
 43          get => (object?)GetValue(SourceKeyProperty);
 44          set => SetValue(SourceKeyProperty, value);
 45      }
 46  
 47      // Using a DependencyProperty as the backing store for SourceKey.  This enables animation, styling, binding, etc...
 48      public static readonly DependencyProperty SourceKeyProperty =
 49          DependencyProperty.Register(nameof(SourceKey), typeof(object), typeof(IconBox), new PropertyMetadata(null, OnSourceKeyPropertyChanged));
 50  
 51      private TypedEventHandler<IconBox, SourceRequestedEventArgs>? _sourceRequested;
 52  
 53      /// <summary>
 54      /// Gets or sets the <see cref="SourceRequested"/> event handler to provide the value of the <see cref="IconSource"/> for the <see cref="Source"/> property from the provided <see cref="SourceKey"/>.
 55      /// </summary>
 56      public event TypedEventHandler<IconBox, SourceRequestedEventArgs>? SourceRequested
 57      {
 58          add
 59          {
 60              _sourceRequested += value;
 61              if (_sourceRequested?.GetInvocationList().Length == 1)
 62              {
 63                  Refresh();
 64              }
 65          }
 66          remove => _sourceRequested -= value;
 67      }
 68  
 69      public IconBox()
 70      {
 71          TabFocusNavigation = KeyboardNavigationMode.Once;
 72          IsTabStop = false;
 73          HorizontalContentAlignment = HorizontalAlignment.Center;
 74          VerticalContentAlignment = VerticalAlignment.Center;
 75  
 76          Loaded += OnLoaded;
 77          Unloaded += OnUnloaded;
 78          ActualThemeChanged += OnActualThemeChanged;
 79          SizeChanged += OnSizeChanged;
 80  
 81          UpdateLastFontSize();
 82      }
 83  
 84      private void UpdateLastFontSize()
 85      {
 86          _lastFontSize =
 87              Pick(Width)
 88              ?? Pick(Height)
 89              ?? Pick(ActualWidth)
 90              ?? Pick(ActualHeight)
 91              ?? DefaultIconFontSize;
 92  
 93          return;
 94  
 95          static double? Pick(double value) => double.IsFinite(value) && value > 0 ? value : null;
 96      }
 97  
 98      private void OnSizeChanged(object s, SizeChangedEventArgs e)
 99      {
100          UpdateLastFontSize();
101  
102          if (Source is FontIconSource fontIcon)
103          {
104              fontIcon.FontSize = _lastFontSize;
105          }
106      }
107  
108      private void OnActualThemeChanged(FrameworkElement sender, object args)
109      {
110          if (_lastTheme == ActualTheme)
111          {
112              return;
113          }
114  
115          _lastTheme = ActualTheme;
116          Refresh();
117      }
118  
119      private void OnLoaded(object sender, RoutedEventArgs e)
120      {
121          _lastTheme = ActualTheme;
122          UpdateLastFontSize();
123  
124          if (XamlRoot is not null)
125          {
126              _lastScale = XamlRoot.RasterizationScale;
127              XamlRoot.Changed += OnXamlRootChanged;
128          }
129      }
130  
131      private void OnUnloaded(object sender, RoutedEventArgs e)
132      {
133          if (XamlRoot is not null)
134          {
135              XamlRoot.Changed -= OnXamlRootChanged;
136          }
137      }
138  
139      private void OnXamlRootChanged(XamlRoot sender, XamlRootChangedEventArgs args)
140      {
141          var newScale = sender.RasterizationScale;
142          var changedLastTheme = _lastTheme != ActualTheme;
143          _lastTheme = ActualTheme;
144          if ((changedLastTheme || Math.Abs(newScale - _lastScale) > 0.01) && SourceKey is not null)
145          {
146              _lastScale = newScale;
147              UpdateSourceKey(this, SourceKey);
148          }
149      }
150  
151      private void Refresh()
152      {
153          if (SourceKey is not null)
154          {
155              UpdateSourceKey(this, SourceKey);
156          }
157      }
158  
159      private static void OnSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
160      {
161          if (d is not IconBox self)
162          {
163              return;
164          }
165  
166          switch (e.NewValue)
167          {
168              case null:
169                  self.Content = null;
170                  self.Padding = default;
171                  break;
172              case FontIconSource fontIcon:
173                  if (self.Content is IconSourceElement iconSourceElement)
174                  {
175                      iconSourceElement.IconSource = fontIcon;
176                  }
177                  else
178                  {
179                      fontIcon.FontSize = self._lastFontSize;
180  
181                      // For inexplicable reasons, FontIconSource.CreateIconElement
182                      // doesn't work, so do it ourselves
183                      // TODO: File platform bug?
184                      IconSourceElement elem = new()
185                      {
186                          HorizontalAlignment = HorizontalAlignment.Center,
187                          VerticalAlignment = VerticalAlignment.Center,
188                          IconSource = fontIcon,
189                      };
190                      self.Content = elem;
191                  }
192  
193                  self.Padding = new Thickness(Math.Round(self._lastFontSize * -0.2));
194  
195                  break;
196              case BitmapIconSource bitmapIcon:
197                  if (self.Content is IconSourceElement iconSourceElement2)
198                  {
199                      iconSourceElement2.IconSource = bitmapIcon;
200                  }
201                  else
202                  {
203                      self.Content = bitmapIcon.CreateIconElement();
204                  }
205  
206                  self.Padding = default;
207  
208                  break;
209              case IconSource source:
210                  self.Content = source.CreateIconElement();
211                  self.Padding = default;
212                  break;
213              default:
214                  throw new InvalidOperationException($"New value of {e.NewValue} is not of type IconSource.");
215          }
216      }
217  
218      private static void OnSourceKeyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
219      {
220          if (d is not IconBox self)
221          {
222              return;
223          }
224  
225          UpdateSourceKey(self, e.NewValue);
226      }
227  
228      private static void UpdateSourceKey(IconBox iconBox, object? sourceKey)
229      {
230          if (sourceKey is null)
231          {
232              iconBox.Source = null;
233              return;
234          }
235  
236          Callback(iconBox, sourceKey);
237      }
238  
239      private static async void Callback(IconBox iconBox, object? sourceKey)
240      {
241          try
242          {
243              var iconBoxSourceRequestedHandler = iconBox._sourceRequested;
244  
245              if (iconBoxSourceRequestedHandler is null)
246              {
247                  return;
248              }
249  
250              var eventArgs = new SourceRequestedEventArgs(sourceKey, iconBox._lastTheme, iconBox._lastScale);
251              await iconBoxSourceRequestedHandler.InvokeAsync(iconBox, eventArgs);
252  
253              // After the await:
254              // Is the icon we're looking up now, the one we still
255              // want to find? Since this IconBox might be used in a
256              // list virtualization situation, it's very possible we
257              // may have already been set to a new icon before we
258              // even got back from the await.
259              if (eventArgs.Key != sourceKey)
260              {
261                  // If the requested icon has changed, then just bail
262                  return;
263              }
264  
265              if (eventArgs.Value == iconBox.Source)
266              {
267                  return;
268              }
269  
270              iconBox.Source = eventArgs.Value;
271          }
272          catch (Exception ex)
273          {
274              // Exception from TryEnqueue bypasses the global error handler,
275              // and crashes the app.
276              Logger.LogError("Failed to set icon", ex);
277          }
278      }
279  }