fix(tray): keep the macOS icon a template image in every state

The attention state left template mode to carry the amber badge, which
forced the glyph to a hardcoded mid-grey that was illegible on many menu
bar appearances. Drop the badge (and the grey recolor) on macOS: the icon
now stays a template glyph the system recolors for the bar, and agent
status lives in the tooltip and tray menu. Windows/Linux keep the amber
corner badge on their colored icon.
This commit is contained in:
l0ng-ai
2026-07-17 15:23:58 +08:00
parent 627f5fe174
commit 3f61bbbc34
3 changed files with 73 additions and 33 deletions
+36 -13
View File
@@ -1,12 +1,12 @@
//! Tray bitmap rendering: the bundled SVGs rasterized with `resvg` (gpui's
//! own SVG path only yields a tinted alpha mask, so the tray draws its own).
//!
//! Two states per platform:
//! Per platform:
//! - macOS: the outline terminal glyph (`tray.svg`) as a *template* image —
//! the system recolors its alpha for light/dark menu bars. Attention swaps
//! to a non-template variant: the glyph recolored to a mid-grey that reads
//! on both bar appearances, plus an amber badge (template images can't
//! carry color, so attention opts out of templating).
//! the system recolors its alpha for light/dark menu bars, permanently.
//! Attention never touches the bitmap (template images can't carry color,
//! and leaving template mode made the glyph illegible); the tooltip and
//! menu carry agent status instead (see `native.rs`).
//! - Windows / Linux: the colored app icon (`app-icon.svg`); attention
//! punches a transparent ring into the corner and fills an amber badge, so
//! the badge separates from the orange tile behind it.
@@ -37,22 +37,32 @@ const SIZE: u32 = 32;
/// The `Waiting` amber, same hue as the in-window status dot
/// (`AgentStatus::dot_rgb`).
#[cfg(not(target_os = "macos"))]
const AMBER: (u8, u8, u8) = (0xF5, 0x9E, 0x0B);
/// Render the tray icon. `attention` = some agent is blocked on the user.
/// `None` only on a malformed bundled SVG, i.e. never in practice — callers
/// treat it as "no icon change".
/// Render the tray icon: the template outline glyph, always — attention
/// never touches the bitmap, so the icon stays a template image the system
/// keeps legible on any bar. `None` only on a malformed bundled SVG, i.e.
/// never in practice — callers treat it as "no icon change".
#[cfg(target_os = "macos")]
pub(super) fn render() -> Option<RgbaImage> {
let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?;
let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?;
resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut());
Some(to_rgba(&pixmap))
}
/// Render the tray icon. `attention` = some agent is blocked on the user —
/// stamps the amber badge into the colored app icon. `None` only on a
/// malformed bundled SVG, i.e. never in practice — callers treat it as "no
/// icon change".
#[cfg(not(target_os = "macos"))]
pub(super) fn render(attention: bool) -> Option<RgbaImage> {
let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?;
let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?;
resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut());
if attention {
// macOS attention leaves template mode (color needs real RGB), so the
// glyph must carry its own color: a mid-grey legible on both light
// and dark menu bars. Colored platforms keep the icon's own colors.
#[cfg(target_os = "macos")]
recolor(&mut pixmap, (0x8E, 0x8E, 0x93));
badge(&mut pixmap);
}
@@ -202,6 +212,7 @@ fn recolor(pixmap: &mut tiny_skia::Pixmap, rgb: (u8, u8, u8)) {
/// Stamp the amber attention badge in the top-right corner: first clear a
/// slightly larger disc so the badge is ringed by transparency (separating it
/// from whatever the glyph or a colored tile puts behind it), then fill.
#[cfg(not(target_os = "macos"))]
fn badge(pixmap: &mut tiny_skia::Pixmap) {
let s = SIZE as f32;
let (cx, cy) = (s * 0.78, s * 0.22);
@@ -301,8 +312,20 @@ mod tests {
assert_eq!((px[1], px[2]), (0, 0));
}
/// The template glyph renders at the declared size with visible coverage.
#[cfg(target_os = "macos")]
#[test]
fn render_produces_template_glyph() {
let img = render().unwrap();
assert_eq!((img.width, img.height), (SIZE, SIZE));
assert_eq!(img.data.len(), (SIZE * SIZE * 4) as usize);
let covered = img.data.chunks_exact(4).filter(|p| p[3] > 0).count();
assert!(covered > 0, "icon rendered fully transparent");
}
/// Both tray states render at the declared size with visible coverage,
/// and the attention badge actually changes the bitmap.
#[cfg(not(target_os = "macos"))]
#[test]
fn render_produces_both_states() {
let normal = render(false).unwrap();
+12 -6
View File
@@ -1,10 +1,12 @@
//! System tray / menu bar status item.
//!
//! The tray is the app's face outside the window: the icon flips to an
//! attention state the moment any pane's coding agent blocks on the user
//! (amber `Waiting`), and the menu lists every agent pane — click one to
//! reveal it — plus window/notification/quit controls. Menu labels are
//! English, matching the native app menus (`ui::theme::set_menus`).
//! The tray is the app's face outside the window: on Windows/Linux the icon
//! flips to an amber-badged attention state the moment any pane's coding
//! agent blocks on the user (`Waiting`); on macOS the icon is a template
//! image that stays calm in every state (legible on any bar) — agent status
//! lives in the tooltip and menu instead. The menu lists every agent pane —
//! click one to reveal it — plus window/notification/quit controls. Menu
//! labels are English, matching the native app menus (`ui::theme::set_menus`).
//!
//! Platform split (see Cargo.toml for the why):
//! - macOS / Windows: tauri's `tray-icon` (NSStatusItem / Shell_NotifyIcon),
@@ -90,7 +92,10 @@ pub(crate) struct TraySnapshot {
}
impl TraySnapshot {
/// Whether any agent is blocked on the user — drives the attention icon.
/// Whether any agent is blocked on the user — drives the attention badge
/// on Windows/Linux (the macOS icon stays calm; the tooltip and menu
/// carry agent status there).
#[cfg_attr(target_os = "macos", allow(dead_code))]
pub(crate) fn attention(&self) -> bool {
self.agents.iter().any(|a| a.status == AgentStatus::Waiting)
}
@@ -266,6 +271,7 @@ mod tests {
fn attention_follows_waiting_and_tooltip_counts() {
assert!(snapshot_with_agent(AgentStatus::Waiting).attention());
assert!(!snapshot_with_agent(AgentStatus::Working).attention());
assert!(!snapshot_with_agent(AgentStatus::Done).attention());
assert_eq!(
snapshot_with_agent(AgentStatus::Waiting).tooltip(),
"tty7 — 1 waiting"
+25 -14
View File
@@ -14,8 +14,10 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub(super) struct Backend {
tray: TrayIcon,
/// The icon state currently shown, so a snapshot diff that doesn't flip
/// attention skips the bitmap rebuild.
/// Whether the amber badge is currently stamped, so a snapshot diff that
/// doesn't flip attention skips the bitmap rebuild. macOS tracks nothing:
/// its template glyph never changes (see `icon.rs`).
#[cfg(not(target_os = "macos"))]
attention: bool,
}
@@ -37,12 +39,15 @@ impl Backend {
}
}));
#[cfg(target_os = "macos")]
let img = icon::render()?;
#[cfg(not(target_os = "macos"))]
let img = icon::render(false)?;
let icon = Icon::from_rgba(img.data, img.width, img.height).ok()?;
let tray = TrayIconBuilder::new()
.with_icon(icon)
// The calm glyph is a template on macOS (system recolors it for
// the bar); a no-op on Windows.
// The glyph is a template on macOS (system recolors it for the
// bar, in both states); a no-op on Windows.
.with_icon_as_template(true)
.with_tooltip("tty7")
.with_menu(Box::new(build_menu(&TraySnapshot::default())))
@@ -59,24 +64,30 @@ impl Backend {
};
Some(Self {
tray,
#[cfg(not(target_os = "macos"))]
attention: false,
})
}
/// Push a changed snapshot into the native item: menu always (it's what
/// changed), icon only across an attention flip.
/// changed); on Windows, the badge only across an attention flip. The
/// macOS icon is a template image in every state — the system recolors it
/// for the bar, and it never carries an attention mark (status lives in
/// the tooltip and menu).
pub(super) fn update(&mut self, snap: &TraySnapshot) {
self.tray.set_menu(Some(Box::new(build_menu(snap))));
let _ = self.tray.set_tooltip(Some(snap.tooltip()));
let attention = snap.attention();
if attention != self.attention {
self.attention = attention;
if let Some(img) = icon::render(attention)
&& let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height)
{
// Attention leaves template mode: the amber badge needs
// its real color (macOS; the flag is a no-op on Windows).
let _ = self.tray.set_icon_with_as_template(Some(icon), !attention);
// Windows: flip the amber corner badge on the colored icon.
#[cfg(not(target_os = "macos"))]
{
let attention = snap.attention();
if attention != self.attention {
self.attention = attention;
if let Some(img) = icon::render(attention)
&& let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height)
{
let _ = self.tray.set_icon(Some(icon));
}
}
}
}