mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
Add semantic generated icon colors and shapes
This commit is contained in:
@@ -10,13 +10,17 @@ 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.
|
||||
/// Parses <c>|Swatch|color[|dark][|circle|square]</c> and
|
||||
/// <c>|Initials|text|color[|dark][|circle|square]</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.
|
||||
/// Both protocols also accept danger, subtle, info, warning, success, neutral,
|
||||
/// dark, normal, or transparent as a semantic color. A semantic color supplies
|
||||
/// both light and dark values and may be followed only by an optional shape. Use
|
||||
/// two explicit hex colors to customize the light and dark values separately.
|
||||
/// </summary>
|
||||
internal static class GeneratedIconProtocol
|
||||
{
|
||||
@@ -44,7 +48,7 @@ internal static class GeneratedIconProtocol
|
||||
{
|
||||
var swatchPayload = value.AsSpan(SwatchPrefix.Length);
|
||||
if (HasCanonicalStyleTokenCasing(swatchPayload)
|
||||
|| !TryParseSwatch(swatchPayload, out _, out _, out _))
|
||||
|| !TryParseSwatch(swatchPayload, out _, out _, out _, out _))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
@@ -239,12 +243,13 @@ internal static class GeneratedIconProtocol
|
||||
value!.AsSpan(SwatchPrefix.Length),
|
||||
out var light,
|
||||
out var dark,
|
||||
out _))
|
||||
out _,
|
||||
out var shape))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
svg = CreateSwatchSvg(SelectColor(light, dark, theme));
|
||||
svg = CreateSwatchSvg(SelectColor(light, dark, theme), shape);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
@@ -296,7 +301,7 @@ internal static class GeneratedIconProtocol
|
||||
switch (Classify(value))
|
||||
{
|
||||
case Kind.Swatch:
|
||||
return TryParseSwatch(value!.AsSpan(SwatchPrefix.Length), out _, out _, out var hasDark) && hasDark;
|
||||
return TryParseSwatch(value!.AsSpan(SwatchPrefix.Length), out _, out _, out var hasDark, out _) && hasDark;
|
||||
|
||||
case Kind.Initials:
|
||||
// Foreground contrast can depend on the surface theme when the
|
||||
@@ -313,29 +318,59 @@ internal static class GeneratedIconProtocol
|
||||
ReadOnlySpan<char> payload,
|
||||
out RgbaColor light,
|
||||
out RgbaColor dark,
|
||||
out bool hasDark)
|
||||
out bool hasDark,
|
||||
out BackgroundShape shape)
|
||||
{
|
||||
light = default;
|
||||
dark = default;
|
||||
hasDark = false;
|
||||
shape = BackgroundShape.Circle;
|
||||
payload = TrimOptionalTrailingSeparator(payload);
|
||||
if (!TryReadToken(ref payload, out var lightToken) || !TryParseColor(lightToken, out light))
|
||||
if (!TryReadToken(ref payload, out var lightToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
dark = light;
|
||||
if (!payload.IsEmpty)
|
||||
if (TryParseSemanticColorPair(lightToken, out light, out dark))
|
||||
{
|
||||
if (!TryReadToken(ref payload, out var darkToken) || !TryParseColor(darkToken, out dark))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
hasDark = true;
|
||||
hasDark = light != dark;
|
||||
return TryParseOptionalShape(ref payload, out shape);
|
||||
}
|
||||
|
||||
return payload.IsEmpty;
|
||||
if (!TryParseColor(lightToken, out light))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryParseOptionalDarkAndShape(ref payload, light, out dark, out hasDark, out shape);
|
||||
}
|
||||
|
||||
private static bool TryParseSemanticColorPair(
|
||||
ReadOnlySpan<char> value,
|
||||
out RgbaColor light,
|
||||
out RgbaColor dark)
|
||||
{
|
||||
light = default;
|
||||
dark = default;
|
||||
if (value.IsEmpty || value[0] == '#')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return SemanticIconColor.TryResolvePair(value, out var lightValue, out var darkValue)
|
||||
&& TryParseColor(lightValue, out light)
|
||||
&& TryParseColor(darkValue, out dark);
|
||||
}
|
||||
|
||||
private static bool TryParseOptionalShape(
|
||||
ref ReadOnlySpan<char> payload,
|
||||
out BackgroundShape shape)
|
||||
{
|
||||
shape = BackgroundShape.Circle;
|
||||
return payload.IsEmpty
|
||||
|| (TryReadToken(ref payload, out var shapeToken)
|
||||
&& TryParseShape(shapeToken, out shape)
|
||||
&& payload.IsEmpty);
|
||||
}
|
||||
|
||||
private static bool TryParseInitials(
|
||||
@@ -344,53 +379,34 @@ internal static class GeneratedIconProtocol
|
||||
out RgbaColor light,
|
||||
out RgbaColor dark,
|
||||
out bool hasDark,
|
||||
out InitialsShape shape)
|
||||
out BackgroundShape shape)
|
||||
{
|
||||
initials = string.Empty;
|
||||
light = default;
|
||||
dark = default;
|
||||
hasDark = false;
|
||||
shape = InitialsShape.Circle;
|
||||
shape = BackgroundShape.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))
|
||||
|| !TryReadToken(ref payload, out var lightToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
dark = light;
|
||||
if (!payload.IsEmpty)
|
||||
if (TryParseSemanticColorPair(lightToken, out light, out dark))
|
||||
{
|
||||
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;
|
||||
}
|
||||
hasDark = light != dark;
|
||||
return TryParseOptionalShape(ref payload, out shape);
|
||||
}
|
||||
|
||||
if (!payload.IsEmpty)
|
||||
if (!TryParseColor(lightToken, out light))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return TryParseOptionalDarkAndShape(ref payload, light, out dark, out hasDark, out shape);
|
||||
}
|
||||
|
||||
private static bool TryNormalizeInitials(ReadOnlySpan<char> value, out string initials)
|
||||
@@ -510,17 +526,55 @@ internal static class GeneratedIconProtocol
|
||||
.Replace("|", "%7C", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool TryParseShape(ReadOnlySpan<char> value, out InitialsShape shape)
|
||||
private static bool TryParseOptionalDarkAndShape(
|
||||
ref ReadOnlySpan<char> payload,
|
||||
RgbaColor light,
|
||||
out RgbaColor dark,
|
||||
out bool hasDark,
|
||||
out BackgroundShape shape)
|
||||
{
|
||||
if (value.Equals("circle", StringComparison.OrdinalIgnoreCase))
|
||||
dark = light;
|
||||
hasDark = false;
|
||||
shape = BackgroundShape.Circle;
|
||||
if (payload.IsEmpty)
|
||||
{
|
||||
shape = InitialsShape.Circle;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("rounded", StringComparison.OrdinalIgnoreCase))
|
||||
if (!TryReadToken(ref payload, out var nextToken))
|
||||
{
|
||||
shape = InitialsShape.RoundedSquare;
|
||||
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;
|
||||
}
|
||||
|
||||
return payload.IsEmpty;
|
||||
}
|
||||
|
||||
private static bool TryParseShape(ReadOnlySpan<char> value, out BackgroundShape shape)
|
||||
{
|
||||
if (value.Equals("circle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
shape = BackgroundShape.Circle;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("square", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
shape = BackgroundShape.Square;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -661,18 +715,13 @@ internal static class GeneratedIconProtocol
|
||||
private static RgbaColor SelectColor(RgbaColor light, RgbaColor dark, ElementTheme theme) =>
|
||||
theme == ElementTheme.Dark ? dark : light;
|
||||
|
||||
private static byte[] CreateSwatchSvg(RgbaColor color)
|
||||
private static byte[] CreateSwatchSvg(RgbaColor color, BackgroundShape shape)
|
||||
{
|
||||
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();
|
||||
WriteBackground(writer, color, shape);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
@@ -684,31 +733,13 @@ internal static class GeneratedIconProtocol
|
||||
bool useEvenOddFill,
|
||||
RgbaColor background,
|
||||
ElementTheme theme,
|
||||
InitialsShape shape)
|
||||
BackgroundShape 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();
|
||||
WriteBackground(writer, background, shape);
|
||||
|
||||
if (!string.IsNullOrEmpty(pathData))
|
||||
{
|
||||
@@ -729,6 +760,31 @@ internal static class GeneratedIconProtocol
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteBackground(XmlWriter writer, RgbaColor color, BackgroundShape shape)
|
||||
{
|
||||
// The generated background is the icon, so it fills 31 of the 32 view-box
|
||||
// units. The 0.5-unit guard on each edge keeps antialiased pixels in bounds.
|
||||
if (shape == BackgroundShape.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, color);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
private static XmlWriter CreateSvgWriter(Stream stream) =>
|
||||
XmlWriter.Create(
|
||||
stream,
|
||||
@@ -743,6 +799,9 @@ internal static class GeneratedIconProtocol
|
||||
private static void WriteSvgStart(XmlWriter writer)
|
||||
{
|
||||
writer.WriteStartElement("svg", "http://www.w3.org/2000/svg");
|
||||
|
||||
// The 32x32 view box is a scale-independent design grid; it does not request
|
||||
// a 32-pixel output. The loader rasterizes it for the calling surface's size.
|
||||
writer.WriteAttributeString("viewBox", "0 0 32 32");
|
||||
}
|
||||
|
||||
@@ -785,10 +844,10 @@ internal static class GeneratedIconProtocol
|
||||
Initials,
|
||||
}
|
||||
|
||||
private enum InitialsShape
|
||||
private enum BackgroundShape
|
||||
{
|
||||
Circle,
|
||||
RoundedSquare,
|
||||
Square,
|
||||
}
|
||||
|
||||
private readonly record struct RgbaColor(byte A, byte R, byte G, byte B);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the shared semantic color vocabulary used by generated and themed icons.
|
||||
/// </summary>
|
||||
internal static class SemanticIconColor
|
||||
{
|
||||
public static string GetDefault(ElementTheme theme) =>
|
||||
theme == ElementTheme.Dark ? "#60CDFF" : "#0067C0";
|
||||
|
||||
public static bool IsSemantic(ReadOnlySpan<char> value) =>
|
||||
TryResolvePair(value, out _, out _);
|
||||
|
||||
public static bool TryResolve(
|
||||
ReadOnlySpan<char> value,
|
||||
ElementTheme theme,
|
||||
out string color)
|
||||
{
|
||||
if (!TryResolvePair(value, out var light, out var dark))
|
||||
{
|
||||
color = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
color = theme == ElementTheme.Dark ? dark : light;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryResolvePair(
|
||||
ReadOnlySpan<char> value,
|
||||
out string light,
|
||||
out string dark)
|
||||
{
|
||||
if (value.Equals("danger", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#C42B1C";
|
||||
dark = "#FF99A4";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("subtle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#616161";
|
||||
dark = "#C5C5C5";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("info", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#0067C0";
|
||||
dark = "#60CDFF";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("warning", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#9D5D00";
|
||||
dark = "#FCE100";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("success", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#0F7B0F";
|
||||
dark = "#6CCB5F";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("neutral", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#8A8A8A";
|
||||
dark = "#9D9D9D";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("dark", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#1B1A19";
|
||||
dark = "#1B1A19";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("normal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#000000";
|
||||
dark = "#FFFFFF";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Equals("transparent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
light = "#00000000";
|
||||
dark = "#00000000";
|
||||
return true;
|
||||
}
|
||||
|
||||
light = string.Empty;
|
||||
dark = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,13 @@ public class GeneratedIconProtocolTests
|
||||
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);
|
||||
Assert.AreEqual("15.5", shape.Attribute("r")?.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ThemeAwareSwatchSelectsThemeColorAndUsesThemeInCacheIdentity()
|
||||
{
|
||||
const string Value = "|Swatch|#FF0067C0|#FF60CDFF|";
|
||||
const string Value = "|Swatch|#FF0067C0|#FF60CDFF|square|";
|
||||
|
||||
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg(Value, ElementTheme.Light, out var lightSvg));
|
||||
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg(Value, ElementTheme.Dark, out var darkSvg));
|
||||
@@ -53,10 +53,66 @@ public class GeneratedIconProtocolTests
|
||||
Assert.AreEqual(ElementTheme.Default, GeneratedIconProtocol.GetCacheTheme(Value, ElementTheme.Dark));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("danger", "#C42B1C", "#FF99A4", true, null)]
|
||||
[DataRow("subtle", "#616161", "#C5C5C5", true, null)]
|
||||
[DataRow("info", "#0067C0", "#60CDFF", true, null)]
|
||||
[DataRow("warning", "#9D5D00", "#FCE100", true, null)]
|
||||
[DataRow("success", "#0F7B0F", "#6CCB5F", true, null)]
|
||||
[DataRow("neutral", "#8A8A8A", "#9D9D9D", true, null)]
|
||||
[DataRow("dark", "#1B1A19", "#1B1A19", false, null)]
|
||||
[DataRow("normal", "#000000", "#FFFFFF", true, null)]
|
||||
[DataRow("transparent", "#000000", "#000000", false, "0")]
|
||||
public void SwatchSupportsSemanticColors(
|
||||
string semanticColor,
|
||||
string expectedLight,
|
||||
string expectedDark,
|
||||
bool isThemeDependent,
|
||||
string? expectedOpacity)
|
||||
{
|
||||
var value = $"|Swatch|{semanticColor}|square|";
|
||||
|
||||
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg(value, ElementTheme.Light, out var lightSvg));
|
||||
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg(value, ElementTheme.Dark, out var darkSvg));
|
||||
|
||||
Assert.AreEqual(expectedLight, GetBackgroundFill(lightSvg));
|
||||
Assert.AreEqual(expectedDark, GetBackgroundFill(darkSvg));
|
||||
Assert.AreEqual(expectedOpacity, GetBackgroundOpacity(lightSvg));
|
||||
Assert.AreEqual(expectedOpacity, GetBackgroundOpacity(darkSvg));
|
||||
Assert.IsNotNull(ParseSvg(lightSvg).Element(SvgName("rect")));
|
||||
Assert.AreEqual(
|
||||
isThemeDependent ? ElementTheme.Light : ElementTheme.Default,
|
||||
GeneratedIconProtocol.GetCacheTheme(value, ElementTheme.Light));
|
||||
Assert.AreEqual(
|
||||
isThemeDependent ? ElementTheme.Dark : ElementTheme.Default,
|
||||
GeneratedIconProtocol.GetCacheTheme(value, ElementTheme.Dark));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task InitialsSupportsNormalAndTransparentSemanticBackgrounds()
|
||||
{
|
||||
const string Normal = "|Initials|N|normal|circle|";
|
||||
const string Transparent = "|Initials|T|transparent|square|";
|
||||
|
||||
var normalLight = await CreateSvgAsync(Normal, ElementTheme.Light);
|
||||
var normalDark = await CreateSvgAsync(Normal, ElementTheme.Dark);
|
||||
Assert.AreEqual("#000000", GetBackgroundFill(normalLight));
|
||||
Assert.AreEqual("#FFFFFF", GetBackgroundFill(normalDark));
|
||||
Assert.AreEqual("#FFFFFF", GetForegroundFill(normalLight));
|
||||
Assert.AreEqual("#000000", GetForegroundFill(normalDark));
|
||||
|
||||
var transparentLight = await CreateSvgAsync(Transparent, ElementTheme.Light);
|
||||
var transparentDark = await CreateSvgAsync(Transparent, ElementTheme.Dark);
|
||||
Assert.AreEqual("0", GetBackgroundOpacity(transparentLight));
|
||||
Assert.AreEqual("0", GetBackgroundOpacity(transparentDark));
|
||||
Assert.AreEqual("#000000", GetForegroundFill(transparentLight));
|
||||
Assert.AreEqual("#FFFFFF", GetForegroundFill(transparentDark));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TranslucentInitialsUsesThemeForContrastAndCacheIdentity()
|
||||
{
|
||||
const string Value = "|Initials|AB|#80000000|rounded|";
|
||||
const string Value = "|Initials|AB|#80000000|square|";
|
||||
|
||||
var lightSvg = await CreateSvgAsync(Value, ElementTheme.Light);
|
||||
var darkSvg = await CreateSvgAsync(Value, ElementTheme.Dark);
|
||||
@@ -68,13 +124,13 @@ public class GeneratedIconProtocolTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task InitialsSupportsCircleRoundedSquareAndVectorGlyphs()
|
||||
public async Task InitialsSupportsCircleSquareAndVectorGlyphs()
|
||||
{
|
||||
var circleSvg = await CreateSvgAsync(
|
||||
"|Initials|a|#FFFFFFFF|circle|",
|
||||
ElementTheme.Light);
|
||||
var roundedSvg = await CreateSvgAsync(
|
||||
"|Initials|CP|#FF005FB8|#FF60CDFF|rounded|",
|
||||
var squareSvg = await CreateSvgAsync(
|
||||
"|Initials|CP|#FF005FB8|#FF60CDFF|square|",
|
||||
ElementTheme.Dark);
|
||||
|
||||
var circle = ParseSvg(circleSvg);
|
||||
@@ -82,10 +138,23 @@ public class GeneratedIconProtocolTests
|
||||
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));
|
||||
var square = ParseSvg(squareSvg);
|
||||
Assert.IsNotNull(square.Element(SvgName("rect")));
|
||||
Assert.AreEqual("#60CDFF", square.Element(SvgName("rect"))?.Attribute("fill")?.Value);
|
||||
Assert.IsFalse(string.IsNullOrEmpty(square.Element(SvgName("path"))?.Attribute("d")?.Value));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SwatchAndInitialsShareCircleAndSquareBackgroundGeometry()
|
||||
{
|
||||
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg("|Swatch|#0067C0|", ElementTheme.Light, out var circleSwatch));
|
||||
var circleInitials = await CreateSvgAsync("|Initials|A|#0067C0|", ElementTheme.Light);
|
||||
Assert.IsTrue(GeneratedIconProtocol.TryCreateSwatchSvg("|Swatch|#0067C0|square|", ElementTheme.Light, out var squareSwatch));
|
||||
var squareInitials = await CreateSvgAsync("|Initials|A|#0067C0|square|", ElementTheme.Light);
|
||||
|
||||
Assert.AreEqual(GetBackgroundGeometry(circleSwatch), GetBackgroundGeometry(circleInitials));
|
||||
Assert.AreEqual(GetBackgroundGeometry(squareSwatch), GetBackgroundGeometry(squareInitials));
|
||||
Assert.AreNotEqual(GetBackgroundGeometry(circleSwatch), GetBackgroundGeometry(squareSwatch));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
@@ -110,8 +179,8 @@ public class GeneratedIconProtocolTests
|
||||
[TestMethod]
|
||||
public async Task InitialsPercentEncodingDistinguishesSeparatorAndPercentText()
|
||||
{
|
||||
const string Separator = "|Initials|A%7CB|#0F7B0F|rounded|";
|
||||
const string Percent = "|Initials|%25|#0F7B0F|rounded|";
|
||||
const string Separator = "|Initials|A%7CB|#0F7B0F|square|";
|
||||
const string Percent = "|Initials|%25|#0F7B0F|square|";
|
||||
|
||||
var separatorSvg = await CreateSvgAsync(Separator, ElementTheme.Light);
|
||||
var percentSvg = await CreateSvgAsync(Percent, ElementTheme.Light);
|
||||
@@ -153,8 +222,10 @@ public class GeneratedIconProtocolTests
|
||||
[DataTestMethod]
|
||||
[DataRow("|Swatch|#fff|", "|Swatch|#FFF|")]
|
||||
[DataRow("|Swatch|#abcdef|#a1b2c3|", "|Swatch|#ABCDEF|#A1B2C3|")]
|
||||
[DataRow("|Swatch|INFO|SQUARE|", "|Swatch|info|square|")]
|
||||
[DataRow("|Initials|CP|#fff|CIRCLE|", "|Initials|CP|#FFF|circle|")]
|
||||
[DataRow("|Initials|cp|#abcdef|CIRCLE|", "|Initials|CP|#ABCDEF|circle|")]
|
||||
[DataRow("|Initials|CP|WARNING|SQUARE|", "|Initials|CP|warning|square|")]
|
||||
public void EquivalentGeneratedStyleTokensShareCacheIdentity(string value, string canonical)
|
||||
{
|
||||
Assert.AreEqual(canonical, GeneratedIconProtocol.GetCacheIdentity(value));
|
||||
@@ -166,10 +237,12 @@ public class GeneratedIconProtocolTests
|
||||
[DataTestMethod]
|
||||
[DataRow("|Swatch|#FFF|")]
|
||||
[DataRow("|Swatch|#ABCDEF|#A1B2C3|")]
|
||||
[DataRow("|Swatch|info|square|")]
|
||||
[DataRow("|Initials|A|#0067C0|circle|")]
|
||||
[DataRow("|Initials|AB|#0067C0|rounded|")]
|
||||
[DataRow("|Initials|AB|#0067C0|square|")]
|
||||
[DataRow("|Initials|JP|#0067C0|circle|")]
|
||||
[DataRow("|Initials|123|#0067C0|rounded|")]
|
||||
[DataRow("|Initials|123|#0067C0|square|")]
|
||||
[DataRow("|Initials|CP|warning|square|")]
|
||||
public void CanonicalGeneratedIdentitiesReuseInput(string value)
|
||||
{
|
||||
Assert.AreSame(value, GeneratedIconProtocol.GetCacheIdentity(value));
|
||||
@@ -178,7 +251,7 @@ public class GeneratedIconProtocolTests
|
||||
[TestMethod]
|
||||
public async Task MissingInitialsFontDegradesToBackgroundTile()
|
||||
{
|
||||
var svg = await CreateSvgAsync("|Initials|\U0010FFFF|#C42B1C|rounded|", ElementTheme.Light);
|
||||
var svg = await CreateSvgAsync("|Initials|\U0010FFFF|#C42B1C|square|", ElementTheme.Light);
|
||||
var root = ParseSvg(svg);
|
||||
|
||||
Assert.IsNotNull(root.Element(SvgName("rect")));
|
||||
@@ -191,6 +264,7 @@ public class GeneratedIconProtocolTests
|
||||
[DataRow("|Swatch|")]
|
||||
[DataRow("|Swatch|red|")]
|
||||
[DataRow("|Swatch|#12345|")]
|
||||
[DataRow("|Swatch|#123456|triangle|")]
|
||||
[DataRow("|Swatch|#123456|#654321|#ABCDEF|")]
|
||||
[DataRow("|swatch|#123456|")]
|
||||
[DataRow("|Initials||#123456|")]
|
||||
@@ -203,7 +277,8 @@ public class GeneratedIconProtocolTests
|
||||
[DataRow("|Initials|%FF|#123456|")]
|
||||
[DataRow("|Initials|%F0%9F%91|#123456|")]
|
||||
[DataRow("|Initials|AB|#123456|triangle|")]
|
||||
[DataRow("|Initials|AB|#123456|#654321|rounded|extra|")]
|
||||
[DataRow("|Initials|AB|unknown|circle|")]
|
||||
[DataRow("|Initials|AB|#123456|#654321|square|extra|")]
|
||||
public async Task InvalidProtocolIsRejected(string? value)
|
||||
{
|
||||
var (success, svg) = await TryCreateSvgAsync(value, ElementTheme.Light);
|
||||
@@ -252,8 +327,32 @@ public class GeneratedIconProtocolTests
|
||||
return (root.Element(SvgName("circle")) ?? root.Element(SvgName("rect")))?.Attribute("fill")?.Value;
|
||||
}
|
||||
|
||||
private static string? GetBackgroundOpacity(byte[] svg)
|
||||
{
|
||||
var root = ParseSvg(svg);
|
||||
return (root.Element(SvgName("circle")) ?? root.Element(SvgName("rect")))?.Attribute("fill-opacity")?.Value;
|
||||
}
|
||||
|
||||
private static string? GetForegroundFill(byte[] svg) =>
|
||||
ParseSvg(svg).Element(SvgName("path"))?.Attribute("fill")?.Value;
|
||||
|
||||
private static string GetBackgroundGeometry(byte[] svg)
|
||||
{
|
||||
var root = ParseSvg(svg);
|
||||
var background = root.Element(SvgName("circle")) ?? root.Element(SvgName("rect"));
|
||||
Assert.IsNotNull(background);
|
||||
|
||||
var geometry = background.Name.LocalName;
|
||||
foreach (var attribute in background.Attributes())
|
||||
{
|
||||
if (attribute.Name.LocalName is not "fill" and not "fill-opacity")
|
||||
{
|
||||
geometry += $"|{attribute.Name.LocalName}={attribute.Value}";
|
||||
}
|
||||
}
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
private static XName SvgName(string localName) => XName.Get(localName, "http://www.w3.org/2000/svg");
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ public class IconLoadDiagnosticsTests
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("|Swatch|#FF0067C0|", "GeneratedSwatch")]
|
||||
[DataRow("|Initials|CP|#FF005FB8|rounded|", "GeneratedInitials")]
|
||||
[DataRow("|Initials|CP|#FF005FB8|square|", "GeneratedInitials")]
|
||||
public void GeneratedIconProtocolUsesSpecificInputKind(string icon, string expectedKind)
|
||||
{
|
||||
IconLoadDiagnostics.Start();
|
||||
|
||||
@@ -71,7 +71,7 @@ public class IconPathConverterTests
|
||||
public void GeneratedInitialsDoNotShapeInSynchronousConverter()
|
||||
{
|
||||
using var prepared = IconPathConverter.Prepare(
|
||||
"|Initials|CP|#FF005FB8|#FF60CDFF|rounded|",
|
||||
"|Initials|CP|#FF005FB8|#FF60CDFF|square|",
|
||||
null,
|
||||
20,
|
||||
ElementTheme.Dark);
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<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\SemanticIconColor.cs" Link="Helpers\Icons\SemanticIconColor.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" />
|
||||
|
||||
@@ -13,9 +13,14 @@ 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"),
|
||||
"|Swatch|#FF0067C0|#FF60CDFF|square|",
|
||||
"Theme-aware square swatch",
|
||||
"Uses separate light and dark colors with the square background shape"),
|
||||
|
||||
BuildIconItem(
|
||||
"|Swatch|success|circle|",
|
||||
"Semantic success swatch",
|
||||
"Uses a theme-aware semantic color with the circle background shape"),
|
||||
|
||||
BuildIconItem(
|
||||
"|Initials|A|#FF7A3E9D|circle|",
|
||||
@@ -23,10 +28,20 @@ internal sealed partial class SampleIconPage : ListPage
|
||||
"Uses an automatically contrasting foreground"),
|
||||
|
||||
BuildIconItem(
|
||||
"|Initials|CP|#FF005FB8|#FF60CDFF|rounded|",
|
||||
"Theme-aware rounded initials avatar",
|
||||
"|Initials|CP|#FF005FB8|#FF60CDFF|square|",
|
||||
"Theme-aware square initials avatar",
|
||||
"Uses separate light and dark background colors"),
|
||||
|
||||
BuildIconItem(
|
||||
"|Initials|N|normal|circle|",
|
||||
"Semantic normal initials avatar",
|
||||
"Uses the normal theme foreground as its background color"),
|
||||
|
||||
BuildIconItem(
|
||||
"|Initials|T|transparent|square|",
|
||||
"Transparent initials avatar",
|
||||
"Uses a transparent square background and a theme-aware foreground"),
|
||||
|
||||
/*
|
||||
* 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