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.

<!-- Please review the items on the PR checklist before submitting-->
## 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

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2026-02-02 18:16:43 +01:00
committed by GitHub
parent b5991642f8
commit dca532cf4b
28 changed files with 1091 additions and 345 deletions

View File

@@ -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}" />
<TextBlock
x:Name="TitleTextBlock"
Grid.Column="1"
@@ -83,7 +83,7 @@
HorizontalAlignment="Left"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" />
<TextBlock
x:Name="TitleTextBlock"
Grid.Column="1"

View File

@@ -49,7 +49,7 @@
Height="16"
AutomationProperties.AccessibilityView="Raw"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" />
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsCard.HeaderIcon>

View File

@@ -46,7 +46,7 @@
HorizontalAlignment="Left"
VerticalAlignment="Center"
SourceKey="{x:Bind Icon}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"

View File

@@ -2,15 +2,11 @@
// 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.WinUI.Deferred;
using ManagedCommon;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.Deferred;
using Microsoft.Terminal.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Controls;
@@ -20,7 +16,11 @@ namespace Microsoft.CmdPal.UI.Controls;
/// </summary>
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;
/// <summary>
/// Gets or sets the <see cref="IconSource"/> to display within the <see cref="IconBox"/>. Overwritten, if <see cref="SourceKey"/> 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<IconBox, SourceRequestedEventArgs>? _sourceRequested;
/// <summary>
/// Gets or sets the <see cref="SourceRequested"/> event handler to provide the value of the <see cref="IconSource"/> for the <see cref="Source"/> property from the provided <see cref="SourceKey"/>.
/// </summary>
public event TypedEventHandler<IconBox, SourceRequestedEventArgs>? SourceRequested;
public event TypedEventHandler<IconBox, SourceRequestedEventArgs>? 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);
}
}
}

View File

@@ -11,11 +11,13 @@ namespace Microsoft.CmdPal.UI.Controls;
/// <summary>
/// See <see cref="IconBox.SourceRequested"/> event.
/// </summary>
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;
}

View File

@@ -72,6 +72,7 @@
<local:IconBox
x:Name="PART_Icon"
Grid.Column="0"
Width="12"
Height="12"
Margin="{Binding Text, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource IconMarginConverter}}"
SourceKey="{TemplateBinding Icon}" />

View File

@@ -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;
}
}