File: TrayApplication.Panel.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.
 
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.Win32;
 
namespace Aspire.Tray;
 
/// <summary>
/// The flyout panel, the tray's only UI, shown by any activation of the notification icon.
/// Layout, glyphs and interaction live in the shared <see cref="TrayPanel"/>; this part hosts it
/// in a WS_POPUP window, forwards input, and draws the display list: shapes with the GDI+ flat
/// API (anti-aliased) and text with GDI DrawTextW (ClearType on an opaque background).
/// UI Automation sees the panel through TrayApplication.PanelAutomation.cs.
/// </summary>
internal sealed unsafe partial class TrayApplication
{
    private const string PanelClassName = "Aspire.Tray.Flyout";
    private const double PanelScreenMargin = 12;
    private const double WheelStep = 48;
 
    private readonly Dictionary<(PanelFontRole Role, uint Dpi), nint> _panelFonts = [];
    private TrayPanel? _panel;
    private nint _panelWindow;
    private bool _panelClassRegistered;
    private bool _panelVisible;
    private bool _panelTrackingMouse;
    private bool _panelUsesDwmCorners;
    private bool _panelPaletteDirty = true;
    private long _panelHiddenAt;
    private long _panelShownAt;
    private uint _panelDpi = 96;
    private nuint _gdiplusToken;
    private nint _panelLogo;
    private nint _panelTooltip;
    private readonly Dictionary<(string Application, int Size), nint> _appIcons = [];
    private List<(NativeMethods.Rect Bounds, string Text)> _panelTooltips = [];
    private uint _panelLogoDpi;
    private string _panelFontFace = "Segoe UI Variable Text";
 
    private double PanelScale => _panelDpi / 96.0;
 
    private void TogglePanel(bool keyboard)
    {
        if (_panelVisible && Environment.TickCount64 - _panelShownAt < 300)
        {
            // One gesture can arrive twice, such as the two clicks of a double-click or a repeated
            // NIN_KEYSELECT; the panel it just opened stays open.
            return;
        }
        if (_panelVisible || Environment.TickCount64 - _panelHiddenAt < 300)
        {
            // Clicking the icon first deactivates the panel, which already hid it; that same
            // click should not reopen it.
            HidePanel();
            return;
        }
        ShowPanel(keyboard);
    }
 
    private void ShowPanel(bool keyboard = false)
    {
        if (_modalDepth != 0 || _quitRequested)
        {
            return;
        }
        EnsurePanel();
        controller.PruneMissingPinnedAppHosts();
        _panel!.OpenInApplications = _installedApplications.Select(application => application.Title).ToArray();
        _panel.Update(controller.State);
        _panel.Reset();
        if (keyboard)
        {
            _panel.FocusFirst();
        }
        _panelShownAt = Environment.TickCount64;
        _panelPaletteDirty = true;
        _panelVisible = true;
        LayoutPanel(show: true);
        // Foreground activation is a request. Without it the panel never gets the
        // WM_ACTIVATE(WA_INACTIVE) that closes it when the user clicks elsewhere, so a refused
        // request would leave a topmost popup behind; close it, as a menu that cannot take the
        // foreground does, rather than keep it.
        if (NativeMethods.SetForegroundWindow(_panelWindow) == 0)
        {
            Program.Log("Windows kept the Aspire flyout in the background; it was closed again.");
            HidePanel();
            return;
        }
        NativeMethods.SetFocus(_panelWindow);
    }
 
    /// <summary>
    /// Whether a WM_CONTEXTMENU activation of the icon came from the keyboard (Shift+F10 or the
    /// context-menu key on the focused icon) rather than a right click, so the panel can show
    /// its keyboard focus. The notification doesn't say; a right click always has the pointer
    /// over the icon, and the keys are usually still down.
    /// </summary>
    private bool IsKeyboardContextMenu()
    {
        if (NativeMethods.GetKeyState(0x5D) < 0 || NativeMethods.GetKeyState(0x79) < 0) // VK_APPS, VK_F10
        {
            return true;
        }
        var identifier = new NativeMethods.NotifyIconIdentifier
        {
            Size = (uint)sizeof(NativeMethods.NotifyIconIdentifier), Window = _window, Id = 1
        };
        return NativeMethods.ShellNotifyIconGetRect(in identifier, out var icon) >= 0
            && NativeMethods.GetCursorPos(out var point) != 0
            && (point.X < icon.Left || point.X >= icon.Right || point.Y < icon.Top || point.Y >= icon.Bottom);
    }
 
    private void HidePanel()
    {
        if (!_panelVisible)
        {
            return;
        }
        _panelVisible = false;
        _panelHiddenAt = Environment.TickCount64;
        NativeMethods.ShowWindow(_panelWindow, 0); // SW_HIDE
    }
 
    private void EnsurePanel()
    {
        if (_panelWindow != 0)
        {
            return;
        }
        var input = new NativeMethods.GdiplusStartupInput { GdiplusVersion = 1 };
        if (NativeMethods.GdiplusStartup(out _gdiplusToken, in input, 0) != 0)
        {
            throw new NativeCallException("GdiplusStartup");
        }
        fixed (char* name = PanelClassName)
        {
            var windowClass = new NativeMethods.WindowClass
            {
                Style = NativeMethods.CsDropShadow,
                WindowProcedure = &PanelProcedure,
                Instance = _module,
                Cursor = NativeMethods.LoadCursor(0, 32512), // IDC_ARROW
                ClassName = name
            };
            NativeCallException.Require(NativeMethods.RegisterClass(ref windowClass) != 0, "RegisterClassW(flyout)");
        }
        _panelClassRegistered = true;
        _panel = new TrayPanel(controller.State, GetPanelPalette()) { FileManagerName = "File Explorer", QuitVerb = "Exit" };
        // A tool window keeps the flyout off the taskbar and Alt+Tab; owning it by the
        // message window keeps it above that window's dialogs' z-order rules.
        _panelWindow = NativeMethods.CreateWindowEx(NativeMethods.WsExToolWindow | NativeMethods.WsExTopmost, PanelClassName, "Aspire",
            NativeMethods.WsPopup, 0, 0, 1, 1, _window, 0, _module, 0);
        NativeCallException.Require(_panelWindow != 0, "CreateWindowExW(flyout)");
        // Windows 11 rounds popups through DWM (DWMWA_WINDOW_CORNER_PREFERENCE = 33, DWMWCP_ROUND = 2).
        // Windows 10 rejects the attribute; the panel is then rounded with a window region.
        // https://learn.microsoft.com/windows/apps/desktop/modernize/apply-rounded-corners
        var preference = 2;
        _panelUsesDwmCorners = NativeMethods.DwmSetWindowAttribute(_panelWindow, 33, &preference, sizeof(int)) >= 0;
    }
 
    private void LayoutPanel(bool show)
    {
        var anchor = GetNotifyIconRect();
        var center = new NativeMethods.Point { X = (anchor.Left + anchor.Right) / 2, Y = (anchor.Top + anchor.Bottom) / 2 };
        var monitor = NativeMethods.MonitorFromPoint(center, 2); // MONITOR_DEFAULTTONEAREST
        var info = new NativeMethods.MonitorInfo { Size = (uint)sizeof(NativeMethods.MonitorInfo) };
        NativeCallException.Require(NativeMethods.GetMonitorInfo(monitor, ref info) != 0, "GetMonitorInfoW(flyout)");
        if (NativeMethods.GetDpiForMonitor(monitor, 0, out var dpi, out _) < 0 || dpi == 0)
        {
            dpi = NativeMethods.GetDpiForWindow(_window);
        }
        _panelDpi = dpi == 0 ? 96 : dpi;
        if (_panelPaletteDirty)
        {
            _panel!.Palette = GetPanelPalette();
            _panelPaletteDirty = false;
        }
        var scale = PanelScale;
        var work = info.Work;
        var margin = (int)Math.Round(PanelScreenMargin * scale);
        _panel!.MaxHeight = Math.Min(TrayPanel.DefaultMaxHeight, (work.Bottom - work.Top - 2 * margin) / scale);
        var width = (int)Math.Round(TrayPanel.Width * scale);
        var height = (int)Math.Round(_panel.Scene.Height * scale);
 
        // The taskbar is on the edge where the work area is smaller than the monitor.
        int x, y;
        if (work.Top > info.Monitor.Top)
        {
            x = center.X - width / 2;
            y = work.Top + margin;
        }
        else if (work.Left > info.Monitor.Left)
        {
            x = work.Left + margin;
            y = center.Y - height / 2;
        }
        else if (work.Right < info.Monitor.Right)
        {
            x = work.Right - margin - width;
            y = center.Y - height / 2;
        }
        else
        {
            x = center.X - width / 2;
            y = work.Bottom - margin - height;
        }
        x = Math.Clamp(x, work.Left + margin, Math.Max(work.Left + margin, work.Right - margin - width));
        y = Math.Clamp(y, work.Top + margin, Math.Max(work.Top + margin, work.Bottom - margin - height));
 
        // HWND_TOPMOST; SWP_NOACTIVATE (0x10) unless showing, then SWP_SHOWWINDOW (0x40).
        NativeCallException.Require(NativeMethods.SetWindowPos(_panelWindow, -1, x, y, width, height, show ? 0x40u : 0x10u) != 0,
            "SetWindowPos(flyout)");
        if (!_panelUsesDwmCorners)
        {
            var diameter = (int)Math.Round(2 * TrayPanel.CornerRadius * scale);
            var region = NativeMethods.CreateRoundRectRgn(0, 0, width + 1, height + 1, diameter, diameter);
            // The system owns the region after a successful SetWindowRgn.
            if (region != 0 && NativeMethods.SetWindowRgn(_panelWindow, region, 1) == 0)
            {
                Cleanup(NativeMethods.DeleteObject(region) != 0, "DeleteObject(region)");
            }
        }
        NativeMethods.InvalidateRect(_panelWindow, 0, 0);
        SyncPanelAutomation();
        SyncPanelTooltips();
    }
 
    /// <summary>
    /// Keeps one tooltip tool per visible icon button. TTF_SUBCLASS lets the tooltip control watch
    /// the panel's mouse messages itself. Tools are replaced only when a tooltip or its rectangle
    /// changes (scrolling, DPI, a new view), so hovering doesn't restart the tooltip's delay.
    /// </summary>
    private void SyncPanelTooltips()
    {
        if (_panelTooltip == 0)
        {
            // The Common Controls v6 manifest selects the themed tooltip; this registers its class.
            var controls = new NativeMethods.CommonControls
            {
                Size = (uint)sizeof(NativeMethods.CommonControls), Classes = NativeMethods.IccBarClasses
            };
            NativeCallException.Require(NativeMethods.InitCommonControlsEx(in controls) != 0, "InitCommonControlsEx");
            // Owned by the panel so it stays above it and hides with it.
            _panelTooltip = NativeMethods.CreateWindowEx(NativeMethods.WsExTopmost, "tooltips_class32", "",
                NativeMethods.WsPopup | NativeMethods.TtsAlwaysTip | NativeMethods.TtsNoPrefix, 0, 0, 0, 0, _panelWindow, 0, _module, 0);
            NativeCallException.Require(_panelTooltip != 0, "CreateWindowExW(flyout tooltip)");
        }
        var tooltips = _panel!.Scene.Targets.Where(target => target.Tooltip is not null && !target.HitBounds.IsEmpty)
            .Select(target => (Bounds: ToDevice(target.HitBounds), Text: target.Tooltip!)).ToList();
        if (tooltips.SequenceEqual(_panelTooltips))
        {
            return;
        }
        for (var i = 0; i < _panelTooltips.Count; i++)
        {
            var tool = new NativeMethods.ToolInfo { Size = (uint)sizeof(NativeMethods.ToolInfo), Window = _panelWindow, Id = (nuint)i };
            NativeMethods.SendMessage(_panelTooltip, NativeMethods.TtmDelTool, 0, (nint)(&tool));
        }
        _panelTooltips = tooltips;
        for (var i = 0; i < tooltips.Count; i++)
        {
            // The control copies the text, so the pinned buffer only has to outlive the call.
            fixed (char* text = tooltips[i].Text)
            {
                var tool = new NativeMethods.ToolInfo
                {
                    Size = (uint)sizeof(NativeMethods.ToolInfo),
                    Flags = NativeMethods.TtfSubclass,
                    Window = _panelWindow,
                    Id = (nuint)i,
                    Rect = tooltips[i].Bounds,
                    Text = text
                };
                NativeCallException.Require(NativeMethods.SendMessage(_panelTooltip, NativeMethods.TtmAddTool, 0, (nint)(&tool)) != 0,
                    "TTM_ADDTOOLW(flyout)");
            }
        }
    }
 
    private NativeMethods.Rect GetNotifyIconRect()
    {
        var identifier = new NativeMethods.NotifyIconIdentifier
        {
            Size = (uint)sizeof(NativeMethods.NotifyIconIdentifier), Window = _window, Id = 1
        };
        if (NativeMethods.ShellNotifyIconGetRect(in identifier, out var rect) >= 0)
        {
            return rect;
        }
        // The icon can be in the overflow area; fall back to the pointer.
        NativeCallException.Require(NativeMethods.GetCursorPos(out var point) != 0, "GetCursorPos(flyout)");
        return new() { Left = point.X, Top = point.Y, Right = point.X, Bottom = point.Y };
    }
 
    private void UpdatePanel(TrayViewState state)
    {
        // RefreshState also runs on the 200 ms timer; repaint only for a new state or theme.
        if (!_panelVisible || _panel is null || (ReferenceEquals(state, _panel.State) && !_panelPaletteDirty))
        {
            return;
        }
        if (_panel.Update(state) || _panelPaletteDirty)
        {
            ApplyPanelResponse(PanelResponse.Changed);
        }
    }
 
    private void ApplyPanelResponse(PanelResponse response)
    {
        if (response.Redraw && _panelVisible)
        {
            NativeMethods.GetWindowRect(_panelWindow, out var rect);
            if (_panelPaletteDirty || Math.Abs(rect.Bottom - rect.Top - Math.Round(_panel!.Scene.Height * PanelScale)) > 0.5)
            {
                LayoutPanel(show: false);
            }
            else
            {
                NativeMethods.InvalidateRect(_panelWindow, 0, 0);
                SyncPanelAutomation();
                SyncPanelTooltips();
            }
        }
        if (response.Action is { } action)
        {
            RunPanelAction(action);
        }
    }
 
    private void RunPanelAction(PanelAction action)
    {
        var id = action.Host?.Id ?? default;
        switch (action.Kind)
        {
            case PanelActionKind.OpenDashboard:
                HidePanel();
                Dispatch(new(ActionKind.Dashboard, id));
                break;
            case PanelActionKind.Stop:
                Dispatch(new(ActionKind.Stop, id));
                break;
            case PanelActionKind.Start:
                Dispatch(new(ActionKind.Start, id));
                break;
            case PanelActionKind.OpenIn:
                Dispatch(new(ActionKind.OpenIn, id, _installedApplications.FirstOrDefault(application => application.Title == action.Application)));
                break;
            case PanelActionKind.ShowInFolder:
                Dispatch(new(ActionKind.Explorer, id));
                break;
            case PanelActionKind.CopyPath:
                Dispatch(new(ActionKind.CopyPath, id));
                break;
            case PanelActionKind.TogglePin:
                Dispatch(new(ActionKind.TogglePin, id));
                break;
            case PanelActionKind.ClearRecent:
                Dispatch(new(ActionKind.ClearRecent));
                break;
            case PanelActionKind.RemoveRecent:
                Dispatch(new(ActionKind.RemoveRecent, id));
                break;
            case PanelActionKind.Documentation:
                HidePanel();
                Dispatch(new(ActionKind.Documentation));
                break;
            case PanelActionKind.Settings:
                ApplyPanelResponse(_panel!.ShowSettings(PanelSettings.Read(startupSettings, _interactiveSmoke, LogStartupFailure)));
                break;
            case PanelActionKind.SetLaunchAtSignIn:
                ApplyPanelResponse(_panel!.UpdateSettings(
                    PanelSettings.SetLaunchAtSignIn(startupSettings, action.Enabled, _interactiveSmoke, LogStartupFailure)));
                break;
            case PanelActionKind.Quit:
                HidePanel();
                Dispatch(new(ActionKind.Quit));
                break;
            case PanelActionKind.Close:
                HidePanel();
                break;
        }
    }
 
    private static void LogStartupFailure(Exception exception)
        => Program.Log($"Tray startup settings failed in the panel ({exception.GetType().Name}): {exception.Message}");
 
    private static TrayPanelPalette GetPanelPalette()
    {
        if (s_previewPalette is { } preview)
        {
            return preview;
        }
        // SPI_GETHIGHCONTRAST; HCF_HIGHCONTRASTON = 1.
        var contrast = new NativeMethods.HighContrast { Size = (uint)sizeof(NativeMethods.HighContrast) };
        if (NativeMethods.GetHighContrast(0x42, contrast.Size, ref contrast, 0) != 0 && (contrast.Flags & 1) != 0)
        {
            // COLOR_WINDOW (5), COLOR_WINDOWTEXT (8), COLOR_HIGHLIGHT (13), COLOR_GRAYTEXT (17).
            return TrayPanelPalette.FromSystemColors(SystemColor(5), SystemColor(8), SystemColor(13), SystemColor(17));
        }
        return IsLightAppTheme() ? TrayPanelPalette.Light : TrayPanelPalette.Dark;
 
        static PanelColor SystemColor(int index)
        {
            var value = NativeMethods.GetSysColor(index);
            return new((byte)value, (byte)(value >> 8), (byte)(value >> 16));
        }
    }
 
    private static bool IsLightAppTheme()
    {
        // Windows exposes the app light/dark choice only through this registry value; it is
        // absent on older builds, which were light only.
        // https://learn.microsoft.com/windows/apps/desktop/modernize/apply-windows-themes
        try
        {
            using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
            return key?.GetValue("AppsUseLightTheme") is not int value || value != 0;
        }
        catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException)
        {
            return true;
        }
    }
 
    private void PaintPanel()
    {
        var dc = NativeMethods.BeginPaint(_panelWindow, out var paint);
        try
        {
            if (dc == 0 || _panel is null)
            {
                return;
            }
            NativeMethods.GetClientRect(_panelWindow, out var client);
            var width = client.Right - client.Left;
            var height = client.Bottom - client.Top;
            // Double-buffer: draw into a memory bitmap, then copy it in one BitBlt.
            var memory = NativeMethods.CreateCompatibleDC(dc);
            NativeCallException.Require(memory != 0, "CreateCompatibleDC(flyout)");
            var bitmap = NativeMethods.CreateCompatibleBitmap(dc, width, height);
            try
            {
                NativeCallException.Require(bitmap != 0, "CreateCompatibleBitmap(flyout)");
                var previous = NativeMethods.SelectObject(memory, bitmap);
                RenderPanel(memory, width, height);
                NativeCallException.Require(NativeMethods.BitBlt(dc, 0, 0, width, height, memory, 0, 0, 0x00CC0020) != 0, "BitBlt(flyout)"); // SRCCOPY
                NativeMethods.SelectObject(memory, previous);
            }
            finally
            {
                if (bitmap != 0)
                {
                    Cleanup(NativeMethods.DeleteObject(bitmap) != 0, "DeleteObject(flyout bitmap)");
                }
                Cleanup(NativeMethods.DeleteDC(memory) != 0, "DeleteDC(flyout)");
            }
        }
        finally
        {
            NativeMethods.EndPaint(_panelWindow, in paint);
        }
    }
 
    private void RenderPanel(nint dc, int width, int height)
    {
        var scene = _panel!.Scene;
        var palette = _panel.Palette;
        var scale = (float)PanelScale;
        RequireGdiplus(NativeMethods.GdipCreateFromHDC(dc, out var graphics), "GdipCreateFromHDC");
        try
        {
            // AntiAlias (4) and PixelOffsetModeHalf (4): pixel centers sit at .5 like AppKit and SVG,
            // so a 1-DIP line at an integer coordinate covers whole pixels.
            RequireGdiplus(NativeMethods.GdipSetSmoothingMode(graphics, 4), "GdipSetSmoothingMode");
            RequireGdiplus(NativeMethods.GdipSetPixelOffsetMode(graphics, 4), "GdipSetPixelOffsetMode");
            RequireGdiplus(NativeMethods.GdipScaleWorldTransform(graphics, scale, scale, 0), "GdipScaleWorldTransform");
            // The window is opaque: start from the panel color so the corners outside the
            // rounded body match it where DWM or the region clips them.
            FillPanelPath(graphics, PanelPath.Rectangle(new(0, 0, width / scale + 1, height / scale + 1)), palette.Background);
            var clips = new Stack<(uint State, PanelRect Bounds)>();
            foreach (var command in scene.Commands)
            {
                switch (command)
                {
                    case FillPathCommand fill:
                        FillPanelPath(graphics, fill.Path, fill.Color);
                        break;
                    case StrokePathCommand stroke:
                        StrokePanelPath(graphics, stroke.Path, stroke.Color, stroke.Width);
                        break;
                    case TextCommand text:
                        WithGdiDc(graphics, clips, gdi => DrawPanelText(gdi, text, palette));
                        break;
                    case LogoCommand logo:
                        WithGdiDc(graphics, clips, gdi => DrawPanelLogo(gdi, logo.Bounds));
                        break;
                    case AppIconCommand appIcon:
                        var rect = ToDevice(appIcon.Bounds);
                        if (GetAppIcon(appIcon.Application, rect.Right - rect.Left) is not 0 and var icon)
                        {
                            WithGdiDc(graphics, clips, gdi => NativeCallException.Require(
                                NativeMethods.DrawIconEx(gdi, rect.Left, rect.Top, icon, rect.Right - rect.Left, rect.Bottom - rect.Top, 0, 0, 3) != 0,
                                "DrawIconEx(application icon)")); // DI_NORMAL
                        }
                        else
                        {
                            foreach (var shape in appIcon.Fallback)
                            {
                                if (shape is FillPathCommand fallbackFill)
                                {
                                    FillPanelPath(graphics, fallbackFill.Path, fallbackFill.Color);
                                }
                                else if (shape is StrokePathCommand fallbackStroke)
                                {
                                    StrokePanelPath(graphics, fallbackStroke.Path, fallbackStroke.Color, fallbackStroke.Width);
                                }
                            }
                        }
                        break;
                    case PushClipCommand clip:
                        RequireGdiplus(NativeMethods.GdipSaveGraphics(graphics, out var state), "GdipSaveGraphics");
                        // CombineModeIntersect (1).
                        RequireGdiplus(NativeMethods.GdipSetClipRect(graphics, (float)clip.Bounds.X, (float)clip.Bounds.Y,
                            (float)clip.Bounds.Width, (float)clip.Bounds.Height, 1), "GdipSetClipRect");
                        clips.Push((state, clips.Count == 0 ? clip.Bounds : clips.Peek().Bounds.Intersect(clip.Bounds)));
                        break;
                    case PopClipCommand:
                        RequireGdiplus(NativeMethods.GdipRestoreGraphics(graphics, clips.Pop().State), "GdipRestoreGraphics");
                        break;
                }
            }
        }
        finally
        {
            NativeMethods.GdipDeleteGraphics(graphics);
        }
    }
 
    private void WithGdiDc(nint graphics, Stack<(uint State, PanelRect Bounds)> clips, Action<nint> draw)
    {
        // GDI text must not interleave with buffered GDI+ output: GdipGetDC flushes GDI+ and
        // lends the DC until GdipReleaseDC. The DC does not carry the GDI+ clip or transform,
        // so apply the current clip in device pixels here.
        RequireGdiplus(NativeMethods.GdipGetDC(graphics, out var dc), "GdipGetDC");
        var saved = NativeMethods.SaveDC(dc);
        try
        {
            if (clips.Count != 0)
            {
                var clip = ToDevice(clips.Peek().Bounds);
                NativeMethods.IntersectClipRect(dc, clip.Left, clip.Top, clip.Right, clip.Bottom);
            }
            draw(dc);
        }
        finally
        {
            NativeMethods.RestoreDC(dc, saved);
            NativeMethods.GdipReleaseDC(graphics, dc);
        }
    }
 
    private static void FillPanelPath(nint graphics, PanelPath source, PanelColor color)
    {
        var path = CreateGdiplusPath(source);
        try
        {
            RequireGdiplus(NativeMethods.GdipCreateSolidFill(ToArgb(color), out var brush), "GdipCreateSolidFill");
            try
            {
                RequireGdiplus(NativeMethods.GdipFillPath(graphics, brush, path), "GdipFillPath");
            }
            finally
            {
                NativeMethods.GdipDeleteBrush(brush);
            }
        }
        finally
        {
            NativeMethods.GdipDeletePath(path);
        }
    }
 
    private static void StrokePanelPath(nint graphics, PanelPath source, PanelColor color, double width)
    {
        var path = CreateGdiplusPath(source);
        try
        {
            // UnitWorld (0) so the width scales with the DPI transform; LineCapRound (2), LineJoinRound (2).
            RequireGdiplus(NativeMethods.GdipCreatePen1(ToArgb(color), (float)width, 0, out var pen), "GdipCreatePen1");
            try
            {
                RequireGdiplus(NativeMethods.GdipSetPenLineCap197819(pen, 2, 2, 2), "GdipSetPenLineCap");
                RequireGdiplus(NativeMethods.GdipSetPenLineJoin(pen, 2), "GdipSetPenLineJoin");
                RequireGdiplus(NativeMethods.GdipDrawPath(graphics, pen, path), "GdipDrawPath");
            }
            finally
            {
                NativeMethods.GdipDeletePen(pen);
            }
        }
        finally
        {
            NativeMethods.GdipDeletePath(path);
        }
    }
 
    /// <summary>
    /// Converts a shared path to a GDI+ path through GdipCreatePath2's point and type arrays.
    /// Types: 0 starts a figure, 1 is a line point, 3 is a Bézier point (three per curve),
    /// and the 0x80 flag on a figure's last point closes it.
    /// https://learn.microsoft.com/windows/win32/api/gdipluspath/nf-gdipluspath-graphicspath-graphicspath(constpointf_constbyte_int_fillmode)
    /// </summary>
    private static nint CreateGdiplusPath(PanelPath source)
    {
        var points = new List<NativeMethods.PointF>();
        var types = new List<byte>();
        foreach (var segment in source.Segments)
        {
            switch (segment.Verb)
            {
                case PanelPathVerb.MoveTo:
                    points.Add(new() { X = (float)segment.X3, Y = (float)segment.Y3 });
                    types.Add(0);
                    break;
                case PanelPathVerb.LineTo:
                    points.Add(new() { X = (float)segment.X3, Y = (float)segment.Y3 });
                    types.Add(1);
                    break;
                case PanelPathVerb.CubicTo:
                    points.Add(new() { X = (float)segment.X1, Y = (float)segment.Y1 });
                    points.Add(new() { X = (float)segment.X2, Y = (float)segment.Y2 });
                    points.Add(new() { X = (float)segment.X3, Y = (float)segment.Y3 });
                    types.Add(3);
                    types.Add(3);
                    types.Add(3);
                    break;
                case PanelPathVerb.Close when types.Count > 0:
                    types[^1] |= 0x80;
                    break;
            }
        }
        var pointArray = points.ToArray();
        var typeArray = types.ToArray();
        fixed (NativeMethods.PointF* pointData = pointArray)
        fixed (byte* typeData = typeArray)
        {
            // FillModeAlternate (0).
            RequireGdiplus(NativeMethods.GdipCreatePath2(pointData, typeData, pointArray.Length, 0, out var path), "GdipCreatePath2");
            return path;
        }
    }
 
    private void DrawPanelText(nint dc, TextCommand text, TrayPanelPalette palette)
    {
        var font = GetPanelFont(dc, text.Font);
        var previous = NativeMethods.SelectObject(dc, font);
        try
        {
            NativeMethods.SetBkMode(dc, 1); // TRANSPARENT
            // GDI text cannot blend, so resolve any alpha against the panel background.
            var color = text.Color.A == 255 ? text.Color : text.Color.Over(palette.Background);
            NativeMethods.SetTextColor(dc, (uint)(color.R | (color.G << 8) | (color.B << 16)));
            var rect = ToDevice(text.Bounds);
            // DT_NOPREFIX keeps '&' in paths literal.
            var format = 0x800u | text.Alignment switch
            {
                PanelTextAlignment.Center => 0x1u, // DT_CENTER
                PanelTextAlignment.Trailing => 0x2u, // DT_RIGHT
                _ => 0u
            };
            if (text.MaxLines > 1)
            {
                // DT_WORDBREAK | DT_EDITCONTROL (hide a partial last line) | DT_END_ELLIPSIS, top-aligned.
                format |= 0x10 | 0x2000 | 0x8000;
            }
            else
            {
                // DT_SINGLELINE | DT_END_ELLIPSIS (0x8000), top-aligned: the box's top is placed one
                // ascent above the scene's baseline (in whole device pixels), so the baseline lands
                // there exactly. DT_VCENTER centered Segoe UI's line box instead, whose ascent and
                // descent differ from SF's, so text sat at a different height than on macOS.
                format |= 0x20 | 0x8000;
                NativeCallException.Require(NativeMethods.GetTextMetrics(dc, out var metrics) != 0, "GetTextMetricsW(flyout)");
                rect.Top = (int)Math.Round(text.Baseline * PanelScale) - metrics.Ascent;
                rect.Bottom = rect.Top + metrics.Height;
            }
            var value = text.MaxLines <= 1 && text.Truncation == PanelTruncation.Middle
                ? FitMiddle(dc, text.Text, rect.Right - rect.Left)
                : text.Text;
            NativeMethods.DrawText(dc, value, value.Length, ref rect, format);
        }
        finally
        {
            NativeMethods.SelectObject(dc, previous);
        }
    }
 
    // Middle truncation that matches AppKit's NSLineBreakByTruncatingMiddle: keep both ends of
    // "Running · C:\work\shop\AppHost" and replace the middle with an ellipsis. DT_PATH_ELLIPSIS
    // is not equivalent: it always keeps the text after the last backslash, so on a narrow row
    // it shrinks the status prefix to "Ru…" while the path tail stays intact.
    private static string FitMiddle(nint dc, string value, int width)
    {
        if (MeasureText(dc, value) <= width)
        {
            return value;
        }
 
        // Cut on text-element boundaries so a surrogate pair or combining sequence stays whole.
        var elements = System.Globalization.StringInfo.ParseCombiningCharacters(value);
        string Build(int keep)
        {
            var tail = keep / 2;
            var head = keep - tail;
            return value[..(head < elements.Length ? elements[head] : value.Length)] + "\u2026" +
                (tail == 0 ? string.Empty : value[elements[^tail]..]);
        }
 
        // Binary search the largest number of kept text elements that still fits.
        var low = 0;
        var high = elements.Length - 1;
        while (low < high)
        {
            var middle = (low + high + 1) / 2;
            if (MeasureText(dc, Build(middle)) <= width)
            {
                low = middle;
            }
            else
            {
                high = middle - 1;
            }
        }
        return Build(low);
    }
 
    private static int MeasureText(nint dc, string value)
    {
        var rect = new NativeMethods.Rect();
        // DT_CALCRECT | DT_SINGLELINE | DT_NOPREFIX measures with the font selected into dc.
        NativeMethods.DrawText(dc, value, value.Length, ref rect, 0x400 | 0x20 | 0x800);
        return rect.Right - rect.Left;
    }
 
    private void DrawPanelLogo(nint dc, PanelRect bounds)
    {
        var rect = ToDevice(bounds);
        var size = rect.Right - rect.Left;
        if (_panelLogo == 0 || _panelLogoDpi != _panelDpi)
        {
            if (_panelLogo != 0)
            {
                Cleanup(NativeMethods.DestroyIcon(_panelLogo) != 0, "DestroyIcon(flyout logo)");
            }
            _panelLogo = NativeMethods.LoadImage(0, Path.Combine(AppContext.BaseDirectory, "Aspire.ico"),
                NativeMethods.ImageIcon, size, size, NativeMethods.LrLoadFromFile);
            NativeCallException.Require(_panelLogo != 0, "LoadImageW(flyout logo)");
            _panelLogoDpi = _panelDpi;
        }
        NativeCallException.Require(NativeMethods.DrawIconEx(dc, rect.Left, rect.Top, _panelLogo, size, size, 0, 0, 3) != 0,
            "DrawIconEx(flyout logo)"); // DI_NORMAL
    }
 
    /// <summary>
    /// Returns the icon of an installed "Open in" application at a pixel size, or 0 when it has
    /// none that the shell can extract (an app execution alias such as wt.exe is a reparse point
    /// without icon resources), in which case the panel's generic glyph is drawn.
    /// </summary>
    private nint GetAppIcon(string title, int size)
    {
        if (!_appIcons.TryGetValue((title, size), out var icon))
        {
            var executable = _installedApplications.FirstOrDefault(application => application.Title == title)?.Executable;
            if (executable is null || NativeMethods.SHDefExtractIcon(executable, 0, 0, out icon, null, (uint)size) != 0)
            {
                icon = 0;
            }
            _appIcons.Add((title, size), icon);
        }
        return icon;
    }
 
    private nint GetPanelFont(nint dc, PanelFontRole role)
    {
        if (_panelFonts.TryGetValue((role, _panelDpi), out var font))
        {
            return font;
        }
        // A negative height selects the em size, which matches the DIP sizes used by AppKit.
        // FW_SEMIBOLD (600) or FW_NORMAL (400); CLEARTYPE_QUALITY (5).
        var height = -(int)Math.Round(PanelFonts.Size(role) * PanelScale);
        var weight = PanelFonts.IsSemibold(role) ? 600 : 400;
        font = NativeMethods.CreateFont(height, 0, 0, 0, weight, 0, 0, 0, 1, 0, 0, 5, 0, _panelFontFace);
        NativeCallException.Require(font != 0, "CreateFontW(flyout)");
        if (_panelFontFace != "Segoe UI" && !UsesFace(dc, font, _panelFontFace))
        {
            // Segoe UI Variable ships with Windows 11; Windows 10 substitutes another face.
            Cleanup(NativeMethods.DeleteObject(font) != 0, "DeleteObject(flyout font)");
            _panelFontFace = "Segoe UI";
            return GetPanelFont(dc, role);
        }
        _panelFonts.Add((role, _panelDpi), font);
        return font;
 
        static bool UsesFace(nint dc, nint font, string face)
        {
            var previous = NativeMethods.SelectObject(dc, font);
            try
            {
                char* buffer = stackalloc char[64];
                var length = NativeMethods.GetTextFace(dc, 64, buffer);
                return length > 0 && new string(buffer, 0, length - 1).Equals(face, StringComparison.OrdinalIgnoreCase);
            }
            finally
            {
                NativeMethods.SelectObject(dc, previous);
            }
        }
    }
 
    private NativeMethods.Rect ToDevice(PanelRect rect)
    {
        var scale = PanelScale;
        return new()
        {
            Left = (int)Math.Round(rect.X * scale),
            Top = (int)Math.Round(rect.Y * scale),
            Right = (int)Math.Round(rect.Right * scale),
            Bottom = (int)Math.Round(rect.Bottom * scale)
        };
    }
 
    private static uint ToArgb(PanelColor color) => (uint)(color.A << 24 | color.R << 16 | color.G << 8 | color.B);
 
    private static void RequireGdiplus(int status, string operation)
    {
        if (status != 0)
        {
            throw new NativeCallException($"{operation} (GDI+ status {status})");
        }
    }
 
    private nint HandlePanelMessage(nint window, uint message, nuint wParam, nint lParam)
    {
        if (_panel is null)
        {
            return NativeMethods.DefWindowProc(window, message, wParam, lParam);
        }
        // Client coordinates are signed 16-bit values in device pixels.
        var x = unchecked((short)((nuint)lParam & 0xFFFF)) / PanelScale;
        var y = unchecked((short)(((nuint)lParam >> 16) & 0xFFFF)) / PanelScale;
        switch (message)
        {
            case NativeMethods.WmPaint:
                PaintPanel();
                return 0;
            case NativeMethods.WmEraseBackground:
                return 1;
            case NativeMethods.WmMouseMove:
                if (!_panelTrackingMouse)
                {
                    // TME_LEAVE (2) asks for a single WM_MOUSELEAVE.
                    var track = new NativeMethods.TrackMouseEventData
                    {
                        Size = (uint)sizeof(NativeMethods.TrackMouseEventData), Flags = 2, Window = window
                    };
                    _panelTrackingMouse = NativeMethods.TrackMouseEvent(ref track) != 0;
                }
                ApplyPanelResponse(_panel.PointerMoved(x, y));
                return 0;
            case NativeMethods.WmMouseLeave:
                _panelTrackingMouse = false;
                ApplyPanelResponse(_panel.PointerLeft());
                return 0;
            case NativeMethods.WmLeftButtonDown:
                NativeMethods.SetCapture(window);
                ApplyPanelResponse(_panel.PointerDown(x, y));
                return 0;
            case NativeMethods.WmLeftButtonUp:
                NativeMethods.ReleaseCapture();
                ApplyPanelResponse(_panel.PointerUp(x, y));
                return 0;
            case NativeMethods.WmMouseWheel:
                // WHEEL_DELTA (120) per notch; a positive delta means the wheel moved away from the user.
                var delta = unchecked((short)((wParam >> 16) & 0xFFFF));
                ApplyPanelResponse(_panel.Scroll(delta / 120.0 * WheelStep));
                return 0;
            case NativeMethods.WmKeyDown when wParam == 0xBC && NativeMethods.GetKeyState(0x11) < 0:
                // Ctrl+comma (VK_OEM_COMMA with VK_CONTROL) opens Settings, as in other Windows apps.
                if (!_panel.IsSettingsOpen)
                {
                    RunPanelAction(new(PanelActionKind.Settings));
                }
                return 0;
            case NativeMethods.WmKeyDown:
                PanelKey? key = (int)wParam switch
                {
                    0x26 => PanelKey.Up,
                    0x28 => PanelKey.Down,
                    0x09 => NativeMethods.GetKeyState(0x10) < 0 ? PanelKey.ShiftTab : PanelKey.Tab,
                    0x0D => PanelKey.Enter,
                    0x20 => PanelKey.Space,
                    0x1B => PanelKey.Escape,
                    0x24 => PanelKey.Home,
                    0x23 => PanelKey.End,
                    _ => null
                };
                if (key is { } value)
                {
                    ApplyPanelResponse(_panel.Key(value));
                    return 0;
                }
                break;
            case NativeMethods.WmActivate when (wParam & 0xFFFF) == 0 && _previewExport is null:
                // WA_INACTIVE: clicking anywhere else closes the flyout, like a menu. A pending
                // smoke preview capture keeps it open: the console that launched it takes focus back.
                HidePanel();
                return 0;
            case NativeMethods.WmDpiChanged when _panelVisible:
                LayoutPanel(show: false);
                return 0;
            case NativeMethods.WmGetObject when unchecked((int)lParam) == NativeMethods.UiaRootObjectId:
                return HandlePanelGetObject(window, wParam, lParam);
            case WmPanelAutomationInvoke:
                InvokeFromAutomation((int)wParam);
                return 0;
        }
        return NativeMethods.DefWindowProc(window, message, wParam, lParam);
    }
 
    private void DisposePanel()
    {
        if (_panelTooltip != 0)
        {
            Cleanup(NativeMethods.DestroyWindow(_panelTooltip) != 0, "DestroyWindow(flyout tooltip)");
            _panelTooltip = 0;
            _panelTooltips = [];
        }
        if (_panelWindow != 0)
        {
            DisposePanelAutomation();
            Cleanup(NativeMethods.DestroyWindow(_panelWindow) != 0, "DestroyWindow(flyout)");
            _panelWindow = 0;
        }
        if (_panelClassRegistered)
        {
            Cleanup(NativeMethods.UnregisterClass(PanelClassName, _module) != 0, "UnregisterClassW(flyout)");
            _panelClassRegistered = false;
        }
        foreach (var font in _panelFonts.Values)
        {
            Cleanup(NativeMethods.DeleteObject(font) != 0, "DeleteObject(flyout font)");
        }
        _panelFonts.Clear();
        if (_panelLogo != 0)
        {
            Cleanup(NativeMethods.DestroyIcon(_panelLogo) != 0, "DestroyIcon(flyout logo)");
            _panelLogo = 0;
        }
        foreach (var icon in _appIcons.Values.Where(icon => icon != 0))
        {
            Cleanup(NativeMethods.DestroyIcon(icon) != 0, "DestroyIcon(application icon)");
        }
        _appIcons.Clear();
        if (_gdiplusToken != 0)
        {
            NativeMethods.GdiplusShutdown(_gdiplusToken);
            _gdiplusToken = 0;
        }
        _panelVisible = false;
    }
 
    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])]
    private static nint PanelProcedure(nint window, uint message, nuint wParam, nint lParam)
    {
        try
        {
            if (s_current is { } application && window == application._panelWindow)
            {
                return application.HandlePanelMessage(window, message, wParam, lParam);
            }
        }
        catch (Exception ex)
        {
            // Managed exceptions must not cross user32 callback frames.
            if (s_current is { } application)
            {
                application._callbackFailure ??= ex;
                application.ExitCode = 1;
                Program.Log($"Flyout callback failed: {ex.Message}");
                application.QuitOnLoop();
            }
            return 0;
        }
        return NativeMethods.DefWindowProc(window, message, wParam, lParam);
    }
}