File: Helpers\TrayPanelSvgRenderer.cs
Web Access
Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text;
using System.Xml.Linq;
 
namespace Aspire.Tray.Tests.Helpers;
 
/// <summary>
/// Renders a <see cref="TrayPanelScene"/> to SVG for previews and documentation. It mirrors the
/// native renderers: same paths, same colors, and system UI fonts at the same sizes. SVG cannot
/// measure text, so truncation and wrapping use an average glyph width; that is close to, but not
/// exactly, what AppKit and GDI produce.
/// </summary>
internal static class TrayPanelSvgRenderer
{
    private const string FontFamily = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI Variable Text', 'Segoe UI', sans-serif";
    private static readonly XNamespace s_svg = "http://www.w3.org/2000/svg";
    private static readonly XNamespace s_xlink = "http://www.w3.org/1999/xlink";
 
    /// <summary>Renders the scene. <paramref name="logoPng"/> is embedded as a data URI when given.</summary>
    public static string Render(TrayPanelScene scene, byte[]? logoPng = null, double margin = 0)
    {
        var width = scene.Width + 2 * margin;
        var height = scene.Height + 2 * margin;
        var root = new XElement(s_svg + "svg",
            new XAttribute("width", F(width)),
            new XAttribute("height", F(height)),
            new XAttribute("viewBox", $"{F(-margin)} {F(-margin)} {F(width)} {F(height)}"),
            new XAttribute(XNamespace.Xmlns + "xlink", s_xlink.NamespaceName),
            new XAttribute("font-family", FontFamily));
        var defs = new XElement(s_svg + "defs");
        root.Add(defs);
        if (margin > 0)
        {
            defs.Add(new XElement(s_svg + "filter", new XAttribute("id", "shadow"),
                new XAttribute("x", "-20%"), new XAttribute("y", "-20%"), new XAttribute("width", "140%"), new XAttribute("height", "140%"),
                new XElement(s_svg + "feDropShadow", new XAttribute("dx", "0"), new XAttribute("dy", "6"),
                    new XAttribute("stdDeviation", "9"), new XAttribute("flood-opacity", "0.22"))));
            root.Add(new XElement(s_svg + "rect", new XAttribute("width", F(scene.Width)), new XAttribute("height", F(scene.Height)),
                new XAttribute("rx", F(TrayPanel.CornerRadius)), new XAttribute("fill", "#000"), new XAttribute("filter", "url(#shadow)")));
        }
 
        var stack = new Stack<XElement>();
        var current = root;
        var clipId = 0;
        foreach (var command in scene.Commands)
        {
            switch (command)
            {
                case FillPathCommand fill:
                    current.Add(new XElement(s_svg + "path", new XAttribute("d", ToPathData(fill.Path)), Paint("fill", fill.Color)));
                    break;
                case StrokePathCommand stroke:
                    current.Add(new XElement(s_svg + "path", new XAttribute("d", ToPathData(stroke.Path)), new XAttribute("fill", "none"),
                        Paint("stroke", stroke.Color), new XAttribute("stroke-width", F(stroke.Width)),
                        new XAttribute("stroke-linecap", "round"), new XAttribute("stroke-linejoin", "round")));
                    break;
                case TextCommand text:
                    current.Add(RenderText(text));
                    break;
                case AppIconCommand appIcon:
                    // The renderer has no application icons; draw the generic glyph hosts fall back to.
                    foreach (var shape in appIcon.Fallback)
                    {
                        current.Add(shape switch
                        {
                            FillPathCommand fill => new XElement(s_svg + "path", new XAttribute("d", ToPathData(fill.Path)), Paint("fill", fill.Color)),
                            StrokePathCommand stroke => new XElement(s_svg + "path", new XAttribute("d", ToPathData(stroke.Path)), new XAttribute("fill", "none"),
                                Paint("stroke", stroke.Color), new XAttribute("stroke-width", F(stroke.Width)),
                                new XAttribute("stroke-linecap", "round"), new XAttribute("stroke-linejoin", "round")),
                            _ => throw new InvalidOperationException("App icon fallbacks are shapes only.")
                        });
                    }
                    break;
                case LogoCommand logo:
                    current.Add(logoPng is null
                        ? new XElement(s_svg + "rect", Bounds(logo.Bounds), new XAttribute("rx", "6"), new XAttribute("fill", "#512BD4"))
                        : new XElement(s_svg + "image", Bounds(logo.Bounds),
                            new XAttribute("href", "data:image/png;base64," + Convert.ToBase64String(logoPng))));
                    break;
                case PushClipCommand clip:
                    var id = $"clip{clipId++}";
                    defs.Add(new XElement(s_svg + "clipPath", new XAttribute("id", id), new XElement(s_svg + "rect", Bounds(clip.Bounds))));
                    var group = new XElement(s_svg + "g", new XAttribute("clip-path", $"url(#{id})"));
                    current.Add(group);
                    stack.Push(current);
                    current = group;
                    break;
                case PopClipCommand:
                    current = stack.Pop();
                    break;
            }
        }
        if (stack.Count != 0)
        {
            throw new InvalidOperationException("Unbalanced clip commands.");
        }
        return root.ToString();
    }
 
    public static string ToPathData(PanelPath path)
    {
        var builder = new StringBuilder();
        foreach (var segment in path.Segments)
        {
            builder.Append(segment.Verb switch
            {
                PanelPathVerb.MoveTo => $"M{F(segment.X3)} {F(segment.Y3)}",
                PanelPathVerb.LineTo => $"L{F(segment.X3)} {F(segment.Y3)}",
                PanelPathVerb.CubicTo => $"C{F(segment.X1)} {F(segment.Y1)} {F(segment.X2)} {F(segment.Y2)} {F(segment.X3)} {F(segment.Y3)}",
                _ => "Z"
            });
        }
        return builder.ToString();
    }
 
    private static XElement RenderText(TextCommand text)
    {
        var size = PanelFonts.Size(text.Font);
        var lines = Layout(text.Text, text.Bounds.Width, size, text.MaxLines, text.Truncation);
        var (anchor, x) = text.Alignment switch
        {
            PanelTextAlignment.Center => ("middle", text.Bounds.CenterX),
            PanelTextAlignment.Trailing => ("end", text.Bounds.Right),
            _ => ("start", text.Bounds.X)
        };
        var lineHeight = Math.Round(size * 1.3);
        var element = new XElement(s_svg + "text",
            new XAttribute("x", F(x)),
            new XAttribute("font-size", F(size)),
            new XAttribute("font-weight", PanelFonts.IsSemibold(text.Font) ? "600" : "400"),
            new XAttribute("text-anchor", anchor),
            Paint("fill", text.Color));
        if (text.Font == PanelFontRole.Section)
        {
            element.Add(new XAttribute("letter-spacing", "0.4"));
        }
        if (text.MaxLines == 1)
        {
            // Center a single line from the em box, as the native renderers do from ascent and descent.
            element.Add(new XAttribute("y", F(text.Bounds.CenterY + size * 0.35)), lines[0]);
            return element;
        }
        for (var i = 0; i < lines.Count; i++)
        {
            element.Add(new XElement(s_svg + "tspan", new XAttribute("x", F(x)),
                new XAttribute("y", F(text.Bounds.Y + size + i * lineHeight - 1)), lines[i]));
        }
        return element;
    }
 
    private static List<string> Layout(string text, double width, double size, int maxLines, PanelTruncation truncation)
    {
        var capacity = Math.Max(1, (int)(width / (size * 0.53)));
        if (maxLines <= 1)
        {
            return [Truncate(text, capacity, truncation)];
        }
        var lines = new List<string>();
        var line = "";
        var words = text.Split(' ');
        for (var i = 0; i < words.Length; i++)
        {
            var candidate = line.Length == 0 ? words[i] : line + " " + words[i];
            if (candidate.Length <= capacity || line.Length == 0)
            {
                line = candidate;
                continue;
            }
            if (lines.Count == maxLines - 1)
            {
                lines.Add(Truncate(string.Join(' ', [line, .. words[i..]]), capacity, PanelTruncation.End));
                return lines;
            }
            lines.Add(line);
            line = words[i];
        }
        lines.Add(Truncate(line, capacity, PanelTruncation.End));
        return lines;
    }
 
    private static string Truncate(string text, int capacity, PanelTruncation truncation)
    {
        if (text.Length <= capacity)
        {
            return text;
        }
        if (truncation == PanelTruncation.Middle)
        {
            var tail = (capacity - 1) / 2;
            return text[..(capacity - 1 - tail)] + "…" + text[^tail..];
        }
        return text[..(capacity - 1)].TrimEnd() + "…";
    }
 
    private static object[] Paint(string name, PanelColor color)
        => color.A == 255
            ? [new XAttribute(name, color.ToHex())]
            : [new XAttribute(name, (color with { A = 255 }).ToHex()), new XAttribute(name + "-opacity", F(color.Alpha))];
 
    private static object[] Bounds(PanelRect rect)
        => [new XAttribute("x", F(rect.X)), new XAttribute("y", F(rect.Y)), new XAttribute("width", F(rect.Width)), new XAttribute("height", F(rect.Height))];
 
    private static string F(double value) => Math.Round(value, 2).ToString(CultureInfo.InvariantCulture);
}