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

@@ -899,6 +899,7 @@ LEFTTEXT
LError
LEVELID
LExit
LFU
lhwnd
LIBFUZZER
LIBID

View File

@@ -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<ICommandProvider, RemoteDesktopCommandProvider>();
}
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<IThemeService, ThemeService>();
services.AddSingleton<ResourceSwapper>();
services.AddIconServices(dispatcherQueue);
}
private static void AddCoreServices(ServiceCollection services)

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

View File

@@ -27,6 +27,11 @@
<CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius>
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
<x:Double x:Key="SmallGridSize">32</x:Double>
<x:Double x:Key="MediumGridSize">48</x:Double>
<x:Double x:Key="MediumGridContainerSize">100</x:Double>
<x:Double x:Key="GalleryGridSize">160</x:Double>
<x:Double x:Key="ListViewItemMinHeight">40</x:Double>
<x:Double x:Key="ListViewSectionMinHeight">0</x:Double>
<x:Double x:Key="ListViewSeparatorMinHeight">0</x:Double>
@@ -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}" />
<StackPanel
Grid.Column="1"
@@ -377,21 +382,21 @@
<cpcontrols:IconBox
x:Name="GridIconBorder"
Width="28"
Height="28"
Width="{StaticResource SmallGridSize}"
Height="{StaticResource SmallGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested32}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="MediumGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
<Grid
Width="100"
Height="100"
Width="{StaticResource MediumGridContainerSize}"
Height="{StaticResource MediumGridContainerSize}"
Padding="8"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
CornerRadius="{StaticResource MediumGridViewItemCornerRadius}"
@@ -403,15 +408,15 @@
<cpcontrols:IconBox
x:Name="GridIconBorder"
Grid.Row="0"
Width="36"
Height="36"
Width="{StaticResource MediumGridSize}"
Height="{StaticResource MediumGridSize}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}" />
<TextBlock
x:Name="TitleTextBlock"
Grid.Row="1"
@@ -430,7 +435,7 @@
<DataTemplate x:Key="GalleryGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
<StackPanel
Width="160"
Width="{StaticResource GalleryGridSize}"
Margin="4"
Padding="0"
HorizontalAlignment="Center"
@@ -442,8 +447,8 @@
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid
Width="160"
Height="160"
Width="{StaticResource GalleryGridSize}"
Height="{StaticResource GalleryGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
@@ -456,7 +461,7 @@
CornerRadius="4"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested256}" />
</Viewbox>
</Grid>
@@ -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}" />
<TextBlock
Margin="0,4,0,0"
HorizontalAlignment="Center"

View File

@@ -0,0 +1,299 @@
// 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.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.CmdPal.Core.Common.Helpers;
namespace Microsoft.CmdPal.UI.Helpers;
/// <summary>
/// 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.
/// </summary>
internal sealed class AdaptiveCache<TKey, TValue>
where TKey : IEquatable<TKey>
{
private readonly int _capacity;
private readonly double _decayFactor;
private readonly TimeSpan _decayInterval;
private readonly ConcurrentDictionary<TKey, CacheEntry> _map;
private readonly ConcurrentStack<CacheEntry> _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<TKey, CacheEntry>(Environment.ProcessorCount, capacity);
_maintenanceCallback = static state =>
{
var cache = (AdaptiveCache<TKey, TValue>)state!;
try
{
cache.PerformCleanup();
}
finally
{
cache._maintenanceSwitch.Clear();
}
};
}
public TValue GetOrAdd<TArg>(TKey key, Func<TKey, TArg, TValue> 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);
}
}
}
}
/// <summary>
/// Calculates the survival score of an entry.
/// Higher score = stay in cache; Lower score = priority for eviction.
/// </summary>
[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);
}
/// <summary>
/// Represents a single pooled entry in the cache, containing the value and
/// atomic metadata for adaptive eviction logic.
/// </summary>
private sealed class CacheEntry
{
/// <summary>
/// Gets the key associated with this entry. Used primarily for identification during cleanup.
/// </summary>
public TKey Key { get; private set; } = default!;
/// <summary>
/// Gets the cached value. This reference is cleared on eviction to allow GC collection.
/// </summary>
public TValue Value { get; private set; } = default!;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This allows the use of Interlocked.CompareExchange to perform thread-safe floating point
/// arithmetic without a global lock.
/// </remarks>
private long _frequencyBits;
/// <summary>
/// The tick (monotonically increasing counter) of the last time this entry was accessed.
/// </summary>
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);
}
}
}

View File

@@ -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;
/// <summary>
/// Common async event handler provides the cache lookup function for the <see cref="IconBox.SourceRequested"/> deferred event.
/// </summary>
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();
}
}
}

View File

@@ -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<IconSource?> GetIconSource(IconDataViewModel icon) =>
// todo: actually implement a cache of some sort
IconToSource(icon);
private async Task<IconSource?> 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<IconSource?> 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<BitmapImage> 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<T> TryEnqueueAsync<T>(DispatcherQueue dispatcher, Func<Task<T>> function)
{
var completionSource = new TaskCompletionSource<T>();
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;
}
}

View File

@@ -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<IconCacheKey, Task<IconSource?>> _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<IconCacheKey, Task<IconSource?>>(cacheSize, TimeSpan.FromMinutes(60));
}
public CachedIconSourceProvider(IconLoaderService loader, int iconSize, int cacheSize)
: this(loader, new Size(iconSize, iconSize), cacheSize)
{
}
public Task<IconSource?> 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<IconSource?> GetOrCreateSlowPath(IconCacheKey key, IconDataViewModel icon, double scale)
{
lock (_lock)
{
if (_cache.TryGet(key, out var existingTask))
{
return existingTask;
}
var tcs = new TaskCompletionSource<IconSource?>(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<IconCacheKey>
{
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);
}
}

View File

@@ -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<IconSource?> tcs,
IconLoadPriority priority);
}

View File

@@ -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<IconSource?> GetIconSource(IconDataViewModel icon, double scale);
}

View File

@@ -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;
/// <summary>
/// Common async event handler provides the cache lookup function for the <see cref="IconBox.SourceRequested"/> deferred event.
/// </summary>
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<IIconSourceProvider>(WellKnownIconSize.Size20);
_provider32 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size32);
_provider64 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size64);
_provider256 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(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
}

View File

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

View File

@@ -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<Func<Task>> _highPriorityQueue = Channel.CreateBounded<Func<Task>>(32);
private readonly Channel<Func<Task>> _lowPriorityQueue = Channel.CreateUnbounded<Func<Task>>();
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<IconSource?> 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<Task>? 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<Task> 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<IconSource?> 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<IconSource?> 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<IconSource?> 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);
}
}

View File

@@ -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<IIconLoaderService>(loader);
// Keyed providers by size
services.AddKeyedSingleton<IIconSourceProvider>(
WellKnownIconSize.Size20,
(_, _) => new CachedIconSourceProvider(loader, 20, 1024));
services.AddKeyedSingleton<IIconSourceProvider>(
WellKnownIconSize.Size32,
(_, _) => new IconSourceProvider(loader, 32));
services.AddKeyedSingleton<IIconSourceProvider>(
WellKnownIconSize.Size64,
(_, _) => new CachedIconSourceProvider(loader, 64, 256));
services.AddKeyedSingleton<IIconSourceProvider>(
WellKnownIconSize.Size256,
(_, _) => new CachedIconSourceProvider(loader, 256, 64));
return services;
}
}

View File

@@ -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<IconSource?> GetIconSource(IconDataViewModel icon, double scale)
{
var tcs = new TaskCompletionSource<IconSource?>(TaskCreationOptions.RunContinuationsAsynchronously);
_loader.EnqueueLoad(
icon.Icon,
icon.FontFamily,
icon.Data?.Unsafe,
_iconSize,
scale,
tcs);
return tcs.Task;
}
}

View File

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

View File

@@ -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;
/// <summary>
/// Extensions to <see cref="TypedEventHandler{TSender, TResult}"/> for Deferred Events.
/// </summary>
public static class TypedEventHandlerExtensions
{
/// <summary>
/// Use to invoke an async <see cref="TypedEventHandler{TSender, TResult}"/> using <see cref="DeferredEventArgs"/>.
/// </summary>
/// <typeparam name="S">Type of sender.</typeparam>
/// <typeparam name="R"><see cref="EventArgs"/> type.</typeparam>
/// <param name="eventHandler"><see cref="TypedEventHandler{TSender, TResult}"/> to be invoked.</param>
/// <param name="sender">Sender of the event.</param>
/// <param name="eventArgs"><see cref="EventArgs"/> instance.</param>
/// <returns><see cref="Task"/> to wait on deferred event handler.</returns>
#pragma warning disable CA1715 // Identifiers should have correct prefix
#pragma warning disable SA1314 // Type parameter names should begin with T
public static Task InvokeAsync<S, R>(this TypedEventHandler<S, R> 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);
/// <summary>
/// Use to invoke an async <see cref="TypedEventHandler{TSender, TResult}"/> using <see cref="DeferredEventArgs"/> with a <see cref="CancellationToken"/>.
/// </summary>
/// <typeparam name="S">Type of sender.</typeparam>
/// <typeparam name="R"><see cref="EventArgs"/> type.</typeparam>
/// <param name="eventHandler"><see cref="TypedEventHandler{TSender, TResult}"/> to be invoked.</param>
/// <param name="sender">Sender of the event.</param>
/// <param name="eventArgs"><see cref="EventArgs"/> instance.</param>
/// <param name="cancellationToken"><see cref="CancellationToken"/> option.</param>
/// <returns><see cref="Task"/> to wait on deferred event handler.</returns>
#pragma warning disable CA1715 // Identifiers should have correct prefix
#pragma warning disable SA1314 // Type parameter names should begin with T
public static Task InvokeAsync<S, R>(this TypedEventHandler<S, R> 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<TypedEventHandler<S, R>>()
.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);
}
}

View File

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

View File

@@ -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}" />
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="{x:Bind Name}" />
</StackPanel>
</Button>
@@ -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}">
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation
@@ -429,7 +429,7 @@
HorizontalAlignment="Left"
AutomationProperties.AccessibilityView="Raw"
SourceKey="{x:Bind ViewModel.Details.HeroImage, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}"
Visibility="{x:Bind HasHeroImage, Mode=OneWay}" />
<TextBlock

View File

@@ -60,7 +60,7 @@
Height="20"
AutomationProperties.AccessibilityView="Raw"
SourceKey="{x:Bind ViewModel.Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" />
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsCard.HeaderIcon>
@@ -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}" />
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsExpander.HeaderIcon>
@@ -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}" />
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsExpander.HeaderIcon>

View File

@@ -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}" />
</controls:Case>
<controls:Case Value="False">
<Image

View File

@@ -7,7 +7,7 @@ namespace Microsoft.Terminal.UI
{
// static Windows.UI.Xaml.Controls.IconElement IconWUX(String path);
// static Windows.UI.Xaml.Controls.IconSource IconSourceWUX(String path);
static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, Boolean convertToGrayscale, String fontFamily);
static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, Boolean convertToGrayscale, String fontFamily, Int32 targetSize);
static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path);
static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path, Int32 targetSize);
};