File: TrayApplication.Icons.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;
 
internal sealed unsafe partial class TrayApplication
{
    private void UpdateTrayIcon(TrayViewState state)
    {
        var status = TrayIconStatusRules.Get(state);
        if (_iconState == status)
        {
            return;
        }
        _iconData.Icon = _artwork!.TrayIcon(status);
        fixed (char* tip = _iconData.Tip)
        {
            var buffer = new Span<char>(tip, 128);
            buffer.Clear();
            var text = $"Aspire - {TrayIconStatusRules.Describe(status)}";
            text.AsSpan().CopyTo(buffer);
        }
        if (_iconAdded && NativeMethods.ShellNotifyIcon(NativeMethods.NimModify, ref _iconData) == 0)
        {
            _iconAdded = false;
            _restoreAttempts = 0;
            Program.Log("The notification icon needs to be restored.");
        }
        _iconState = status;
    }
 
    /// <summary>Owns the notification icons at one DPI.</summary>
    private sealed class Artwork : IDisposable
    {
        private readonly TrayApplication _owner;
        private readonly List<nint> _icons = [];
        private bool _disposed;
 
        internal Artwork(TrayApplication owner, uint dpi)
        {
            _owner = owner;
            dpi = dpi == 0 ? 96u : dpi;
            Size = NativeMethods.GetSystemMetricsForDpi(NativeMethods.SmCxSmallIcon, dpi);
            NativeCallException.Require(Size > 0, "GetSystemMetricsForDpi");
            try
            {
                var original = NativeMethods.LoadImage(0, Path.Combine(AppContext.BaseDirectory, "Aspire.ico"),
                    NativeMethods.ImageIcon, Size, Size, NativeMethods.LrLoadFromFile);
                NativeCallException.Require(original != 0, "LoadImageW(Aspire.ico)");
                _icons.Add(original);
                Original = original;
                var light = IsLightTaskbar();
                // The panel's success and warning colors, picked for contrast with the taskbar.
                Running = CreateTrayIcon(original, light ? 0xFF1E9E57 : 0xFF34C46E, dimmed: false);
                Attention = CreateTrayIcon(original, light ? 0xFFD98A00 : 0xFFF2A93B, dimmed: false);
                Dimmed = CreateTrayIcon(original, badge: null, dimmed: true);
            }
            catch
            {
                Dispose();
                throw;
            }
        }
 
        internal int Size { get; }
        internal nint Original { get; }
        internal nint Running { get; }
        internal nint Attention { get; }
        internal nint Dimmed { get; }
 
        internal nint TrayIcon(TrayIconStatus status) => status switch
        {
            TrayIconStatus.Running => Running,
            TrayIconStatus.Attention => Attention,
            TrayIconStatus.Idle => Original,
            _ => Dimmed
        };
 
        private static bool IsLightTaskbar()
        {
            // The taskbar follows the system theme, not the app theme. The value is absent on
            // builds older than the light taskbar, which were dark.
            // https://learn.microsoft.com/windows/apps/desktop/modernize/apply-windows-themes
            try
            {
                using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
                return key?.GetValue("SystemUsesLightTheme") is int value && value != 0;
            }
            catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException)
            {
                return false;
            }
        }
 
        private nint CreateTrayIcon(nint original, uint? badge, bool dimmed)
        {
            using var canvas = new PixelCanvas(_owner, Size);
            canvas.DrawIcon(original);
            // The same rules as macOS: the colored logo alone when idle, a status dot in a
            // transparent cut-out while AppHosts run, and a faded logo while discovery is
            // connecting or unavailable. The cut-out shows the taskbar itself, so the dot
            // stays readable on light, dark and accent-colored taskbars.
            if (badge is uint color)
            {
                var radius = Size * 0.21;
                var center = Size - radius - Size * 0.01;
                canvas.Erase(center, center, radius + Size * 0.07);
                canvas.Circle(center, center, radius, color);
            }
            if (dimmed)
            {
                canvas.Fade(0.45);
            }
            // CreateIconIndirect copies both bitmaps. A zero AND mask is used with the
            // 32-bit alpha channel; CreateBitmap(NULL) would leave that mask uninitialized.
            var maskBytes = new byte[((Size + 15) / 16) * 2 * Size];
            fixed (byte* maskBits = maskBytes)
            {
                var mask = NativeMethods.CreateBitmap(Size, Size, 1, 1, (nint)maskBits);
                NativeCallException.Require(mask != 0, "CreateBitmap(icon mask)");
                try
                {
                    var info = new NativeMethods.IconInfo { IsIcon = 1, Color = canvas.Handle, Mask = mask };
                    var icon = NativeMethods.CreateIconIndirect(ref info);
                    NativeCallException.Require(icon != 0, "CreateIconIndirect");
                    _icons.Add(icon);
                    return icon;
                }
                finally
                {
                    _owner.Cleanup(NativeMethods.DeleteObject(mask) != 0, "DeleteObject(icon mask)");
                }
            }
        }
 
        public void Dispose()
        {
            if (_disposed)
            {
                return;
            }
            _disposed = true;
            foreach (var icon in _icons)
            {
                _owner.Cleanup(NativeMethods.DestroyIcon(icon) != 0, "DestroyIcon");
            }
        }
    }
 
    private sealed class PixelCanvas : IDisposable
    {
        private readonly TrayApplication _owner;
        private readonly int _size;
        internal nint Handle { get; private set; }
        internal uint* Pixels { get; }
 
        internal PixelCanvas(TrayApplication owner, int size)
        {
            _owner = owner;
            _size = size;
            var info = new NativeMethods.BitmapInfo
            {
                Size = 40, Width = size, Height = -size, Planes = 1, BitCount = 32
            };
            Handle = NativeMethods.CreateDIBSection(0, ref info, 0, out var bits, 0, 0);
            NativeCallException.Require(Handle != 0, "CreateDIBSection");
            Pixels = (uint*)bits;
            new Span<uint>(Pixels, size * size).Clear();
        }
 
        internal void Circle(double x, double y, double radius, uint color)
            => DrawShape((px, py) => Math.Sqrt((px - x) * (px - x) + (py - y) * (py - y)) - radius, color);
 
        /// <summary>Clears an anti-aliased disc to transparent.</summary>
        internal void Erase(double x, double y, double radius)
        {
            for (var row = 0; row < _size; row++)
            {
                for (var column = 0; column < _size; column++)
                {
                    var distance = Math.Sqrt((column + 0.5 - x) * (column + 0.5 - x) + (row + 0.5 - y) * (row + 0.5 - y)) - radius;
                    var coverage = Math.Clamp(0.5 - distance, 0, 1);
                    if (coverage > 0)
                    {
                        Scale(ref Pixels[row * _size + column], 1 - coverage);
                    }
                }
            }
        }
 
        /// <summary>Multiplies the whole canvas's opacity, keeping premultiplied channels consistent.</summary>
        internal void Fade(double opacity)
        {
            for (var index = 0; index < _size * _size; index++)
            {
                Scale(ref Pixels[index], opacity);
            }
        }
 
        private static void Scale(ref uint pixel, double factor)
        {
            // Premultiplied BGRA: every channel scales by the same factor.
            uint Channel(uint value, int shift) => (uint)Math.Round(((value >> shift) & 255) * factor) << shift;
            pixel = Channel(pixel, 24) | Channel(pixel, 16) | Channel(pixel, 8) | Channel(pixel, 0);
        }
 
        private void DrawShape(Func<double, double, double> distance, uint color)
        {
            // One-pixel coverage ramp and source-over compositing keep edges smooth at
            // fractional DPI sizes. Icon DIBs require premultiplied BGRA, not straight alpha.
            for (var row = 0; row < _size; row++)
            {
                for (var column = 0; column < _size; column++)
                {
                    var coverage = Math.Clamp(0.5 - distance(column + 0.5, row + 0.5), 0, 1);
                    if (coverage > 0)
                    {
                        var alpha = (uint)Math.Round(coverage * (color >> 24));
                        var previous = Pixels[row * _size + column];
                        var inverse = 255 - alpha;
                        uint Channel(int shift) => (((color >> shift) & 255) * alpha
                            + ((previous >> shift) & 255) * inverse + 127) / 255;
                        Pixels[row * _size + column] = (alpha + ((previous >> 24) * inverse + 127) / 255) << 24
                            | Channel(16) << 16 | Channel(8) << 8 | Channel(0);
                    }
                }
            }
        }
 
        internal void DrawIcon(nint icon)
        {
            var dc = NativeMethods.CreateCompatibleDC(0);
            NativeCallException.Require(dc != 0, "CreateCompatibleDC");
            nint previous = 0;
            try
            {
                previous = NativeMethods.SelectObject(dc, Handle);
                NativeCallException.Require(previous != 0 && previous != -1, "SelectObject");
                NativeCallException.Require(NativeMethods.DrawIconEx(dc, 0, 0, icon, _size, _size, 0, 0, 3) != 0, "DrawIconEx");
                // GDI may batch drawing; synchronize before directly modifying DIB pixels.
                NativeCallException.Require(NativeMethods.GdiFlush() != 0, "GdiFlush");
            }
            finally
            {
                if (previous != 0 && previous != -1)
                {
                    _owner.Cleanup(NativeMethods.SelectObject(dc, previous) != 0, "SelectObject(restore)");
                }
                _owner.Cleanup(NativeMethods.DeleteDC(dc) != 0, "DeleteDC");
            }
        }
 
        public void Dispose()
        {
            if (Handle != 0)
            {
                _owner.Cleanup(NativeMethods.DeleteObject(Handle) != 0, "DeleteObject(canvas)");
                Handle = 0;
            }
        }
    }
}