Add semantic generated icon colors and shapes

This commit is contained in:
Jiří Polášek
2026-08-12 06:51:53 +02:00
parent c347460e63
commit 84b94a19e5
7 changed files with 382 additions and 101 deletions

View File

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

View File

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

View File

@@ -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");
}

View File

@@ -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();

View File

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

View File

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

View File

@@ -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').