diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index abbc39cda..63ce622ab 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -760,7 +760,10 @@ private void InitializeTrayIcon() // Pre-create tray menu window at startup to avoid creation crashes later InitializeTrayMenuWindow(); - var iconPath = IconHelper.GetStatusIconPath(ConnectionStatus.Disconnected); + // Start with the status-badged lobster (neutral/gray dot) so the tray icon + // mirrors the companion-app status from first paint, even before the first + // connection-state update arrives. + var iconPath = StatusBadgeIconFactory.GetBadgedIconPath(ConnectionStatusAccent.Neutral); _trayIcon = new TrayIcon(1, iconPath, BuildTrayTooltip()); _trayIconCoordinator = new TrayIconCoordinator( _trayIcon, diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs new file mode 100644 index 000000000..113f2ff81 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs @@ -0,0 +1,249 @@ +using OpenClawTray.Services; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.Versioning; + +namespace OpenClawTray.Helpers; + +/// +/// Builds application icons that surface the connection status on the lobster +/// mascot as a small badge in the bottom-right corner. To avoid a distracting +/// always-on indicator, only attention states are badged: +/// +/// disconnected / neutral → a grey dot, +/// error → a red dot with a white "-" (minus) glyph. +/// +/// Healthy states (connected, connecting) render the plain lobster with no badge. +/// Composed icons are cached per and written +/// to a temp folder as multi-resolution .ico files so both the tray icon +/// () and the desktop/taskbar window +/// icon (Window.SetIcon(string)) can consume them. +/// +[SupportedOSPlatform("windows")] +internal static class StatusBadgeIconFactory +{ + private static readonly string AssetsPath = Path.Combine(AppContext.BaseDirectory, "Assets"); + private static readonly string OutputDir = Path.Combine(Path.GetTempPath(), "OpenClawTray", "StatusIcons"); + private static readonly object Gate = new(); + private static readonly ConcurrentDictionary Cache = new(); + + // Cover the sizes the shell requests for the tray (16-32 by DPI) and the + // taskbar/window (up to 256), so LoadImage always finds a crisp match. + private static readonly int[] Sizes = { 16, 20, 24, 32, 40, 48, 64, 128, 256 }; + + /// + /// Returns a filesystem path to a lobster icon badged with the status dot for + /// . Falls back to the plain app icon on failure. + /// + public static string GetBadgedIconPath(ConnectionStatusAccent accent) + { + if (Cache.TryGetValue(accent, out var cached) && File.Exists(cached)) + return cached; + + lock (Gate) + { + if (Cache.TryGetValue(accent, out cached) && File.Exists(cached)) + return cached; + + var (path, isFallback) = Build(accent); + + // Only cache successfully composed icons. Caching the fallback would + // permanently pin the un-badged icon after a transient GDI+ failure. + if (!isFallback) + Cache[accent] = path; + + return path; + } + } + + /// Maps a status accent to its dot colour (mirrors the companion-app dot). + public static Color DotColor(ConnectionStatusAccent accent) => accent switch + { + ConnectionStatusAccent.Success => Color.FromArgb(76, 175, 80), // Green – connected + ConnectionStatusAccent.Caution => Color.FromArgb(255, 193, 7), // Amber – connecting / attention + ConnectionStatusAccent.Critical => Color.FromArgb(244, 67, 54), // Red – error + _ => Color.FromArgb(158, 158, 158), // Gray – disconnected / neutral + }; + + /// + /// Whether gets a status badge. Only attention states + /// are badged so the icon is quiet when everything is healthy: disconnected + /// (neutral) and error (critical). Connected/connecting render the plain lobster. + /// + public static bool ShouldBadge(ConnectionStatusAccent accent) => + accent is ConnectionStatusAccent.Neutral or ConnectionStatusAccent.Critical; + + /// Whether the badge carries the "-" (minus) glyph. Only the error state does. + public static bool HasDash(ConnectionStatusAccent accent) => + accent is ConnectionStatusAccent.Critical; + + private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accent) + { + var fallback = Path.Combine(AssetsPath, "openclaw.ico"); + try + { + Directory.CreateDirectory(OutputDir); + var outputPath = Path.Combine(OutputDir, $"openclaw-{accent}".ToLowerInvariant() + ".ico"); + + // Only attention states (neutral/critical) get a dot; healthy states + // render the plain lobster (null dot colour). + Color? dotColor = ShouldBadge(accent) ? DotColor(accent) : null; + + using var baseImage = LoadBaseImage(); + File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, dotColor, HasDash(accent), Sizes)); + return (outputPath, false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to build status-badged icon for {accent}: {ex.Message}"); + return (fallback, true); + } + } + + /// + /// Composes the (optionally badged) lobster at every requested size and packs + /// the frames into a single multi-resolution .ico byte array. A null + /// renders the plain lobster. Exposed for testing. + /// + internal static byte[] CreateIcoBytes(Bitmap baseImage, Color? dotColor, bool withDash, IReadOnlyList sizes) + { + var frames = new List(sizes.Count); + foreach (var size in sizes) + { + using var composed = Compose(baseImage, size, dotColor, withDash); + using var ms = new MemoryStream(); + composed.Save(ms, ImageFormat.Png); + frames.Add(ms.ToArray()); + } + + return BuildIco(frames, sizes); + } + + /// The icon sizes baked into every composed .ico container. + internal static IReadOnlyList IconSizes => Sizes; + + private static Bitmap LoadBaseImage() + { + // Prefer the high-resolution unplated mascot PNG (transparent background) + // for crisp scaling; fall back to the multi-size app .ico. + var png = Path.Combine(AssetsPath, "Square44x44Logo.targetsize-256_altform-unplated.png"); + if (File.Exists(png)) + return new Bitmap(png); + + var ico = Path.Combine(AssetsPath, "openclaw.ico"); + using var icon = new Icon(ico, 256, 256); + return icon.ToBitmap(); + } + + /// + /// The dot diameter as a fraction of the icon size. Tiny tray icons need a + /// proportionally larger dot to stay legible at 16-32px, while large taskbar + /// / alt-tab icons look cleaner with a subtler corner dot. Interpolates + /// between the two extremes. Exposed for testing. + /// + internal static double DotFraction(int size) + { + const double maxFraction = 0.44; // <= 32px (tray legibility) + const double minFraction = 0.26; // >= 256px (subtle corner dot) + const int minSize = 32; + const int maxSize = 256; + + if (size <= minSize) + return maxFraction; + if (size >= maxSize) + return minFraction; + + var t = (double)(size - minSize) / (maxSize - minSize); + return maxFraction + (t * (minFraction - maxFraction)); + } + + internal static Bitmap Compose(Bitmap baseImage, int size, Color? dotColor, bool withDash = false) + { + var bmp = new Bitmap(size, size, PixelFormat.Format32bppArgb); + bmp.SetResolution(96, 96); + + using var g = Graphics.FromImage(bmp); + g.SmoothingMode = SmoothingMode.AntiAlias; + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.CompositingQuality = CompositingQuality.HighQuality; + g.Clear(Color.Transparent); + + g.DrawImage(baseImage, new Rectangle(0, 0, size, size)); + + // No badge for healthy states: render the plain lobster. + if (dotColor is not Color color) + return bmp; + + // Dot geometry: bottom-right corner with a white ring for contrast against + // both the red mascot and arbitrary taskbar backgrounds. The dot scales by + // size so it is legible on tiny tray icons yet subtle on large icons; the + // ring is tied to the dot so it shrinks proportionally too. + float dot = size * (float)DotFraction(size); + float ring = Math.Max(1f, dot * 0.14f); + float pad = size * 0.02f; + float outer = dot + (ring * 2f); + float outerX = size - outer - pad; + float outerY = size - outer - pad; + + using (var ringBrush = new SolidBrush(Color.White)) + g.FillEllipse(ringBrush, outerX, outerY, outer, outer); + + using (var dotBrush = new SolidBrush(color)) + g.FillEllipse(dotBrush, outerX + ring, outerY + ring, dot, dot); + + // Error badge carries a white "-" (minus) glyph centred on the dot. + if (withDash) + { + float cx = outerX + ring + (dot / 2f); + float cy = outerY + ring + (dot / 2f); + float dashW = dot * 0.52f; + float dashH = Math.Max(2f, dot * 0.18f); + using var dashBrush = new SolidBrush(Color.White); + g.FillRectangle(dashBrush, cx - (dashW / 2f), cy - (dashH / 2f), dashW, dashH); + } + + return bmp; + } + + /// + /// Packs PNG frames into a Vista-style .ico container (PNG-compressed entries, + /// supported by LoadImage on Windows 10+). + /// + private static byte[] BuildIco(IReadOnlyList pngFrames, IReadOnlyList sizes) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + var count = (ushort)pngFrames.Count; + writer.Write((ushort)0); // idReserved + writer.Write((ushort)1); // idType = icon + writer.Write(count); // idCount + + var offset = 6 + (16 * count); + for (var i = 0; i < pngFrames.Count; i++) + { + var dim = (byte)(sizes[i] >= 256 ? 0 : sizes[i]); // 0 encodes 256 + writer.Write(dim); // bWidth + writer.Write(dim); // bHeight + writer.Write((byte)0); // bColorCount + writer.Write((byte)0); // bReserved + writer.Write((ushort)1); // wPlanes + writer.Write((ushort)32); // wBitCount + writer.Write((uint)pngFrames[i].Length); // dwBytesInRes + writer.Write((uint)offset); // dwImageOffset + offset += pngFrames[i].Length; + } + + foreach (var frame in pngFrames) + writer.Write(frame); + + writer.Flush(); + return ms.ToArray(); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs index bd4da850d..0b1ec60fb 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs @@ -64,6 +64,26 @@ public static bool IsOperatorChannelLive(GatewayConnectionSnapshot snapshot) => }, }; + /// + /// Resolves the status accent (Success/Caution/Critical/Neutral) used for the + /// companion-app status dot, so the tray and desktop icons can mirror it. + /// Prefers the richer when available and + /// falls back to the legacy . + /// + public static ConnectionStatusAccent Accent(OverallConnectionState? overall, ConnectionStatus fallback) + { + if (overall is OverallConnectionState state) + return Pill(state).Accent; + + return fallback switch + { + ConnectionStatus.Connected => ConnectionStatusAccent.Success, + ConnectionStatus.Connecting => ConnectionStatusAccent.Caution, + ConnectionStatus.Error => ConnectionStatusAccent.Critical, + _ => ConnectionStatusAccent.Neutral, + }; + } + public static (string LabelKey, ConnectionStatusAccent Accent) Pill(OverallConnectionState overall) => overall switch { OverallConnectionState.Connected or OverallConnectionState.Ready => diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs index 1471b11a2..37a40d40f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs @@ -1,4 +1,5 @@ using Microsoft.UI.Dispatching; +using OpenClawTray.Helpers; using System; using WinUIEx; @@ -39,8 +40,10 @@ internal void UpdateTrayIcon() if (!_isAlive()) return; - var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "openclaw.ico"); - var tooltip = BuildTrayTooltip(); + var snapshot = _captureSnapshot(); + var accent = ConnectionStatusPresenter.Accent(snapshot.OverallState, snapshot.Status); + var iconPath = StatusBadgeIconFactory.GetBadgedIconPath(accent); + var tooltip = new TrayTooltipBuilder(snapshot).Build(); try { @@ -60,7 +63,4 @@ internal void ApplyTrayTooltip(string tooltip) _trayIcon.Tooltip = tooltip; } - - private string BuildTrayTooltip() => - new TrayTooltipBuilder(_captureSnapshot()).Build(); } diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs index 309253625..96e91fc6b 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs @@ -114,7 +114,7 @@ public HubWindow() this.SetWindowSize(1000, 650); this.CenterOnScreen(); - this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected)); + ApplyWindowStatusIcon(ConnectionStatusAccent.Neutral); RootGrid.SizeChanged += OnRootGridSizeChanged; @@ -771,6 +771,7 @@ private void UpdateTitleBarStatus(ConnectionStatus status) var (text, accent) = ComputePillState(status, snapshot); StatusPillText.Text = text; StatusPillDot.Fill = AccentBrush(accent); + ApplyWindowStatusIcon(accent); } internal void UpdateTitleBarStatus(GatewayConnectionSnapshot snapshot, ConnectionStatus status) @@ -778,6 +779,21 @@ internal void UpdateTitleBarStatus(GatewayConnectionSnapshot snapshot, Connectio var (text, accent) = ComputePillState(status, snapshot); StatusPillText.Text = text; StatusPillDot.Fill = AccentBrush(accent); + ApplyWindowStatusIcon(accent); + } + + // Mirrors the companion-app status dot onto the desktop/taskbar window icon: + // the lobster with a coloured dot in the bottom-right corner. + private void ApplyWindowStatusIcon(ConnectionStatusAccent accent) + { + try + { + this.SetIcon(StatusBadgeIconFactory.GetBadgedIconPath(accent)); + } + catch (Exception ex) + { + Logger.Warn($"Failed to update window status icon: {ex.Message}"); + } } private static (string Text, ConnectionStatusAccent Accent) ComputePillState( diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 870abb364..f969dc163 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -795,6 +795,34 @@ public void TrayCoordinator_UpdateGuardsLivenessBeforeTouchingIcon() Assert.True(guardIndex < setIconIndex, "Liveness guard must run before SetIcon"); } + [Fact] + public void TrayCoordinator_UsesStatusBadgedLobsterIcon() + { + var method = ExtractMethod(ReadCoordinatorSource(), "UpdateTrayIcon"); + + // The tray lobster mirrors the companion-app status dot instead of the + // static openclaw.ico, so it must resolve the accent and the badged icon. + Assert.Contains("ConnectionStatusPresenter.Accent(", method); + Assert.Contains("StatusBadgeIconFactory.GetBadgedIconPath(", method); + Assert.DoesNotContain("\"openclaw.ico\"", method); + } + + [Fact] + public void HubWindow_DesktopIconMirrorsStatusAccent() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs")); + + // The desktop/taskbar icon is refreshed from the same accent as the pill. + var apply = ExtractMethod(source, "ApplyWindowStatusIcon"); + Assert.Contains("StatusBadgeIconFactory.GetBadgedIconPath(accent)", apply); + + // Both status-update paths must repaint the window icon. + var update = ExtractMethod(source, "UpdateTitleBarStatus"); + Assert.Contains("ApplyWindowStatusIcon(accent)", update); + } + [Fact] public void AppNotifications_ConnectionIssueUsesStableDedupeKey() { diff --git a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs index 4a2b106c8..284d71ac5 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs @@ -31,6 +31,35 @@ public void Pill_ReadyAndConnected_BothReadConnected() ConnectionStatusPresenter.Pill(OverallConnectionState.Ready)); } + [Theory] + [InlineData(OverallConnectionState.Connected, (int)ConnectionStatusAccent.Success)] + [InlineData(OverallConnectionState.Ready, (int)ConnectionStatusAccent.Success)] + [InlineData(OverallConnectionState.Connecting, (int)ConnectionStatusAccent.Caution)] + [InlineData(OverallConnectionState.Degraded, (int)ConnectionStatusAccent.Caution)] + [InlineData(OverallConnectionState.PairingRequired, (int)ConnectionStatusAccent.Caution)] + [InlineData(OverallConnectionState.Error, (int)ConnectionStatusAccent.Critical)] + [InlineData(OverallConnectionState.Idle, (int)ConnectionStatusAccent.Neutral)] + [InlineData(OverallConnectionState.Disconnecting, (int)ConnectionStatusAccent.Neutral)] + public void Accent_PrefersOverallState_MatchingPill(OverallConnectionState overall, int expectedAccent) + { + // The tray/desktop status dot must mirror the companion-app pill accent. + Assert.Equal((ConnectionStatusAccent)expectedAccent, + ConnectionStatusPresenter.Accent(overall, ConnectionStatus.Disconnected)); + Assert.Equal(ConnectionStatusPresenter.Pill(overall).Accent, + ConnectionStatusPresenter.Accent(overall, ConnectionStatus.Disconnected)); + } + + [Theory] + [InlineData(ConnectionStatus.Connected, (int)ConnectionStatusAccent.Success)] + [InlineData(ConnectionStatus.Connecting, (int)ConnectionStatusAccent.Caution)] + [InlineData(ConnectionStatus.Error, (int)ConnectionStatusAccent.Critical)] + [InlineData(ConnectionStatus.Disconnected, (int)ConnectionStatusAccent.Neutral)] + public void Accent_FallsBackToLegacyStatus_WhenOverallNull(ConnectionStatus status, int expectedAccent) + { + Assert.Equal((ConnectionStatusAccent)expectedAccent, + ConnectionStatusPresenter.Accent(null, status)); + } + [Theory] [InlineData(OverallConnectionState.Connected, ConnectionStatus.Connected)] [InlineData(OverallConnectionState.Ready, ConnectionStatus.Connected)] diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 50527f1ec..e843868fb 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -24,6 +24,7 @@ + @@ -83,6 +84,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs new file mode 100644 index 000000000..6d67ce2bc --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs @@ -0,0 +1,191 @@ +using OpenClawTray.Helpers; +using OpenClawTray.Services; +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.Versioning; +using Xunit; + +namespace OpenClaw.Tray.Tests; + +/// +/// Verifies the lobster tray/desktop icon badge policy: only attention states are +/// badged (grey dot for disconnected, red dot with a "-" for error); healthy +/// states (connected/connecting) render the plain lobster with no badge. +/// +[SupportedOSPlatform("windows")] +public sealed class StatusBadgeIconFactoryTests +{ + [Theory] + [InlineData((int)ConnectionStatusAccent.Success, 76, 175, 80)] + [InlineData((int)ConnectionStatusAccent.Caution, 255, 193, 7)] + [InlineData((int)ConnectionStatusAccent.Critical, 244, 67, 54)] + [InlineData((int)ConnectionStatusAccent.Neutral, 158, 158, 158)] + public void DotColor_MapsAccentToStatusColor(int accent, int r, int g, int b) + { + var color = StatusBadgeIconFactory.DotColor((ConnectionStatusAccent)accent); + Assert.Equal(Color.FromArgb(r, g, b), color); + } + + [Theory] + [InlineData((int)ConnectionStatusAccent.Neutral, true)] + [InlineData((int)ConnectionStatusAccent.Critical, true)] + [InlineData((int)ConnectionStatusAccent.Success, false)] + [InlineData((int)ConnectionStatusAccent.Caution, false)] + public void ShouldBadge_OnlyNeutralAndCritical(int accent, bool expected) + { + Assert.Equal(expected, StatusBadgeIconFactory.ShouldBadge((ConnectionStatusAccent)accent)); + } + + [Theory] + [InlineData((int)ConnectionStatusAccent.Critical, true)] + [InlineData((int)ConnectionStatusAccent.Neutral, false)] + [InlineData((int)ConnectionStatusAccent.Success, false)] + [InlineData((int)ConnectionStatusAccent.Caution, false)] + public void HasDash_OnlyCritical(int accent, bool expected) + { + Assert.Equal(expected, StatusBadgeIconFactory.HasDash((ConnectionStatusAccent)accent)); + } + + [Fact] + public void Compose_DrawsDotInBottomRightCorner() + { + const int size = 64; + using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb); + // Fully transparent base so the only opaque pixels come from the dot. + + using var composed = StatusBadgeIconFactory.Compose( + baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Neutral)); + + // Bottom-right region carries the dot. + var dotPixel = composed.GetPixel((int)(size * 0.80), (int)(size * 0.80)); + Assert.True(dotPixel.A > 200, "Dot should be opaque in the bottom-right corner"); + + // Top-left stays transparent (no badge, base was empty). + var cornerPixel = composed.GetPixel(2, 2); + Assert.True(cornerPixel.A < 40, "Top-left corner should remain transparent"); + } + + [Fact] + public void Compose_NullColor_RendersPlainLobsterWithNoBadge() + { + const int size = 64; + using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb); + // Transparent base + null dot colour => the whole icon stays transparent. + + using var composed = StatusBadgeIconFactory.Compose(baseImage, size, dotColor: null); + + var dotPixel = composed.GetPixel((int)(size * 0.80), (int)(size * 0.80)); + Assert.True(dotPixel.A < 40, "Healthy state should render no dot in the bottom-right corner"); + } + + [Fact] + public void Compose_ErrorState_DrawsRedDotWithWhiteDash() + { + const int size = 64; + using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb); + + using var composed = StatusBadgeIconFactory.Compose( + baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical), withDash: true); + + var (cx, cy, dot) = DotCenter(size); + + // Centre of the dot is covered by the white minus glyph. + var centre = composed.GetPixel((int)cx, (int)cy); + Assert.True(centre.R > 230 && centre.G > 230 && centre.B > 230, "Dash centre should be white"); + + // Below the dash (still inside the dot) reads red. + var belowDash = composed.GetPixel((int)cx, (int)(cy + dot * 0.30f)); + Assert.True(belowDash.A > 200 && belowDash.R > belowDash.G && belowDash.R > belowDash.B, + "Dot around the dash should read red"); + } + + [Fact] + public void Compose_UsesDistinctColorPerAccent() + { + const int size = 64; + using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb); + var px = (int)(size * 0.80); + + using var neutral = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Neutral)); + using var critical = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical)); + + var gray = neutral.GetPixel(px, px); + var red = critical.GetPixel(px, px); + Assert.True(Math.Abs(gray.R - gray.G) < 20 && Math.Abs(gray.G - gray.B) < 20, "Neutral dot is grey"); + Assert.True(red.R > red.G && red.R > red.B, "Critical dot is red-dominant"); + } + + [Fact] + public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons() + { + // Tiny tray icons get the largest dot for legibility. + Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(16), 3); + Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(32), 3); + + // Large taskbar / alt-tab icons get the subtlest dot. + Assert.Equal(0.26, StatusBadgeIconFactory.DotFraction(256), 3); + + // Monotonically shrinks as the icon grows between the two extremes. + var f48 = StatusBadgeIconFactory.DotFraction(48); + var f128 = StatusBadgeIconFactory.DotFraction(128); + Assert.True(f48 < 0.44 && f48 > f128, "48px dot fraction sits between the extremes"); + Assert.True(f128 > 0.26 && f128 < f48, "128px dot fraction is subtler than 48px but above the floor"); + } + + [Fact] + public void CreateIcoBytes_ProducesValidMultiSizeIcon() + { + var sizes = new[] { 16, 32, 48 }; + using var baseImage = new Bitmap(256, 256, PixelFormat.Format32bppArgb); + + var bytes = StatusBadgeIconFactory.CreateIcoBytes( + baseImage, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical), withDash: true, sizes); + + // ICONDIR header: reserved=0, type=1 (icon), count=frames. + Assert.Equal(0, bytes[0] | bytes[1]); + Assert.Equal(1, bytes[2] | (bytes[3] << 8)); + var count = bytes[4] | (bytes[5] << 8); + Assert.Equal(sizes.Length, count); + + // The container round-trips back into a real icon. + using var stream = new MemoryStream(bytes); + using var icon = new Icon(stream); + Assert.NotNull(icon); + } + + [Fact] + public void CreateIcoBytes_NullColor_ProducesValidPlainIcon() + { + var sizes = new[] { 16, 32 }; + using var baseImage = new Bitmap(256, 256, PixelFormat.Format32bppArgb); + + var bytes = StatusBadgeIconFactory.CreateIcoBytes(baseImage, dotColor: null, withDash: false, sizes); + + using var stream = new MemoryStream(bytes); + using var icon = new Icon(stream); + Assert.NotNull(icon); + } + + [Fact] + public void IconSizes_CoverTrayAndTaskbarResolutions() + { + Assert.Contains(16, StatusBadgeIconFactory.IconSizes); // tray at 100% DPI + Assert.Contains(32, StatusBadgeIconFactory.IconSizes); // tray at 200% DPI / taskbar + Assert.Contains(256, StatusBadgeIconFactory.IconSizes); // high-DPI taskbar + } + + // Mirrors the dot geometry in StatusBadgeIconFactory.Compose so tests can sample + // the dot centre precisely. + private static (float Cx, float Cy, float Dot) DotCenter(int size) + { + float dot = size * (float)StatusBadgeIconFactory.DotFraction(size); + float ring = Math.Max(1f, dot * 0.14f); + float pad = size * 0.02f; + float outer = dot + (ring * 2f); + float outerX = size - outer - pad; + float outerY = size - outer - pad; + return (outerX + ring + (dot / 2f), outerY + ring + (dot / 2f), dot); + } +}