Add generated swatch and initials icon protocols

This commit is contained in:
Jiří Polášek
2026-08-12 06:39:58 +02:00
parent 3a11581532
commit c347460e63
19 changed files with 1680 additions and 10 deletions

View File

@@ -28,6 +28,8 @@ internal sealed class AppIconProtocolProcessor : IIconProtocolProcessor
public ReadOnlySpan<string> ProtocolPrefixes => AppIconProtocol.ProtocolPrefixes;
public string GetCacheIdentity(string value) => value;
public ElementTheme GetCacheTheme(string value, ElementTheme theme) => ElementTheme.Default;
public IconLoadInputKind ClassifyInput(string value) => IconLoadInputKind.SpecializedAppIcon;

View File

@@ -61,8 +61,11 @@ internal sealed class CachedIconSourceProvider : IIconSourceProvider
ElementTheme theme = ElementTheme.Default)
{
var protocolProcessor = IconProtocolRegistry.Find(icon.Icon);
var iconIdentity = icon.Icon is { } iconString && protocolProcessor is not null
? protocolProcessor.GetCacheIdentity(iconString)
: icon.Icon;
var cacheTheme = protocolProcessor?.GetCacheTheme(icon.Icon!, theme) ?? ElementTheme.Default;
var key = new IconCacheKey(icon, scale, cacheTheme);
var key = new IconCacheKey(icon, iconIdentity, scale, cacheTheme);
var partition = ClassifyCachePartition(icon.Icon, protocolProcessor);
var cache = GetCache(partition);
var cacheSize = GetCacheSize(partition);
@@ -256,9 +259,13 @@ internal sealed class CachedIconSourceProvider : IIconSourceProvider
private readonly int _scale;
private readonly ElementTheme _theme;
public IconCacheKey(IconDataViewModel icon, double scale, ElementTheme cacheTheme)
public IconCacheKey(
IconDataViewModel icon,
string? iconIdentity,
double scale,
ElementTheme cacheTheme)
{
_icon = icon.Icon;
_icon = iconIdentity;
_fontFamily = icon.FontFamily;
_streamIdentity = icon.Data?.Unsafe is { } stream
? StreamIdentities.GetValue(stream, static _ => new StreamIdentity())

View File

@@ -0,0 +1,795 @@
// 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.Globalization;
using System.Text;
using System.Xml;
using Microsoft.UI.Xaml;
namespace Microsoft.CmdPal.UI.Helpers;
/// <summary>
/// Parses <c>|Swatch|color[|dark]</c> and
/// <c>|Initials|text|color[|dark][|circle|rounded]</c> icon strings.
/// Initials accept one to three Unicode text elements. A literal percent sign in
/// the text token is encoded as <c>%25</c>, and a literal separator as <c>%7C</c>.
/// Percent encoding keeps this hand-authored protocol legible; the machine-generated
/// app-icon protocol uses length-prefixed fields instead.
/// Colors use the XAML #RGB, #ARGB, #RRGGBB, or #AARRGGBB forms.
/// </summary>
internal static class GeneratedIconProtocol
{
private const int MaxEncodedInitialsLength = 96;
private const int MaxInitialsLength = 32;
private const int MaxInitialsTextElements = 3;
private const string SwatchPrefix = "|Swatch|";
private const string InitialsPrefix = "|Initials|";
private static readonly string[] ProtocolPrefixValues = [SwatchPrefix, InitialsPrefix];
private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
public static ReadOnlySpan<string> ProtocolPrefixes => ProtocolPrefixValues;
public static string GetCacheIdentity(string value)
{
var kind = Classify(value);
if (kind == Kind.None)
{
return value;
}
try
{
if (kind == Kind.Swatch)
{
var swatchPayload = value.AsSpan(SwatchPrefix.Length);
if (HasCanonicalStyleTokenCasing(swatchPayload)
|| !TryParseSwatch(swatchPayload, out _, out _, out _))
{
return value;
}
return CanonicalizeStyleTokenCasing(value, SwatchPrefix.Length);
}
var payload = value.AsSpan(InitialsPrefix.Length);
var separator = payload.IndexOf('|');
if (separator <= 0)
{
return value;
}
var initialsToken = payload[..separator];
var stylePayload = payload[(separator + 1)..];
var hasCanonicalInitials = IsCanonicalAsciiInitials(initialsToken);
var hasCanonicalStyle = HasCanonicalStyleTokenCasing(stylePayload);
if (hasCanonicalInitials && hasCanonicalStyle)
{
return value;
}
if (!TryParseInitials(
payload,
out var initials,
out _,
out _,
out _,
out _))
{
return value;
}
if (hasCanonicalInitials)
{
return CanonicalizeStyleTokenCasing(
value,
InitialsPrefix.Length + separator + 1);
}
// Canonicalize both Unicode representation and token escaping before
// cache lookup, so visually identical text cannot occupy two entries.
var escapedInitials = EscapeInitialsToken(initials);
var identity = InitialsPrefix
+ escapedInitials
+ payload[separator..].ToString();
return hasCanonicalStyle
? identity
: CanonicalizeStyleTokenCasing(
identity,
InitialsPrefix.Length + escapedInitials.Length + 1);
}
catch
{
return value;
}
}
private static bool IsCanonicalAsciiInitials(ReadOnlySpan<char> value)
{
if (value.Length is < 1 or > MaxInitialsTextElements)
{
return false;
}
foreach (var character in value)
{
if (character is not (>= 'A' and <= 'Z')
and not (>= '0' and <= '9'))
{
return false;
}
}
return true;
}
private static bool HasCanonicalStyleTokenCasing(ReadOnlySpan<char> payload)
{
payload = TrimOptionalTrailingSeparator(payload);
while (TryReadToken(ref payload, out var token))
{
if (token.IsEmpty)
{
continue;
}
if (token[0] == '#')
{
foreach (var character in token[1..])
{
if (character is >= 'a' and <= 'f')
{
return false;
}
}
}
else
{
foreach (var character in token)
{
if (character is >= 'A' and <= 'Z')
{
return false;
}
}
}
}
return true;
}
private static string CanonicalizeStyleTokenCasing(string value, int styleStart) =>
string.Create(
value.Length,
(Value: value, StyleStart: styleStart),
static (destination, state) =>
{
state.Value.AsSpan().CopyTo(destination);
var remaining = destination[state.StyleStart..];
while (!remaining.IsEmpty)
{
var separator = remaining.IndexOf('|');
var token = separator < 0 ? remaining : remaining[..separator];
if (!token.IsEmpty)
{
if (token[0] == '#')
{
for (var index = 1; index < token.Length; index++)
{
if (token[index] is >= 'a' and <= 'f')
{
token[index] = (char)(token[index] - ('a' - 'A'));
}
}
}
else
{
for (var index = 0; index < token.Length; index++)
{
if (token[index] is >= 'A' and <= 'Z')
{
token[index] = (char)(token[index] + ('a' - 'A'));
}
}
}
}
if (separator < 0)
{
break;
}
remaining = remaining[(separator + 1)..];
}
});
public static Kind Classify(string? value)
{
if (value?.StartsWith(SwatchPrefix, StringComparison.Ordinal) == true)
{
return Kind.Swatch;
}
if (value?.StartsWith(InitialsPrefix, StringComparison.Ordinal) == true)
{
return Kind.Initials;
}
return Kind.None;
}
public static ElementTheme GetCacheTheme(string? value, ElementTheme theme)
{
if (!IsThemeDependent(value))
{
return ElementTheme.Default;
}
return theme == ElementTheme.Dark ? ElementTheme.Dark : ElementTheme.Light;
}
public static bool TryCreateSwatchSvg(string? value, ElementTheme theme, out byte[] svg)
{
svg = [];
try
{
if (Classify(value) != Kind.Swatch
|| !TryParseSwatch(
value!.AsSpan(SwatchPrefix.Length),
out var light,
out var dark,
out _))
{
return false;
}
svg = CreateSwatchSvg(SelectColor(light, dark, theme));
return true;
}
catch
{
svg = [];
return false;
}
}
public static bool TryCreateInitialsSvg(string? value, ElementTheme theme, out byte[] svg)
{
svg = [];
try
{
if (Classify(value) != Kind.Initials
|| !TryParseInitials(
value!.AsSpan(InitialsPrefix.Length),
out var initials,
out var light,
out var dark,
out _,
out var shape))
{
return false;
}
var hasGlyph = InitialsTextRenderer.TryCreatePathData(
initials,
out var pathData,
out var useEvenOddFill);
svg = CreateInitialsSvg(
hasGlyph ? pathData : null,
useEvenOddFill,
SelectColor(light, dark, theme),
theme,
shape);
return true;
}
catch
{
svg = [];
return false;
}
}
private static bool IsThemeDependent(string? value)
{
switch (Classify(value))
{
case Kind.Swatch:
return TryParseSwatch(value!.AsSpan(SwatchPrefix.Length), out _, out _, out var hasDark) && hasDark;
case Kind.Initials:
// Foreground contrast can depend on the surface theme when the
// background is translucent. Keep every initials entry isolated
// by theme so this cheap discriminator never has to parse it.
return true;
default:
return false;
}
}
private static bool TryParseSwatch(
ReadOnlySpan<char> payload,
out RgbaColor light,
out RgbaColor dark,
out bool hasDark)
{
light = default;
dark = default;
hasDark = false;
payload = TrimOptionalTrailingSeparator(payload);
if (!TryReadToken(ref payload, out var lightToken) || !TryParseColor(lightToken, out light))
{
return false;
}
dark = light;
if (!payload.IsEmpty)
{
if (!TryReadToken(ref payload, out var darkToken) || !TryParseColor(darkToken, out dark))
{
return false;
}
hasDark = true;
}
return payload.IsEmpty;
}
private static bool TryParseInitials(
ReadOnlySpan<char> payload,
out string initials,
out RgbaColor light,
out RgbaColor dark,
out bool hasDark,
out InitialsShape shape)
{
initials = string.Empty;
light = default;
dark = default;
hasDark = false;
shape = InitialsShape.Circle;
payload = TrimOptionalTrailingSeparator(payload);
if (!TryReadToken(ref payload, out var initialsToken)
|| !TryNormalizeInitials(initialsToken, out initials)
|| !TryReadToken(ref payload, out var lightToken)
|| !TryParseColor(lightToken, out light))
{
return false;
}
dark = light;
if (!payload.IsEmpty)
{
if (!TryReadToken(ref payload, out var nextToken))
{
return false;
}
if (TryParseColor(nextToken, out var darkColor))
{
dark = darkColor;
hasDark = true;
if (!payload.IsEmpty
&& (!TryReadToken(ref payload, out var shapeToken) || !TryParseShape(shapeToken, out shape)))
{
return false;
}
}
else if (!TryParseShape(nextToken, out shape))
{
return false;
}
}
if (!payload.IsEmpty)
{
return false;
}
return true;
}
private static bool TryNormalizeInitials(ReadOnlySpan<char> value, out string initials)
{
initials = string.Empty;
if (value.IsEmpty
|| value.Length > MaxEncodedInitialsLength
|| !TryDecodeInitialsToken(value, out var decoded))
{
return false;
}
decoded = decoded.Trim();
if (decoded.Length is < 1 or > MaxInitialsLength)
{
return false;
}
// Preserve the original ASCII behavior while making canonically equivalent
// Unicode spellings share rendering and cache identity.
var normalized = decoded
.Normalize(NormalizationForm.FormC)
.ToUpperInvariant()
.Normalize(NormalizationForm.FormC);
if (normalized.Length is < 1 or > MaxInitialsLength)
{
return false;
}
var remaining = normalized.AsSpan();
var textElementCount = 0;
while (!remaining.IsEmpty)
{
var textElementLength = StringInfo.GetNextTextElementLength(remaining);
if (textElementLength <= 0 || ++textElementCount > MaxInitialsTextElements)
{
return false;
}
remaining = remaining[textElementLength..];
}
foreach (var rune in normalized.EnumerateRunes())
{
var category = Rune.GetUnicodeCategory(rune);
if (category is UnicodeCategory.Control
or UnicodeCategory.LineSeparator
or UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
initials = normalized;
return true;
}
private static bool TryDecodeInitialsToken(ReadOnlySpan<char> value, out string decoded)
{
decoded = string.Empty;
if (value.IndexOf('%') < 0)
{
decoded = value.ToString();
return true;
}
var builder = new StringBuilder(value.Length);
Span<byte> escapedBytes = stackalloc byte[MaxEncodedInitialsLength / 3];
for (var index = 0; index < value.Length; index++)
{
if (value[index] != '%')
{
builder.Append(value[index]);
continue;
}
var byteCount = 0;
while (index < value.Length && value[index] == '%')
{
if (index > value.Length - 3
|| !TryParseHexByte(value.Slice(index + 1, 2), out var escapedByte))
{
return false;
}
escapedBytes[byteCount++] = escapedByte;
index += 3;
}
try
{
builder.Append(StrictUtf8.GetString(escapedBytes[..byteCount]));
}
catch (DecoderFallbackException)
{
return false;
}
index--;
}
decoded = builder.ToString();
return true;
}
private static string EscapeInitialsToken(string value)
{
if (value.AsSpan().IndexOfAny('%', '|') < 0)
{
return value;
}
// Escape '%' first. Reversing these calls would escape the '%' introduced
// for a literal separator and make the canonical token decode incorrectly.
return value
.Replace("%", "%25", StringComparison.Ordinal)
.Replace("|", "%7C", StringComparison.Ordinal);
}
private static bool TryParseShape(ReadOnlySpan<char> value, out InitialsShape shape)
{
if (value.Equals("circle", StringComparison.OrdinalIgnoreCase))
{
shape = InitialsShape.Circle;
return true;
}
if (value.Equals("rounded", StringComparison.OrdinalIgnoreCase))
{
shape = InitialsShape.RoundedSquare;
return true;
}
shape = default;
return false;
}
private static bool TryParseColor(ReadOnlySpan<char> value, out RgbaColor color)
{
color = default;
if (value.IsEmpty || value[0] != '#')
{
return false;
}
value = value[1..];
switch (value.Length)
{
case 3:
if (!TryParseHexDigit(value[0], out var shortRed)
|| !TryParseHexDigit(value[1], out var shortGreen)
|| !TryParseHexDigit(value[2], out var shortBlue))
{
return false;
}
color = new RgbaColor(255, ExpandHexDigit(shortRed), ExpandHexDigit(shortGreen), ExpandHexDigit(shortBlue));
return true;
case 4:
if (!TryParseHexDigit(value[0], out var shortAlpha)
|| !TryParseHexDigit(value[1], out shortRed)
|| !TryParseHexDigit(value[2], out shortGreen)
|| !TryParseHexDigit(value[3], out shortBlue))
{
return false;
}
color = new RgbaColor(
ExpandHexDigit(shortAlpha),
ExpandHexDigit(shortRed),
ExpandHexDigit(shortGreen),
ExpandHexDigit(shortBlue));
return true;
case 6:
if (!TryParseHexByte(value[..2], out var red)
|| !TryParseHexByte(value.Slice(2, 2), out var green)
|| !TryParseHexByte(value.Slice(4, 2), out var blue))
{
return false;
}
color = new RgbaColor(255, red, green, blue);
return true;
case 8:
if (!TryParseHexByte(value[..2], out var alpha)
|| !TryParseHexByte(value.Slice(2, 2), out red)
|| !TryParseHexByte(value.Slice(4, 2), out green)
|| !TryParseHexByte(value.Slice(6, 2), out blue))
{
return false;
}
color = new RgbaColor(alpha, red, green, blue);
return true;
default:
return false;
}
}
private static bool TryParseHexByte(ReadOnlySpan<char> value, out byte result)
{
if (!TryParseHexDigit(value[0], out var high) || !TryParseHexDigit(value[1], out var low))
{
result = 0;
return false;
}
result = (byte)((high << 4) | low);
return true;
}
private static bool TryParseHexDigit(char value, out byte result)
{
if (value is >= '0' and <= '9')
{
result = (byte)(value - '0');
return true;
}
if (value is >= 'A' and <= 'F')
{
result = (byte)(value - 'A' + 10);
return true;
}
if (value is >= 'a' and <= 'f')
{
result = (byte)(value - 'a' + 10);
return true;
}
result = 0;
return false;
}
private static byte ExpandHexDigit(byte value) => (byte)((value << 4) | value);
private static ReadOnlySpan<char> TrimOptionalTrailingSeparator(ReadOnlySpan<char> value) =>
!value.IsEmpty && value[^1] == '|' ? value[..^1] : value;
private static bool TryReadToken(ref ReadOnlySpan<char> remaining, out ReadOnlySpan<char> token)
{
if (remaining.IsEmpty)
{
token = default;
return false;
}
var separator = remaining.IndexOf('|');
if (separator < 0)
{
token = remaining;
remaining = [];
}
else
{
token = remaining[..separator];
remaining = remaining[(separator + 1)..];
}
return !token.IsEmpty;
}
private static RgbaColor SelectColor(RgbaColor light, RgbaColor dark, ElementTheme theme) =>
theme == ElementTheme.Dark ? dark : light;
private static byte[] CreateSwatchSvg(RgbaColor color)
{
using var stream = new MemoryStream();
using (var writer = CreateSvgWriter(stream))
{
WriteSvgStart(writer);
writer.WriteStartElement("circle");
writer.WriteAttributeString("cx", "16");
writer.WriteAttributeString("cy", "16");
writer.WriteAttributeString("r", "12");
WriteFill(writer, color);
writer.WriteEndElement();
writer.WriteEndElement();
}
return stream.ToArray();
}
private static byte[] CreateInitialsSvg(
string? pathData,
bool useEvenOddFill,
RgbaColor background,
ElementTheme theme,
InitialsShape shape)
{
using var stream = new MemoryStream();
using (var writer = CreateSvgWriter(stream))
{
WriteSvgStart(writer);
if (shape == InitialsShape.Circle)
{
writer.WriteStartElement("circle");
writer.WriteAttributeString("cx", "16");
writer.WriteAttributeString("cy", "16");
writer.WriteAttributeString("r", "15.5");
}
else
{
writer.WriteStartElement("rect");
writer.WriteAttributeString("x", "0.5");
writer.WriteAttributeString("y", "0.5");
writer.WriteAttributeString("width", "31");
writer.WriteAttributeString("height", "31");
writer.WriteAttributeString("rx", "7");
}
WriteFill(writer, background);
writer.WriteEndElement();
if (!string.IsNullOrEmpty(pathData))
{
writer.WriteStartElement("path");
writer.WriteAttributeString("d", pathData);
if (useEvenOddFill)
{
writer.WriteAttributeString("fill-rule", "evenodd");
}
WriteFill(writer, GetContrastingForeground(background, theme));
writer.WriteEndElement();
}
writer.WriteEndElement();
}
return stream.ToArray();
}
private static XmlWriter CreateSvgWriter(Stream stream) =>
XmlWriter.Create(
stream,
new XmlWriterSettings
{
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
OmitXmlDeclaration = true,
Indent = false,
CloseOutput = false,
});
private static void WriteSvgStart(XmlWriter writer)
{
writer.WriteStartElement("svg", "http://www.w3.org/2000/svg");
writer.WriteAttributeString("viewBox", "0 0 32 32");
}
private static void WriteFill(XmlWriter writer, RgbaColor color)
{
writer.WriteAttributeString("fill", FormattableString.Invariant($"#{color.R:X2}{color.G:X2}{color.B:X2}"));
if (color.A != byte.MaxValue)
{
writer.WriteAttributeString(
"fill-opacity",
(color.A / 255d).ToString("0.###", CultureInfo.InvariantCulture));
}
}
private static RgbaColor GetContrastingForeground(RgbaColor background, ElementTheme theme)
{
var surface = theme == ElementTheme.Dark ? (byte)32 : byte.MaxValue;
var red = Composite(background.R, background.A, surface);
var green = Composite(background.G, background.A, surface);
var blue = Composite(background.B, background.A, surface);
var luminance = (0.2126 * ToLinear(red)) + (0.7152 * ToLinear(green)) + (0.0722 * ToLinear(blue));
return luminance > 0.179
? new RgbaColor(255, 0, 0, 0)
: new RgbaColor(255, 255, 255, 255);
}
private static byte Composite(byte foreground, byte alpha, byte background) =>
(byte)(((foreground * alpha) + (background * (byte.MaxValue - alpha)) + 127) / byte.MaxValue);
private static double ToLinear(byte channel)
{
var value = channel / 255d;
return value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4);
}
internal enum Kind
{
None,
Swatch,
Initials,
}
private enum InitialsShape
{
Circle,
RoundedSquare,
}
private readonly record struct RgbaColor(byte A, byte R, byte G, byte B);
}

View File

@@ -0,0 +1,76 @@
// 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;
namespace Microsoft.CmdPal.UI.Helpers;
internal sealed class GeneratedIconProtocolProcessor : IIconProtocolProcessor
{
public static GeneratedIconProtocolProcessor Instance { get; } = new();
private GeneratedIconProtocolProcessor()
{
}
public IconCachePartition CachePartition => IconCachePartition.Other;
public ReadOnlySpan<string> ProtocolPrefixes => GeneratedIconProtocol.ProtocolPrefixes;
public string GetCacheIdentity(string value) => GeneratedIconProtocol.GetCacheIdentity(value);
public ElementTheme GetCacheTheme(string value, ElementTheme theme) =>
GeneratedIconProtocol.GetCacheTheme(value, theme);
public IconLoadInputKind ClassifyInput(string value) =>
GeneratedIconProtocol.Classify(value) switch
{
GeneratedIconProtocol.Kind.Swatch => IconLoadInputKind.GeneratedSwatch,
GeneratedIconProtocol.Kind.Initials => IconLoadInputKind.GeneratedInitials,
_ => IconLoadInputKind.String,
};
public bool TryPrepareSynchronously(
string value,
int targetSize,
ElementTheme theme,
out IconPathConverter.PreparedIcon preparedIcon)
{
if (GeneratedIconProtocol.Classify(value) == GeneratedIconProtocol.Kind.Initials)
{
// Font fallback and outline extraction must never run in a synchronous
// caller such as the WinUI STA. PrepareAsync owns all initials work.
preparedIcon = null!;
return false;
}
preparedIcon = GeneratedIconProtocol.TryCreateSwatchSvg(value, theme, out var svg)
? IconPathConverter.PreparedIcon.FromSvgData(svg, targetSize)
: IconPathConverter.PreparedIcon.Empty();
return true;
}
public async ValueTask<IconProtocolProcessingResult> PrepareAsync(
string value,
int targetSize,
ElementTheme theme)
{
if (GeneratedIconProtocol.Classify(value) != GeneratedIconProtocol.Kind.Initials)
{
_ = TryPrepareSynchronously(value, targetSize, theme, out var synchronousIcon);
return IconProtocolProcessingResult.FromPreparedIcon(synchronousIcon);
}
// The loader currently calls async processors from a worker, but keep this
// boundary independently safe if another caller reaches it from the STA.
return await Task.Run(
() =>
{
var preparedIcon = GeneratedIconProtocol.TryCreateInitialsSvg(value, theme, out var svg)
? IconPathConverter.PreparedIcon.FromSvgData(svg, targetSize)
: IconPathConverter.PreparedIcon.Empty();
return IconProtocolProcessingResult.FromPreparedIcon(preparedIcon);
}).ConfigureAwait(false);
}
}

View File

@@ -13,6 +13,8 @@ internal interface IIconProtocolProcessor
ReadOnlySpan<string> ProtocolPrefixes { get; }
string GetCacheIdentity(string value);
ElementTheme GetCacheTheme(string value, ElementTheme theme);
IconLoadInputKind ClassifyInput(string value);

View File

@@ -11,4 +11,6 @@ internal enum IconLoadInputKind
ShellBinary,
Stream,
SpecializedAppIcon,
GeneratedSwatch,
GeneratedInitials,
}

View File

@@ -396,6 +396,7 @@ internal sealed partial class IconLoaderService : IIconLoaderService
IconPathConverter.PreparedIconKind.SvgUri => IconDispatcherMaterializationKind.SvgUri,
IconPathConverter.PreparedIconKind.Glyph => IconDispatcherMaterializationKind.Glyph,
IconPathConverter.PreparedIconKind.Binary => IconDispatcherMaterializationKind.Binary,
IconPathConverter.PreparedIconKind.SvgData => IconDispatcherMaterializationKind.SvgData,
_ => IconDispatcherMaterializationKind.Unknown,
};

View File

@@ -11,6 +11,7 @@ using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
using DrawingIcon = System.Drawing.Icon;
using DrawingImageLockMode = System.Drawing.Imaging.ImageLockMode;
using DrawingPixelFormat = System.Drawing.Imaging.PixelFormat;
@@ -92,8 +93,8 @@ internal static partial class IconPathConverter
/// Attempts to create an icon source without an asynchronous bitmap transfer.
/// </summary>
/// <returns>
/// <see langword="false"/> only when a populated binary icon must be transferred
/// to a <see cref="SoftwareBitmapSource"/> asynchronously.
/// <see langword="false"/> only when generated SVG data or a populated binary icon
/// must be transferred to its XAML image source asynchronously.
/// </returns>
public static bool TryCreateIconSourceSynchronously(
PreparedIcon icon,
@@ -122,6 +123,10 @@ internal static partial class IconPathConverter
iconSource = new ImageIconSource { ImageSource = svg };
return true;
case PreparedIconKind.SvgData:
iconSource = null!;
return false;
case PreparedIconKind.Glyph:
iconSource = new FontIconSource
{
@@ -148,7 +153,7 @@ internal static partial class IconPathConverter
}
catch
{
iconSource = icon.Kind == PreparedIconKind.Binary
iconSource = icon.Kind is PreparedIconKind.Binary or PreparedIconKind.SvgData
? new ImageIconSource()
: CreateEmptyIconSource();
return true;
@@ -159,10 +164,48 @@ internal static partial class IconPathConverter
/// Completes icon-source creation after <see cref="TryCreateIconSourceSynchronously"/>
/// returned <see langword="false"/> for the same prepared icon.
/// </summary>
public static Task<IconSource> CompleteIconSourceCreationAsync(PreparedIcon icon) =>
icon.TakeSoftwareBitmap() is { } softwareBitmap
? CreateBinaryIconSourceAsync(softwareBitmap)
: Task.FromResult<IconSource>(new ImageIconSource());
public static Task<IconSource> CompleteIconSourceCreationAsync(PreparedIcon icon)
{
if (icon.Kind == PreparedIconKind.Binary)
{
return icon.TakeSoftwareBitmap() is { } softwareBitmap
? CreateBinaryIconSourceAsync(softwareBitmap)
: Task.FromResult<IconSource>(new ImageIconSource());
}
return icon.Kind == PreparedIconKind.SvgData
? CreateSvgIconSourceAsync(icon.SvgData!, icon.TargetSize)
: Task.FromResult<IconSource>(CreateEmptyIconSource());
}
private static async Task<IconSource> CreateSvgIconSourceAsync(byte[] svgData, int targetSize)
{
try
{
using var stream = new InMemoryRandomAccessStream();
using (var writer = new DataWriter(stream))
{
writer.WriteBytes(svgData);
await writer.StoreAsync();
writer.DetachStream();
}
stream.Seek(0);
var svg = new SvgImageSource();
if (targetSize > 0)
{
svg.RasterizePixelWidth = targetSize;
svg.RasterizePixelHeight = targetSize;
}
await svg.SetSourceAsync(stream);
return new ImageIconSource { ImageSource = svg };
}
catch
{
return new ImageIconSource();
}
}
private static async Task<IconSource> CreateBinaryIconSourceAsync(SoftwareBitmap softwareBitmap)
{
@@ -302,6 +345,7 @@ internal static partial class IconPathConverter
Uri? uri = null,
string? glyph = null,
string? fontFamily = null,
byte[]? svgData = null,
SoftwareBitmap? softwareBitmap = null,
int targetSize = 0)
{
@@ -309,6 +353,7 @@ internal static partial class IconPathConverter
Uri = uri;
Glyph = glyph;
FontFamily = fontFamily;
SvgData = svgData;
_softwareBitmap = softwareBitmap;
TargetSize = targetSize;
}
@@ -321,6 +366,8 @@ internal static partial class IconPathConverter
public string? FontFamily { get; }
public byte[]? SvgData { get; }
public SoftwareBitmap? SoftwareBitmap => _softwareBitmap;
public int TargetSize { get; }
@@ -333,6 +380,9 @@ internal static partial class IconPathConverter
public static PreparedIcon FromGlyph(string glyph, string fontFamily, int targetSize) =>
new(PreparedIconKind.Glyph, glyph: glyph, fontFamily: fontFamily, targetSize: targetSize);
public static PreparedIcon FromSvgData(byte[] svgData, int targetSize) =>
new(PreparedIconKind.SvgData, svgData: svgData, targetSize: targetSize);
public static PreparedIcon FromBinary(SoftwareBitmap? bitmap) =>
new(PreparedIconKind.Binary, softwareBitmap: bitmap);
@@ -352,6 +402,7 @@ internal static partial class IconPathConverter
Empty,
BitmapUri,
SvgUri,
SvgData,
Glyph,
Binary,
}

View File

@@ -12,6 +12,7 @@ internal static class IconProtocolRegistry
private static readonly IIconProtocolProcessor[] Processors =
[
AppIconProtocolProcessor.Instance,
GeneratedIconProtocolProcessor.Instance,
];
static IconProtocolRegistry()

View File

@@ -0,0 +1,179 @@
// 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.Globalization;
using System.Numerics;
using ManagedCommon;
using Microsoft.Graphics.Canvas;
using Microsoft.Graphics.Canvas.Geometry;
using Microsoft.Graphics.Canvas.Text;
using Windows.UI.Text;
using WinRT;
namespace Microsoft.CmdPal.UI.Helpers;
internal static class InitialsTextRenderer
{
private const float LayoutExtent = 512;
private const float NominalFontSize = 100;
private const float TargetHeight = 18;
private const float TargetWidth = 23;
private const float ViewBoxSize = 32;
// Initials preparation is dispatched to a worker before this is touched. Keep
// its software device private so font-outline extraction cannot contend with a
// Win2D device used by XAML on the STA.
private static Lazy<CanvasDevice> _device = CreateDevice();
private static int _outlineFailureLogged;
public static bool TryCreatePathData(
string text,
out string pathData,
out bool useEvenOddFill)
{
pathData = string.Empty;
useEvenOddFill = false;
var device = Volatile.Read(ref _device);
try
{
using var format = new CanvasTextFormat
{
Direction = CanvasTextDirection.LeftToRightThenTopToBottom,
FontFamily = "Segoe UI",
FontSize = NominalFontSize,
FontWeight = new FontWeight { Weight = 600 },
LocaleName = CultureInfo.CurrentUICulture.Name,
WordWrapping = CanvasWordWrapping.NoWrap,
};
if (!HasFontForEveryCharacter(text, format))
{
return false;
}
using var layout = new CanvasTextLayout(
device.Value,
text,
format,
LayoutExtent,
LayoutExtent);
using var geometry = CanvasGeometry.CreateText(layout);
var bounds = geometry.ComputeBounds();
if (bounds.Width <= 0 || bounds.Height <= 0)
{
return false;
}
var scale = MathF.Min(
TargetWidth / (float)bounds.Width,
TargetHeight / (float)bounds.Height);
var width = (float)bounds.Width * scale;
var height = (float)bounds.Height * scale;
var transform = Matrix3x2.CreateTranslation(-(float)bounds.X, -(float)bounds.Y)
* Matrix3x2.CreateScale(scale)
* Matrix3x2.CreateTranslation(
(ViewBoxSize - width) / 2,
(ViewBoxSize - height) / 2);
using var transformed = geometry.Transform(transform);
var receiver = new SvgPathDataReceiver();
transformed.SendPathTo(receiver);
pathData = receiver.PathData;
useEvenOddFill = receiver.UseEvenOddFill;
return pathData.Length > 0;
}
catch (Exception ex)
{
var failure = ex;
try
{
ResetDeviceIfLost(device, ex.HResult);
}
catch (Exception resetFailure)
{
failure = new AggregateException(
"Failed while checking whether the initials rendering device was lost.",
ex,
resetFailure);
}
if (Interlocked.Exchange(ref _outlineFailureLogged, 1) == 0)
{
Logger.LogError("Initials outline extraction failed; falling back to background tile", failure);
}
pathData = string.Empty;
useEvenOddFill = false;
return false;
}
}
private static Lazy<CanvasDevice> CreateDevice() =>
new(
static () => new CanvasDevice(forceSoftwareRenderer: true) { LowPriority = true },
// PublicationOnly deliberately retries transient CanvasDevice factory failures.
// Concurrent losing devices use GC-based release, matching the lock-free lifetime
// policy used when replacing a lost device.
LazyThreadSafetyMode.PublicationOnly);
private static void ResetDeviceIfLost(Lazy<CanvasDevice> device, int failureHResult)
{
if (device.IsValueCreated)
{
var canvasDevice = device.Value;
if (canvasDevice.IsDeviceLost(failureHResult) || canvasDevice.IsDeviceLost())
{
// Do not dispose the lost device here: another worker may still be
// unwinding a call through it. Once those calls finish, replacing
// the Lazy releases the last long-lived reference without a lock.
Interlocked.CompareExchange(ref _device, CreateDevice(), device);
}
}
}
private static bool HasFontForEveryCharacter(string text, CanvasTextFormat format)
{
var analyzer = new CanvasTextAnalyzer(
text,
CanvasTextDirection.LeftToRightThenTopToBottom);
try
{
var mappings = analyzer.GetFonts(format);
Span<bool> covered = stackalloc bool[text.Length];
foreach (var mapping in mappings)
{
var range = mapping.Key;
if (range.CharacterIndex < 0
|| range.CharacterCount <= 0
|| range.CharacterIndex > text.Length - range.CharacterCount)
{
return false;
}
covered.Slice(range.CharacterIndex, range.CharacterCount).Fill(true);
}
foreach (var isCovered in covered)
{
if (!isCovered)
{
return false;
}
}
return true;
}
finally
{
// CanvasTextAnalyzer's projected IDisposable currently queries an IID
// the Win2D runtime object does not expose. Releasing its owned object
// reference avoids both that InvalidCastException and finalizer-delayed
// retention of the analyzer's native state.
((IWinRTObject)analyzer).NativeObject.Dispose();
}
}
}

View File

@@ -0,0 +1,127 @@
// 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.Globalization;
using System.Numerics;
using System.Text;
using Microsoft.Graphics.Canvas.Geometry;
namespace Microsoft.CmdPal.UI.Helpers;
internal sealed partial class SvgPathDataReceiver : ICanvasPathReceiver
{
private readonly StringBuilder _path = new();
private bool _includeFigure;
public string PathData => _path.ToString();
public bool UseEvenOddFill { get; private set; }
public void BeginFigure(Vector2 startPoint, CanvasFigureFill figureFill)
{
_includeFigure = figureFill != CanvasFigureFill.DoesNotAffectFills;
if (_includeFigure)
{
_path.Append('M');
AppendPoint(startPoint);
}
}
public void AddArc(
Vector2 endPoint,
float radiusX,
float radiusY,
float rotationAngle,
CanvasSweepDirection sweepDirection,
CanvasArcSize arcSize)
{
if (!_includeFigure)
{
return;
}
_path.Append('A');
AppendNumber(radiusX);
_path.Append(' ');
AppendNumber(radiusY);
_path.Append(' ');
AppendNumber(rotationAngle);
_path.Append(arcSize == CanvasArcSize.Large ? " 1 " : " 0 ");
_path.Append(sweepDirection == CanvasSweepDirection.Clockwise ? "1 " : "0 ");
AppendPoint(endPoint);
}
public void AddCubicBezier(Vector2 controlPoint1, Vector2 controlPoint2, Vector2 endPoint)
{
if (!_includeFigure)
{
return;
}
_path.Append('C');
AppendPoint(controlPoint1);
_path.Append(' ');
AppendPoint(controlPoint2);
_path.Append(' ');
AppendPoint(endPoint);
}
public void AddLine(Vector2 endPoint)
{
if (_includeFigure)
{
_path.Append('L');
AppendPoint(endPoint);
}
}
public void AddQuadraticBezier(Vector2 controlPoint, Vector2 endPoint)
{
if (!_includeFigure)
{
return;
}
_path.Append('Q');
AppendPoint(controlPoint);
_path.Append(' ');
AppendPoint(endPoint);
}
public void SetFilledRegionDetermination(CanvasFilledRegionDetermination filledRegionDetermination) =>
UseEvenOddFill = filledRegionDetermination == CanvasFilledRegionDetermination.Alternate;
public void SetSegmentOptions(CanvasFigureSegmentOptions figureSegmentOptions)
{
// Segment options only affect stroking. Initials serialize filled geometry.
_ = figureSegmentOptions;
}
public void EndFigure(CanvasFigureLoop figureLoop)
{
if (_includeFigure && figureLoop == CanvasFigureLoop.Closed)
{
_path.Append('Z');
}
_includeFigure = false;
}
private void AppendPoint(Vector2 point)
{
AppendNumber(point.X);
_path.Append(' ');
AppendNumber(point.Y);
}
private void AppendNumber(float value)
{
if (MathF.Abs(value) < 0.0005f)
{
value = 0;
}
_path.Append(value.ToString("0.###", CultureInfo.InvariantCulture));
}
}

View File

@@ -213,6 +213,26 @@ public partial class CachedIconSourceProviderTests
await Task.WhenAll(first, second);
}
[TestMethod]
[Timeout(5_000)]
public async Task CanonicallyEquivalentInitialsShareCacheEntry()
{
var loader = new ControllableIconLoader();
var provider = CreateProvider(loader);
var precomposed = new IconDataViewModel { Icon = "|Initials|Å|#0067C0|circle|" };
var decomposed = new IconDataViewModel { Icon = "|Initials|A\u030A|#0067C0|circle|" };
var percentEncoded = new IconDataViewModel { Icon = "|Initials|%C3%85|#0067C0|circle|" };
var first = provider.GetIconSource(precomposed, 1.0, theme: ElementTheme.Light);
loader.CompleteNext(null);
await first;
Assert.IsTrue(SpinWait.SpinUntil(() => GetInFlightCount(provider) == 0, TimeSpan.FromSeconds(2)));
Assert.AreSame(first, provider.GetIconSource(decomposed, 1.0, theme: ElementTheme.Light));
Assert.AreSame(first, provider.GetIconSource(percentEncoded, 1.0, theme: ElementTheme.Light));
Assert.AreEqual(1, loader.EnqueueCount);
}
[TestMethod]
[Timeout(5_000)]
public async Task FailedLoadIsRemovedAndCanBeRetried()

View File

@@ -0,0 +1,259 @@
// 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.Text;
using System.Xml.Linq;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.UI.Xaml;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.UnitTests;
[TestClass]
public class GeneratedIconProtocolTests
{
[DataTestMethod]
[DataRow("|Swatch|#07A|", "#0077AA", null)]
[DataRow("|Swatch|#807A|", "#0077AA", "0.533")]
[DataRow("|Swatch|#102030", "#102030", null)]
[DataRow("|Swatch|#80102030|", "#102030", "0.502")]
public void SwatchSupportsXamlHexColorForms(string value, string expectedFill, string? expectedOpacity)
{
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg(value, ElementTheme.Light, out var svg));
var shape = ParseSvg(svg).Element(SvgName("circle"));
Assert.IsNotNull(shape);
Assert.AreEqual(expectedFill, shape.Attribute("fill")?.Value);
Assert.AreEqual(expectedOpacity, shape.Attribute("fill-opacity")?.Value);
Assert.AreEqual("12", shape.Attribute("r")?.Value);
}
[TestMethod]
public void ThemeAwareSwatchSelectsThemeColorAndUsesThemeInCacheIdentity()
{
const string Value = "|Swatch|#FF0067C0|#FF60CDFF|";
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg(Value, ElementTheme.Light, out var lightSvg));
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg(Value, ElementTheme.Dark, out var darkSvg));
Assert.AreEqual("#0067C0", GetBackgroundFill(lightSvg));
Assert.AreEqual("#60CDFF", GetBackgroundFill(darkSvg));
Assert.AreEqual(ElementTheme.Light, GeneratedIconProtocol.GetCacheTheme(Value, ElementTheme.Light));
Assert.AreEqual(ElementTheme.Dark, GeneratedIconProtocol.GetCacheTheme(Value, ElementTheme.Dark));
Assert.AreEqual(ElementTheme.Light, GeneratedIconProtocol.GetCacheTheme(Value, ElementTheme.Default));
}
[TestMethod]
public void SingleColorSwatchSharesCacheIdentityAcrossThemes()
{
const string Value = "|Swatch|#0067C0|";
Assert.AreEqual(ElementTheme.Default, GeneratedIconProtocol.GetCacheTheme(Value, ElementTheme.Light));
Assert.AreEqual(ElementTheme.Default, GeneratedIconProtocol.GetCacheTheme(Value, ElementTheme.Dark));
}
[TestMethod]
public async Task TranslucentInitialsUsesThemeForContrastAndCacheIdentity()
{
const string Value = "|Initials|AB|#80000000|rounded|";
var lightSvg = await CreateSvgAsync(Value, ElementTheme.Light);
var darkSvg = await CreateSvgAsync(Value, ElementTheme.Dark);
Assert.AreEqual("#000000", GetForegroundFill(lightSvg));
Assert.AreEqual("#FFFFFF", GetForegroundFill(darkSvg));
Assert.AreEqual(ElementTheme.Light, GeneratedIconProtocol.GetCacheTheme(Value, ElementTheme.Light));
Assert.AreEqual(ElementTheme.Dark, GeneratedIconProtocol.GetCacheTheme(Value, ElementTheme.Dark));
}
[TestMethod]
public async Task InitialsSupportsCircleRoundedSquareAndVectorGlyphs()
{
var circleSvg = await CreateSvgAsync(
"|Initials|a|#FFFFFFFF|circle|",
ElementTheme.Light);
var roundedSvg = await CreateSvgAsync(
"|Initials|CP|#FF005FB8|#FF60CDFF|rounded|",
ElementTheme.Dark);
var circle = ParseSvg(circleSvg);
Assert.IsNotNull(circle.Element(SvgName("circle")));
Assert.IsFalse(string.IsNullOrEmpty(circle.Element(SvgName("path"))?.Attribute("d")?.Value));
Assert.AreEqual("#000000", circle.Element(SvgName("path"))?.Attribute("fill")?.Value);
var rounded = ParseSvg(roundedSvg);
Assert.IsNotNull(rounded.Element(SvgName("rect")));
Assert.AreEqual("#60CDFF", rounded.Element(SvgName("rect"))?.Attribute("fill")?.Value);
Assert.IsFalse(string.IsNullOrEmpty(rounded.Element(SvgName("path"))?.Attribute("d")?.Value));
}
[DataTestMethod]
[DataRow("Æ")]
[DataRow("Ж")]
[DataRow("Ω")]
[DataRow("東")]
[DataRow("ش")]
[DataRow("A\u030A")]
[DataRow("👩‍💻")]
[DataRow("👩‍💻Å東")]
public async Task InitialsSupportsOneToThreeUnicodeTextElements(string initials)
{
var svg = await CreateSvgAsync($"|Initials|{initials}|#0067C0|circle|", ElementTheme.Light);
var path = ParseSvg(svg).Element(SvgName("path"));
Assert.IsNotNull(path);
Assert.IsFalse(string.IsNullOrEmpty(path.Attribute("d")?.Value));
Assert.IsFalse(Encoding.UTF8.GetString(svg).Contains("<text", StringComparison.Ordinal));
}
[TestMethod]
public async Task InitialsPercentEncodingDistinguishesSeparatorAndPercentText()
{
const string Separator = "|Initials|A%7CB|#0F7B0F|rounded|";
const string Percent = "|Initials|%25|#0F7B0F|rounded|";
var separatorSvg = await CreateSvgAsync(Separator, ElementTheme.Light);
var percentSvg = await CreateSvgAsync(Percent, ElementTheme.Light);
Assert.IsNotNull(ParseSvg(separatorSvg).Element(SvgName("path")));
Assert.IsNotNull(ParseSvg(percentSvg).Element(SvgName("path")));
Assert.AreNotEqual(
GeneratedIconProtocol.GetCacheIdentity(Separator),
GeneratedIconProtocol.GetCacheIdentity(Percent));
}
[TestMethod]
public void InitialsCacheIdentityUsesCanonicalUnicodeAndEscaping()
{
const string Precomposed = "|Initials|Å|#0067C0|circle|";
const string Decomposed = "|Initials|A\u030A|#0067C0|circle|";
const string EscapedPrecomposed = "|Initials|%C3%85|#0067C0|circle|";
const string CanonicalAscii = "|Initials|JP|#0067C0|circle|";
const string LowercaseAscii = "|Initials|jp|#0067C0|circle|";
const string PaddedAscii = "|Initials| JP |#0067C0|circle|";
var expected = GeneratedIconProtocol.GetCacheIdentity(Precomposed);
Assert.AreEqual(expected, GeneratedIconProtocol.GetCacheIdentity(Decomposed));
Assert.AreEqual(expected, GeneratedIconProtocol.GetCacheIdentity(EscapedPrecomposed));
StringAssert.Contains(
GeneratedIconProtocol.GetCacheIdentity("|Initials|A%7CB|#0067C0|circle|"),
"A%7CB");
StringAssert.Contains(
GeneratedIconProtocol.GetCacheIdentity("|Initials|%25|#0067C0|circle|"),
"%25");
Assert.AreEqual(
GeneratedIconProtocol.GetCacheIdentity(CanonicalAscii),
GeneratedIconProtocol.GetCacheIdentity(LowercaseAscii));
Assert.AreEqual(
GeneratedIconProtocol.GetCacheIdentity(CanonicalAscii),
GeneratedIconProtocol.GetCacheIdentity(PaddedAscii));
}
[DataTestMethod]
[DataRow("|Swatch|#fff|", "|Swatch|#FFF|")]
[DataRow("|Swatch|#abcdef|#a1b2c3|", "|Swatch|#ABCDEF|#A1B2C3|")]
[DataRow("|Initials|CP|#fff|CIRCLE|", "|Initials|CP|#FFF|circle|")]
[DataRow("|Initials|cp|#abcdef|CIRCLE|", "|Initials|CP|#ABCDEF|circle|")]
public void EquivalentGeneratedStyleTokensShareCacheIdentity(string value, string canonical)
{
Assert.AreEqual(canonical, GeneratedIconProtocol.GetCacheIdentity(value));
Assert.AreEqual(
GeneratedIconProtocol.GetCacheIdentity(canonical),
GeneratedIconProtocol.GetCacheIdentity(value));
}
[DataTestMethod]
[DataRow("|Swatch|#FFF|")]
[DataRow("|Swatch|#ABCDEF|#A1B2C3|")]
[DataRow("|Initials|A|#0067C0|circle|")]
[DataRow("|Initials|AB|#0067C0|rounded|")]
[DataRow("|Initials|JP|#0067C0|circle|")]
[DataRow("|Initials|123|#0067C0|rounded|")]
public void CanonicalGeneratedIdentitiesReuseInput(string value)
{
Assert.AreSame(value, GeneratedIconProtocol.GetCacheIdentity(value));
}
[TestMethod]
public async Task MissingInitialsFontDegradesToBackgroundTile()
{
var svg = await CreateSvgAsync("|Initials|\U0010FFFF|#C42B1C|rounded|", ElementTheme.Light);
var root = ParseSvg(svg);
Assert.IsNotNull(root.Element(SvgName("rect")));
Assert.IsNull(root.Element(SvgName("path")));
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow("|Swatch|")]
[DataRow("|Swatch|red|")]
[DataRow("|Swatch|#12345|")]
[DataRow("|Swatch|#123456|#654321|#ABCDEF|")]
[DataRow("|swatch|#123456|")]
[DataRow("|Initials||#123456|")]
[DataRow("|Initials|TOOLONG|#123456|")]
[DataRow("|Initials|ABCD|#123456|")]
[DataRow("|Initials|A%|#123456|")]
[DataRow("|Initials|A%7|#123456|")]
[DataRow("|Initials|A%XX|#123456|")]
[DataRow("|Initials|%C3|#123456|")]
[DataRow("|Initials|%FF|#123456|")]
[DataRow("|Initials|%F0%9F%91|#123456|")]
[DataRow("|Initials|AB|#123456|triangle|")]
[DataRow("|Initials|AB|#123456|#654321|rounded|extra|")]
public async Task InvalidProtocolIsRejected(string? value)
{
var (success, svg) = await TryCreateSvgAsync(value, ElementTheme.Light);
Assert.IsFalse(success);
Assert.AreEqual(0, svg.Length);
}
private static async Task<byte[]> CreateSvgAsync(string value, ElementTheme theme)
{
var (success, svg) = await TryCreateSvgAsync(value, theme);
Assert.IsTrue(success);
return svg;
}
private static async Task<(bool Success, byte[] Svg)> TryCreateSvgAsync(
string? value,
ElementTheme theme)
{
var processor = IconProtocolRegistry.Find(value);
if (processor is null)
{
return (false, []);
}
IconPathConverter.PreparedIcon? preparedIcon;
if (!processor.TryPrepareSynchronously(value!, 32, theme, out preparedIcon))
{
using var result = await processor.PrepareAsync(value!, 32, theme);
preparedIcon = result.TakePreparedIcon();
}
using (preparedIcon)
{
return preparedIcon?.Kind == IconPathConverter.PreparedIconKind.SvgData
&& preparedIcon.SvgData is { Length: > 0 } svg
? (true, svg)
: (false, []);
}
}
private static XElement ParseSvg(byte[] svg) => XDocument.Parse(Encoding.UTF8.GetString(svg)).Root!;
private static string? GetBackgroundFill(byte[] svg)
{
var root = ParseSvg(svg);
return (root.Element(SvgName("circle")) ?? root.Element(SvgName("rect")))?.Attribute("fill")?.Value;
}
private static string? GetForegroundFill(byte[] svg) =>
ParseSvg(svg).Element(SvgName("path"))?.Attribute("fill")?.Value;
private static XName SvgName(string localName) => XName.Get(localName, "http://www.w3.org/2000/svg");
}

View File

@@ -244,6 +244,34 @@ public class IconLoadDiagnosticsTests
Assert.IsFalse(report.Text.Contains("shell32", StringComparison.OrdinalIgnoreCase));
}
[DataTestMethod]
[DataRow("|Swatch|#FF0067C0|", "GeneratedSwatch")]
[DataRow("|Initials|CP|#FF005FB8|rounded|", "GeneratedInitials")]
public void GeneratedIconProtocolUsesSpecificInputKind(string icon, string expectedKind)
{
IconLoadDiagnostics.Start();
var request = IconLoadDiagnostics.BeginRequest(IconRequestReason.SourceChanged, 1.0);
var load = IconLoadDiagnostics.CreateLoad(
request,
icon,
hasStream: false,
width: 20,
height: 20,
scale: 1.0);
Assert.IsNotNull(load);
request.RecordProviderResolution(IconProviderResolution.NewLoad, load);
load.SetResult(null);
load.Complete();
request.Complete(IconRequestStatus.Empty);
var report = IconLoadDiagnostics.StopAndCreateReport();
Assert.IsNotNull(report);
StringAssert.Contains(report.Text, $" {expectedKind}: 1");
Assert.IsFalse(report.Text.Contains(icon, StringComparison.Ordinal));
}
[TestMethod]
[Timeout(5_000)]
public async Task SchedulerReportCapturesCoordinatorAndWorkerHandoff()

View File

@@ -2,7 +2,9 @@
// 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.Text;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.UI.Xaml;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Graphics.Imaging;
@@ -64,4 +66,16 @@ public class IconPathConverterTests
Assert.AreEqual(IconPathConverter.PreparedIconKind.Glyph, relativeText.Kind);
Assert.AreEqual("\u25CC", relativeText.Glyph);
}
[TestMethod]
public void GeneratedInitialsDoNotShapeInSynchronousConverter()
{
using var prepared = IconPathConverter.Prepare(
"|Initials|CP|#FF005FB8|#FF60CDFF|rounded|",
null,
20,
ElementTheme.Dark);
Assert.AreEqual(IconPathConverter.PreparedIconKind.Empty, prepared.Kind);
}
}

View File

@@ -3,6 +3,8 @@
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.UI.Xaml;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.UnitTests;
@@ -64,4 +66,34 @@ public class IconPresentationStateTests
Assert.IsNull(state.RequestFallback);
Assert.IsNull(state.SelectSource(preferFallbackForResolvedSource: false));
}
[TestMethod]
public async Task AsyncInitialsUsesPlacementFallbackUntilResolution()
{
const string Value = "|Initials|Å|info|circle|";
var state = new IconPresentationState<string>
{
PlacementFallback = "placement",
};
state.SetResolvedSource("recycled", expectsImageSource: true);
state.BeginSourceChange();
Assert.IsFalse(GeneratedIconProtocolProcessor.Instance.TryPrepareSynchronously(
Value,
20,
ElementTheme.Light,
out var synchronousIcon));
Assert.IsNull(synchronousIcon);
Assert.AreEqual("placement", state.SelectSource(preferFallbackForResolvedSource: false));
using var result = await GeneratedIconProtocolProcessor.Instance.PrepareAsync(
Value,
20,
ElementTheme.Light);
using var preparedIcon = result.TakePreparedIcon();
Assert.IsNotNull(preparedIcon);
state.SetResolvedSource("initials", expectsImageSource: true);
Assert.AreEqual("initials", state.SelectSource(preferFallbackForResolvedSource: false));
}
}

View File

@@ -39,6 +39,58 @@ public class IconProtocolRegistryTests
}
}
[DataTestMethod]
[DataRow("|Swatch|#FF0067C0|", "GeneratedSwatch", true)]
[DataRow("|Initials|CP|#FF0067C0|circle|", "GeneratedInitials", false)]
public void BuiltInRegistryFindsGeneratedIconProcessor(
string value,
string inputKind,
bool preparesSynchronously)
{
var processor = IconProtocolRegistry.Find(value);
Assert.IsNotNull(processor);
Assert.AreSame(GeneratedIconProtocolProcessor.Instance, processor);
Assert.AreEqual(IconCachePartition.Other, processor.CachePartition);
Assert.AreEqual(inputKind, processor.ClassifyInput(value).ToString());
Assert.AreEqual(preparesSynchronously, processor.TryPrepareSynchronously(
value,
20,
ElementTheme.Light,
out var preparedIcon));
using (preparedIcon)
{
if (preparesSynchronously)
{
Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgData, preparedIcon!.Kind);
}
else
{
Assert.IsNull(preparedIcon);
}
}
}
[TestMethod]
public async Task InitialsPreparationRunsThroughAsyncProcessorPath()
{
const string Value = "|Initials|CP|#FF0067C0|circle|";
var processor = IconProtocolRegistry.Find(Value);
Assert.IsNotNull(processor);
Assert.IsFalse(processor.TryPrepareSynchronously(
Value,
20,
ElementTheme.Light,
out var synchronousIcon));
Assert.IsNull(synchronousIcon);
using var result = await processor.PrepareAsync(Value, 20, ElementTheme.Light);
using var preparedIcon = result.TakePreparedIcon();
Assert.IsNotNull(preparedIcon);
Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgData, preparedIcon.Kind);
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
@@ -150,6 +202,8 @@ public class IconProtocolRegistryTests
}
}
public string GetCacheIdentity(string value) => value;
public ElementTheme GetCacheTheme(string value, ElementTheme theme) => ElementTheme.Default;
public IconLoadInputKind ClassifyInput(string value) => IconLoadInputKind.String;

View File

@@ -17,6 +17,7 @@
<ItemGroup>
<PackageReference Include="MSTest" />
<PackageReference Include="Microsoft.Graphics.Win2D" />
<PackageReference Include="System.Drawing.Common" />
</ItemGroup>
@@ -38,6 +39,10 @@
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\FontIconGlyphClassifier.cs" Link="Helpers\Icons\FontIconGlyphClassifier.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\FontIconGlyphKind.cs" Link="Helpers\Icons\FontIconGlyphKind.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\FontIconSizeCalculator.cs" Link="Helpers\Icons\FontIconSizeCalculator.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\GeneratedIconProtocol.cs" Link="Helpers\Icons\GeneratedIconProtocol.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\GeneratedIconProtocolProcessor.cs" Link="Helpers\Icons\GeneratedIconProtocolProcessor.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\InitialsTextRenderer.cs" Link="Helpers\Icons\InitialsTextRenderer.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\SvgPathDataReceiver.cs" Link="Helpers\Icons\SvgPathDataReceiver.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\IconCachePartition.cs" Link="Helpers\Icons\IconCachePartition.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\IconLoadDemand.cs" Link="Helpers\Icons\IconLoadDemand.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\IconLoadDemandStage.cs" Link="Helpers\Icons\IconLoadDemandStage.cs" />

View File

@@ -12,6 +12,21 @@ internal sealed partial class SampleIconPage : ListPage
{
private readonly IListItem[] _items =
[
BuildIconItem(
"|Swatch|#FF0067C0|#FF60CDFF|",
"Theme-aware generated swatch",
"Uses a compact color protocol instead of an extension-generated bitmap"),
BuildIconItem(
"|Initials|A|#FF7A3E9D|circle|",
"Generated circular initials avatar",
"Uses an automatically contrasting foreground"),
BuildIconItem(
"|Initials|CP|#FF005FB8|#FF60CDFF|rounded|",
"Theme-aware rounded initials avatar",
"Uses separate light and dark background colors"),
/*
* Quick intro to Unicode in source code:
* - Every character has a code point (e.g., U+0041 = 'A').