From e981a68325ecf9253b11f89e678de192df6b1af8 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:43:49 -0700 Subject: [PATCH 1/3] Mirror connection status dot onto lobster tray and desktop icons Compose the lobster mascot with a coloured status dot in its bottom-right corner (green=connected, amber=connecting/attention, red=error, gray= disconnected), mirroring the companion-app HubWindow status pill onto both the tray icon and the desktop/taskbar window icon. - Add StatusBadgeIconFactory: builds cached multi-resolution .ico files per status accent by badging the unplated lobster PNG; thread-safe, falls back to the plain app icon on failure without pinning the fallback in cache. - Add ConnectionStatusPresenter.Accent() so tray and window derive the same accent used by the visible status pill. - Wire TrayIconCoordinator and HubWindow (both UpdateTitleBarStatus overloads and initial setup) to apply the badged icon; initialise the tray icon with the badged neutral lobster from first paint. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 5 +- .../Helpers/StatusBadgeIconFactory.cs | 187 ++++++++++++++++++ .../Services/ConnectionStatusPresenter.cs | 20 ++ .../Services/TrayIconCoordinator.cs | 10 +- .../Windows/HubWindow.xaml.cs | 18 +- .../AppRefactorContractTests.cs | 28 +++ .../ConnectionStatusPresenterTests.cs | 29 +++ .../OpenClaw.Tray.Tests.csproj | 2 + .../StatusBadgeIconFactoryTests.cs | 93 +++++++++ 9 files changed, 385 insertions(+), 7 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs create mode 100644 tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs 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..56d735780 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs @@ -0,0 +1,187 @@ +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 mirror the companion-app connection status dot: +/// the lobster mascot with a coloured status dot rendered in the bottom-right +/// corner. 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 + }; + + 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"); + + using var baseImage = LoadBaseImage(); + File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, DotColor(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 badged lobster at every requested size and packs the frames + /// into a single multi-resolution .ico byte array. Exposed for testing. + /// + internal static byte[] CreateIcoBytes(Bitmap baseImage, Color dotColor, IReadOnlyList sizes) + { + var frames = new List(sizes.Count); + foreach (var size in sizes) + { + using var composed = Compose(baseImage, size, dotColor); + 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(); + } + + internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor) + { + 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)); + + // Dot geometry: bottom-right corner with a white ring for contrast against + // both the red mascot and arbitrary taskbar backgrounds. + float ring = Math.Max(1f, size * 0.06f); + float pad = size * 0.02f; + float dot = size * 0.44f; + 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(dotColor)) + g.FillEllipse(dotBrush, outerX + ring, outerY + ring, dot, dot); + + 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..c21111f29 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs @@ -0,0 +1,93 @@ +using OpenClawTray.Helpers; +using OpenClawTray.Services; +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 is composed with a status dot in the +/// bottom-right corner, mirroring the companion-app connection status. +/// +[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); + } + + [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.Success)); + + // Bottom-right region carries the coloured dot. + var dotPixel = composed.GetPixel((int)(size * 0.85), (int)(size * 0.85)); + Assert.True(dotPixel.A > 200, "Dot should be opaque in the bottom-right corner"); + Assert.True(dotPixel.G > dotPixel.R && dotPixel.G > dotPixel.B, "Success dot should read green"); + + // 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_UsesDistinctColorPerAccent() + { + const int size = 64; + using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb); + var px = (int)(size * 0.85); + + using var success = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success)); + using var critical = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical)); + + var green = success.GetPixel(px, px); + var red = critical.GetPixel(px, px); + Assert.True(green.G > green.R, "Success dot is green-dominant"); + Assert.True(red.R > red.G, "Critical dot is red-dominant"); + } + + [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.Caution), 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 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 + } +} From c403007df7df182506806045fa3c8d1ba0d5fcc4 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:52:18 -0700 Subject: [PATCH 2/3] Scale status dot by icon size The dot diameter now interpolates from 44%% of the icon on tiny tray icons (<=32px, for legibility) down to 26%% on large taskbar / alt-tab icons (>=256px, a subtler corner dot), instead of a fixed 44%%. The white ring is tied to the dot so it shrinks proportionally. Adds DotFraction(size) with a unit test covering the scaling curve. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Helpers/StatusBadgeIconFactory.cs | 30 +++++++++++++++++-- .../StatusBadgeIconFactoryTests.cs | 21 +++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs index 56d735780..a17ab7f07 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs @@ -117,6 +117,28 @@ private static Bitmap LoadBaseImage() 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) { var bmp = new Bitmap(size, size, PixelFormat.Format32bppArgb); @@ -132,10 +154,12 @@ internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor) g.DrawImage(baseImage, new Rectangle(0, 0, size, size)); // Dot geometry: bottom-right corner with a white ring for contrast against - // both the red mascot and arbitrary taskbar backgrounds. - float ring = Math.Max(1f, size * 0.06f); + // 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 dot = size * 0.44f; float outer = dot + (ring * 2f); float outerX = size - outer - pad; float outerY = size - outer - pad; diff --git a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs index c21111f29..112757e49 100644 --- a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs +++ b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs @@ -37,7 +37,7 @@ public void Compose_DrawsDotInBottomRightCorner() baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success)); // Bottom-right region carries the coloured dot. - var dotPixel = composed.GetPixel((int)(size * 0.85), (int)(size * 0.85)); + 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"); Assert.True(dotPixel.G > dotPixel.R && dotPixel.G > dotPixel.B, "Success dot should read green"); @@ -46,12 +46,29 @@ public void Compose_DrawsDotInBottomRightCorner() Assert.True(cornerPixel.A < 40, "Top-left corner should remain transparent"); } + [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 Compose_UsesDistinctColorPerAccent() { const int size = 64; using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb); - var px = (int)(size * 0.85); + var px = (int)(size * 0.80); using var success = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success)); using var critical = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical)); From ee3db702845378d601b206948bb7add45d02f423 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:42:35 -0700 Subject: [PATCH 3/3] Only badge the lobster for attention states (disconnected, error) Per group feedback a constant green connected dot is distracting. The tray and desktop icons now show a status badge only for attention states: - disconnected / neutral -> grey dot - error -> red dot with a white minus (-) glyph Connected and connecting render the plain lobster with no badge. - StatusBadgeIconFactory.ShouldBadge()/HasDash() encode the policy; Compose takes a nullable dot colour (null = plain lobster) and an optional dash. - Update StatusBadgeIconFactoryTests for the new policy and dash rendering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Helpers/StatusBadgeIconFactory.cs | 60 +++++++-- .../StatusBadgeIconFactoryTests.cs | 123 +++++++++++++++--- 2 files changed, 151 insertions(+), 32 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs index a17ab7f07..113f2ff81 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs @@ -11,10 +11,16 @@ namespace OpenClawTray.Helpers; /// -/// Builds application icons that mirror the companion-app connection status dot: -/// the lobster mascot with a coloured status dot rendered in the bottom-right -/// corner. Composed icons are cached per and -/// written to a temp folder as multi-resolution .ico files so both the tray icon +/// 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. /// @@ -64,6 +70,18 @@ public static string GetBadgedIconPath(ConnectionStatusAccent accent) _ => 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"); @@ -72,8 +90,12 @@ private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accen 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(accent), Sizes)); + File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, dotColor, HasDash(accent), Sizes)); return (outputPath, false); } catch (Exception ex) @@ -84,15 +106,16 @@ private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accen } /// - /// Composes the badged lobster at every requested size and packs the frames - /// into a single multi-resolution .ico byte array. Exposed for testing. + /// 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, IReadOnlyList sizes) + 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); + using var composed = Compose(baseImage, size, dotColor, withDash); using var ms = new MemoryStream(); composed.Save(ms, ImageFormat.Png); frames.Add(ms.ToArray()); @@ -139,7 +162,7 @@ internal static double DotFraction(int size) return maxFraction + (t * (minFraction - maxFraction)); } - internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor) + 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); @@ -153,6 +176,10 @@ internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor) 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 @@ -167,9 +194,20 @@ internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor) using (var ringBrush = new SolidBrush(Color.White)) g.FillEllipse(ringBrush, outerX, outerY, outer, outer); - using (var dotBrush = new SolidBrush(dotColor)) + 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; } diff --git a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs index 112757e49..6d67ce2bc 100644 --- a/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs +++ b/tests/OpenClaw.Tray.Tests/StatusBadgeIconFactoryTests.cs @@ -1,5 +1,6 @@ using OpenClawTray.Helpers; using OpenClawTray.Services; +using System; using System.Drawing; using System.Drawing.Imaging; using System.IO; @@ -9,8 +10,9 @@ namespace OpenClaw.Tray.Tests; /// -/// Verifies the lobster tray/desktop icon is composed with a status dot in the -/// bottom-right corner, mirroring the companion-app connection status. +/// 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 @@ -26,6 +28,26 @@ public void DotColor_MapsAccentToStatusColor(int accent, int r, int g, int b) 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() { @@ -34,12 +56,11 @@ public void Compose_DrawsDotInBottomRightCorner() // Fully transparent base so the only opaque pixels come from the dot. using var composed = StatusBadgeIconFactory.Compose( - baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success)); + baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Neutral)); - // Bottom-right region carries the coloured dot. + // 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"); - Assert.True(dotPixel.G > dotPixel.R && dotPixel.G > dotPixel.B, "Success dot should read green"); // Top-left stays transparent (no badge, base was empty). var cornerPixel = composed.GetPixel(2, 2); @@ -47,20 +68,37 @@ public void Compose_DrawsDotInBottomRightCorner() } [Fact] - public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons() + public void Compose_NullColor_RendersPlainLobsterWithNoBadge() { - // 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); + const int size = 64; + using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb); + // Transparent base + null dot colour => the whole icon stays transparent. - // Large taskbar / alt-tab icons get the subtlest dot. - Assert.Equal(0.26, StatusBadgeIconFactory.DotFraction(256), 3); + using var composed = StatusBadgeIconFactory.Compose(baseImage, size, dotColor: null); - // 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"); + 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] @@ -70,13 +108,30 @@ public void Compose_UsesDistinctColorPerAccent() using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb); var px = (int)(size * 0.80); - using var success = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success)); + using var neutral = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Neutral)); using var critical = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical)); - var green = success.GetPixel(px, px); + var gray = neutral.GetPixel(px, px); var red = critical.GetPixel(px, px); - Assert.True(green.G > green.R, "Success dot is green-dominant"); - Assert.True(red.R > red.G, "Critical dot is red-dominant"); + 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] @@ -86,7 +141,7 @@ public void CreateIcoBytes_ProducesValidMultiSizeIcon() using var baseImage = new Bitmap(256, 256, PixelFormat.Format32bppArgb); var bytes = StatusBadgeIconFactory.CreateIcoBytes( - baseImage, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Caution), sizes); + baseImage, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical), withDash: true, sizes); // ICONDIR header: reserved=0, type=1 (icon), count=frames. Assert.Equal(0, bytes[0] | bytes[1]); @@ -100,6 +155,19 @@ public void CreateIcoBytes_ProducesValidMultiSizeIcon() 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() { @@ -107,4 +175,17 @@ public void IconSizes_CoverTrayAndTaskbarResolutions() 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); + } }