From 3f61bbbc34cd4b374c7a78656af23f3823da90c9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:23:58 +0800 Subject: [PATCH] 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. --- src/ui/tray/icon.rs | 49 +++++++++++++++++++++++++++++++------------ src/ui/tray/mod.rs | 18 ++++++++++------ src/ui/tray/native.rs | 39 +++++++++++++++++++++------------- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/src/ui/tray/icon.rs b/src/ui/tray/icon.rs index 1d7c92a6..cde24399 100644 --- a/src/ui/tray/icon.rs +++ b/src/ui/tray/icon.rs @@ -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 { + 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 { 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(); diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs index a41d246c..df8b0a0b 100644 --- a/src/ui/tray/mod.rs +++ b/src/ui/tray/mod.rs @@ -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" diff --git a/src/ui/tray/native.rs b/src/ui/tray/native.rs index 5e818250..cecbd24d 100644 --- a/src/ui/tray/native.rs +++ b/src/ui/tray/native.rs @@ -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)); + } } } }