mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
Add generated swatch and initials icon protocols
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -11,4 +11,6 @@ internal enum IconLoadInputKind
|
||||
ShellBinary,
|
||||
Stream,
|
||||
SpecializedAppIcon,
|
||||
GeneratedSwatch,
|
||||
GeneratedInitials,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ internal static class IconProtocolRegistry
|
||||
private static readonly IIconProtocolProcessor[] Processors =
|
||||
[
|
||||
AppIconProtocolProcessor.Instance,
|
||||
GeneratedIconProtocolProcessor.Instance,
|
||||
];
|
||||
|
||||
static IconProtocolRegistry()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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').
|
||||
|
||||
Reference in New Issue
Block a user