Skip to content

Commit ee3db70

Browse files
karkarlCopilot
andcommitted
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>
1 parent c403007 commit ee3db70

2 files changed

Lines changed: 151 additions & 32 deletions

File tree

src/OpenClaw.Tray.WinUI/Helpers/StatusBadgeIconFactory.cs

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@
1111
namespace OpenClawTray.Helpers;
1212

1313
/// <summary>
14-
/// Builds application icons that mirror the companion-app connection status dot:
15-
/// the lobster mascot with a coloured status dot rendered in the bottom-right
16-
/// corner. Composed icons are cached per <see cref="ConnectionStatusAccent"/> and
17-
/// written to a temp folder as multi-resolution .ico files so both the tray icon
14+
/// Builds application icons that surface the connection status on the lobster
15+
/// mascot as a small badge in the bottom-right corner. To avoid a distracting
16+
/// always-on indicator, only attention states are badged:
17+
/// <list type="bullet">
18+
/// <item>disconnected / neutral → a grey dot,</item>
19+
/// <item>error → a red dot with a white "-" (minus) glyph.</item>
20+
/// </list>
21+
/// Healthy states (connected, connecting) render the plain lobster with no badge.
22+
/// Composed icons are cached per <see cref="ConnectionStatusAccent"/> and written
23+
/// to a temp folder as multi-resolution .ico files so both the tray icon
1824
/// (<see cref="WinUIEx.TrayIcon.SetIcon(string)"/>) and the desktop/taskbar window
1925
/// icon (<c>Window.SetIcon(string)</c>) can consume them.
2026
/// </summary>
@@ -64,6 +70,18 @@ public static string GetBadgedIconPath(ConnectionStatusAccent accent)
6470
_ => Color.FromArgb(158, 158, 158), // Gray – disconnected / neutral
6571
};
6672

73+
/// <summary>
74+
/// Whether <paramref name="accent"/> gets a status badge. Only attention states
75+
/// are badged so the icon is quiet when everything is healthy: disconnected
76+
/// (neutral) and error (critical). Connected/connecting render the plain lobster.
77+
/// </summary>
78+
public static bool ShouldBadge(ConnectionStatusAccent accent) =>
79+
accent is ConnectionStatusAccent.Neutral or ConnectionStatusAccent.Critical;
80+
81+
/// <summary>Whether the badge carries the "-" (minus) glyph. Only the error state does.</summary>
82+
public static bool HasDash(ConnectionStatusAccent accent) =>
83+
accent is ConnectionStatusAccent.Critical;
84+
6785
private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accent)
6886
{
6987
var fallback = Path.Combine(AssetsPath, "openclaw.ico");
@@ -72,8 +90,12 @@ private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accen
7290
Directory.CreateDirectory(OutputDir);
7391
var outputPath = Path.Combine(OutputDir, $"openclaw-{accent}".ToLowerInvariant() + ".ico");
7492

93+
// Only attention states (neutral/critical) get a dot; healthy states
94+
// render the plain lobster (null dot colour).
95+
Color? dotColor = ShouldBadge(accent) ? DotColor(accent) : null;
96+
7597
using var baseImage = LoadBaseImage();
76-
File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, DotColor(accent), Sizes));
98+
File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, dotColor, HasDash(accent), Sizes));
7799
return (outputPath, false);
78100
}
79101
catch (Exception ex)
@@ -84,15 +106,16 @@ private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accen
84106
}
85107

86108
/// <summary>
87-
/// Composes the badged lobster at every requested size and packs the frames
88-
/// into a single multi-resolution .ico byte array. Exposed for testing.
109+
/// Composes the (optionally badged) lobster at every requested size and packs
110+
/// the frames into a single multi-resolution .ico byte array. A null
111+
/// <paramref name="dotColor"/> renders the plain lobster. Exposed for testing.
89112
/// </summary>
90-
internal static byte[] CreateIcoBytes(Bitmap baseImage, Color dotColor, IReadOnlyList<int> sizes)
113+
internal static byte[] CreateIcoBytes(Bitmap baseImage, Color? dotColor, bool withDash, IReadOnlyList<int> sizes)
91114
{
92115
var frames = new List<byte[]>(sizes.Count);
93116
foreach (var size in sizes)
94117
{
95-
using var composed = Compose(baseImage, size, dotColor);
118+
using var composed = Compose(baseImage, size, dotColor, withDash);
96119
using var ms = new MemoryStream();
97120
composed.Save(ms, ImageFormat.Png);
98121
frames.Add(ms.ToArray());
@@ -139,7 +162,7 @@ internal static double DotFraction(int size)
139162
return maxFraction + (t * (minFraction - maxFraction));
140163
}
141164

142-
internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor)
165+
internal static Bitmap Compose(Bitmap baseImage, int size, Color? dotColor, bool withDash = false)
143166
{
144167
var bmp = new Bitmap(size, size, PixelFormat.Format32bppArgb);
145168
bmp.SetResolution(96, 96);
@@ -153,6 +176,10 @@ internal static Bitmap Compose(Bitmap baseImage, int size, Color dotColor)
153176

154177
g.DrawImage(baseImage, new Rectangle(0, 0, size, size));
155178

179+
// No badge for healthy states: render the plain lobster.
180+
if (dotColor is not Color color)
181+
return bmp;
182+
156183
// Dot geometry: bottom-right corner with a white ring for contrast against
157184
// both the red mascot and arbitrary taskbar backgrounds. The dot scales by
158185
// 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)
167194
using (var ringBrush = new SolidBrush(Color.White))
168195
g.FillEllipse(ringBrush, outerX, outerY, outer, outer);
169196

170-
using (var dotBrush = new SolidBrush(dotColor))
197+
using (var dotBrush = new SolidBrush(color))
171198
g.FillEllipse(dotBrush, outerX + ring, outerY + ring, dot, dot);
172199

200+
// Error badge carries a white "-" (minus) glyph centred on the dot.
201+
if (withDash)
202+
{
203+
float cx = outerX + ring + (dot / 2f);
204+
float cy = outerY + ring + (dot / 2f);
205+
float dashW = dot * 0.52f;
206+
float dashH = Math.Max(2f, dot * 0.18f);
207+
using var dashBrush = new SolidBrush(Color.White);
208+
g.FillRectangle(dashBrush, cx - (dashW / 2f), cy - (dashH / 2f), dashW, dashH);
209+
}
210+
173211
return bmp;
174212
}
175213

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using OpenClawTray.Helpers;
22
using OpenClawTray.Services;
3+
using System;
34
using System.Drawing;
45
using System.Drawing.Imaging;
56
using System.IO;
@@ -9,8 +10,9 @@
910
namespace OpenClaw.Tray.Tests;
1011

1112
/// <summary>
12-
/// Verifies the lobster tray/desktop icon is composed with a status dot in the
13-
/// bottom-right corner, mirroring the companion-app connection status.
13+
/// Verifies the lobster tray/desktop icon badge policy: only attention states are
14+
/// badged (grey dot for disconnected, red dot with a "-" for error); healthy
15+
/// states (connected/connecting) render the plain lobster with no badge.
1416
/// </summary>
1517
[SupportedOSPlatform("windows")]
1618
public sealed class StatusBadgeIconFactoryTests
@@ -26,6 +28,26 @@ public void DotColor_MapsAccentToStatusColor(int accent, int r, int g, int b)
2628
Assert.Equal(Color.FromArgb(r, g, b), color);
2729
}
2830

31+
[Theory]
32+
[InlineData((int)ConnectionStatusAccent.Neutral, true)]
33+
[InlineData((int)ConnectionStatusAccent.Critical, true)]
34+
[InlineData((int)ConnectionStatusAccent.Success, false)]
35+
[InlineData((int)ConnectionStatusAccent.Caution, false)]
36+
public void ShouldBadge_OnlyNeutralAndCritical(int accent, bool expected)
37+
{
38+
Assert.Equal(expected, StatusBadgeIconFactory.ShouldBadge((ConnectionStatusAccent)accent));
39+
}
40+
41+
[Theory]
42+
[InlineData((int)ConnectionStatusAccent.Critical, true)]
43+
[InlineData((int)ConnectionStatusAccent.Neutral, false)]
44+
[InlineData((int)ConnectionStatusAccent.Success, false)]
45+
[InlineData((int)ConnectionStatusAccent.Caution, false)]
46+
public void HasDash_OnlyCritical(int accent, bool expected)
47+
{
48+
Assert.Equal(expected, StatusBadgeIconFactory.HasDash((ConnectionStatusAccent)accent));
49+
}
50+
2951
[Fact]
3052
public void Compose_DrawsDotInBottomRightCorner()
3153
{
@@ -34,33 +56,49 @@ public void Compose_DrawsDotInBottomRightCorner()
3456
// Fully transparent base so the only opaque pixels come from the dot.
3557

3658
using var composed = StatusBadgeIconFactory.Compose(
37-
baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success));
59+
baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Neutral));
3860

39-
// Bottom-right region carries the coloured dot.
61+
// Bottom-right region carries the dot.
4062
var dotPixel = composed.GetPixel((int)(size * 0.80), (int)(size * 0.80));
4163
Assert.True(dotPixel.A > 200, "Dot should be opaque in the bottom-right corner");
42-
Assert.True(dotPixel.G > dotPixel.R && dotPixel.G > dotPixel.B, "Success dot should read green");
4364

4465
// Top-left stays transparent (no badge, base was empty).
4566
var cornerPixel = composed.GetPixel(2, 2);
4667
Assert.True(cornerPixel.A < 40, "Top-left corner should remain transparent");
4768
}
4869

4970
[Fact]
50-
public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons()
71+
public void Compose_NullColor_RendersPlainLobsterWithNoBadge()
5172
{
52-
// Tiny tray icons get the largest dot for legibility.
53-
Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(16), 3);
54-
Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(32), 3);
73+
const int size = 64;
74+
using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
75+
// Transparent base + null dot colour => the whole icon stays transparent.
5576

56-
// Large taskbar / alt-tab icons get the subtlest dot.
57-
Assert.Equal(0.26, StatusBadgeIconFactory.DotFraction(256), 3);
77+
using var composed = StatusBadgeIconFactory.Compose(baseImage, size, dotColor: null);
5878

59-
// Monotonically shrinks as the icon grows between the two extremes.
60-
var f48 = StatusBadgeIconFactory.DotFraction(48);
61-
var f128 = StatusBadgeIconFactory.DotFraction(128);
62-
Assert.True(f48 < 0.44 && f48 > f128, "48px dot fraction sits between the extremes");
63-
Assert.True(f128 > 0.26 && f128 < f48, "128px dot fraction is subtler than 48px but above the floor");
79+
var dotPixel = composed.GetPixel((int)(size * 0.80), (int)(size * 0.80));
80+
Assert.True(dotPixel.A < 40, "Healthy state should render no dot in the bottom-right corner");
81+
}
82+
83+
[Fact]
84+
public void Compose_ErrorState_DrawsRedDotWithWhiteDash()
85+
{
86+
const int size = 64;
87+
using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
88+
89+
using var composed = StatusBadgeIconFactory.Compose(
90+
baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical), withDash: true);
91+
92+
var (cx, cy, dot) = DotCenter(size);
93+
94+
// Centre of the dot is covered by the white minus glyph.
95+
var centre = composed.GetPixel((int)cx, (int)cy);
96+
Assert.True(centre.R > 230 && centre.G > 230 && centre.B > 230, "Dash centre should be white");
97+
98+
// Below the dash (still inside the dot) reads red.
99+
var belowDash = composed.GetPixel((int)cx, (int)(cy + dot * 0.30f));
100+
Assert.True(belowDash.A > 200 && belowDash.R > belowDash.G && belowDash.R > belowDash.B,
101+
"Dot around the dash should read red");
64102
}
65103

66104
[Fact]
@@ -70,13 +108,30 @@ public void Compose_UsesDistinctColorPerAccent()
70108
using var baseImage = new Bitmap(size, size, PixelFormat.Format32bppArgb);
71109
var px = (int)(size * 0.80);
72110

73-
using var success = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Success));
111+
using var neutral = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Neutral));
74112
using var critical = StatusBadgeIconFactory.Compose(baseImage, size, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical));
75113

76-
var green = success.GetPixel(px, px);
114+
var gray = neutral.GetPixel(px, px);
77115
var red = critical.GetPixel(px, px);
78-
Assert.True(green.G > green.R, "Success dot is green-dominant");
79-
Assert.True(red.R > red.G, "Critical dot is red-dominant");
116+
Assert.True(Math.Abs(gray.R - gray.G) < 20 && Math.Abs(gray.G - gray.B) < 20, "Neutral dot is grey");
117+
Assert.True(red.R > red.G && red.R > red.B, "Critical dot is red-dominant");
118+
}
119+
120+
[Fact]
121+
public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons()
122+
{
123+
// Tiny tray icons get the largest dot for legibility.
124+
Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(16), 3);
125+
Assert.Equal(0.44, StatusBadgeIconFactory.DotFraction(32), 3);
126+
127+
// Large taskbar / alt-tab icons get the subtlest dot.
128+
Assert.Equal(0.26, StatusBadgeIconFactory.DotFraction(256), 3);
129+
130+
// Monotonically shrinks as the icon grows between the two extremes.
131+
var f48 = StatusBadgeIconFactory.DotFraction(48);
132+
var f128 = StatusBadgeIconFactory.DotFraction(128);
133+
Assert.True(f48 < 0.44 && f48 > f128, "48px dot fraction sits between the extremes");
134+
Assert.True(f128 > 0.26 && f128 < f48, "128px dot fraction is subtler than 48px but above the floor");
80135
}
81136

82137
[Fact]
@@ -86,7 +141,7 @@ public void CreateIcoBytes_ProducesValidMultiSizeIcon()
86141
using var baseImage = new Bitmap(256, 256, PixelFormat.Format32bppArgb);
87142

88143
var bytes = StatusBadgeIconFactory.CreateIcoBytes(
89-
baseImage, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Caution), sizes);
144+
baseImage, StatusBadgeIconFactory.DotColor(ConnectionStatusAccent.Critical), withDash: true, sizes);
90145

91146
// ICONDIR header: reserved=0, type=1 (icon), count=frames.
92147
Assert.Equal(0, bytes[0] | bytes[1]);
@@ -100,11 +155,37 @@ public void CreateIcoBytes_ProducesValidMultiSizeIcon()
100155
Assert.NotNull(icon);
101156
}
102157

158+
[Fact]
159+
public void CreateIcoBytes_NullColor_ProducesValidPlainIcon()
160+
{
161+
var sizes = new[] { 16, 32 };
162+
using var baseImage = new Bitmap(256, 256, PixelFormat.Format32bppArgb);
163+
164+
var bytes = StatusBadgeIconFactory.CreateIcoBytes(baseImage, dotColor: null, withDash: false, sizes);
165+
166+
using var stream = new MemoryStream(bytes);
167+
using var icon = new Icon(stream);
168+
Assert.NotNull(icon);
169+
}
170+
103171
[Fact]
104172
public void IconSizes_CoverTrayAndTaskbarResolutions()
105173
{
106174
Assert.Contains(16, StatusBadgeIconFactory.IconSizes); // tray at 100% DPI
107175
Assert.Contains(32, StatusBadgeIconFactory.IconSizes); // tray at 200% DPI / taskbar
108176
Assert.Contains(256, StatusBadgeIconFactory.IconSizes); // high-DPI taskbar
109177
}
178+
179+
// Mirrors the dot geometry in StatusBadgeIconFactory.Compose so tests can sample
180+
// the dot centre precisely.
181+
private static (float Cx, float Cy, float Dot) DotCenter(int size)
182+
{
183+
float dot = size * (float)StatusBadgeIconFactory.DotFraction(size);
184+
float ring = Math.Max(1f, dot * 0.14f);
185+
float pad = size * 0.02f;
186+
float outer = dot + (ring * 2f);
187+
float outerX = size - outer - pad;
188+
float outerY = size - outer - pad;
189+
return (outerX + ring + (dot / 2f), outerY + ring + (dot / 2f), dot);
190+
}
110191
}

0 commit comments

Comments
 (0)