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 }