From 0890630aa141a41647d60f85a9aaeb32878a932e Mon Sep 17 00:00:00 2001 From: LiarCoder Date: Tue, 23 Jun 2026 23:06:05 +0800 Subject: [PATCH] feat: #40 add "Usage Display" option --- README.md | 11 +- src/localization/dutch.rs | 3 + src/localization/english.rs | 3 + src/localization/french.rs | 3 + src/localization/german.rs | 3 + src/localization/japanese.rs | 3 + src/localization/korean.rs | 3 + src/localization/mod.rs | 3 + src/localization/portuguese_brazil.rs | 3 + src/localization/russian.rs | 3 + src/localization/spanish.rs | 3 + src/localization/traditional_chinese.rs | 3 + src/models.rs | 45 +++++ src/poller.rs | 31 +++- src/tray_icon.rs | 106 ++++++++--- src/window.rs | 226 +++++++++++++++++++++--- 16 files changed, 398 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index f8b3c2b..03b27af 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Once running, it will appear in your taskbar and as one or more tray icons in th - Drag the left divider to move the taskbar widget - On multi-monitor setups, drag the widget onto another Windows taskbar to move it to that screen -- Right-click the taskbar widget or tray icon for refresh, displayed models, update frequency, Start with Windows, reset position, language, updates, and exit +- Right-click the taskbar widget or tray icon for refresh, displayed models, usage display mode, update frequency, Start with Windows, reset position, language, updates, and exit - Left-click the tray icon to toggle the taskbar widget on or off - Enable `Start with Windows` from the right-click menu if you want it to launch automatically when you sign in @@ -77,9 +77,15 @@ Use the right-click **Models** menu to choose what the widget displays: When multiple models are shown, each model has its own usage bar and matching usage text color. Antigravity prefers Google's Gemini quota summary when available and falls back to model quota data when needed. +### Usage Display + +Use the top-level **Usage Display** menu below **Models** to choose whether percentages show usage as **Used** or **Remaining**. **Used** remains the default for existing and new installations. + +The selected mode applies to the widget text and bars, tray icon badges, and tray tooltips for every enabled provider. Tray warning colors always reflect used quota so that low remaining quota still keeps the high-usage warning style. + ### System Tray Icon -The tray icon shows your current 5-hour usage as a percentage badge. +The tray icon shows your current 5-hour usage as a percentage badge using the selected usage display mode. If multiple providers are enabled, the app shows one tray icon per provider. If only one model is enabled, it shows one tray icon. @@ -146,6 +152,7 @@ What the app stores locally: - Language preference - Last update check time - Displayed model preferences +- Usage display preference What it does **not** do: diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..4d311bd 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "Instellingen", + usage_display: "Gebruiksweergave", + used_usage: "Gebruikt", + remaining_usage: "Resterend", start_with_windows: "Opstarten met Windows", reset_position: "Positie herstellen", language: "Taal", diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..5b43723 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "Settings", + usage_display: "Usage Display", + used_usage: "Used", + remaining_usage: "Remaining", start_with_windows: "Start with Windows", reset_position: "Reset Position", language: "Language", diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..2ac8a67 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "Paramètres", + usage_display: "Affichage de l’utilisation", + used_usage: "Utilisé", + remaining_usage: "Restant", start_with_windows: "Démarrer avec Windows", reset_position: "Réinitialiser la position", language: "Langue", diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..626de17 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "Einstellungen", + usage_display: "Nutzungsanzeige", + used_usage: "Verwendet", + remaining_usage: "Verbleibend", start_with_windows: "Mit Windows starten", reset_position: "Position zurücksetzen", language: "Sprache", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..1b84b5a 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "設定", + usage_display: "使用量表示", + used_usage: "使用済み", + remaining_usage: "残り", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", language: "言語", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..d7d69fc 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "설정", + usage_display: "사용량 표시", + used_usage: "사용됨", + remaining_usage: "남음", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", language: "언어", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2a06b04..a42e290 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -149,6 +149,9 @@ pub struct Strings { pub codex_model: &'static str, pub antigravity_model: &'static str, pub settings: &'static str, + pub usage_display: &'static str, + pub used_usage: &'static str, + pub remaining_usage: &'static str, pub start_with_windows: &'static str, pub reset_position: &'static str, pub language: &'static str, diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..9d9f83d 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "Configurações", + usage_display: "Exibição de Uso", + used_usage: "Usado", + remaining_usage: "Restante", start_with_windows: "Iniciar com o Windows", reset_position: "Redefinir Posição", language: "Idioma", diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..b80eb15 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "Настройки", + usage_display: "Отображение использования", + used_usage: "Использовано", + remaining_usage: "Осталось", start_with_windows: "Запускать вместе с Windows", reset_position: "Сбросить позицию", language: "Язык", diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..b15517b 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "Configuración", + usage_display: "Visualización de uso", + used_usage: "Usado", + remaining_usage: "Restante", start_with_windows: "Iniciar con Windows", reset_position: "Restablecer posición", language: "Idioma", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..4a7c13b 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -15,6 +15,9 @@ pub(super) const STRINGS: Strings = Strings { codex_model: "Codex", antigravity_model: "Antigravity", settings: "設定", + usage_display: "使用量顯示", + used_usage: "已使用", + remaining_usage: "剩餘", start_with_windows: "開機時啟動", reset_position: "重置位置", language: "語言", diff --git a/src/models.rs b/src/models.rs index da49ef1..4207e52 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,5 +1,25 @@ use std::time::SystemTime; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UsageDisplayMode { + #[default] + Used, + Remaining, +} + +impl UsageDisplayMode { + pub fn display_percentage(self, used_percentage: f64) -> f64 { + let used_percentage = used_percentage.clamp(0.0, 100.0); + match self { + Self::Used => used_percentage, + Self::Remaining => 100.0 - used_percentage, + } + } +} + #[derive(Clone, Debug, Default)] pub struct UsageSection { pub percentage: f64, @@ -18,3 +38,28 @@ pub struct AppUsageData { pub codex: Option, pub antigravity: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usage_display_mode_converts_and_clamps_percentages() { + for (used, expected_used, expected_remaining) in [ + (-5.0, 0.0, 100.0), + (0.0, 0.0, 100.0), + (42.0, 42.0, 58.0), + (100.0, 100.0, 0.0), + (105.0, 100.0, 0.0), + ] { + assert_eq!( + UsageDisplayMode::Used.display_percentage(used), + expected_used + ); + assert_eq!( + UsageDisplayMode::Remaining.display_percentage(used), + expected_remaining + ); + } + } +} diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..7d061a8 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -11,7 +11,7 @@ use std::os::windows::process::CommandExt; use crate::diagnose; use crate::localization::Strings; -use crate::models::{AppUsageData, UsageData, UsageSection}; +use crate::models::{AppUsageData, UsageData, UsageDisplayMode, UsageSection}; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; @@ -1522,8 +1522,15 @@ fn is_leap(y: u64) -> bool { } /// Format a usage section as "X% · Yh" style text -pub fn format_line(section: &UsageSection, strings: Strings) -> String { - let pct = format!("{:.0}%", section.percentage); +pub fn format_line( + section: &UsageSection, + strings: Strings, + usage_display: UsageDisplayMode, +) -> String { + let pct = format!( + "{:.0}%", + usage_display.display_percentage(section.percentage) + ); let cd = format_countdown(section.resets_at, strings); if cd.is_empty() { pct @@ -1603,6 +1610,7 @@ pub fn app_is_past_reset(data: &AppUsageData) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::localization::LanguageId; fn usage_with_session_percent(percentage: f64) -> UsageData { UsageData { @@ -1614,6 +1622,23 @@ mod tests { } } + #[test] + fn format_line_respects_usage_display_mode_and_preserves_countdown() { + let section = UsageSection { + percentage: 42.0, + resets_at: Some(SystemTime::now() + Duration::from_secs(7_201)), + }; + let strings = LanguageId::English.strings(); + + let used = format_line(§ion, strings, UsageDisplayMode::Used); + let remaining = format_line(§ion, strings, UsageDisplayMode::Remaining); + + assert!(used.starts_with("42% · ")); + assert!(remaining.starts_with("58% · ")); + assert_eq!(used.split_once(" · ").unwrap().1, "2h"); + assert_eq!(remaining.split_once(" · ").unwrap().1, "2h"); + } + #[test] fn claude_failure_does_not_block_codex_when_both_are_enabled() { let data = poll_with( diff --git a/src/tray_icon.rs b/src/tray_icon.rs index e2502e2..ba9a74e 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -33,7 +33,8 @@ pub enum TrayIconKind { pub struct TrayIconData { pub kind: TrayIconKind, - pub percent: Option, + pub used_percent: Option, + pub display_percent: Option, pub tooltip: String, } @@ -101,11 +102,16 @@ fn antigravity_fill(percent: f64) -> Color { } } -/// Create a rounded-rectangle tray icon badge showing the usage percentage. -/// For Claude, `percent` = None uses the embedded app icon as the loading state. -/// For Codex and Antigravity, `percent` = None uses a provider placeholder badge. -pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { - if matches!(kind, TrayIconKind::Claude) && percent.is_none() { +/// Create a rounded-rectangle tray icon badge. +/// `used_percent` controls risk colours while `display_percent` controls the badge text. +/// For Claude, no display percentage uses the embedded app icon as the loading state. +/// For Codex and Antigravity, no display percentage uses a provider placeholder badge. +pub fn create_icon( + kind: TrayIconKind, + used_percent: Option, + display_percent: Option, +) -> HICON { + if matches!(kind, TrayIconKind::Claude) && display_percent.is_none() { let app_icon = load_embedded_app_icon(); if !app_icon.is_invalid() { return app_icon; @@ -122,26 +128,30 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { }; let fill = match kind { - TrayIconKind::Claude => interpolated_fill(percent.unwrap_or(0.0)), - TrayIconKind::Codex => codex_fill(percent.unwrap_or(0.0)), - TrayIconKind::Antigravity => antigravity_fill(percent.unwrap_or(0.0)), + TrayIconKind::Claude => interpolated_fill(used_percent.unwrap_or(0.0)), + TrayIconKind::Codex => codex_fill(used_percent.unwrap_or(0.0)), + TrayIconKind::Antigravity => antigravity_fill(used_percent.unwrap_or(0.0)), }; let text_col = match kind { TrayIconKind::Claude => Color::from_hex("#FFFFFF"), - TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"), + TrayIconKind::Codex if used_percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"), TrayIconKind::Codex => Color::from_hex("#FFFFFF"), - TrayIconKind::Antigravity if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1967D2"), + TrayIconKind::Antigravity if used_percent.unwrap_or(0.0) >= 90.0 => { + Color::from_hex("#1967D2") + } TrayIconKind::Antigravity => Color::from_hex("#FFFFFF"), }; let outline_col = match kind { TrayIconKind::Claude => fill, - TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"), + TrayIconKind::Codex if used_percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"), TrayIconKind::Codex => Color::from_hex("#FFFFFF"), - TrayIconKind::Antigravity if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1967D2"), + TrayIconKind::Antigravity if used_percent.unwrap_or(0.0) >= 90.0 => { + Color::from_hex("#1967D2") + } TrayIconKind::Antigravity => Color::from_hex("#FFFFFF"), }; - let display_text = match percent { + let display_text = match display_percent { Some(p) => format!("{}", p.round().clamp(0.0, 999.0) as u32), None => match kind { TrayIconKind::Claude => String::new(), @@ -360,8 +370,14 @@ fn copy_wide_256(s: &str, buf: &mut [u16; 256]) { } /// Register the tray icon with the shell. -pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option, tooltip: &str) { - let hicon = create_icon(kind, percent); +pub fn add( + hwnd: HWND, + kind: TrayIconKind, + used_percent: Option, + display_percent: Option, + tooltip: &str, +) { + let hicon = create_icon(kind, used_percent, display_percent); unsafe { let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); nid.cbSize = std::mem::size_of::() as u32; @@ -379,8 +395,14 @@ pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option, tooltip: &str) } /// Update the tray icon colour and tooltip to reflect current usage. -pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option, tooltip: &str) { - let hicon = create_icon(kind, percent); +pub fn update( + hwnd: HWND, + kind: TrayIconKind, + used_percent: Option, + display_percent: Option, + tooltip: &str, +) { + let hicon = create_icon(kind, used_percent, display_percent); unsafe { let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); nid.cbSize = std::mem::size_of::() as u32; @@ -419,22 +441,58 @@ pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { .find(|icon| matches!(icon.kind, TrayIconKind::Antigravity)); if let Some(icon) = show_claude { - add(hwnd, icon.kind, icon.percent, &icon.tooltip); - update(hwnd, icon.kind, icon.percent, &icon.tooltip); + add( + hwnd, + icon.kind, + icon.used_percent, + icon.display_percent, + &icon.tooltip, + ); + update( + hwnd, + icon.kind, + icon.used_percent, + icon.display_percent, + &icon.tooltip, + ); } else { remove(hwnd, TrayIconKind::Claude); } if let Some(icon) = show_codex { - add(hwnd, icon.kind, icon.percent, &icon.tooltip); - update(hwnd, icon.kind, icon.percent, &icon.tooltip); + add( + hwnd, + icon.kind, + icon.used_percent, + icon.display_percent, + &icon.tooltip, + ); + update( + hwnd, + icon.kind, + icon.used_percent, + icon.display_percent, + &icon.tooltip, + ); } else { remove(hwnd, TrayIconKind::Codex); } if let Some(icon) = show_antigravity { - add(hwnd, icon.kind, icon.percent, &icon.tooltip); - update(hwnd, icon.kind, icon.percent, &icon.tooltip); + add( + hwnd, + icon.kind, + icon.used_percent, + icon.display_percent, + &icon.tooltip, + ); + update( + hwnd, + icon.kind, + icon.used_percent, + icon.display_percent, + &icon.tooltip, + ); } else { remove(hwnd, TrayIconKind::Antigravity); } diff --git a/src/window.rs b/src/window.rs index f6d261e..09f8cbb 100644 --- a/src/window.rs +++ b/src/window.rs @@ -18,7 +18,7 @@ use windows::Win32::UI::WindowsAndMessaging::*; use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; -use crate::models::AppUsageData; +use crate::models::{AppUsageData, UsageDisplayMode}; use crate::native_interop::{ self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, WM_APP_USAGE_UPDATED, @@ -70,6 +70,7 @@ struct AppState { show_claude_code: bool, show_codex: bool, show_antigravity: bool, + usage_display: UsageDisplayMode, data: Option, @@ -93,6 +94,52 @@ struct AppState { widget_visible: bool, } +impl AppState { + fn display_percentage(&self, used_percentage: f64, available: bool) -> f64 { + display_percentage_for_availability(self.usage_display, used_percentage, available) + } + + fn claude_code_usage_available(&self) -> bool { + self.data + .as_ref() + .and_then(|data| data.claude_code.as_ref()) + .is_some() + } + + fn codex_usage_available(&self) -> bool { + self.data + .as_ref() + .and_then(|data| data.codex.as_ref()) + .is_some() + } + + fn antigravity_usage_available(&self) -> bool { + self.data + .as_ref() + .and_then(|data| data.antigravity.as_ref()) + .is_some() + } + + fn antigravity_weekly_usage_available(&self) -> bool { + self.data + .as_ref() + .and_then(|data| data.antigravity.as_ref()) + .is_some_and(|usage| usage.weekly.resets_at.is_some() || usage.weekly.percentage != 0.0) + } +} + +fn display_percentage_for_availability( + usage_display: UsageDisplayMode, + used_percentage: f64, + available: bool, +) -> f64 { + if available { + usage_display.display_percentage(used_percentage) + } else { + used_percentage.clamp(0.0, 100.0) + } +} + #[derive(Clone, Debug)] enum UpdateStatus { Idle, @@ -131,6 +178,8 @@ const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +const IDM_USAGE_DISPLAY_USED: u16 = 80; +const IDM_USAGE_DISPLAY_REMAINING: u16 = 81; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -316,6 +365,8 @@ struct SettingsFile { show_codex: bool, #[serde(default = "default_show_antigravity")] show_antigravity: bool, + #[serde(default)] + usage_display: UsageDisplayMode, } impl Default for SettingsFile { @@ -330,6 +381,7 @@ impl Default for SettingsFile { show_claude_code: true, show_codex: false, show_antigravity: false, + usage_display: UsageDisplayMode::Used, } } } @@ -391,6 +443,7 @@ fn save_state_settings() { show_claude_code: s.show_claude_code, show_codex: s.show_codex, show_antigravity: s.show_antigravity, + usage_display: s.usage_display, }); } } @@ -403,7 +456,10 @@ fn tray_icon_data_from_state() -> Vec { if s.show_claude_code { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Claude, - percent: Some(s.session_percent), + used_percent: Some(s.session_percent), + display_percent: Some( + s.display_percentage(s.session_percent, s.claude_code_usage_available()), + ), tooltip: format!( "{} 5h: {} | 7d: {}", s.language.strings().claude_code_model, @@ -415,7 +471,10 @@ fn tray_icon_data_from_state() -> Vec { if s.show_codex { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Codex, - percent: Some(s.codex_session_percent), + used_percent: Some(s.codex_session_percent), + display_percent: Some( + s.display_percentage(s.codex_session_percent, s.codex_usage_available()), + ), tooltip: format!( "{} 5h: {} | 7d: {}", s.language.strings().codex_model, @@ -427,7 +486,11 @@ fn tray_icon_data_from_state() -> Vec { if s.show_antigravity { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Antigravity, - percent: Some(s.antigravity_session_percent), + used_percent: Some(s.antigravity_session_percent), + display_percent: Some(s.display_percentage( + s.antigravity_session_percent, + s.antigravity_usage_available(), + )), tooltip: format!( "{} 5h: {} | 7d: {}", s.language.strings().antigravity_model, @@ -443,21 +506,24 @@ fn tray_icon_data_from_state() -> Vec { if s.show_claude_code { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Claude, - percent: None, + used_percent: None, + display_percent: None, tooltip: s.language.strings().window_title.to_string(), }); } if s.show_codex { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Codex, - percent: None, + used_percent: None, + display_percent: None, tooltip: s.language.strings().codex_window_title.to_string(), }); } if s.show_antigravity { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Antigravity, - percent: None, + used_percent: None, + display_percent: None, tooltip: s.language.strings().antigravity_window_title.to_string(), }); } @@ -645,28 +711,31 @@ fn refresh_usage_texts(state: &mut AppState) { }; if let Some(claude_code) = data.claude_code.as_ref() { - state.session_text = poller::format_line(&claude_code.session, strings); - state.weekly_text = poller::format_line(&claude_code.weekly, strings); + state.session_text = + poller::format_line(&claude_code.session, strings, state.usage_display); + state.weekly_text = poller::format_line(&claude_code.weekly, strings, state.usage_display); } else if state.show_claude_code { state.session_text = "!".to_string(); state.weekly_text = "!".to_string(); } if let Some(codex) = data.codex.as_ref() { - state.codex_session_text = poller::format_line(&codex.session, strings); - state.codex_weekly_text = poller::format_line(&codex.weekly, strings); + state.codex_session_text = + poller::format_line(&codex.session, strings, state.usage_display); + state.codex_weekly_text = poller::format_line(&codex.weekly, strings, state.usage_display); } else if state.show_codex { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); } if let Some(antigravity) = data.antigravity.as_ref() { - state.antigravity_session_text = poller::format_line(&antigravity.session, strings); + state.antigravity_session_text = + poller::format_line(&antigravity.session, strings, state.usage_display); state.antigravity_weekly_text = if antigravity.weekly.resets_at.is_none() && antigravity.weekly.percentage == 0.0 { "--".to_string() } else { - poller::format_line(&antigravity.weekly, strings) + poller::format_line(&antigravity.weekly, strings, state.usage_display) }; } else if state.show_antigravity { state.antigravity_session_text = "!".to_string(); @@ -1310,6 +1379,7 @@ pub fn run() { show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, show_antigravity: settings.show_antigravity, + usage_display: settings.usage_display, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1443,17 +1513,23 @@ fn render_layered() { s.is_dark, s.embedded, s.language.strings(), - s.session_percent, + s.display_percentage(s.session_percent, s.claude_code_usage_available()), s.session_text.clone(), - s.weekly_percent, + s.display_percentage(s.weekly_percent, s.claude_code_usage_available()), s.weekly_text.clone(), - s.codex_session_percent, + s.display_percentage(s.codex_session_percent, s.codex_usage_available()), s.codex_session_text.clone(), - s.codex_weekly_percent, + s.display_percentage(s.codex_weekly_percent, s.codex_usage_available()), s.codex_weekly_text.clone(), - s.antigravity_session_percent, + s.display_percentage( + s.antigravity_session_percent, + s.antigravity_usage_available(), + ), s.antigravity_session_text.clone(), - s.antigravity_weekly_percent, + s.display_percentage( + s.antigravity_weekly_percent, + s.antigravity_weekly_usage_available(), + ), s.antigravity_weekly_text.clone(), s.show_claude_code, s.show_codex, @@ -2633,6 +2709,22 @@ unsafe extern "system" fn wnd_proc( do_poll(sh); }); } + IDM_USAGE_DISPLAY_USED | IDM_USAGE_DISPLAY_REMAINING => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.usage_display = if id == IDM_USAGE_DISPLAY_REMAINING { + UsageDisplayMode::Remaining + } else { + UsageDisplayMode::Used + }; + refresh_usage_texts(s); + } + } + save_state_settings(); + render_layered(); + sync_tray_icons(hwnd); + } IDM_LANG_SYSTEM | IDM_LANG_ENGLISH | IDM_LANG_DUTCH @@ -2715,6 +2807,7 @@ fn show_context_menu(hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + usage_display, ) = { let state = lock_state(); match state.as_ref() { @@ -2729,6 +2822,7 @@ fn show_context_menu(hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.usage_display, ), None => ( POLL_15_MIN, @@ -2741,6 +2835,7 @@ fn show_context_menu(hwnd: HWND) { true, false, false, + UsageDisplayMode::Used, ), } }; @@ -2835,6 +2930,42 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(models_label.as_ptr()), ); + // Usage display submenu + let usage_display_menu = CreatePopupMenu().unwrap(); + let used_usage_label = native_interop::wide_str(strings.used_usage); + let used_usage_flags = if usage_display == UsageDisplayMode::Used { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + usage_display_menu, + used_usage_flags, + IDM_USAGE_DISPLAY_USED as usize, + PCWSTR::from_raw(used_usage_label.as_ptr()), + ); + + let remaining_usage_label = native_interop::wide_str(strings.remaining_usage); + let remaining_usage_flags = if usage_display == UsageDisplayMode::Remaining { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + usage_display_menu, + remaining_usage_flags, + IDM_USAGE_DISPLAY_REMAINING as usize, + PCWSTR::from_raw(remaining_usage_label.as_ptr()), + ); + + let usage_display_label = native_interop::wide_str(strings.usage_display); + let _ = AppendMenuW( + menu, + MF_POPUP, + usage_display_menu.0 as usize, + PCWSTR::from_raw(usage_display_label.as_ptr()), + ); + // Settings submenu let settings_menu = CreatePopupMenu().unwrap(); @@ -2993,17 +3124,23 @@ fn paint(hdc: HDC, hwnd: HWND) { Some(s) => ( s.is_dark, s.language.strings(), - s.session_percent, + s.display_percentage(s.session_percent, s.claude_code_usage_available()), s.session_text.clone(), - s.weekly_percent, + s.display_percentage(s.weekly_percent, s.claude_code_usage_available()), s.weekly_text.clone(), - s.codex_session_percent, + s.display_percentage(s.codex_session_percent, s.codex_usage_available()), s.codex_session_text.clone(), - s.codex_weekly_percent, + s.display_percentage(s.codex_weekly_percent, s.codex_usage_available()), s.codex_weekly_text.clone(), - s.antigravity_session_percent, + s.display_percentage( + s.antigravity_session_percent, + s.antigravity_usage_available(), + ), s.antigravity_session_text.clone(), - s.antigravity_weekly_percent, + s.display_percentage( + s.antigravity_weekly_percent, + s.antigravity_weekly_usage_available(), + ), s.antigravity_weekly_text.clone(), s.show_claude_code, s.show_codex, @@ -3290,3 +3427,42 @@ fn draw_rounded_rect(hdc: HDC, rect: &RECT, color: &Color, radius: i32) { let _ = DeleteObject(brush); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_without_usage_display_defaults_to_used() { + let settings: SettingsFile = serde_json::from_str(r#"{"tray_offset": 12}"#).unwrap(); + + assert_eq!(settings.tray_offset, 12); + assert_eq!(settings.usage_display, UsageDisplayMode::Used); + } + + #[test] + fn unavailable_usage_is_not_inverted_in_remaining_mode() { + assert_eq!( + display_percentage_for_availability(UsageDisplayMode::Remaining, 0.0, false), + 0.0 + ); + assert_eq!( + display_percentage_for_availability(UsageDisplayMode::Remaining, 42.0, true), + 58.0 + ); + } + + #[test] + fn remaining_usage_display_round_trips_through_settings_json() { + let settings = SettingsFile { + usage_display: UsageDisplayMode::Remaining, + ..SettingsFile::default() + }; + + let json = serde_json::to_string(&settings).unwrap(); + assert!(json.contains(r#""usage_display":"remaining""#)); + + let decoded: SettingsFile = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.usage_display, UsageDisplayMode::Remaining); + } +}