From a923ca3ec0334951d80c2551f99761aed396689c Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:32:25 +0700 Subject: [PATCH 1/5] Add provider tint support to tray icon renderer Add tinted variants of the bar, stacked, and percent renderers that take an optional provider accent. Pace colors win over the accent, error icons ignore it, and an accent below a 2:1 contrast floor against the tray canvas falls back to the usage-level colors. Existing renderers delegate with no tint, so default output is unchanged. --- rust/src/tray/mod.rs | 6 +- rust/src/tray/render.rs | 244 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 240 insertions(+), 10 deletions(-) diff --git a/rust/src/tray/mod.rs b/rust/src/tray/mod.rs index 52a8d64ec4b..ecf821f741c 100755 --- a/rust/src/tray/mod.rs +++ b/rust/src/tray/mod.rs @@ -7,6 +7,8 @@ pub mod render; pub use icon::LoadingPattern; pub use render::{ - TRAY_ICON_SIZE, TrayPaceColor, render_bar_icon_rgba, render_bar_icon_rgba_with_pace, - render_percent_icon_rgba, render_percent_icon_rgba_with_pace, render_stacked_bar_icon_rgba, + TRAY_ICON_SIZE, TrayPaceColor, render_bar_icon_rgba, render_bar_icon_rgba_tinted, + render_bar_icon_rgba_with_pace, render_percent_icon_rgba, render_percent_icon_rgba_tinted, + render_percent_icon_rgba_with_pace, render_stacked_bar_icon_rgba, + render_stacked_bar_icon_rgba_tinted, }; diff --git a/rust/src/tray/render.rs b/rust/src/tray/render.rs index bb516bc7ee9..629e168affd 100644 --- a/rust/src/tray/render.rs +++ b/rust/src/tray/render.rs @@ -61,9 +61,51 @@ fn new_icon_canvas(has_error: bool) -> RgbaImage { image } -fn usage_color(percent: f64, has_error: bool, pace_color: Option) -> Rgba { +/// Minimum WCAG contrast ratio a provider accent needs against the tray canvas +/// (upstream "Color by provider" uses the same 2:1 floor). +const PROVIDER_TINT_MIN_CONTRAST: f64 = 2.0; + +fn relative_luminance((r, g, b): (u8, u8, u8)) -> f64 { + fn channel(value: u8) -> f64 { + let c = f64::from(value) / 255.0; + if c <= 0.03928 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) +} + +/// Whether a provider accent stays readable on the tray canvas. Accents below +/// the floor fall back to the usage-level colours, as upstream does. +fn provider_tint_is_legible(rgb: (u8, u8, u8)) -> bool { + let tint = relative_luminance(rgb); + let canvas = relative_luminance(( + ICON_BACKGROUND_RGB[0], + ICON_BACKGROUND_RGB[1], + ICON_BACKGROUND_RGB[2], + )); + let (lighter, darker) = if tint >= canvas { + (tint, canvas) + } else { + (canvas, tint) + }; + (lighter + 0.05) / (darker + 0.05) >= PROVIDER_TINT_MIN_CONTRAST +} + +fn usage_color( + percent: f64, + has_error: bool, + pace_color: Option, + provider_tint: Option<(u8, u8, u8)>, +) -> Rgba { + // Pace colours keep their own green/red meaning, so they win over the + // accent. Error (stale) icons keep the desaturated look, so no accent. + let provider_tint = provider_tint.filter(|rgb| !has_error && provider_tint_is_legible(*rgb)); let (r, g, b) = pace_color .map(TrayPaceColor::rgb) + .or(provider_tint) .unwrap_or_else(|| UsageLevel::from_percent(percent).color()); if has_error { #[allow( @@ -84,6 +126,7 @@ fn draw_bar_row( percent: f64, has_error: bool, pace_color: Option, + provider_tint: Option<(u8, u8, u8)>, ) { let bar_width = BAR_RIGHT - BAR_LEFT; #[allow( @@ -92,7 +135,7 @@ fn draw_bar_row( )] let fill = ((percent.clamp(0.0, 100.0) / 100.0) * bar_width as f64) as u32; let fill_end = (BAR_LEFT + fill).min(BAR_RIGHT); - let color = usage_color(percent, has_error, pace_color); + let color = usage_color(percent, has_error, pace_color, provider_tint); for y in y_start..y_end { for x in BAR_LEFT..BAR_RIGHT { @@ -127,16 +170,54 @@ pub fn render_bar_icon_rgba_with_pace( weekly_percent: Option, has_error: bool, pace_color: Option, +) -> (Vec, u32, u32) { + render_bar_icon_rgba_tinted(session_percent, weekly_percent, has_error, pace_color, None) +} + +/// Render a usage-bar tray icon with an optional pace tint and an optional +/// provider accent (upstream "Color by provider"). Pace wins over the accent; +/// error icons ignore the accent; a low-contrast accent falls back to the +/// usage-level colours. +pub fn render_bar_icon_rgba_tinted( + session_percent: f64, + weekly_percent: Option, + has_error: bool, + pace_color: Option, + provider_tint: Option<(u8, u8, u8)>, ) -> (Vec, u32, u32) { let mut image = new_icon_canvas(has_error); match weekly_percent { Some(weekly) => { - draw_bar_row(&mut image, 8, 15, session_percent, has_error, pace_color); - draw_bar_row(&mut image, 18, 23, weekly, has_error, pace_color); + draw_bar_row( + &mut image, + 8, + 15, + session_percent, + has_error, + pace_color, + provider_tint, + ); + draw_bar_row( + &mut image, + 18, + 23, + weekly, + has_error, + pace_color, + provider_tint, + ); } None => { - draw_bar_row(&mut image, 10, 22, session_percent, has_error, pace_color); + draw_bar_row( + &mut image, + 10, + 22, + session_percent, + has_error, + pace_color, + provider_tint, + ); } } @@ -152,10 +233,29 @@ pub fn render_stacked_bar_icon_rgba( top_percent: f64, bottom_percent: f64, has_error: bool, +) -> (Vec, u32, u32) { + render_stacked_bar_icon_rgba_tinted(top_percent, bottom_percent, has_error, None, None) +} + +/// Render two stacked provider meters, each with its own optional accent. +pub fn render_stacked_bar_icon_rgba_tinted( + top_percent: f64, + bottom_percent: f64, + has_error: bool, + top_tint: Option<(u8, u8, u8)>, + bottom_tint: Option<(u8, u8, u8)>, ) -> (Vec, u32, u32) { let mut image = new_icon_canvas(has_error); - draw_bar_row(&mut image, 6, 14, top_percent, has_error, None); - draw_bar_row(&mut image, 18, 26, bottom_percent, has_error, None); + draw_bar_row(&mut image, 6, 14, top_percent, has_error, None, top_tint); + draw_bar_row( + &mut image, + 18, + 26, + bottom_percent, + has_error, + None, + bottom_tint, + ); (image.into_raw(), TRAY_ICON_SIZE, TRAY_ICON_SIZE) } @@ -169,6 +269,17 @@ pub fn render_percent_icon_rgba_with_pace( percent: f64, has_error: bool, pace_color: Option, +) -> (Vec, u32, u32) { + render_percent_icon_rgba_tinted(percent, has_error, pace_color, None) +} + +/// Render a compact numeric percent tray icon with an optional pace tint and +/// an optional provider accent (see [`render_bar_icon_rgba_tinted`]). +pub fn render_percent_icon_rgba_tinted( + percent: f64, + has_error: bool, + pace_color: Option, + provider_tint: Option<(u8, u8, u8)>, ) -> (Vec, u32, u32) { const SZ: u32 = TRAY_ICON_SIZE; let mut img = new_icon_canvas(has_error); @@ -198,7 +309,7 @@ pub fn render_percent_icon_rgba_with_pace( let start_x = (SZ.saturating_sub(text_width)) / 2; let start_y = (SZ.saturating_sub(text_height)) / 2; - let color = usage_color(percent, has_error, pace_color); + let color = usage_color(percent, has_error, pace_color, provider_tint); let mut x = start_x; for ch in text.chars() { @@ -417,4 +528,121 @@ mod tests { assert_eq!(pixel(&normal, 8, 12), pixel(&stacked, 8, 8)); assert_eq!(pixel(&normal, 8, 12)[0], pixel(&normal, 8, 12)[1]); } + + const TEAL: (u8, u8, u8) = (73, 163, 176); + + fn pixel_at(rgba: &[u8], x: u32, y: u32) -> [u8; 4] { + let index = ((y * TRAY_ICON_SIZE + x) * 4) as usize; + [ + rgba[index], + rgba[index + 1], + rgba[index + 2], + rgba[index + 3], + ] + } + + #[test] + fn provider_tint_colors_bar_fill_and_keeps_track() { + let (tinted, _, _) = render_bar_icon_rgba_tinted(100.0, None, false, None, Some(TEAL)); + // Filled part of the single meter takes the accent. + assert_eq!(pixel_at(&tinted, 8, 16), [TEAL.0, TEAL.1, TEAL.2, 255]); + // Unfilled track and canvas stay the standard tray colours. + let (partial, _, _) = render_bar_icon_rgba_tinted(25.0, None, false, None, Some(TEAL)); + assert_eq!(pixel_at(&partial, 20, 16), [80, 80, 90, 255]); + assert_eq!(pixel_at(&partial, 2, 16), [60, 60, 70, 255]); + } + + #[test] + fn provider_tint_colors_both_bar_rows() { + let (tinted, _, _) = + render_bar_icon_rgba_tinted(100.0, Some(100.0), false, None, Some(TEAL)); + assert_eq!(pixel_at(&tinted, 8, 10), [TEAL.0, TEAL.1, TEAL.2, 255]); + assert_eq!(pixel_at(&tinted, 8, 20), [TEAL.0, TEAL.1, TEAL.2, 255]); + } + + #[test] + fn provider_tint_colors_percent_glyphs() { + let usage = UsageLevel::from_percent(72.0).color(); + let (tinted, _, _) = render_percent_icon_rgba_tinted(72.0, false, None, Some(TEAL)); + let pixels = tinted.as_chunks::<4>().0; + assert!(pixels.iter().any(|px| px[..3] == [TEAL.0, TEAL.1, TEAL.2])); + assert!( + !pixels + .iter() + .any(|px| px[..3] == [usage.0, usage.1, usage.2]) + ); + } + + #[test] + fn provider_tint_is_off_by_default_and_matches_plain_render() { + let (plain, _, _) = render_bar_icon_rgba(100.0, Some(40.0), false); + let (tinted_none, _, _) = render_bar_icon_rgba_tinted(100.0, Some(40.0), false, None, None); + assert_eq!(plain, tinted_none); + let (percent_plain, _, _) = render_percent_icon_rgba(72.0, false); + let (percent_none, _, _) = render_percent_icon_rgba_tinted(72.0, false, None, None); + assert_eq!(percent_plain, percent_none); + } + + #[test] + fn pace_color_wins_over_provider_tint() { + let (rgba, _, _) = render_bar_icon_rgba_tinted( + 100.0, + None, + false, + Some(TrayPaceColor::Behind), + Some(TEAL), + ); + assert_eq!(pixel_at(&rgba, 8, 16), [76, 175, 80, 255]); + } + + #[test] + fn error_icons_ignore_provider_tint() { + let (plain, _, _) = render_bar_icon_rgba_tinted(100.0, None, true, None, None); + let (tinted, _, _) = render_bar_icon_rgba_tinted(100.0, None, true, None, Some(TEAL)); + assert_eq!(plain, tinted); + let idx = ((16 * TRAY_ICON_SIZE + 8) * 4) as usize; + assert_eq!(tinted[idx], tinted[idx + 1]); + assert_eq!(tinted[idx + 1], tinted[idx + 2]); + } + + #[test] + fn low_contrast_provider_tint_falls_back_to_usage_colors() { + // Near-black accents would vanish on the tray canvas. + let dark = (20, 20, 20); + assert!(!provider_tint_is_legible(dark)); + assert!(!provider_tint_is_legible((60, 60, 70))); + // Cursor's brand orange is legible on the canvas. + assert!(provider_tint_is_legible((245, 78, 0))); + assert!(provider_tint_is_legible(TEAL)); + + let (fallback, _, _) = render_bar_icon_rgba_tinted(100.0, None, false, None, Some(dark)); + let (plain, _, _) = render_bar_icon_rgba(100.0, None, false); + assert_eq!(fallback, plain); + let (fallback_percent, _, _) = + render_percent_icon_rgba_tinted(100.0, false, None, Some(dark)); + let (plain_percent, _, _) = render_percent_icon_rgba(100.0, false); + assert_eq!(fallback_percent, plain_percent); + } + + #[test] + fn stacked_rows_take_their_own_provider_tint() { + let orange = (245, 78, 0); + let (rgba, width, _) = + render_stacked_bar_icon_rgba_tinted(100.0, 100.0, false, Some(TEAL), Some(orange)); + let pixel = |x: u32, y: u32| { + let index = ((y * width + x) * 4) as usize; + [ + rgba[index], + rgba[index + 1], + rgba[index + 2], + rgba[index + 3], + ] + }; + assert_eq!(pixel(8, 8), [TEAL.0, TEAL.1, TEAL.2, 255]); + assert_eq!(pixel(8, 20), [orange.0, orange.1, orange.2, 255]); + // Untinted stacked render keeps the usage-level colour on both rows. + let (plain, _, _) = render_stacked_bar_icon_rgba(100.0, 100.0, false); + let (none, _, _) = render_stacked_bar_icon_rgba_tinted(100.0, 100.0, false, None, None); + assert_eq!(plain, none); + } } From a9e1507dcb731d2ad317cd602ba5157ed477528a Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:32:27 +0700 Subject: [PATCH 2/5] Add menu_bar_color_by_provider setting Store the opt-in Color by provider flag (default off) in settings and the raw serde mirror, and allow it in the portable preferences document so exports and imports carry it. --- rust/src/settings.rs | 6 ++++++ rust/src/settings/preferences_document.rs | 1 + .../settings/preferences_document/tests.rs | 1 + rust/src/settings/raw.rs | 4 ++++ rust/src/settings/tests.rs | 19 +++++++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/rust/src/settings.rs b/rust/src/settings.rs index dbd8873fa99..2c0b9cbbe66 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -222,6 +222,11 @@ pub struct Settings { #[serde(default)] pub menu_bar_color_pace: bool, + /// Tint the Windows tray usage indicator with each shown provider's accent + /// color (upstream "Color by provider", #4321). Defaults off. + #[serde(default)] + pub menu_bar_color_by_provider: bool, + /// Show reserve as `+` and deficit as `-` in signed pace text (upstream /// `menuBarPaceReservePositive`). Display only: the stored pace delta stays /// usage-ahead, so pace colours and stages keep their meaning. @@ -718,6 +723,7 @@ impl Default for Settings { highlight_menu_cards_on_hover: true, workday_tick_style: WorkdayTickStyle::Subtle, menu_bar_color_pace: false, + menu_bar_color_by_provider: false, menu_bar_pace_reserve_positive: false, providers_sorted_alphabetically: false, show_as_used: false, diff --git a/rust/src/settings/preferences_document.rs b/rust/src/settings/preferences_document.rs index b40d73808fc..5ca08cfa0c2 100644 --- a/rust/src/settings/preferences_document.rs +++ b/rust/src/settings/preferences_document.rs @@ -93,6 +93,7 @@ const ALLOWED_PREFERENCES: &[(&str, Validator)] = &[ ("menu_bar_shows_highest_usage", typed::), ("menu_bar_shows_percent", typed::), ("menu_bar_icon_style", |v| one_of(v, &["bars", "percent"])), + ("menu_bar_color_by_provider", typed::), ( "merged_overview_selected_providers", merged_overview_selection, diff --git a/rust/src/settings/preferences_document/tests.rs b/rust/src/settings/preferences_document/tests.rs index 450019f781e..65cbcb68446 100644 --- a/rust/src/settings/preferences_document/tests.rs +++ b/rust/src/settings/preferences_document/tests.rs @@ -49,6 +49,7 @@ fn customized_settings() -> Settings { menu_bar_shows_highest_usage: true, menu_bar_shows_percent: true, menu_bar_icon_style: crate::settings::MenuBarIconStyle::Percent, + menu_bar_color_by_provider: true, merged_overview_selected_providers: vec!["claude".to_string(), "codex".to_string()], quota_warning_markers_visible: false, workday_tick_style: crate::settings::WorkdayTickStyle::HighContrast, diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 6f4bff8725b..060509d1e5c 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -164,6 +164,8 @@ pub(super) struct RawSettings { #[serde(default)] menu_bar_color_pace: bool, #[serde(default)] + menu_bar_color_by_provider: bool, + #[serde(default)] menu_bar_pace_reserve_positive: bool, #[serde(default)] providers_sorted_alphabetically: bool, @@ -373,6 +375,7 @@ impl Default for RawSettings { highlight_menu_cards_on_hover: s.highlight_menu_cards_on_hover, workday_tick_style: Some(s.workday_tick_style.as_str().to_string()), menu_bar_color_pace: s.menu_bar_color_pace, + menu_bar_color_by_provider: s.menu_bar_color_by_provider, menu_bar_pace_reserve_positive: s.menu_bar_pace_reserve_positive, providers_sorted_alphabetically: s.providers_sorted_alphabetically, show_as_used: s.show_as_used, @@ -752,6 +755,7 @@ impl From for Settings { .and_then(WorkdayTickStyle::parse) .unwrap_or_default(), menu_bar_color_pace: raw.menu_bar_color_pace, + menu_bar_color_by_provider: raw.menu_bar_color_by_provider, menu_bar_pace_reserve_positive: raw.menu_bar_pace_reserve_positive, providers_sorted_alphabetically: raw.providers_sorted_alphabetically, show_as_used: raw.show_as_used, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 82f1548ce43..5c32d4a38fc 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -244,6 +244,25 @@ fn tray_pace_color_defaults_off_and_round_trips() { assert!(loaded.menu_bar_color_pace); } +#[test] +fn color_by_provider_defaults_off_and_round_trips() { + // Old settings files carry no key and must load with the toggle off. + let legacy: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#) + .expect("legacy settings without color by provider load"); + assert!(!legacy.menu_bar_color_by_provider); + assert!(!Settings::default().menu_bar_color_by_provider); + + let enabled = Settings { + menu_bar_color_by_provider: true, + ..Settings::default() + }; + let json = serde_json::to_string(&enabled).expect("serialize color by provider"); + assert!(json.contains(r#""menu_bar_color_by_provider":true"#)); + + let loaded: Settings = serde_json::from_str(&json).expect("deserialize color by provider"); + assert!(loaded.menu_bar_color_by_provider); +} + #[test] fn pace_reserve_positive_defaults_off_and_round_trips() { let defaulted: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#) From 4de1dd9d2ce10e7ccfa83b27cbf207b4d58bbde9 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:32:35 +0700 Subject: [PATCH 3/5] Tint tray meters with the provider accent Add the opt-in tint to the tray plan: a single meter or percent glyph takes the shown provider's effective accent, and each stacked row takes its own provider's accent. The accent is parsed from #RRGGBB and malformed values yield no tint. Expose menu_bar_color_by_provider through the settings patch and the bridge DTO, with tests for default-off parity, accent overrides, and stacked rows. --- .../src-tauri/src/commands/bridge.rs | 2 + .../src-tauri/src/commands/settings.rs | 25 +++ .../src-tauri/src/tray_presentation.rs | 87 ++++++++- .../src-tauri/src/tray_presentation_tests.rs | 180 +++++++++++++++++- 4 files changed, 283 insertions(+), 11 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 2fa559fb0fe..ad1a3b3de09 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -1016,6 +1016,7 @@ pub struct SettingsSnapshot { highlight_menu_cards_on_hover: bool, workday_tick_style: codexbar::settings::WorkdayTickStyle, menu_bar_color_pace: bool, + menu_bar_color_by_provider: bool, menu_bar_pace_reserve_positive: bool, providers_sorted_alphabetically: bool, show_as_used: bool, @@ -1185,6 +1186,7 @@ impl From for SettingsSnapshot { highlight_menu_cards_on_hover: settings.highlight_menu_cards_on_hover, workday_tick_style: settings.workday_tick_style, menu_bar_color_pace: settings.menu_bar_color_pace, + menu_bar_color_by_provider: settings.menu_bar_color_by_provider, menu_bar_pace_reserve_positive: settings.menu_bar_pace_reserve_positive, providers_sorted_alphabetically: settings.providers_sorted_alphabetically, show_as_used: settings.show_as_used, diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index fdcaf18209f..9967e17feef 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -44,6 +44,7 @@ pub struct SettingsUpdate { pub highlight_menu_cards_on_hover: Option, pub workday_tick_style: Option, pub menu_bar_color_pace: Option, + pub menu_bar_color_by_provider: Option, pub menu_bar_pace_reserve_positive: Option, pub providers_sorted_alphabetically: Option, pub show_as_used: Option, @@ -185,6 +186,7 @@ impl SettingsUpdate { || self.menu_bar_shows_percent.is_some() || self.menu_bar_icon_style.is_some() || self.menu_bar_color_pace.is_some() + || self.menu_bar_color_by_provider.is_some() || self.show_as_used.is_some() || self.reset_time_relative.is_some() || self.menu_bar_display_mode.is_some() @@ -395,6 +397,9 @@ impl SettingsUpdate { if let Some(v) = self.menu_bar_color_pace { settings.menu_bar_color_pace = v; } + if let Some(v) = self.menu_bar_color_by_provider { + settings.menu_bar_color_by_provider = v; + } if let Some(v) = self.menu_bar_pace_reserve_positive { settings.menu_bar_pace_reserve_positive = v; } @@ -1140,6 +1145,26 @@ mod tests { assert!(!settings.menu_bar_color_pace); } + #[test] + fn apply_display_settings_updates_color_by_provider() { + let mut settings = Settings::default(); + assert!(!settings.menu_bar_color_by_provider); + + SettingsUpdate { + menu_bar_color_by_provider: Some(true), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(settings.menu_bar_color_by_provider); + + SettingsUpdate { + menu_bar_color_by_provider: Some(false), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(!settings.menu_bar_color_by_provider); + } + #[test] fn apply_display_settings_updates_pace_sign() { let mut settings = Settings::default(); diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs index d8f0ac430fb..052a214ef6d 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -5,25 +5,32 @@ use chrono::{DateTime, Utc}; use codexbar::core::{IconLane, ProviderId, UsagePace}; use codexbar::settings::{Language, MetricPreference, Settings, TrayIconMode}; use codexbar::tray::{ - TrayPaceColor, render_bar_icon_rgba_with_pace, render_percent_icon_rgba_with_pace, - render_stacked_bar_icon_rgba, + TrayPaceColor, render_bar_icon_rgba_tinted, render_percent_icon_rgba_tinted, + render_stacked_bar_icon_rgba_tinted, }; +/// Opt-in provider accent for a tray meter (`menu_bar_color_by_provider`). +type ProviderRgb = (u8, u8, u8); + #[derive(Debug, Clone, Copy, PartialEq)] enum TrayIconPlan { Bars { primary_percent: f64, secondary_percent: Option, has_error: bool, + provider_tint: Option, }, Percent { percent: f64, has_error: bool, + provider_tint: Option, }, Stacked { top_percent: f64, bottom_percent: f64, has_error: bool, + top_tint: Option, + bottom_tint: Option, }, } @@ -79,6 +86,7 @@ impl<'a> TrayPresentationPlan<'a> { let prefer_highest = settings.menu_bar_shows_highest_usage; let selected = pick_tray_provider(&healthy, prefer_highest); let icon_pace = tray_pace_color(settings, selected, now); + let single_tint = provider_tint(settings, selected); let (icon, status_rows) = match settings.tray_icon_mode { TrayIconMode::Stacked => { @@ -88,6 +96,8 @@ impl<'a> TrayPresentationPlan<'a> { top_percent: selected_tray_percents(top, settings).0, bottom_percent: selected_tray_percents(bottom, settings).0, has_error, + top_tint: provider_tint(settings, Some(top)), + bottom_tint: provider_tint(settings, Some(bottom)), }, vec![ TrayStatusRow { @@ -114,7 +124,11 @@ impl<'a> TrayPresentationPlan<'a> { .collect(); ( resolve_single_provider_icon_plan( - settings, percents.0, percents.1, has_error, + settings, + percents.0, + percents.1, + has_error, + single_tint, ), rows, ) @@ -133,7 +147,13 @@ impl<'a> TrayPresentationPlan<'a> { }) .collect(); ( - resolve_single_provider_icon_plan(settings, percents.0, percents.1, has_error), + resolve_single_provider_icon_plan( + settings, + percents.0, + percents.1, + has_error, + single_tint, + ), rows, ) } @@ -149,7 +169,13 @@ impl<'a> TrayPresentationPlan<'a> { .into_iter() .collect(); ( - resolve_single_provider_icon_plan(settings, percents.0, percents.1, has_error), + resolve_single_provider_icon_plan( + settings, + percents.0, + percents.1, + has_error, + single_tint, + ), rows, ) } @@ -175,20 +201,32 @@ impl<'a> TrayPresentationPlan<'a> { primary_percent, secondary_percent, has_error, - } => render_bar_icon_rgba_with_pace( + provider_tint, + } => render_bar_icon_rgba_tinted( primary_percent, secondary_percent, has_error, self.icon_pace, + provider_tint, ), - TrayIconPlan::Percent { percent, has_error } => { - render_percent_icon_rgba_with_pace(percent, has_error, self.icon_pace) - } + TrayIconPlan::Percent { + percent, + has_error, + provider_tint, + } => render_percent_icon_rgba_tinted(percent, has_error, self.icon_pace, provider_tint), TrayIconPlan::Stacked { top_percent, bottom_percent, has_error, - } => render_stacked_bar_icon_rgba(top_percent, bottom_percent, has_error), + top_tint, + bottom_tint, + } => render_stacked_bar_icon_rgba_tinted( + top_percent, + bottom_percent, + has_error, + top_tint, + bottom_tint, + ), } } @@ -213,21 +251,50 @@ fn resolve_single_provider_icon_plan( primary_percent: f64, secondary_percent: Option, has_error: bool, + provider_tint: Option, ) -> TrayIconPlan { if settings.menu_bar_icon_style == codexbar::settings::MenuBarIconStyle::Percent { TrayIconPlan::Percent { percent: primary_percent, has_error, + provider_tint, } } else { TrayIconPlan::Bars { primary_percent, secondary_percent, has_error, + provider_tint, } } } +/// Accent tint for the meter of `snapshot` (upstream "Color by provider"). +/// +/// Opt-in through `menu_bar_color_by_provider`. Uses the effective accent, so +/// a per-provider override applies too. A malformed override yields no tint. +/// Legibility against the tray canvas is checked by the renderer. +fn provider_tint( + settings: &Settings, + snapshot: Option<&ProviderUsageSnapshot>, +) -> Option { + if !settings.menu_bar_color_by_provider { + return None; + } + let id = ProviderId::from_cli_name(&snapshot?.provider_id)?; + parse_hex_rgb(&settings.effective_accent_color(id)) +} + +/// Parse `#RRGGBB` (the leading `#` is optional and case is ignored). +fn parse_hex_rgb(value: &str) -> Option { + let hex = value.trim().strip_prefix('#').unwrap_or(value.trim()); + if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let channel = |range: std::ops::Range| u8::from_str_radix(&hex[range], 16).ok(); + Some((channel(0..2)?, channel(2..4)?, channel(4..6)?)) +} + /// Pace tint for the provider that drives a single-provider tray icon. /// /// Opt-in through `menu_bar_color_pace`; on-track and unknown stages keep the diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 9dabb12bad0..267af3f98e0 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -108,6 +108,7 @@ fn single_plan_uses_highest_provider_for_icon_and_summary() { primary_percent: 72.0, secondary_percent: None, has_error: false, + provider_tint: None, } ); assert_eq!( @@ -184,6 +185,8 @@ fn stacked_plan_resolves_distinct_preferences_once() { top_percent: 72.0, bottom_percent: 30.0, has_error: false, + top_tint: None, + bottom_tint: None, } ); assert_eq!( @@ -238,6 +241,8 @@ fn stacked_plan_falls_back_around_stale_and_duplicate_preferences() { top_percent: 30.0, bottom_percent: 72.0, has_error: false, + top_tint: None, + bottom_tint: None, } ); assert_eq!(plan.status_rows[0].snapshot.provider_id, "codex"); @@ -268,6 +273,7 @@ fn one_provider_stacked_mode_falls_back_to_single_provider_bars() { primary_percent: 65.0, secondary_percent: Some(30.0), has_error: false, + provider_tint: None, } ); assert_eq!(plan.status_rows.len(), 1); @@ -293,6 +299,7 @@ fn one_healthy_provider_never_uses_stacked_renderer() { TrayIconPlan::Percent { percent: 30.0, has_error: false, + provider_tint: None, } ); assert_eq!(plan.status_rows.len(), 1); @@ -316,6 +323,7 @@ fn all_errors_produce_error_styled_zero_percent_plan() { TrayIconPlan::Percent { percent: 0.0, has_error: true, + provider_tint: None, } ); assert!(plan.status_rows.is_empty()); @@ -345,6 +353,7 @@ fn plan_uses_selected_metric_and_remaining_display_mode() { primary_percent: 85.0, secondary_percent: Some(80.0), has_error: false, + provider_tint: None, } ); } @@ -366,7 +375,7 @@ fn render_icon_delegates_to_resolved_stacked_renderer() { assert_eq!( plan.render_icon(), - render_stacked_bar_icon_rgba(72.0, 40.0, false) + render_stacked_bar_icon_rgba_tinted(72.0, 40.0, false, None, None) ); } @@ -951,6 +960,7 @@ fn icon_style_not_the_legacy_percent_flag_picks_the_renderer() { TrayIconPlan::Percent { percent: 30.0, has_error: false, + provider_tint: None, } ); @@ -990,3 +1000,171 @@ fn retired_minimal_display_mode_no_longer_forces_most_used_provider() { let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); assert_eq!(plan.status_rows[0].snapshot.provider_id, "codex"); } + +const CODEX_BRAND: (u8, u8, u8) = (0x49, 0xA3, 0xB0); +const CLAUDE_BRAND: (u8, u8, u8) = (0xCC, 0x7C, 0x5E); + +fn color_by_provider_settings(style: codexbar::settings::MenuBarIconStyle) -> Settings { + Settings { + menu_bar_color_by_provider: true, + tray_icon_mode: TrayIconMode::Single, + menu_bar_icon_style: style, + show_as_used: true, + ..Settings::default() + } +} + +fn pixel_at(rgba: &[u8], x: u32, y: u32) -> [u8; 4] { + let index = ((y * codexbar::tray::TRAY_ICON_SIZE + x) * 4) as usize; + [ + rgba[index], + rgba[index + 1], + rgba[index + 2], + rgba[index + 3], + ] +} + +#[test] +fn color_by_provider_defaults_off_and_keeps_usage_colors() { + assert!(!Settings::default().menu_bar_color_by_provider); + let snapshots = vec![fake_snapshot("codex", "Codex", 30.0)]; + let plain = Settings { + tray_icon_mode: TrayIconMode::Single, + show_as_used: true, + ..Settings::default() + }; + let plan = TrayPresentationPlan::resolve(&plain, &snapshots, None); + assert!(matches!( + plan.icon, + TrayIconPlan::Bars { + provider_tint: None, + .. + } + )); +} + +#[test] +fn color_by_provider_tints_single_meter_with_brand_color() { + let snapshots = vec![fake_snapshot("codex", "Codex", 30.0)]; + let settings = color_by_provider_settings(codexbar::settings::MenuBarIconStyle::Bars); + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + assert!(matches!( + plan.icon, + TrayIconPlan::Bars { + provider_tint: Some(CODEX_BRAND), + .. + } + )); + // 30% of the single meter fills x 4..11; x=8 sits in the fill. + let (rgba, _, _) = plan.render_icon(); + assert_eq!( + pixel_at(&rgba, 8, 16), + [CODEX_BRAND.0, CODEX_BRAND.1, CODEX_BRAND.2, 255] + ); +} + +#[test] +fn color_by_provider_tints_percent_icon_text() { + let snapshots = vec![fake_snapshot("codex", "Codex", 30.0)]; + let settings = color_by_provider_settings(codexbar::settings::MenuBarIconStyle::Percent); + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + assert!(matches!( + plan.icon, + TrayIconPlan::Percent { + provider_tint: Some(CODEX_BRAND), + .. + } + )); + let (rgba, _, _) = plan.render_icon(); + let tinted = rgba + .as_chunks::<4>() + .0 + .iter() + .filter(|px| px[..3] == [CODEX_BRAND.0, CODEX_BRAND.1, CODEX_BRAND.2]) + .count(); + assert!(tinted > 0, "percent glyphs should use the brand color"); +} + +#[test] +fn color_by_provider_uses_the_effective_accent_override() { + let snapshots = vec![fake_snapshot("codex", "Codex", 30.0)]; + let mut settings = color_by_provider_settings(codexbar::settings::MenuBarIconStyle::Bars); + settings.set_accent_color(ProviderId::Codex, Some("#FF0000")); + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + assert!(matches!( + plan.icon, + TrayIconPlan::Bars { + provider_tint: Some((255, 0, 0)), + .. + } + )); +} + +#[test] +fn color_by_provider_malformed_override_gives_no_tint() { + let snapshots = vec![fake_snapshot("codex", "Codex", 30.0)]; + let mut settings = color_by_provider_settings(codexbar::settings::MenuBarIconStyle::Bars); + settings.set_accent_color(ProviderId::Codex, Some("not-a-color")); + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + assert!(matches!( + plan.icon, + TrayIconPlan::Bars { + provider_tint: None, + .. + } + )); +} + +#[test] +fn color_by_provider_colors_each_stacked_row_by_its_provider() { + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + let settings = Settings { + menu_bar_color_by_provider: true, + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("codex".to_string()), + stacked_tray_bottom_provider: Some("claude".to_string()), + show_as_used: true, + ..Settings::default() + }; + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + assert!(matches!( + plan.icon, + TrayIconPlan::Stacked { + top_tint: Some(CODEX_BRAND), + bottom_tint: Some(CLAUDE_BRAND), + .. + } + )); + let (rgba, width, _) = plan.render_icon(); + let pixel = |x: u32, y: u32| { + let index = ((y * width + x) * 4) as usize; + [ + rgba[index], + rgba[index + 1], + rgba[index + 2], + rgba[index + 3], + ] + }; + // Top row covers y 6..14, bottom row y 18..26 of the stacked icon. + assert_eq!( + pixel(8, 8), + [CODEX_BRAND.0, CODEX_BRAND.1, CODEX_BRAND.2, 255] + ); + assert_eq!( + pixel(8, 20), + [CLAUDE_BRAND.0, CLAUDE_BRAND.1, CLAUDE_BRAND.2, 255] + ); +} + +#[test] +fn parse_hex_rgb_accepts_only_six_digit_hex() { + assert_eq!(parse_hex_rgb("#49A3B0"), Some(CODEX_BRAND)); + assert_eq!(parse_hex_rgb("49a3b0"), Some(CODEX_BRAND)); + assert_eq!(parse_hex_rgb(" #FF5733 "), Some((255, 87, 51))); + assert_eq!(parse_hex_rgb("#49A3"), None); + assert_eq!(parse_hex_rgb("#GG0000"), None); + assert_eq!(parse_hex_rgb(""), None); +} From 3d9d591f902f4f90d2ddebe855a322320c515c70 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:32:43 +0700 Subject: [PATCH 4/5] Add Color by provider toggle to Menu Bar settings Add the Color by provider switch below the pace toggle, wired to menuBarColorByProvider in the bridge types, with test fixtures and tests for the set call and the stacked-mode state. Add its label and helper strings to all ten locales, with keys in the Rust and TypeScript lists. --- apps/desktop-tauri/src/App.test.tsx | 1 + .../src/floatbar/FloatBar.test.tsx | 1 + apps/desktop-tauri/src/i18n/keys.ts | 2 ++ .../src/surfaces/TrayPanel.test.tsx | 1 + .../surfaces/settings/tabs/AboutTab.test.tsx | 1 + .../settings/tabs/AdvancedTab.test.tsx | 1 + .../surfaces/settings/tabs/GeneralTab.test.tsx | 1 + .../surfaces/settings/tabs/MenuBarTab.test.tsx | 18 ++++++++++++++++++ .../src/surfaces/settings/tabs/MenuBarTab.tsx | 8 ++++++++ apps/desktop-tauri/src/types/bridge.test.ts | 1 + apps/desktop-tauri/src/types/bridge.ts | 3 +++ rust/src/locale.rs | 2 ++ rust/src/locale/en-US.ftl | 2 ++ rust/src/locale/es-MX.ftl | 2 ++ rust/src/locale/ja-JP.ftl | 2 ++ rust/src/locale/ko-KR.ftl | 2 ++ rust/src/locale/pt-BR.ftl | 2 ++ rust/src/locale/ru-RU.ftl | 2 ++ rust/src/locale/tr-TR.ftl | 2 ++ rust/src/locale/uk-UA.ftl | 2 ++ rust/src/locale/zh-CN.ftl | 2 ++ rust/src/locale/zh-TW.ftl | 2 ++ 22 files changed, 60 insertions(+) diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 1ec854a634c..48bb48268af 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -96,6 +96,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { quotaWarningMarkersVisible: true, workdayTickStyle: "subtle", menuBarColorPace: false, + menuBarColorByProvider: false, menuBarPaceReservePositive: false, providersSortedAlphabetically: false, showAsUsed: true, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index d791015972f..420f61d01bf 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -163,6 +163,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { quotaWarningMarkersVisible: true, workdayTickStyle: "subtle", menuBarColorPace: false, + menuBarColorByProvider: false, menuBarPaceReservePositive: false, providersSortedAlphabetically: false, showAsUsed: true, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 57c0f2296ab..a1c31e2847a 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -786,6 +786,8 @@ export const ALL_LOCALE_KEYS = [ "ShowPercentInTrayHelper", "ColorPaceInTray", "ColorPaceInTrayHelper", + "ColorByProviderInTray", + "ColorByProviderInTrayHelper", "PaceSignLabel", "PaceSignHelper", "PaceSignAheadPositive", diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 03a19969886..160011178ff 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -162,6 +162,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { quotaWarningMarkersVisible: true, workdayTickStyle: "subtle", menuBarColorPace: false, + menuBarColorByProvider: false, menuBarPaceReservePositive: false, providersSortedAlphabetically: false, showAsUsed: true, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index bc2506ec275..3ab255e405b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -72,6 +72,7 @@ const settings: SettingsSnapshot = { quotaWarningMarkersVisible: true, workdayTickStyle: "subtle", menuBarColorPace: false, + menuBarColorByProvider: false, menuBarPaceReservePositive: false, providersSortedAlphabetically: false, showAsUsed: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx index 33e10e183bc..cc5fa550fab 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -40,6 +40,7 @@ const settings: SettingsSnapshot = { quotaWarningMarkersVisible: true, workdayTickStyle: "subtle", menuBarColorPace: false, + menuBarColorByProvider: false, menuBarPaceReservePositive: false, providersSortedAlphabetically: false, showAsUsed: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 179d6ad0675..500d219563d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -75,6 +75,7 @@ const settings: SettingsSnapshot = { quotaWarningMarkersVisible: true, workdayTickStyle: "subtle", menuBarColorPace: false, + menuBarColorByProvider: false, menuBarPaceReservePositive: false, providersSortedAlphabetically: false, showAsUsed: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx index 815194abc64..ac2f77c6834 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx @@ -34,6 +34,7 @@ const baseSettings = { menuBarShowsPercent: false, menuBarIconStyle: "bars", menuBarColorPace: false, + menuBarColorByProvider: false, promoteTrayIcon: false, mergedOverviewSelectedProviders: [], } as unknown as SettingsSnapshot; @@ -63,6 +64,7 @@ describe("MenuBarTab layout", () => { expect(fieldLabels(screen.getByRole("region", { name: "MenuBarIconSectionTitle" }))).toEqual([ "MenuBarIconStyleLabel", "ColorPaceInTray", + "ColorByProviderInTray", "PaceSignLabel", "PromoteTrayIconLabel", ]); @@ -110,6 +112,22 @@ describe("MenuBarTab Icon section", () => { expect(set).toHaveBeenCalledWith({ menuBarColorPace: true }); }); + it("updates Color by provider and keeps it independent of the pace toggle", () => { + const { set } = renderTab(); + const colorByProvider = screen.getByRole("checkbox", { name: "ColorByProviderInTray" }) as HTMLInputElement; + expect(colorByProvider.checked).toBe(false); + fireEvent.click(colorByProvider); + expect(set).toHaveBeenCalledWith({ menuBarColorByProvider: true }); + expect(set).not.toHaveBeenCalledWith(expect.objectContaining({ menuBarColorPace: expect.anything() })); + }); + + it("shows a saved Color by provider as checked and not disabled by stacked icons", () => { + renderTab({ menuBarColorByProvider: true, trayIconMode: "stacked" }); + const colorByProvider = screen.getByRole("checkbox", { name: "ColorByProviderInTray" }) as HTMLInputElement; + expect(colorByProvider.checked).toBe(true); + expect(colorByProvider.disabled).toBe(false); + }); + it("saves the Pace sign with ahead-of-pace positive as the default", () => { const { set } = renderTab(); const paceSign = select("PaceSignLabel"); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx index a38f62114c8..77dbfac0118 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx @@ -96,6 +96,14 @@ export default function MenuBarTab({ onChange={(v) => set({ menuBarColorPace: v })} /> + + set({ menuBarColorByProvider: v })} + /> +