From dca532cf4bef4c67c77683dad693de5f8b2448de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Mon, 2 Feb 2026 18:16:43 +0100 Subject: [PATCH] CmdPal: Icon cache (#44538) ## Summary of the Pull Request This PR implements actual cache in IconCacheService and adds some fixes on top for free. The good - `IconCacheService` now caches decoded icons - Ensures that UI thread is not starved by loading icons by limiting number of threads that can load icons at any given time - `IconCacheService` decodes bitmaps directly to the required size to reduce memory usage - `IconBox` now reacts to theme, DPI scale, and size changes immediately - Introduced `AdaptiveCache` with time-based decay to improve icon reuse - Updated `IconCacheProvider` and `IconCacheService` to handle multiple icon sizes and scale-aware caching - Added priority-based decoding in `IconCacheService` for more responsive loading - Extended `IconPathConverter` to support target icon sizes - Switched hero images in `ShellPage` to use the jumbo icon cache - Made `MainWindow` title bar logic resilient to a null `XamlRoot` - Fixed Tag icon positioning - Removes custom `TypedEventHandlerExtensions` in favor of `CommunityToolkit.WinUI.Deferred`. The bad - Since IconData lacks a unique identity, when it includes a stream, it relies on simple reference equality, acknowledging that it might not be stable. We might cache some obsolete garbage because of this, but it is fast and better than nothing at all. Yet another task for the future me. ## PR Checklist - [ ] Closes: - [ ] Closes: #38284 - [ ] Related to: #44407 - [ ] Related to: https://github.com/zadjii-msft/PowerToys/issues/333 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 1 + .../cmdpal/Microsoft.CmdPal.UI/App.xaml.cs | 10 +- .../Controls/ContextMenu.xaml | 4 +- .../Controls/FallbackRanker.xaml | 2 +- .../Controls/FiltersDropDown.xaml | 2 +- .../Microsoft.CmdPal.UI/Controls/IconBox.cs | 294 +++++++++++------ .../Controls/SourceRequestedEventArgs.cs | 4 +- .../Microsoft.CmdPal.UI/Controls/Tag.xaml | 1 + .../Microsoft.CmdPal.UI/Controls/Tag.xaml.cs | 2 +- .../ExtViews/ListPage.xaml | 33 +- .../Helpers/AdaptiveCache`2.cs | 299 ++++++++++++++++++ .../Helpers/IconCacheProvider.cs | 44 --- .../Helpers/IconCacheService.cs | 99 ------ .../Helpers/Icons/CachedIconSourceProvider.cs | 103 ++++++ .../Helpers/Icons/IIconLoaderService.cs | 21 ++ .../Helpers/Icons/IIconSourceProvider.cs | 13 + .../Helpers/Icons/IconCacheProvider.cs | 79 +++++ .../Helpers/Icons/IconLoadPriority.cs | 11 + .../Helpers/Icons/IconLoaderService.cs | 224 +++++++++++++ .../Helpers/Icons/IconServiceRegistration.cs | 37 +++ .../Helpers/Icons/IconSourceProvider.cs | 41 +++ .../Helpers/Icons/WellKnownIconSize.cs | 13 + .../Helpers/TypedEventHandlerExtensions.cs | 75 ----- .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 8 +- .../Microsoft.CmdPal.UI/Pages/ShellPage.xaml | 6 +- .../Settings/ExtensionPage.xaml | 6 +- .../Settings/ExtensionsPage.xaml | 2 +- .../IconPathConverter.idl | 2 +- 28 files changed, 1091 insertions(+), 345 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/AdaptiveCache`2.cs delete mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheProvider.cs delete mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheService.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/CachedIconSourceProvider.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconLoaderService.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconSourceProvider.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconCacheProvider.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadPriority.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconServiceRegistration.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconSourceProvider.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/WellKnownIconSize.cs delete mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs 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}" />