diff --git a/CHANGELOG.md b/CHANGELOG.md index 390c21148b..4b04879486 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- Tray: add a **Progress bars stacked** menu bar style that draws the session and weekly meters as two equal-height rows on the single tray icon, with the same windows and pace tint as **Meter bars**; older settings files keep their style (upstream #4436). - Settings providers: show only enabled providers from the sidebar options menu, search by CLI alias (such as `openai` for Codex), restore the default provider order, and undo a drag reorder or a restore with Ctrl+Z or the options menu. Hidden providers keep their slots when the order changes (upstream #4440). - Add **Color by provider** to Settings > Menu Bar (off by default): tints the tray usage bars and percent text with the shown provider's brand or accent color, keeps pace colors and the error colors unchanged, and falls back to the usual colors when a tint would be hard to see on the tray background (upstream #4321). - OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The chart, menu bar token and Settings controls come in a later release (upstream #4495). diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 8e88d36432..09a89c3df1 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -1208,6 +1208,17 @@ mod tests { codexbar::settings::MenuBarIconStyle::Bars ); assert!(!settings.menu_bar_shows_percent); + + // Progress bars stacked is not the percent style, so the legacy flag stays off. + let update: SettingsUpdate = + serde_json::from_str(r#"{"menuBarIconStyle":"stackedBars"}"#).unwrap(); + assert!(update.refreshes_tray_presentation()); + update.apply_display_settings(&mut settings); + assert_eq!( + settings.menu_bar_icon_style, + codexbar::settings::MenuBarIconStyle::StackedBars + ); + assert!(!settings.menu_bar_shows_percent); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs index 052a214ef6..c35ef7742c 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -6,7 +6,7 @@ use codexbar::core::{IconLane, ProviderId, UsagePace}; use codexbar::settings::{Language, MetricPreference, Settings, TrayIconMode}; use codexbar::tray::{ TrayPaceColor, render_bar_icon_rgba_tinted, render_percent_icon_rgba_tinted, - render_stacked_bar_icon_rgba_tinted, + render_stacked_bar_icon_rgba_tinted, render_stacked_bars_icon_rgba_tinted, }; /// Opt-in provider accent for a tray meter (`menu_bar_color_by_provider`). @@ -20,6 +20,13 @@ enum TrayIconPlan { has_error: bool, provider_tint: Option, }, + /// The same session and weekly windows as `Bars`, drawn as equal rows. + StackedBars { + primary_percent: f64, + secondary_percent: Option, + has_error: bool, + provider_tint: Option, + }, Percent { percent: f64, has_error: bool, @@ -183,7 +190,9 @@ impl<'a> TrayPresentationPlan<'a> { let icon_pace = match icon { TrayIconPlan::Stacked { .. } => None, - TrayIconPlan::Bars { .. } | TrayIconPlan::Percent { .. } => icon_pace, + TrayIconPlan::Bars { .. } + | TrayIconPlan::StackedBars { .. } + | TrayIconPlan::Percent { .. } => icon_pace, }; Self { @@ -214,6 +223,18 @@ impl<'a> TrayPresentationPlan<'a> { has_error, provider_tint, } => render_percent_icon_rgba_tinted(percent, has_error, self.icon_pace, provider_tint), + TrayIconPlan::StackedBars { + primary_percent, + secondary_percent, + has_error, + provider_tint, + } => render_stacked_bars_icon_rgba_tinted( + primary_percent, + secondary_percent, + has_error, + self.icon_pace, + provider_tint, + ), TrayIconPlan::Stacked { top_percent, bottom_percent, @@ -253,19 +274,25 @@ fn resolve_single_provider_icon_plan( has_error: bool, provider_tint: Option, ) -> TrayIconPlan { - if settings.menu_bar_icon_style == codexbar::settings::MenuBarIconStyle::Percent { - TrayIconPlan::Percent { + use codexbar::settings::MenuBarIconStyle; + match settings.menu_bar_icon_style { + MenuBarIconStyle::Percent => TrayIconPlan::Percent { percent: primary_percent, has_error, provider_tint, - } - } else { - TrayIconPlan::Bars { + }, + MenuBarIconStyle::StackedBars => TrayIconPlan::StackedBars { primary_percent, secondary_percent, has_error, provider_tint, - } + }, + MenuBarIconStyle::Bars => TrayIconPlan::Bars { + primary_percent, + secondary_percent, + has_error, + provider_tint, + }, } } 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 267af3f98e..5d5be6c52c 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -1,5 +1,6 @@ use super::*; use codexbar::core::{ProviderId, ProviderStateKind}; +use codexbar::tray::render_stacked_bars_icon_rgba_with_pace; fn fake_snapshot(id: &str, display_name: &str, used_percent: f64) -> ProviderUsageSnapshot { fake_snapshot_with(id, display_name, used_percent, None, None, None) @@ -1001,6 +1002,118 @@ fn retired_minimal_display_mode_no_longer_forces_most_used_provider() { assert_eq!(plan.status_rows[0].snapshot.provider_id, "codex"); } +#[test] +fn stacked_bars_style_plans_the_same_windows_as_bars_as_equal_rows() { + let snapshots = vec![fake_snapshot_with( + "codex", + "Codex", + 30.0, + Some(65.0), + None, + None, + )]; + let bars_settings = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Bars, + show_as_used: true, + ..Settings::default() + }; + let stacked_settings = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::StackedBars, + show_as_used: true, + ..Settings::default() + }; + + let TrayIconPlan::Bars { + primary_percent, + secondary_percent, + has_error, + provider_tint, + } = TrayPresentationPlan::resolve(&bars_settings, &snapshots, None).icon + else { + panic!("bars style should plan Bars"); + }; + let stacked = TrayPresentationPlan::resolve(&stacked_settings, &snapshots, None); + assert_eq!( + stacked.icon, + TrayIconPlan::StackedBars { + primary_percent, + secondary_percent, + has_error, + provider_tint, + } + ); + assert_eq!( + stacked.render_icon(), + render_stacked_bars_icon_rgba_with_pace( + primary_percent, + secondary_percent, + has_error, + None + ) + ); + assert_ne!( + stacked.render_icon(), + TrayPresentationPlan::resolve(&bars_settings, &snapshots, None).render_icon() + ); +} + +#[test] +fn stacked_bars_style_with_one_window_draws_the_bars_fallback() { + let snapshots = vec![fake_snapshot("codex", "Codex", 30.0)]; + let bars_settings = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Bars, + show_as_used: true, + ..Settings::default() + }; + let stacked_settings = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::StackedBars, + ..bars_settings.clone() + }; + let bars = TrayPresentationPlan::resolve(&bars_settings, &snapshots, None); + let stacked = TrayPresentationPlan::resolve(&stacked_settings, &snapshots, None); + assert!(matches!( + stacked.icon, + TrayIconPlan::StackedBars { + secondary_percent: None, + .. + } + )); + assert_eq!(stacked.render_icon(), bars.render_icon()); +} + +#[test] +fn stacked_bars_style_keeps_the_pace_tint_on_both_rows() { + let settings = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::StackedBars, + ..tint_settings() + }; + let snapshots = vec![paced_snapshot( + "synthetic", + paced_window(70.0, Some(300), 150), + )]; + let plan = TrayPresentationPlan::resolve_at(&settings, &snapshots, None, pace_now()); + assert_eq!(plan.icon_pace, Some(TrayPaceColor::Ahead)); + + let TrayIconPlan::StackedBars { + primary_percent, + secondary_percent, + has_error, + provider_tint: None, + } = plan.icon + else { + panic!("stacked bars style should plan StackedBars"); + }; + assert_eq!( + plan.render_icon(), + render_stacked_bars_icon_rgba_with_pace( + primary_percent, + secondary_percent, + has_error, + Some(TrayPaceColor::Ahead), + ) + ); +} + const CODEX_BRAND: (u8, u8, u8) = (0x49, 0xA3, 0xB0); const CLAUDE_BRAND: (u8, u8, u8) = (0xCC, 0x7C, 0x5E); @@ -1168,3 +1281,33 @@ fn parse_hex_rgb_accepts_only_six_digit_hex() { assert_eq!(parse_hex_rgb("#GG0000"), None); assert_eq!(parse_hex_rgb(""), None); } + +#[test] +fn color_by_provider_tints_both_stacked_bars_rows() { + let snapshots = vec![fake_snapshot_with( + "codex", + "Codex", + 30.0, + Some(65.0), + None, + None, + )]; + let settings = color_by_provider_settings(codexbar::settings::MenuBarIconStyle::StackedBars); + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + assert!(matches!( + plan.icon, + TrayIconPlan::StackedBars { + provider_tint: Some(CODEX_BRAND), + .. + } + )); + let (rgba, _, _) = plan.render_icon(); + assert_eq!( + pixel_at(&rgba, 8, 8), + [CODEX_BRAND.0, CODEX_BRAND.1, CODEX_BRAND.2, 255] + ); + assert_eq!( + pixel_at(&rgba, 8, 20), + [CODEX_BRAND.0, CODEX_BRAND.1, CODEX_BRAND.2, 255] + ); +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 2ea4188d82..ca956eae8c 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -1603,6 +1603,7 @@ export const ALL_LOCALE_KEYS = [ "MenuBarIconStyleHelper", "MenuBarIconStyleBars", "MenuBarIconStylePercent", + "MenuBarIconStyleStackedBars", "CombinedIconSectionTitle", "CombinedIconStyleLabel", "CombinedIconStyleSwitcher", 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 ac2f77c683..1fdd5ccc5e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx @@ -101,6 +101,24 @@ describe("MenuBarTab Icon section", () => { expect(set).toHaveBeenCalledWith({ menuBarIconStyle: "percent" }); }); + it("offers Progress bars stacked after the other menu bar styles and saves it", () => { + const { set } = renderTab(); + const options = Array.from(select("MenuBarIconStyleLabel").options).map((o) => [o.value, o.textContent]); + expect(options).toEqual([ + ["bars", "MenuBarIconStyleBars"], + ["percent", "MenuBarIconStylePercent"], + ["stackedBars", "MenuBarIconStyleStackedBars"], + ]); + fireEvent.change(select("MenuBarIconStyleLabel"), { target: { value: "stackedBars" } }); + expect(set).toHaveBeenCalledWith({ menuBarIconStyle: "stackedBars" }); + }); + + it("shows a saved Progress bars stacked style", () => { + renderTab({ menuBarIconStyle: "stackedBars" }); + expect(select("MenuBarIconStyleLabel").value).toBe("stackedBars"); + expect(select("MenuBarIconStyleLabel").disabled).toBe(false); + }); + it("disables the Menu bar style while the icon is stacked", () => { renderTab({ trayIconMode: "stacked" }); expect(select("MenuBarIconStyleLabel").disabled).toBe(true); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx index 77dbfac011..6ee71ca769 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx @@ -84,6 +84,7 @@ export default function MenuBarTab({ options={[ { value: "bars", label: t("MenuBarIconStyleBars") }, { value: "percent", label: t("MenuBarIconStylePercent") }, + { value: "stackedBars", label: t("MenuBarIconStyleStackedBars") }, ]} onChange={(v) => set({ menuBarIconStyle: v as MenuBarIconStyle })} /> diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 7e02cebec1..e965075350 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -79,7 +79,7 @@ export type OverviewLayout = "detailed" | "compact"; /** How cost is rendered on provider MenuCards (#2976). */ export type CostSummaryDisplayStyle = "compact" | "detailed" | "hidden"; /** Settings > Menu Bar > Menu bar style (`menu_bar_icon_style`). */ -export type MenuBarIconStyle = "bars" | "percent"; +export type MenuBarIconStyle = "bars" | "percent" | "stackedBars"; /** Settings > Menu > Workday tick appearance (`workday_tick_style`). */ export type WorkdayTickStyle = "hidden" | "subtle" | "high_contrast"; export type FloatBarOrientation = "horizontal" | "vertical"; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index a6295608e0..595172c49f 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1892,6 +1892,7 @@ locale_keys! { MenuBarIconStyleHelper, MenuBarIconStyleBars, MenuBarIconStylePercent, + MenuBarIconStyleStackedBars, CombinedIconSectionTitle, CombinedIconStyleLabel, CombinedIconStyleSwitcher, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index ee1555f15b..6c06eb9d30 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1562,6 +1562,7 @@ MenuBarIconStyleLabel = Menu bar style MenuBarIconStyleHelper = How the tray icon is drawn. MenuBarIconStyleBars = Meter bars MenuBarIconStylePercent = Icon & percent +MenuBarIconStyleStackedBars = Progress bars stacked CombinedIconSectionTitle = Combined icon CombinedIconStyleLabel = Combined icon style CombinedIconStyleSwitcher = Switcher diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index df9dcf7309..9393fe3138 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1559,6 +1559,7 @@ MenuBarIconStyleLabel = Estilo de la barra de menú MenuBarIconStyleHelper = Cómo se dibuja el icono de la bandeja. MenuBarIconStyleBars = Barras de medidor MenuBarIconStylePercent = Icono y porcentaje +MenuBarIconStyleStackedBars = Barras de progreso apiladas CombinedIconSectionTitle = Icono combinado CombinedIconStyleLabel = Estilo del icono combinado CombinedIconStyleSwitcher = Selector diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 0bca6c3531..c0b9bab15a 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1560,6 +1560,7 @@ MenuBarIconStyleLabel = メニューバーのスタイル MenuBarIconStyleHelper = トレイアイコンの描画方法。 MenuBarIconStyleBars = メーターバー MenuBarIconStylePercent = アイコンとパーセント +MenuBarIconStyleStackedBars = 進度バーのスタック CombinedIconSectionTitle = 統合アイコン CombinedIconStyleLabel = 統合アイコンのスタイル CombinedIconStyleSwitcher = 切り替え diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 3dac7a2655..bf3ab71d22 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1559,6 +1559,7 @@ MenuBarIconStyleLabel = 메뉴 바 스타일 MenuBarIconStyleHelper = 트레이 아이콘을 그리는 방식입니다. MenuBarIconStyleBars = 미터 막대 MenuBarIconStylePercent = 아이콘 및 퍼센트 +MenuBarIconStyleStackedBars = 진행률 막대 세로로 쌓기 CombinedIconSectionTitle = 통합 아이콘 CombinedIconStyleLabel = 통합 아이콘 스타일 CombinedIconStyleSwitcher = 전환기 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 50cf615035..efb11a60e9 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1564,6 +1564,7 @@ MenuBarIconStyleLabel = Estilo da barra de menus MenuBarIconStyleHelper = Como o ícone da bandeja é desenhado. MenuBarIconStyleBars = Barras de medidor MenuBarIconStylePercent = Ícone e porcentagem +MenuBarIconStyleStackedBars = Barras de progresso empilhadas CombinedIconSectionTitle = Ícone combinado CombinedIconStyleLabel = Estilo do ícone combinado CombinedIconStyleSwitcher = Alternador diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 863d6e59b3..a156a6b9fb 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1560,6 +1560,7 @@ MenuBarIconStyleLabel = Стиль значка MenuBarIconStyleHelper = Как отображается значок в трее. MenuBarIconStyleBars = Шкалы MenuBarIconStylePercent = Значок и процент +MenuBarIconStyleStackedBars = Полосы прогресса друг над другом CombinedIconSectionTitle = Общий значок CombinedIconStyleLabel = Стиль общего значка CombinedIconStyleSwitcher = Переключатель diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 29945727a1..c9fa917fa5 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1560,6 +1560,7 @@ MenuBarIconStyleLabel = Menü çubuğu stili MenuBarIconStyleHelper = Tepsi simgesinin nasıl çizileceği. MenuBarIconStyleBars = Ölçüm çubukları MenuBarIconStylePercent = Simge ve yüzde +MenuBarIconStyleStackedBars = İstiflenmiş ilerleme çubukları CombinedIconSectionTitle = Birleşik simge CombinedIconStyleLabel = Birleşik simge stili CombinedIconStyleSwitcher = Değiştirici diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 82f103b739..5c8388b0c9 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1558,6 +1558,7 @@ MenuBarIconStyleLabel = Стиль значка MenuBarIconStyleHelper = Як відображається значок у треї. MenuBarIconStyleBars = Шкали MenuBarIconStylePercent = Значок і відсоток +MenuBarIconStyleStackedBars = Індикатори прогресу штабелем CombinedIconSectionTitle = Спільний значок CombinedIconStyleLabel = Стиль спільного значка CombinedIconStyleSwitcher = Перемикач diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 3f2e267b87..6d58d49dee 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1560,6 +1560,7 @@ MenuBarIconStyleLabel = 菜单栏样式 MenuBarIconStyleHelper = 托盘图标的绘制方式。 MenuBarIconStyleBars = 计量条 MenuBarIconStylePercent = 图标和百分比 +MenuBarIconStyleStackedBars = 进度条堆叠 CombinedIconSectionTitle = 合并图标 CombinedIconStyleLabel = 合并图标样式 CombinedIconStyleSwitcher = 切换器 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index b4a96c61c5..dbaa9194b3 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1560,6 +1560,7 @@ MenuBarIconStyleLabel = 選單欄樣式 MenuBarIconStyleHelper = 系統匣圖示的繪製方式。 MenuBarIconStyleBars = 計量條 MenuBarIconStylePercent = 圖示與百分比 +MenuBarIconStyleStackedBars = 進度條堆疊 CombinedIconSectionTitle = 合併圖示 CombinedIconStyleLabel = 合併圖示樣式 CombinedIconStyleSwitcher = 切換器 diff --git a/rust/src/settings/preferences_document.rs b/rust/src/settings/preferences_document.rs index 4901be6625..819b65e32e 100644 --- a/rust/src/settings/preferences_document.rs +++ b/rust/src/settings/preferences_document.rs @@ -92,7 +92,9 @@ const ALLOWED_PREFERENCES: &[(&str, Validator)] = &[ ("hide_personal_info", typed::), ("menu_bar_shows_highest_usage", typed::), ("menu_bar_shows_percent", typed::), - ("menu_bar_icon_style", |v| one_of(v, &["bars", "percent"])), + ("menu_bar_icon_style", |v| { + one_of(v, &["bars", "percent", "stackedBars"]) + }), ("menu_bar_color_by_provider", typed::), ( "merged_overview_selected_providers", diff --git a/rust/src/settings/preferences_document/tests.rs b/rust/src/settings/preferences_document/tests.rs index 65cbcb6844..7ac9b9e1b9 100644 --- a/rust/src/settings/preferences_document/tests.rs +++ b/rust/src/settings/preferences_document/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::settings::{ - Language, ProviderConfig, ProviderQuotaWarnings, QuotaWarningWindow, ThemePreference, - TrayIconMode, + Language, MenuBarIconStyle, ProviderConfig, ProviderQuotaWarnings, QuotaWarningWindow, + ThemePreference, TrayIconMode, }; use serde_json::json; use std::collections::BTreeMap; @@ -182,6 +182,21 @@ fn round_trips_every_allowlisted_preference() { assert_eq!(target.global_shortcut, "Ctrl+Alt+K"); } +#[test] +fn menu_bar_icon_style_import_accepts_stacked_bars_and_rejects_junk() { + let document = + parse(json!({ "menu_bar_icon_style": "stackedBars" })).expect("stackedBars imports"); + let mut target = Settings::default(); + document.apply_to(&mut target).expect("apply"); + assert_eq!(target.menu_bar_icon_style, MenuBarIconStyle::StackedBars); + + let rejected = parse(json!({ "menu_bar_icon_style": "stacked_bars" })); + assert!( + rejected.is_err(), + "unknown style must be rejected on import" + ); +} + #[test] fn export_is_deterministic_and_sorted() { let first = PreferencesDocument::from_settings(&customized_settings()) diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index a573ce9086..d73f9de3fd 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -2155,7 +2155,11 @@ fn stored_menu_bar_icon_style_wins_over_the_legacy_flag() { #[test] fn menu_bar_icon_style_round_trips() { - for style in [MenuBarIconStyle::Bars, MenuBarIconStyle::Percent] { + for style in [ + MenuBarIconStyle::Bars, + MenuBarIconStyle::Percent, + MenuBarIconStyle::StackedBars, + ] { let settings = Settings { menu_bar_icon_style: style, // The legacy flag disagrees on purpose: the stored key must win. @@ -2169,6 +2173,37 @@ fn menu_bar_icon_style_round_trips() { } } +#[test] +fn stacked_bars_icon_style_loads_from_a_settings_file() { + assert_eq!( + MenuBarIconStyle::StackedBars.as_str(), + "stackedBars", + "the id is the camelCase string the bridge and the file both use" + ); + assert_eq!( + MenuBarIconStyle::parse("stackedBars"), + Some(MenuBarIconStyle::StackedBars) + ); + assert_eq!( + MenuBarIconStyle::parse("STACKEDBARS"), + Some(MenuBarIconStyle::StackedBars) + ); + assert_eq!(MenuBarIconStyle::parse("stacked_bars"), None); + + let stored: Settings = serde_json::from_str( + r#"{ "enabled_providers": [], "menu_bar_shows_percent": false, "menu_bar_icon_style": "stackedBars" }"#, + ) + .expect("stackedBars file loads"); + assert_eq!(stored.menu_bar_icon_style, MenuBarIconStyle::StackedBars); + + // Unknown ids keep the legacy fallback, so a bad value never breaks the file. + let unknown: Settings = serde_json::from_str( + r#"{ "enabled_providers": [], "menu_bar_shows_percent": false, "menu_bar_icon_style": "stacked_bars" }"#, + ) + .expect("unknown style still loads"); + assert_eq!(unknown.menu_bar_icon_style, MenuBarIconStyle::Bars); +} + #[test] fn merged_overview_selection_defaults_empty_and_round_trips() { assert!( diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index 06ce0763de..abc6994fa0 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -629,6 +629,10 @@ pub enum MenuBarIconStyle { Bars, /// Provider icon plus the percent text. Percent, + /// Session and weekly meters drawn as two equal-height rows (upstream + /// "Progress bars stacked", #4436). Older builds read it as `Bars`. + #[serde(rename = "stackedBars")] + StackedBars, } impl MenuBarIconStyle { @@ -637,6 +641,7 @@ impl MenuBarIconStyle { match value.trim().to_ascii_lowercase().as_str() { "bars" => Some(Self::Bars), "percent" => Some(Self::Percent), + "stackedbars" => Some(Self::StackedBars), _ => None, } } @@ -645,6 +650,7 @@ impl MenuBarIconStyle { match self { Self::Bars => "bars", Self::Percent => "percent", + Self::StackedBars => "stackedBars", } } } diff --git a/rust/src/tray/mod.rs b/rust/src/tray/mod.rs index ecf821f741..e614984b39 100755 --- a/rust/src/tray/mod.rs +++ b/rust/src/tray/mod.rs @@ -10,5 +10,6 @@ pub use render::{ 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, + render_stacked_bar_icon_rgba_tinted, render_stacked_bars_icon_rgba_tinted, + render_stacked_bars_icon_rgba_with_pace, }; diff --git a/rust/src/tray/render.rs b/rust/src/tray/render.rs index 629e168aff..ff5beba26e 100644 --- a/rust/src/tray/render.rs +++ b/rust/src/tray/render.rs @@ -259,6 +259,69 @@ pub fn render_stacked_bar_icon_rgba_tinted( (image.into_raw(), TRAY_ICON_SIZE, TRAY_ICON_SIZE) } +/// Render the session and weekly meters of one provider as two equal-height +/// rows (upstream "Progress bars stacked", #4436), with the same optional pace +/// tint as [`render_bar_icon_rgba_with_pace`]. +/// +/// With no weekly window the output is exactly the single thick bar from +/// [`render_bar_icon_rgba_with_pace`], so a provider with one window looks the +/// same under either bar style. +pub fn render_stacked_bars_icon_rgba_with_pace( + session_percent: f64, + weekly_percent: Option, + has_error: bool, + pace_color: Option, +) -> (Vec, u32, u32) { + render_stacked_bars_icon_rgba_tinted( + session_percent, + weekly_percent, + has_error, + pace_color, + None, + ) +} + +/// [`render_stacked_bars_icon_rgba_with_pace`] with an optional provider +/// accent on both rows, following the same rules as +/// [`render_bar_icon_rgba_tinted`]. +pub fn render_stacked_bars_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 Some(weekly) = weekly_percent else { + return render_bar_icon_rgba_tinted( + session_percent, + None, + has_error, + pace_color, + provider_tint, + ); + }; + let mut image = new_icon_canvas(has_error); + draw_bar_row( + &mut image, + 6, + 14, + session_percent, + has_error, + pace_color, + provider_tint, + ); + draw_bar_row( + &mut image, + 18, + 26, + weekly, + has_error, + pace_color, + provider_tint, + ); + (image.into_raw(), TRAY_ICON_SIZE, TRAY_ICON_SIZE) +} + /// Render a compact numeric percent tray icon as raw RGBA bytes. pub fn render_percent_icon_rgba(percent: f64, has_error: bool) -> (Vec, u32, u32) { render_percent_icon_rgba_with_pace(percent, has_error, None) @@ -541,6 +604,112 @@ mod tests { ] } + /// Rows in `rows` whose pixel at column `x` is part of a bar (track or fill), + /// i.e. not the icon background gap. + fn painted_rows(rgba: &[u8], x: u32, rows: std::ops::Range) -> Vec { + rows.filter(|&y| pixel_at(rgba, x, y) != [60, 60, 70, 255]) + .collect() + } + + /// Horizontal extent `(first_x, end_x)` of usage-coloured pixels (neither the + /// track nor the background) across `rows`, or `None` when nothing is filled. + fn filled_span(rgba: &[u8], rows: std::ops::Range) -> Option<(u32, u32)> { + let mut span: Option<(u32, u32)> = None; + for y in rows { + for x in BAR_LEFT..BAR_RIGHT { + let px = pixel_at(rgba, x, y); + if px != [80, 80, 90, 255] && px != [60, 60, 70, 255] { + span = Some(match span { + None => (x, x + 1), + Some((first, end)) => (first.min(x), end.max(x + 1)), + }); + } + } + } + span + } + + #[test] + fn stacked_bars_draws_two_equal_height_rows() { + let (rgba, width, height) = + render_stacked_bars_icon_rgba_with_pace(100.0, Some(0.0), false, None); + assert_eq!((width, height), (TRAY_ICON_SIZE, TRAY_ICON_SIZE)); + + let (r, g, b) = UsageLevel::Critical.color(); + // Session row is filled to the end; weekly row is empty track. + assert_eq!(pixel_at(&rgba, 8, 8), [r, g, b, 255]); + assert_eq!(pixel_at(&rgba, 8, 20), [80, 80, 90, 255]); + // Gap between the rows keeps the icon background. + assert_eq!(pixel_at(&rgba, 8, 15), [60, 60, 70, 255]); + + // Both rows are painted over the same 8-pixel height, separated by a 4-pixel gap. + let top: Vec = (6..14).collect(); + let bottom: Vec = (18..26).collect(); + // Rows 2..30 are the inset canvas; the outer border is transparent. + assert_eq!( + painted_rows(&rgba, 20, 2..30), + [top.clone(), bottom.clone()].concat() + ); + assert_eq!(top.len(), bottom.len()); + } + + #[test] + fn stacked_bars_fill_widths_follow_each_window() { + let (rgba, _, _) = render_stacked_bars_icon_rgba_with_pace(50.0, Some(100.0), false, None); + // Session at 50% fills half of the 24-px track (x 4..16); weekly fills all of it. + assert_eq!(filled_span(&rgba, 6..14), Some((BAR_LEFT, BAR_LEFT + 12))); + assert_eq!(filled_span(&rgba, 18..26), Some((BAR_LEFT, BAR_RIGHT))); + // Nothing fills the gap between the rows. + assert_eq!(filled_span(&rgba, 14..18), None); + } + + #[test] + fn stacked_bars_without_weekly_matches_single_bars_fallback() { + let (stacked, _, _) = render_stacked_bars_icon_rgba_with_pace(42.0, None, false, None); + let (bars, _, _) = render_bar_icon_rgba_with_pace(42.0, None, false, None); + assert_eq!(stacked, bars); + + let (stacked_err, _, _) = render_stacked_bars_icon_rgba_with_pace(42.0, None, true, None); + let (bars_err, _, _) = render_bar_icon_rgba_with_pace(42.0, None, true, None); + assert_eq!(stacked_err, bars_err); + } + + #[test] + fn stacked_bars_applies_pace_tint_to_both_rows() { + let (rgba, _, _) = render_stacked_bars_icon_rgba_with_pace( + 100.0, + Some(100.0), + false, + Some(TrayPaceColor::Behind), + ); + assert_eq!(pixel_at(&rgba, 8, 8), [76, 175, 80, 255]); + assert_eq!(pixel_at(&rgba, 8, 20), [76, 175, 80, 255]); + } + + #[test] + fn stacked_bars_error_state_desaturates_both_rows() { + let (rgba, _, _) = render_stacked_bars_icon_rgba_with_pace(100.0, Some(100.0), true, None); + for (x, y) in [(8, 8), (8, 20)] { + let px = pixel_at(&rgba, x, y); + assert_eq!(px[0], px[1]); + assert_eq!(px[1], px[2]); + } + } + + #[test] + fn stacked_bars_take_the_provider_tint_on_both_rows() { + let (rgba, _, _) = + render_stacked_bars_icon_rgba_tinted(100.0, Some(100.0), false, None, Some(TEAL)); + assert_eq!(pixel_at(&rgba, 8, 8), [TEAL.0, TEAL.1, TEAL.2, 255]); + assert_eq!(pixel_at(&rgba, 8, 20), [TEAL.0, TEAL.1, TEAL.2, 255]); + + let (untinted, _, _) = + render_stacked_bars_icon_rgba_tinted(100.0, Some(100.0), false, None, None); + let (plain, _, _) = + render_stacked_bars_icon_rgba_with_pace(100.0, Some(100.0), false, None); + assert_eq!(untinted, plain); + } + #[test] fn provider_tint_colors_bar_fill_and_keeps_track() { let (tinted, _, _) = render_bar_icon_rgba_tinted(100.0, None, false, None, Some(TEAL));