File: src\Aspire.Tray\Common\TrayPanelGeometry.cs
Web Access
Project: src\src\Aspire.Tray\Windows\Aspire.Tray.Windows.csproj (aspire-tray)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Tray;
 
/// <summary>
/// A rectangle in device-independent pixels with a top-left origin and y growing down,
/// which is the coordinate system of the shared panel on every platform.
/// </summary>
internal readonly record struct PanelRect(double X, double Y, double Width, double Height)
{
    public double Right => X + Width;
    public double Bottom => Y + Height;
    public double CenterX => X + Width / 2;
    public double CenterY => Y + Height / 2;
    public bool IsEmpty => Width <= 0 || Height <= 0;
 
    public bool Contains(double x, double y) => x >= X && x < Right && y >= Y && y < Bottom;
 
    public bool Contains(PanelRect other) => other.X >= X && other.Y >= Y && other.Right <= Right && other.Bottom <= Bottom;
 
    public bool IntersectsWith(PanelRect other) => other.X < Right && X < other.Right && other.Y < Bottom && Y < other.Bottom;
 
    public PanelRect Inset(double dx, double dy) => new(X + dx, Y + dy, Width - 2 * dx, Height - 2 * dy);
 
    public PanelRect Offset(double dx, double dy) => new(X + dx, Y + dy, Width, Height);
 
    public PanelRect Intersect(PanelRect other)
    {
        var x = Math.Max(X, other.X);
        var y = Math.Max(Y, other.Y);
        var right = Math.Min(Right, other.Right);
        var bottom = Math.Min(Bottom, other.Bottom);
        return right <= x || bottom <= y ? default : new(x, y, right - x, bottom - y);
    }
}
 
internal enum PanelPathVerb
{
    MoveTo,
    LineTo,
    CubicTo,
    Close
}
 
/// <summary>
/// One path segment. MoveTo and LineTo use (X3, Y3) as the end point so every verb
/// keeps its end point in the same fields; CubicTo also uses both control points.
/// </summary>
internal readonly record struct PanelPathSegment(PanelPathVerb Verb, double X1, double Y1, double X2, double Y2, double X3, double Y3);
 
/// <summary>
/// A vector path limited to the primitives that AppKit (NSBezierPath), GDI+ and SVG all
/// draw identically: move, line, cubic Bézier and close. Rounded rectangles, circles and
/// arcs are approximated with cubics here so platform renderers never need their own
/// shape helpers and every glyph comes out the same on every OS.
/// </summary>
internal sealed class PanelPath
{
    // Distance of the control points for a 90° arc, 4/3 * tan(π/8).
    // See https://spencermortensen.com/articles/bezier-circle/.
    internal const double QuarterArcKappa = 0.5522847498;
 
    private readonly List<PanelPathSegment> _segments = [];
 
    public IReadOnlyList<PanelPathSegment> Segments => _segments;
 
    public PanelPath MoveTo(double x, double y)
    {
        _segments.Add(new(PanelPathVerb.MoveTo, 0, 0, 0, 0, x, y));
        return this;
    }
 
    public PanelPath LineTo(double x, double y)
    {
        _segments.Add(new(PanelPathVerb.LineTo, 0, 0, 0, 0, x, y));
        return this;
    }
 
    public PanelPath CubicTo(double x1, double y1, double x2, double y2, double x, double y)
    {
        _segments.Add(new(PanelPathVerb.CubicTo, x1, y1, x2, y2, x, y));
        return this;
    }
 
    public PanelPath Close()
    {
        _segments.Add(new(PanelPathVerb.Close, 0, 0, 0, 0, 0, 0));
        return this;
    }
 
    /// <summary>
    /// Appends a circular arc. Angles are in degrees on the y-down panel axes, so 0° points
    /// right, 90° points down and a positive sweep turns clockwise on screen. The arc is
    /// split into segments of 90° or less, which keeps the cubic error below 0.03% of the radius.
    /// </summary>
    public PanelPath Arc(double centerX, double centerY, double radius, double startDegrees, double sweepDegrees, bool connect = false)
    {
        var start = startDegrees * Math.PI / 180;
        var sweep = sweepDegrees * Math.PI / 180;
        var x0 = centerX + radius * Math.Cos(start);
        var y0 = centerY + radius * Math.Sin(start);
        if (connect)
        {
            LineTo(x0, y0);
        }
        else
        {
            MoveTo(x0, y0);
        }
        var count = Math.Max(1, (int)Math.Ceiling(Math.Abs(sweep) / (Math.PI / 2) - 1e-9));
        var step = sweep / count;
        // The tangent length for an arc of angle φ is 4/3 * tan(φ/4) times the radius.
        // A negative step gives a negative factor, which turns the tangents around too.
        var k = 4.0 / 3.0 * Math.Tan(step / 4) * radius;
        for (var i = 0; i < count; i++)
        {
            var a0 = start + step * i;
            var a1 = a0 + step;
            var (s0, c0) = Math.SinCos(a0);
            var (s1, c1) = Math.SinCos(a1);
            CubicTo(
                centerX + radius * c0 - k * s0, centerY + radius * s0 + k * c0,
                centerX + radius * c1 + k * s1, centerY + radius * s1 - k * c1,
                centerX + radius * c1, centerY + radius * s1);
        }
        return this;
    }
 
    public static PanelPath Circle(double centerX, double centerY, double radius)
        => new PanelPath().Arc(centerX, centerY, radius, 0, 360).Close();
 
    public static PanelPath Rectangle(PanelRect rect)
        => new PanelPath().MoveTo(rect.X, rect.Y).LineTo(rect.Right, rect.Y).LineTo(rect.Right, rect.Bottom)
            .LineTo(rect.X, rect.Bottom).Close();
 
    public static PanelPath RoundedRect(PanelRect rect, double radius)
        => RoundedRect(rect, radius, radius, radius, radius);
 
    /// <summary>Builds a rounded rectangle with independent corner radii, clockwise from the top-left corner.</summary>
    public static PanelPath RoundedRect(PanelRect rect, double topLeft, double topRight, double bottomRight, double bottomLeft)
    {
        var limit = Math.Min(rect.Width, rect.Height) / 2;
        topLeft = Math.Clamp(topLeft, 0, limit);
        topRight = Math.Clamp(topRight, 0, limit);
        bottomRight = Math.Clamp(bottomRight, 0, limit);
        bottomLeft = Math.Clamp(bottomLeft, 0, limit);
        var path = new PanelPath().MoveTo(rect.X + topLeft, rect.Y).LineTo(rect.Right - topRight, rect.Y);
        if (topRight > 0)
        {
            path.Arc(rect.Right - topRight, rect.Y + topRight, topRight, -90, 90, connect: true);
        }
        path.LineTo(rect.Right, rect.Bottom - bottomRight);
        if (bottomRight > 0)
        {
            path.Arc(rect.Right - bottomRight, rect.Bottom - bottomRight, bottomRight, 0, 90, connect: true);
        }
        path.LineTo(rect.X + bottomLeft, rect.Bottom);
        if (bottomLeft > 0)
        {
            path.Arc(rect.X + bottomLeft, rect.Bottom - bottomLeft, bottomLeft, 90, 90, connect: true);
        }
        path.LineTo(rect.X, rect.Y + topLeft);
        if (topLeft > 0)
        {
            path.Arc(rect.X + topLeft, rect.Y + topLeft, topLeft, 180, 90, connect: true);
        }
        return path.Close();
    }
 
    /// <summary>Returns a copy scaled by <paramref name="scale"/> and then moved by the offset.</summary>
    public PanelPath Transform(double scale, double offsetX, double offsetY)
    {
        var result = new PanelPath();
        foreach (var s in _segments)
        {
            result._segments.Add(s.Verb == PanelPathVerb.Close
                ? s
                : new(s.Verb,
                    s.X1 * scale + offsetX, s.Y1 * scale + offsetY,
                    s.X2 * scale + offsetX, s.Y2 * scale + offsetY,
                    s.X3 * scale + offsetX, s.Y3 * scale + offsetY));
        }
        return result;
    }
 
    /// <summary>Returns the bounds of every end and control point, which contain the drawn curve.</summary>
    public PanelRect GetControlBounds()
    {
        double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue;
        foreach (var s in _segments)
        {
            switch (s.Verb)
            {
                case PanelPathVerb.CubicTo:
                    Include(s.X1, s.Y1);
                    Include(s.X2, s.Y2);
                    Include(s.X3, s.Y3);
                    break;
                case PanelPathVerb.MoveTo or PanelPathVerb.LineTo:
                    Include(s.X3, s.Y3);
                    break;
            }
        }
        return minX > maxX ? default : new(minX, minY, maxX - minX, maxY - minY);
 
        void Include(double x, double y)
        {
            minX = Math.Min(minX, x);
            minY = Math.Min(minY, y);
            maxX = Math.Max(maxX, x);
            maxY = Math.Max(maxY, y);
        }
    }
}