diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index d94db89953..15e48cff17 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -899,6 +899,7 @@ LEFTTEXT LError LEVELID LExit +LFU lhwnd LIBFUZZER LIBID diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 3d6a7ef634..eb103d3157 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -32,6 +32,7 @@ using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; // To learn more about WinUI, the WinUI project structure, @@ -72,6 +73,8 @@ public partial class App : Application, IDisposable Services = ConfigureServices(); + IconCacheProvider.Initialize(Services); + this.InitializeComponent(); // Ensure types used in XAML are preserved for AOT compilation @@ -113,12 +116,13 @@ public partial class App : Application, IDisposable // Root services services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext()); + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); AddBuiltInCommands(services); AddCoreServices(services); - AddUIServices(services); + AddUIServices(services, dispatcherQueue); return services.BuildServiceProvider(); } @@ -169,7 +173,7 @@ public partial class App : Application, IDisposable services.AddSingleton(); } - private static void AddUIServices(ServiceCollection services) + private static void AddUIServices(ServiceCollection services, DispatcherQueue dispatcherQueue) { // Models var sm = SettingsModel.LoadSettings(); @@ -188,6 +192,8 @@ public partial class App : Application, IDisposable services.AddSingleton(); services.AddSingleton(); + + services.AddIconServices(dispatcherQueue); } private static void AddCoreServices(ServiceCollection services) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml index 36167717ea..4d72e91b6a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -42,7 +42,7 @@ Margin="4,0,0,0" HorizontalAlignment="Left" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml index f8c888e8f8..e92dbd912e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml @@ -46,7 +46,7 @@ HorizontalAlignment="Left" VerticalAlignment="Center" SourceKey="{x:Bind Icon}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> public partial class IconBox : ContentControl { - private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); + private double _lastScale; + private ElementTheme _lastTheme; + private double _lastFontSize; + + private const double DefaultIconFontSize = 16.0; /// /// Gets or sets the to display within the . Overwritten, if is used instead. @@ -48,10 +48,23 @@ public partial class IconBox : ContentControl public static readonly DependencyProperty SourceKeyProperty = DependencyProperty.Register(nameof(SourceKey), typeof(object), typeof(IconBox), new PropertyMetadata(null, OnSourceKeyPropertyChanged)); + private TypedEventHandler? _sourceRequested; + /// /// Gets or sets the event handler to provide the value of the for the property from the provided . /// - public event TypedEventHandler? SourceRequested; + public event TypedEventHandler? SourceRequested + { + add + { + _sourceRequested += value; + if (_sourceRequested?.GetInvocationList().Length == 1) + { + Refresh(); + } + } + remove => _sourceRequested -= value; + } public IconBox() { @@ -59,119 +72,208 @@ public partial class IconBox : ContentControl IsTabStop = false; HorizontalContentAlignment = HorizontalAlignment.Center; VerticalContentAlignment = VerticalAlignment.Center; + + Loaded += OnLoaded; + Unloaded += OnUnloaded; + ActualThemeChanged += OnActualThemeChanged; + SizeChanged += OnSizeChanged; + + UpdateLastFontSize(); + } + + private void UpdateLastFontSize() + { + _lastFontSize = + Pick(Width) + ?? Pick(Height) + ?? Pick(ActualWidth) + ?? Pick(ActualHeight) + ?? DefaultIconFontSize; + + return; + + static double? Pick(double value) => double.IsFinite(value) && value > 0 ? value : null; + } + + private void OnSizeChanged(object s, SizeChangedEventArgs e) + { + UpdateLastFontSize(); + + if (Source is FontIconSource fontIcon) + { + fontIcon.FontSize = _lastFontSize; + } + } + + private void OnActualThemeChanged(FrameworkElement sender, object args) + { + if (_lastTheme == ActualTheme) + { + return; + } + + _lastTheme = ActualTheme; + Refresh(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + _lastTheme = ActualTheme; + UpdateLastFontSize(); + + if (XamlRoot is not null) + { + _lastScale = XamlRoot.RasterizationScale; + XamlRoot.Changed += OnXamlRootChanged; + } + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (XamlRoot is not null) + { + XamlRoot.Changed -= OnXamlRootChanged; + } + } + + private void OnXamlRootChanged(XamlRoot sender, XamlRootChangedEventArgs args) + { + var newScale = sender.RasterizationScale; + var changedLastTheme = _lastTheme != ActualTheme; + _lastTheme = ActualTheme; + if ((changedLastTheme || Math.Abs(newScale - _lastScale) > 0.01) && SourceKey is not null) + { + _lastScale = newScale; + UpdateSourceKey(this, SourceKey); + } + } + + private void Refresh() + { + if (SourceKey is not null) + { + UpdateSourceKey(this, SourceKey); + } } private static void OnSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - if (d is IconBox @this) + if (d is not IconBox self) { - switch (e.NewValue) - { - case null: - @this.Content = null; - break; - case FontIconSource fontIco: - fontIco.FontSize = double.IsNaN(@this.Width) ? @this.Height : @this.Width; + return; + } + + switch (e.NewValue) + { + case null: + self.Content = null; + self.Padding = default; + break; + case FontIconSource fontIcon: + if (self.Content is IconSourceElement iconSourceElement) + { + iconSourceElement.IconSource = fontIcon; + } + else + { + fontIcon.FontSize = self._lastFontSize; // For inexplicable reasons, FontIconSource.CreateIconElement // doesn't work, so do it ourselves // TODO: File platform bug? IconSourceElement elem = new() { - IconSource = fontIco, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, + IconSource = fontIcon, }; - @this.Content = elem; - break; - case IconSource source: - @this.Content = source.CreateIconElement(); - break; - default: - throw new InvalidOperationException($"New value of {e.NewValue} is not of type IconSource."); - } + self.Content = elem; + } + + self.Padding = new Thickness(Math.Round(self._lastFontSize * -0.2)); + + break; + case BitmapIconSource bitmapIcon: + if (self.Content is IconSourceElement iconSourceElement2) + { + iconSourceElement2.IconSource = bitmapIcon; + } + else + { + self.Content = bitmapIcon.CreateIconElement(); + } + + self.Padding = default; + + break; + case IconSource source: + self.Content = source.CreateIconElement(); + self.Padding = default; + break; + default: + throw new InvalidOperationException($"New value of {e.NewValue} is not of type IconSource."); } } private static void OnSourceKeyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - if (d is IconBox @this) + if (d is not IconBox self) { - if (e.NewValue is null) + return; + } + + UpdateSourceKey(self, e.NewValue); + } + + private static void UpdateSourceKey(IconBox iconBox, object? sourceKey) + { + if (sourceKey is null) + { + iconBox.Source = null; + return; + } + + Callback(iconBox, sourceKey); + } + + private static async void Callback(IconBox iconBox, object? sourceKey) + { + try + { + var iconBoxSourceRequestedHandler = iconBox._sourceRequested; + + if (iconBoxSourceRequestedHandler is null) { - @this.Source = null; + return; } - else + + var eventArgs = new SourceRequestedEventArgs(sourceKey, iconBox._lastTheme, iconBox._lastScale); + await iconBoxSourceRequestedHandler.InvokeAsync(iconBox, eventArgs); + + // After the await: + // Is the icon we're looking up now, the one we still + // want to find? Since this IconBox might be used in a + // list virtualization situation, it's very possible we + // may have already been set to a new icon before we + // even got back from the await. + if (eventArgs.Key != sourceKey) { - // TODO GH #239 switch back when using the new MD text block - // Switching back to EnqueueAsync has broken icons in tags (they don't show) - // _ = @this._queue.EnqueueAsync(() => - @this._queue.TryEnqueue(async void () => - { - try - { - if (@this.SourceRequested is null) - { - return; - } - - var requestedTheme = @this.ActualTheme; - var eventArgs = new SourceRequestedEventArgs(e.NewValue, requestedTheme); - - await @this.SourceRequested.InvokeAsync(@this, eventArgs); - - // After the await: - // Is the icon we're looking up now, the one we still - // want to find? Since this IconBox might be used in a - // list virtualization situation, it's very possible we - // may have already been set to a new icon before we - // even got back from the await. - if (eventArgs.Key != @this.SourceKey) - { - // If the requested icon has changed, then just bail - return; - } - - @this.Source = eventArgs.Value; - - // Here's a little lesson in trickery: - // Emoji are rendered just a bit bigger than Segoe Icons. - // Just enough bigger that they get clipped if you put - // them in a box at the same size. - // - // So, if the icon we get back was a font icon, - // and the glyph for that icon is NOT in the range of - // Segoe icons, then let's give the icon some extra space - var iconData = eventArgs.Key switch - { - IconDataViewModel key => key, - IconInfoViewModel info => requestedTheme == ElementTheme.Light ? info.Light : info.Dark, - _ => null, - }; - - if (iconData?.Icon is not null && @this.Source is FontIconSource) - { - var iconSize = - !double.IsNaN(@this.Width) ? @this.Width : - !double.IsNaN(@this.Height) ? @this.Height : - @this.ActualWidth > 0 ? @this.ActualWidth : - @this.ActualHeight; - - @this.Padding = new Thickness(Math.Round(iconSize * -0.2)); - } - else - { - @this.Padding = default; - } - } - catch (Exception ex) - { - // Exception from TryEnqueue bypasses the global error handler, - // and crashes the app. - Logger.LogError("Failed to set icon", ex); - } - }); + // If the requested icon has changed, then just bail + return; } + + if (eventArgs.Value == iconBox.Source) + { + return; + } + + iconBox.Source = eventArgs.Value; + } + catch (Exception ex) + { + // Exception from TryEnqueue bypasses the global error handler, + // and crashes the app. + Logger.LogError("Failed to set icon", ex); } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SourceRequestedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SourceRequestedEventArgs.cs index 670bf13a7a..5528217f89 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SourceRequestedEventArgs.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SourceRequestedEventArgs.cs @@ -11,11 +11,13 @@ namespace Microsoft.CmdPal.UI.Controls; /// /// See event. /// -public class SourceRequestedEventArgs(object? key, ElementTheme requestedTheme) : DeferredEventArgs +public class SourceRequestedEventArgs(object? key, ElementTheme requestedTheme, double scale = 1.0) : DeferredEventArgs { public object? Key { get; private set; } = key; public IconSource? Value { get; set; } public ElementTheme Theme => requestedTheme; + + public double Scale => scale; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml index 7cf917b21c..14386896f3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml @@ -72,6 +72,7 @@ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs index b74cc54687..405d4341a9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs @@ -72,7 +72,7 @@ public partial class Tag : Control if (GetTemplateChild(TagIconBox) is IconBox iconBox) { - iconBox.SourceRequested += IconCacheProvider.SourceRequested; + iconBox.SourceRequested += IconCacheProvider.SourceRequested20; iconBox.Visibility = HasIcon ? Visibility.Visible : Visibility.Collapsed; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml index c0edf83390..2f670d62b4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml @@ -27,6 +27,11 @@ 8 8 + 32 + 48 + 100 + 160 + 40 0 0 @@ -288,7 +293,7 @@ AutomationProperties.AccessibilityView="Raw" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested32}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested256}" /> @@ -582,7 +587,7 @@ Margin="8" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind ViewModel.EmptyContent.Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}" /> +/// A high-performance, near-lock-free adaptive cache optimized for UI Icons. +/// Eviction merely drops references to allow the GC to manage UI-bound lifetimes. +/// +internal sealed class AdaptiveCache + where TKey : IEquatable +{ + private readonly int _capacity; + private readonly double _decayFactor; + private readonly TimeSpan _decayInterval; + + private readonly ConcurrentDictionary _map; + private readonly ConcurrentStack _pool = []; + private readonly WaitCallback _maintenanceCallback; + + private long _currentTick; + private long _lastDecayTicks = DateTime.UtcNow.Ticks; + private InterlockedBoolean _maintenanceSwitch = new(false); + + public AdaptiveCache(int capacity = 384, TimeSpan? decayInterval = null, double decayFactor = 0.5) + { + _capacity = capacity; + _decayInterval = decayInterval ?? TimeSpan.FromMinutes(5); + _decayFactor = decayFactor; + _map = new ConcurrentDictionary(Environment.ProcessorCount, capacity); + + _maintenanceCallback = static state => + { + var cache = (AdaptiveCache)state!; + try + { + cache.PerformCleanup(); + } + finally + { + cache._maintenanceSwitch.Clear(); + } + }; + } + + public TValue GetOrAdd(TKey key, Func factory, TArg arg) + { + if (_map.TryGetValue(key, out var entry)) + { + entry.Update(Interlocked.Increment(ref _currentTick)); + return entry.Value!; + } + + if (!_pool.TryPop(out var newEntry)) + { + newEntry = new CacheEntry(); + } + + var value = factory(key, arg); + var tick = Interlocked.Increment(ref _currentTick); + newEntry.Initialize(key, value, 1.0, tick); + + if (!_map.TryAdd(key, newEntry)) + { + newEntry.Clear(); + _pool.Push(newEntry); + + if (_map.TryGetValue(key, out var existing)) + { + existing.Update(tick); + return existing.Value!; + } + } + + if (ShouldMaintenanceRun()) + { + TryRunMaintenance(); + } + + return value; + } + + public bool TryGet(TKey key, [MaybeNullWhen(false)] out TValue value) + { + if (_map.TryGetValue(key, out var entry)) + { + entry.Update(Interlocked.Increment(ref _currentTick)); + value = entry.Value; + return true; + } + + value = default; + return false; + } + + public void Add(TKey key, TValue value) + { + var tick = Interlocked.Increment(ref _currentTick); + + if (_map.TryGetValue(key, out var existing)) + { + existing.Update(tick); + existing.SetValue(value); + return; + } + + if (!_pool.TryPop(out var newEntry)) + { + newEntry = new CacheEntry(); + } + + newEntry.Initialize(key, value, 1.0, tick); + + if (!_map.TryAdd(key, newEntry)) + { + newEntry.Clear(); + _pool.Push(newEntry); + } + + if (ShouldMaintenanceRun()) + { + TryRunMaintenance(); + } + } + + public bool TryRemove(TKey key) + { + if (_map.TryRemove(key, out var evicted)) + { + evicted.Clear(); + _pool.Push(evicted); + return true; + } + + return false; + } + + public void Clear() + { + foreach (var key in _map.Keys) + { + TryRemove(key); + } + + Interlocked.Exchange(ref _currentTick, 0); + } + + private bool ShouldMaintenanceRun() + { + return _map.Count > _capacity || (DateTime.UtcNow.Ticks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks; + } + + private void TryRunMaintenance() + { + if (_maintenanceSwitch.Set()) + { + ThreadPool.UnsafeQueueUserWorkItem(_maintenanceCallback, this); + } + } + + private void PerformCleanup() + { + var nowTicks = DateTime.UtcNow.Ticks; + var isDecay = (nowTicks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks; + if (isDecay) + { + Interlocked.Exchange(ref _lastDecayTicks, nowTicks); + } + + var currentTick = Interlocked.Read(ref _currentTick); + + foreach (var (key, entry) in _map) + { + if (isDecay) + { + entry.Decay(_decayFactor); + } + + var score = CalculateScore(entry, currentTick); + + if (score < 0.1 || _map.Count > _capacity) + { + if (_map.TryRemove(key, out var evicted)) + { + evicted.Clear(); + _pool.Push(evicted); + } + } + } + } + + /// + /// Calculates the survival score of an entry. + /// Higher score = stay in cache; Lower score = priority for eviction. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double CalculateScore(CacheEntry entry, long currentTick) + { + // Tuning parameter: How much weight to give recency vs frequency. + // - a larger ageWeight makes the cache behave more like LRU (Least Recently Used). + // - a smaller ageWeight makes it behave more like LFU (Least Frequently Used). + const double ageWeight = 0.001; + + var frequency = entry.GetFrequency(); + var age = currentTick - entry.GetLastAccess(); + + return frequency - (age * ageWeight); + } + + /// + /// Represents a single pooled entry in the cache, containing the value and + /// atomic metadata for adaptive eviction logic. + /// + private sealed class CacheEntry + { + /// + /// Gets the key associated with this entry. Used primarily for identification during cleanup. + /// + public TKey Key { get; private set; } = default!; + + /// + /// Gets the cached value. This reference is cleared on eviction to allow GC collection. + /// + public TValue Value { get; private set; } = default!; + + /// + /// Stores the frequency count as double bits to allow for Interlocked atomic math. + /// Frequencies are decayed over time to ensure the cache adapts to new usage patterns. + /// + /// + /// This allows the use of Interlocked.CompareExchange to perform thread-safe floating point + /// arithmetic without a global lock. + /// + private long _frequencyBits; + + /// + /// The tick (monotonically increasing counter) of the last time this entry was accessed. + /// + private long _lastAccessTick; + + public void Initialize(TKey key, TValue value, double frequency, long lastAccessTick) + { + Key = key; + Value = value; + _frequencyBits = BitConverter.DoubleToInt64Bits(frequency); + _lastAccessTick = lastAccessTick; + } + + public void SetValue(TValue value) + { + Value = value; + } + + public void Clear() + { + Key = default!; + Value = default!; + } + + public void Update(long tick) + { + Interlocked.Exchange(ref _lastAccessTick, tick); + long initial, updated; + do + { + initial = Interlocked.Read(ref _frequencyBits); + updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) + 1.0); + } + while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial); + } + + public void Decay(double factor) + { + long initial, updated; + do + { + initial = Interlocked.Read(ref _frequencyBits); + updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) * factor); + } + while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial); + } + + public double GetFrequency() + { + return BitConverter.Int64BitsToDouble(Interlocked.Read(ref _frequencyBits)); + } + + public long GetLastAccess() + { + return Interlocked.Read(ref _lastAccessTick); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheProvider.cs deleted file mode 100644 index 2687909cfa..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheProvider.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.CmdPal.Core.ViewModels; -using Microsoft.CmdPal.UI.Controls; - -namespace Microsoft.CmdPal.UI.Helpers; - -/// -/// Common async event handler provides the cache lookup function for the deferred event. -/// -public static partial class IconCacheProvider -{ - private static readonly IconCacheService IconService = new(Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()); - -#pragma warning disable IDE0060 // Remove unused parameter - public static async void SourceRequested(IconBox sender, SourceRequestedEventArgs args) -#pragma warning restore IDE0060 // Remove unused parameter - { - if (args.Key is null) - { - return; - } - - if (args.Key is IconDataViewModel iconData) - { - var deferral = args.GetDeferral(); - - args.Value = await IconService.GetIconSource(iconData); - - deferral.Complete(); - } - else if (args.Key is IconInfoViewModel iconInfo) - { - var deferral = args.GetDeferral(); - - var data = args.Theme == Microsoft.UI.Xaml.ElementTheme.Dark ? iconInfo.Dark : iconInfo.Light; - args.Value = await IconService.GetIconSource(data); - - deferral.Complete(); - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheService.cs deleted file mode 100644 index a506344f61..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheService.cs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics; -using Microsoft.CmdPal.Core.ViewModels; -using Microsoft.Terminal.UI; -using Microsoft.UI.Dispatching; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media.Imaging; -using Windows.Storage.Streams; - -namespace Microsoft.CmdPal.UI.Helpers; - -public sealed class IconCacheService(DispatcherQueue dispatcherQueue) -{ - public Task GetIconSource(IconDataViewModel icon) => - - // todo: actually implement a cache of some sort - IconToSource(icon); - - private async Task IconToSource(IconDataViewModel icon) - { - try - { - if (!string.IsNullOrEmpty(icon.Icon)) - { - var source = IconPathConverter.IconSourceMUX(icon.Icon, false, icon.FontFamily); - return source; - } - else if (icon.Data is not null) - { - try - { - return await StreamToIconSource(icon.Data.Unsafe!); - } - catch (Exception ex) - { - Debug.WriteLine("Failed to load icon from stream: " + ex); - } - } - } - catch - { - } - - return null; - } - - private async Task StreamToIconSource(IRandomAccessStreamReference iconStreamRef) - { - if (iconStreamRef is null) - { - return null; - } - - var bitmap = await IconStreamToBitmapImageAsync(iconStreamRef); - var icon = new ImageIconSource() { ImageSource = bitmap }; - return icon; - } - - private async Task IconStreamToBitmapImageAsync(IRandomAccessStreamReference iconStreamRef) - { - // Return the bitmap image via TaskCompletionSource. Using WCT's EnqueueAsync does not suffice here, since if - // we're already on the thread of the DispatcherQueue then it just directly calls the function, with no async involved. - return await TryEnqueueAsync(dispatcherQueue, async () => - { - using var bitmapStream = await iconStreamRef.OpenReadAsync(); - var itemImage = new BitmapImage(); - await itemImage.SetSourceAsync(bitmapStream); - return itemImage; - }); - } - - private static Task TryEnqueueAsync(DispatcherQueue dispatcher, Func> function) - { - var completionSource = new TaskCompletionSource(); - - var enqueued = dispatcher.TryEnqueue(DispatcherQueuePriority.Normal, async void () => - { - try - { - var result = await function(); - completionSource.SetResult(result); - } - catch (Exception ex) - { - completionSource.SetException(ex); - } - }); - - if (!enqueued) - { - completionSource.SetException(new InvalidOperationException("Failed to enqueue the operation on the UI dispatcher")); - } - - return completionSource.Task; - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/CachedIconSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/CachedIconSourceProvider.cs new file mode 100644 index 0000000000..589e40d10f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/CachedIconSourceProvider.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal sealed class CachedIconSourceProvider : IIconSourceProvider +{ + private readonly AdaptiveCache> _cache; + private readonly Size _iconSize; + private readonly IconLoaderService _loader; + private readonly Lock _lock = new(); + + public CachedIconSourceProvider(IconLoaderService loader, Size iconSize, int cacheSize) + { + _loader = loader; + _iconSize = iconSize; + _cache = new AdaptiveCache>(cacheSize, TimeSpan.FromMinutes(60)); + } + + public CachedIconSourceProvider(IconLoaderService loader, int iconSize, int cacheSize) + : this(loader, new Size(iconSize, iconSize), cacheSize) + { + } + + public Task GetIconSource(IconDataViewModel icon, double scale) + { + var key = new IconCacheKey(icon, scale); + + return _cache.TryGet(key, out var existingTask) + ? existingTask + : GetOrCreateSlowPath(key, icon, scale); + } + + private Task GetOrCreateSlowPath(IconCacheKey key, IconDataViewModel icon, double scale) + { + lock (_lock) + { + if (_cache.TryGet(key, out var existingTask)) + { + return existingTask; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _loader.EnqueueLoad( + icon.Icon, + icon.FontFamily, + icon.Data?.Unsafe, + _iconSize, + scale, + tcs); + + var task = tcs.Task; + + _ = task.ContinueWith( + _ => + { + lock (_lock) + { + _cache.TryRemove(key); + } + }, + TaskContinuationOptions.OnlyOnFaulted); + + _cache.Add(key, task); + return task; + } + } + + private readonly struct IconCacheKey : IEquatable + { + private readonly string? _icon; + private readonly string? _fontFamily; + private readonly int _streamRefHashCode; + private readonly int _scale; + + public IconCacheKey(IconDataViewModel icon, double scale) + { + _icon = icon.Icon; + _fontFamily = icon.FontFamily; + _streamRefHashCode = icon.Data?.Unsafe is { } stream + ? RuntimeHelpers.GetHashCode(stream) + : 0; + _scale = (int)(100 * Math.Round(scale, 2)); + } + + public bool Equals(IconCacheKey other) => + _icon == other._icon && + _fontFamily == other._fontFamily && + _streamRefHashCode == other._streamRefHashCode && + _scale == other._scale; + + public override bool Equals(object? obj) => obj is IconCacheKey other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(_icon, _fontFamily, _streamRefHashCode, _scale); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconLoaderService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconLoaderService.cs new file mode 100644 index 0000000000..c7f7f27e1b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconLoaderService.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal interface IIconLoaderService : IAsyncDisposable +{ + void EnqueueLoad( + string? iconString, + string? fontFamily, + IRandomAccessStreamReference? streamRef, + Size iconSize, + double scale, + TaskCompletionSource tcs, + IconLoadPriority priority); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconSourceProvider.cs new file mode 100644 index 0000000000..1d3f3fc646 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconSourceProvider.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal interface IIconSourceProvider +{ + Task GetIconSource(IconDataViewModel icon, double scale); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconCacheProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconCacheProvider.cs new file mode 100644 index 0000000000..40de39b5ab --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconCacheProvider.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.UI.Controls; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// +/// Common async event handler provides the cache lookup function for the deferred event. +/// +public static partial class IconCacheProvider +{ + /* + Memory Usage Considerations (raw estimates): + | Icon Size | Per Icon | Count | Total | Per Icon @ 200% | Total @ 200% | Per Icon @ 300% | Total @ 300% | + | --------- | -------: | ----: | -------: | --------------: | -----------: | --------------: | -----------: | + | 20×20 | 1.6 KB | 1024 | 1.6 MB | 6.4 KB | 6.4 MB | 14.4 KB | 14.4 MB | + | 32×32 | 4.0 KB | 512 | 2.0 MB | 16 KB | 8.0 MB | 36.0 KB | 18.0 MB | + | 48×48 | 9.0 KB | 256 | 2.3 MB | 36 KB | 9.0 MB | 81.0 KB | 20.3 MB | + | 64×64 | 16.0 KB | 64 | 1.0 MB | 64 KB | 4.0 MB | 144.0 KB | 9.0 MB | + | 256×256 | 256.0 KB | 64 | 16.0 MB | 1 MB | 64.0 MB | 2.3 MB | 144 MB | + */ + + private static IIconSourceProvider _provider20 = null!; + private static IIconSourceProvider _provider32 = null!; + private static IIconSourceProvider _provider64 = null!; + private static IIconSourceProvider _provider256 = null!; + + public static void Initialize(IServiceProvider serviceProvider) + { + _provider20 = serviceProvider.GetRequiredKeyedService(WellKnownIconSize.Size20); + _provider32 = serviceProvider.GetRequiredKeyedService(WellKnownIconSize.Size32); + _provider64 = serviceProvider.GetRequiredKeyedService(WellKnownIconSize.Size64); + _provider256 = serviceProvider.GetRequiredKeyedService(WellKnownIconSize.Size256); + } + + private static async void SourceRequestedCore(IIconSourceProvider service, SourceRequestedEventArgs args) + { + if (args.Key is null) + { + return; + } + + var deferral = args.GetDeferral(); + + try + { + args.Value = args.Key switch + { + IconDataViewModel iconData => await service.GetIconSource(iconData, args.Scale), + IconInfoViewModel iconInfo => await service.GetIconSource( + args.Theme == Microsoft.UI.Xaml.ElementTheme.Light ? iconInfo.Light : iconInfo.Dark, + args.Scale), + _ => null, + }; + } + finally + { + deferral.Complete(); + } + } + +#pragma warning disable IDE0060 // Remove unused parameter + public static void SourceRequested20(IconBox sender, SourceRequestedEventArgs args) + => SourceRequestedCore(_provider20, args); + + public static void SourceRequested32(IconBox sender, SourceRequestedEventArgs args) + => SourceRequestedCore(_provider32, args); + + public static void SourceRequested64(IconBox sender, SourceRequestedEventArgs args) + => SourceRequestedCore(_provider64, args); + + public static void SourceRequested256(IconBox sender, SourceRequestedEventArgs args) + => SourceRequestedCore(_provider256, args); +#pragma warning restore IDE0060 // Remove unused parameter +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadPriority.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadPriority.cs new file mode 100644 index 0000000000..ff824da548 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadPriority.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Helpers; + +internal enum IconLoadPriority +{ + Low, + High, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs new file mode 100644 index 0000000000..ef93ccb040 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading.Channels; +using CommunityToolkit.WinUI; +using ManagedCommon; +using Microsoft.Terminal.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Foundation; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal sealed partial class IconLoaderService : IIconLoaderService +{ + private const DispatcherQueuePriority LoadingPriorityOnDispatcher = DispatcherQueuePriority.Low; + private const int DefaultIconSize = 256; + private const int MaxWorkerCount = 4; + + private static readonly int WorkerCount = Math.Clamp(Environment.ProcessorCount / 2, 1, MaxWorkerCount); + + private readonly Channel> _highPriorityQueue = Channel.CreateBounded>(32); + private readonly Channel> _lowPriorityQueue = Channel.CreateUnbounded>(); + private readonly Task[] _workers; + private readonly DispatcherQueue _dispatcherQueue; + + public IconLoaderService(DispatcherQueue dispatcherQueue) + { + _dispatcherQueue = dispatcherQueue; + _workers = new Task[WorkerCount]; + + for (var i = 0; i < WorkerCount; i++) + { + _workers[i] = Task.Run(ProcessQueueAsync); + } + } + + public void EnqueueLoad( + string? iconString, + string? fontFamily, + IRandomAccessStreamReference? streamRef, + Size iconSize, + double scale, + TaskCompletionSource tcs, + IconLoadPriority priority = IconLoadPriority.Low) + { + var workItem = () => LoadAndCompleteAsync(iconString, fontFamily, streamRef, iconSize, scale, tcs); + + if (priority == IconLoadPriority.High) + { + if (_highPriorityQueue.Writer.TryWrite(workItem)) + { + return; + } + +#if DEBUG + Logger.LogDebug("High priority icon queue full, falling back to low priority"); +#endif + } + + _lowPriorityQueue.Writer.TryWrite(workItem); + } + + public async ValueTask DisposeAsync() + { + _highPriorityQueue.Writer.Complete(); + _lowPriorityQueue.Writer.Complete(); + + await Task.WhenAll(_workers).ConfigureAwait(false); + } + + private async Task ProcessQueueAsync() + { + while (true) + { + Func? workItem; + + if (_highPriorityQueue.Reader.TryRead(out workItem)) + { + await ExecuteWork(workItem).ConfigureAwait(false); + continue; + } + + var highWait = _highPriorityQueue.Reader.WaitToReadAsync().AsTask(); + var lowWait = _lowPriorityQueue.Reader.WaitToReadAsync().AsTask(); + + await Task.WhenAny(highWait, lowWait).ConfigureAwait(false); + + // Check if both channels are completed (disposal) + if (_highPriorityQueue.Reader.Completion.IsCompleted && + _lowPriorityQueue.Reader.Completion.IsCompleted) + { + // Drain any remaining items + while (_highPriorityQueue.Reader.TryRead(out workItem)) + { + await ExecuteWork(workItem).ConfigureAwait(false); + } + + while (_lowPriorityQueue.Reader.TryRead(out workItem)) + { + await ExecuteWork(workItem).ConfigureAwait(false); + } + + break; + } + + if (_highPriorityQueue.Reader.TryRead(out workItem) || + _lowPriorityQueue.Reader.TryRead(out workItem)) + { + await ExecuteWork(workItem).ConfigureAwait(false); + } + } + + static async Task ExecuteWork(Func workItem) + { + try + { + await workItem().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError("Failed to load icon", ex); + } + } + } + + private async Task LoadAndCompleteAsync( + string? iconString, + string? fontFamily, + IRandomAccessStreamReference? streamRef, + Size iconSize, + double scale, + TaskCompletionSource tcs) + { + try + { + var result = await LoadIconCoreAsync(iconString, fontFamily, streamRef, iconSize, scale).ConfigureAwait(false); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + } + + private async Task LoadIconCoreAsync( + string? iconString, + string? fontFamily, + IRandomAccessStreamReference? streamRef, + Size iconSize, + double scale) + { + var scaledSize = new Size(iconSize.Width * scale, iconSize.Height * scale); + + if (!string.IsNullOrEmpty(iconString)) + { + return await _dispatcherQueue + .EnqueueAsync(() => GetStringIconSource(iconString, fontFamily, scaledSize), LoadingPriorityOnDispatcher) + .ConfigureAwait(false); + } + + if (streamRef != null) + { + try + { + using var bitmapStream = await streamRef.OpenReadAsync().AsTask().ConfigureAwait(false); + + return await _dispatcherQueue + .EnqueueAsync(BuildImageSource, LoadingPriorityOnDispatcher) + .ConfigureAwait(false); + + async Task BuildImageSource() + { + var bitmap = new BitmapImage(); + ApplyDecodeSize(bitmap, scaledSize); + await bitmap.SetSourceAsync(bitmapStream); + return new ImageIconSource { ImageSource = bitmap }; + } + } +#pragma warning disable CS0168 // Variable is declared but never used + catch (Exception ex) +#pragma warning restore CS0168 // Variable is declared but never used + { +#if DEBUG + Logger.LogDebug($"Failed to open icon stream: {ex}"); +#endif + return null; + } + } + + return null; + } + + private static void ApplyDecodeSize(BitmapImage bitmap, Size size) + { + if (size.IsEmpty) + { + return; + } + + if (size.Width >= size.Height) + { + bitmap.DecodePixelWidth = (int)size.Width; + } + else + { + bitmap.DecodePixelHeight = (int)size.Height; + } + } + + private static IconSource? GetStringIconSource(string iconString, string? fontFamily, Size size) + { + var iconSize = (int)Math.Max(size.Width, size.Height); + if (iconSize == 0) + { + iconSize = DefaultIconSize; + } + + return IconPathConverter.IconSourceMUX(iconString, false, fontFamily, iconSize); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconServiceRegistration.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconServiceRegistration.cs new file mode 100644 index 0000000000..c5f3709276 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconServiceRegistration.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Dispatching; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static class IconServiceRegistration +{ + public static IServiceCollection AddIconServices(this IServiceCollection services, DispatcherQueue dispatcherQueue) + { + // Single shared loader + var loader = new IconLoaderService(dispatcherQueue); + services.AddSingleton(loader); + + // Keyed providers by size + services.AddKeyedSingleton( + WellKnownIconSize.Size20, + (_, _) => new CachedIconSourceProvider(loader, 20, 1024)); + + services.AddKeyedSingleton( + WellKnownIconSize.Size32, + (_, _) => new IconSourceProvider(loader, 32)); + + services.AddKeyedSingleton( + WellKnownIconSize.Size64, + (_, _) => new CachedIconSourceProvider(loader, 64, 256)); + + services.AddKeyedSingleton( + WellKnownIconSize.Size256, + (_, _) => new CachedIconSourceProvider(loader, 256, 64)); + + return services; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconSourceProvider.cs new file mode 100644 index 0000000000..13c6ed764a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconSourceProvider.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal sealed class IconSourceProvider : IIconSourceProvider +{ + private readonly IconLoaderService _loader; + private readonly Size _iconSize; + + public IconSourceProvider(IconLoaderService loader, Size iconSize) + { + _loader = loader; + _iconSize = iconSize; + } + + public IconSourceProvider(IconLoaderService loader, int iconSize) + : this(loader, new Size(iconSize, iconSize)) + { + } + + public Task GetIconSource(IconDataViewModel icon, double scale) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _loader.EnqueueLoad( + icon.Icon, + icon.FontFamily, + icon.Data?.Unsafe, + _iconSize, + scale, + tcs); + + return tcs.Task; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/WellKnownIconSize.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/WellKnownIconSize.cs new file mode 100644 index 0000000000..e35cbd46f5 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/WellKnownIconSize.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Helpers; + +internal enum WellKnownIconSize +{ + Size20 = 20, + Size32 = 32, + Size64 = 64, + Size256 = 256, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs deleted file mode 100644 index 70bfffe6b3..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using CommunityToolkit.Common.Deferred; -using Windows.Foundation; - -// Pilfered from CommunityToolkit.WinUI.Deferred -namespace Microsoft.CmdPal.UI.Deferred; - -/// -/// Extensions to for Deferred Events. -/// -public static class TypedEventHandlerExtensions -{ - /// - /// Use to invoke an async using . - /// - /// Type of sender. - /// type. - /// to be invoked. - /// Sender of the event. - /// instance. - /// to wait on deferred event handler. -#pragma warning disable CA1715 // Identifiers should have correct prefix -#pragma warning disable SA1314 // Type parameter names should begin with T - public static Task InvokeAsync(this TypedEventHandler eventHandler, S sender, R eventArgs) -#pragma warning restore SA1314 // Type parameter names should begin with T -#pragma warning restore CA1715 // Identifiers should have correct prefix - where R : DeferredEventArgs => InvokeAsync(eventHandler, sender, eventArgs, CancellationToken.None); - - /// - /// Use to invoke an async using with a . - /// - /// Type of sender. - /// type. - /// to be invoked. - /// Sender of the event. - /// instance. - /// option. - /// to wait on deferred event handler. -#pragma warning disable CA1715 // Identifiers should have correct prefix -#pragma warning disable SA1314 // Type parameter names should begin with T - public static Task InvokeAsync(this TypedEventHandler eventHandler, S sender, R eventArgs, CancellationToken cancellationToken) -#pragma warning restore SA1314 // Type parameter names should begin with T -#pragma warning restore CA1715 // Identifiers should have correct prefix - where R : DeferredEventArgs - { - if (eventHandler is null) - { - return Task.CompletedTask; - } - - var tasks = eventHandler.GetInvocationList() - .OfType>() - .Select(invocationDelegate => - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - invocationDelegate(sender, eventArgs); - -#pragma warning disable CS0618 // Type or member is obsolete - var deferral = eventArgs.GetCurrentDeferralAndReset(); - - return deferral?.WaitForCompletion(cancellationToken) ?? Task.CompletedTask; -#pragma warning restore CS0618 // Type or member is obsolete - }) - .ToArray(); - - return Task.WhenAll(tasks); - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index bc083cd1bd..d54fbe93d2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -648,8 +648,14 @@ public sealed partial class MainWindow : WindowEx, // Updates our window s.t. the top of the window is draggable. private void UpdateRegionsForCustomTitleBar() { + var xamlRoot = RootElement.XamlRoot; + if (xamlRoot is null) + { + return; + } + // Specify the interactive regions of the title bar. - var scaleAdjustment = RootElement.XamlRoot.RasterizationScale; + var scaleAdjustment = xamlRoot.RasterizationScale; // Get the rectangle around our XAML content. We're going to mark this // rectangle as "Passthrough", so that the normal window operations diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index df40e597c3..18bc15298d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -92,7 +92,7 @@ Height="16" Margin="0,3,8,0" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> @@ -296,7 +296,7 @@ ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind ViewModel.CurrentPage.Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" Visibility="{x:Bind ViewModel.CurrentPage.IsNested, Mode=OneWay}"> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> @@ -94,7 +94,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> @@ -165,7 +165,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml index 528e8d2351..e01f26b571 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml @@ -239,7 +239,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" />