Add placement-aware fallbacks for recycled icons

This commit is contained in:
Jiří Polášek
2026-08-12 06:33:39 +02:00
parent 630e72317e
commit b360914ccf
11 changed files with 268 additions and 16 deletions

View File

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="24" height="24" rx="5" fill="#808080" fill-opacity="0.35"/>
</svg>

After

Width:  |  Height:  |  Size: 190 B

View File

@@ -20,6 +20,7 @@ public partial class IconBox : ContentControl
{
private const double DefaultIconFontSize = 16.0;
private static long _nextDiagnosticId;
private readonly IconPresentationState<IconSource> _presentation = new();
private double _lastScale;
private ElementTheme _lastTheme;
@@ -56,6 +57,33 @@ public partial class IconBox : ContentControl
public static readonly DependencyProperty SourceProperty =
DependencyProperty.Register(nameof(Source), typeof(IconSource), typeof(IconBox), new PropertyMetadata(null, OnSourcePropertyChanged));
/// <summary>
/// Gets or sets the source displayed while <see cref="SourceKey"/> is being resolved or cannot produce an icon.
/// A placement fallback takes precedence over a fallback supplied by the source provider.
/// </summary>
public IconSource? FallbackSource
{
get => (IconSource?)GetValue(FallbackSourceProperty);
set => SetValue(FallbackSourceProperty, value);
}
public static readonly DependencyProperty FallbackSourceProperty =
DependencyProperty.Register(nameof(FallbackSource), typeof(IconSource), typeof(IconBox), new PropertyMetadata(null, OnFallbackSourcePropertyChanged));
/// <summary>
/// Gets or sets a value indicating whether an image-oriented request that resolves to a
/// font icon should use <see cref="FallbackSource"/> instead. This is intended for image
/// placements, such as application hero images, where a glyph is not appropriate.
/// </summary>
public bool PreferFallbackSourceForFontIcons
{
get => (bool)GetValue(PreferFallbackSourceForFontIconsProperty);
set => SetValue(PreferFallbackSourceForFontIconsProperty, value);
}
public static readonly DependencyProperty PreferFallbackSourceForFontIconsProperty =
DependencyProperty.Register(nameof(PreferFallbackSourceForFontIcons), typeof(bool), typeof(IconBox), new PropertyMetadata(false, OnPreferFallbackSourceForFontIconsPropertyChanged));
/// <summary>
/// Gets or sets a value to use as the <see cref="SourceKey"/> to retrieve an <see cref="IconSource"/> to set as the <see cref="Source"/>.
/// </summary>
@@ -443,6 +471,28 @@ public partial class IconBox : ContentControl
}
}
private static void OnFallbackSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not IconBox self)
{
return;
}
self._presentation.PlacementFallback = e.NewValue as IconSource;
if (self.SourceKey is not null)
{
self.UpdatePresentedSource();
}
}
private static void OnPreferFallbackSourceForFontIconsPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is IconBox self && self.SourceKey is not null)
{
self.UpdatePresentedSource();
}
}
private static void OnSourceKeyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not IconBox self)
@@ -451,6 +501,7 @@ public partial class IconBox : ContentControl
}
self.AdvanceRequestVersion();
self._presentation.BeginSourceChange();
if (e.NewValue is null)
{
@@ -459,6 +510,9 @@ public partial class IconBox : ContentControl
return;
}
// A recycled IconBox must stop presenting the preceding item's icon before
// the replacement request has a chance to yield or enter the load queue.
self.UpdatePresentedSource();
self.RequestRefresh(IconRequestReason.SourceChanged);
}
@@ -486,7 +540,13 @@ public partial class IconBox : ContentControl
Diagnostics = diagnostics,
};
iconBox.TrackActiveRequest(requestVersion, diagnostics, eventArgs);
await sourceRequested.InvokeAsync(iconBox, eventArgs);
var invocation = sourceRequested.InvokeAsync(iconBox, eventArgs);
if (!invocation.IsCompleted)
{
iconBox.SetRequestFallback(requestVersion, sourceKey, eventArgs.FallbackSource);
}
await invocation;
// After the await:
// Is the icon we're looking up now, the one we still
@@ -501,7 +561,9 @@ public partial class IconBox : ContentControl
return;
}
iconBox.Source = eventArgs.Value;
iconBox._presentation.SetRequestFallback(eventArgs.FallbackSource);
iconBox._presentation.SetResolvedSource(eventArgs.Value, eventArgs.ExpectsImageSource);
iconBox.UpdatePresentedSource();
diagnostics.Complete(
eventArgs.Value is null ? IconRequestStatus.Empty : IconRequestStatus.Applied,
eventArgs.Value);
@@ -529,4 +591,29 @@ public partial class IconBox : ContentControl
}
}
}
private void SetRequestFallback(long requestVersion, object sourceKey, IconSource? fallbackSource)
{
if (requestVersion != _requestVersion || !ReferenceEquals(sourceKey, SourceKey))
{
return;
}
_presentation.SetRequestFallback(fallbackSource);
UpdatePresentedSource();
}
private void UpdatePresentedSource()
{
var resolvedSource = _presentation.ResolvedSource;
// Replacing a valid glyph requires both opt-ins: the placement must prefer
// an image fallback, and the provider must identify this as an image request.
// This keeps app hero images image-only without replacing emoji or other glyph heroes.
var preferFallback = resolvedSource is null
|| (PreferFallbackSourceForFontIcons && _presentation.ResolvedSourceExpectsImage && resolvedSource is FontIconSource)
|| resolvedSource is BitmapIconSource { UriSource: null }
|| resolvedSource is ImageIconSource { ImageSource: null };
Source = _presentation.SelectSource(preferFallback);
}
}

View File

@@ -0,0 +1,44 @@
// 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.Controls;
internal sealed class IconPresentationState<T>
where T : class
{
public T? PlacementFallback { get; set; }
public T? RequestFallback { get; private set; }
public T? ResolvedSource { get; private set; }
public bool HasResolvedSource { get; private set; }
public bool ResolvedSourceExpectsImage { get; private set; }
public void BeginSourceChange()
{
RequestFallback = null;
ResolvedSource = null;
HasResolvedSource = false;
ResolvedSourceExpectsImage = false;
}
public void SetRequestFallback(T? source) => RequestFallback = source;
public void SetResolvedSource(T? source, bool expectsImageSource)
{
ResolvedSource = source;
HasResolvedSource = true;
ResolvedSourceExpectsImage = expectsImageSource;
}
public T? SelectSource(bool preferFallbackForResolvedSource)
{
var fallback = PlacementFallback ?? RequestFallback;
return !HasResolvedSource || (preferFallbackForResolvedSource && fallback is not null)
? fallback
: ResolvedSource;
}
}

View File

@@ -20,6 +20,19 @@ public class SourceRequestedEventArgs(object? key, ElementTheme requestedTheme,
public IconSource? Value { get; set; }
/// <summary>
/// Gets or sets an optional source to display while <see cref="Value"/> is being resolved.
/// Handlers should set this before their first asynchronous suspension so the control can present it immediately.
/// </summary>
public IconSource? FallbackSource { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this request is expected to resolve to an image.
/// This lets image-oriented placements reject a glyph produced by a final fallback path
/// without affecting ordinary glyph requests.
/// </summary>
internal bool ExpectsImageSource { get; set; }
public ElementTheme Theme => requestedTheme;
public double Scale => scale;

View File

@@ -5,7 +5,11 @@
using ManagedCommon;
using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
namespace Microsoft.CmdPal.UI.Helpers;
@@ -14,12 +18,15 @@ namespace Microsoft.CmdPal.UI.Helpers;
/// </summary>
public static partial class IconProvider
{
private static readonly Uri AppIconFallbackUri = new("ms-appx:///Assets/Icons/AppIconFallback.svg");
private static IIconSourceProvider _provider16 = null!;
private static IIconSourceProvider _provider20 = null!;
private static IIconSourceProvider _provider32 = null!;
private static IIconSourceProvider _provider64 = null!;
private static IIconSourceProvider _provider256 = null!;
private static IIconSourceProvider _providerUnbound = null!;
private static ImageIconSource? _appIconFallbackSource;
public static void Initialize(IServiceProvider serviceProvider)
{
@@ -42,22 +49,29 @@ public static partial class IconProvider
try
{
args.Value = args.Key switch
var iconData = args.Key switch
{
IconDataViewModel iconData => await service.GetIconSource(
IconDataViewModel value => value,
IconInfoViewModel value => value.IconForTheme(args.Theme == ElementTheme.Light),
_ => null,
};
if (iconData is not null && AppIconProtocol.IsProtocol(iconData.Icon))
{
args.FallbackSource = _appIconFallbackSource ??= new ImageIconSource
{
ImageSource = new SvgImageSource(AppIconFallbackUri),
};
args.ExpectsImageSource = true;
}
args.Value = iconData is null
? null
: await service.GetIconSource(
iconData,
args.Scale,
args.Diagnostics,
args,
args.Theme),
IconInfoViewModel iconInfo => await service.GetIconSource(
args.Theme == Microsoft.UI.Xaml.ElementTheme.Light ? iconInfo.Light : iconInfo.Dark,
args.Scale,
args.Diagnostics,
args,
args.Theme),
_ => null,
};
args.Theme);
}
catch (Exception ex)
{

View File

@@ -234,6 +234,10 @@
<Content Update="..\Microsoft.CmdPal.UI.ViewModels\Assets\template.zip">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="Assets\Icons\AppIconFallback.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<DeploymentContent>true</DeploymentContent>
</Content>
<Content Update="Assets\StoreLogo.dark.svg">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</Content>

View File

@@ -473,10 +473,17 @@
HorizontalAlignment="Left"
AutomationProperties.AccessibilityView="Raw"
DiagnosticScope="HeroImage"
PreferFallbackSourceForFontIcons="True"
RequestSite="Details"
SourceKey="{x:Bind ViewModel.Details.HeroImage, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}"
Visibility="{x:Bind HasHeroImage, Mode=OneWay}" />
Visibility="{x:Bind HasHeroImage, Mode=OneWay}">
<cpcontrols:IconBox.FallbackSource>
<BitmapIconSource
ShowAsMonochrome="False"
UriSource="ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png" />
</cpcontrols:IconBox.FallbackSource>
</cpcontrols:IconBox>
<TextBlock
Grid.Row="1"

View File

@@ -63,7 +63,13 @@
DiagnosticScope="ExtensionSummary"
RequestSite="Settings"
SourceKey="{x:Bind ViewModel.Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" />
SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}">
<cpcontrols:IconBox.FallbackSource>
<BitmapIconSource
ShowAsMonochrome="False"
UriSource="ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png" />
</cpcontrols:IconBox.FallbackSource>
</cpcontrols:IconBox>
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsCard.HeaderIcon>

View File

@@ -156,7 +156,13 @@
DiagnosticScope="ExtensionList"
RequestSite="Settings"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" />
SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}">
<cpcontrols:IconBox.FallbackSource>
<BitmapIconSource
ShowAsMonochrome="False"
UriSource="ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png" />
</cpcontrols:IconBox.FallbackSource>
</cpcontrols:IconBox>
</controls:Case>
<controls:Case Value="False">
<Image

View File

@@ -0,0 +1,67 @@
// 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.UI.Controls;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.UnitTests;
[TestClass]
public class IconPresentationStateTests
{
[TestMethod]
public void SourceChangeImmediatelyReplacesResolvedSourceWithPlacementFallback()
{
var state = new IconPresentationState<string>
{
PlacementFallback = "placement",
};
state.SetResolvedSource("preceding item", expectsImageSource: true);
Assert.IsTrue(state.ResolvedSourceExpectsImage);
state.BeginSourceChange();
Assert.AreEqual("placement", state.SelectSource(preferFallbackForResolvedSource: false));
Assert.IsFalse(state.HasResolvedSource);
Assert.IsFalse(state.ResolvedSourceExpectsImage);
}
[TestMethod]
public void PlacementFallbackTakesPrecedenceOverRequestFallback()
{
var state = new IconPresentationState<string>
{
PlacementFallback = "placement",
};
state.SetRequestFallback("request");
Assert.AreEqual("placement", state.SelectSource(preferFallbackForResolvedSource: false));
}
[TestMethod]
public void ResolvedSourceReplacesFallbackUnlessPlacementPrefersFallback()
{
var state = new IconPresentationState<string>
{
PlacementFallback = "fallback",
};
state.SetResolvedSource("resolved", expectsImageSource: false);
Assert.AreEqual("resolved", state.SelectSource(preferFallbackForResolvedSource: false));
Assert.AreEqual("fallback", state.SelectSource(preferFallbackForResolvedSource: true));
}
[TestMethod]
public void NewSourceDoesNotRetainPreviousRequestFallback()
{
var state = new IconPresentationState<string>();
state.SetRequestFallback("preceding request");
state.BeginSourceChange();
Assert.IsNull(state.RequestFallback);
Assert.IsNull(state.SelectSource(preferFallbackForResolvedSource: false));
}
}

View File

@@ -28,6 +28,7 @@
<Compile Include="..\..\Microsoft.CmdPal.UI\Controls\IconRefreshState.cs" Link="Controls\IconRefreshState.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\AppIconProtocolProcessor.cs" Link="Helpers\Icons\AppIconProtocolProcessor.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Controls\IconRequestSite.cs" Link="Controls\IconRequestSite.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Controls\IconPresentationState`1.cs" Link="Controls\IconPresentationState`1.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\AdaptiveCache`2.cs" Link="Helpers\AdaptiveCache`2.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\AdaptiveCacheRemovalReason.cs" Link="Helpers\AdaptiveCacheRemovalReason.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\BinaryIconReference.cs" Link="Helpers\Icons\BinaryIconReference.cs" />