diff --git a/.github/screenshots/etd.png b/.github/screenshots/etd.png new file mode 100644 index 0000000..e200092 Binary files /dev/null and b/.github/screenshots/etd.png differ diff --git a/README.md b/README.md index f8b3c2b..a897926 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,14 @@ The Claude Code tray icon uses the same warm usage colors as the Claude bar. The Hovering over a tray icon shows the usage values for that model. +### Estimated Time to Depletion (ETD) + +Enable **Show ETD** from the right-click menu (off by default) to see how long until you run out at your current pace. When a usage bar is on track to deplete before its window resets, the cell appends the estimate after the remaining time — for example `90% · 1d rem · 13h ETD` means roughly one day until the weekly window resets, but at the current burn rate you would run out in about 13 hours. Cells that are pacing safely show nothing extra. + +![ETD](.github/screenshots/etd.png) + +Inspired by [issue #21](https://github.com/CodeZeno/Claude-Code-Usage-Monitor/issues/21). + ## Diagnostics If you need to troubleshoot startup or visibility issues, run: diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..fcc2111 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -45,6 +45,9 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Antigravity-authenticatiefout", antigravity_token_expired_body: "Open Antigravity en meld je opnieuw aan. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", + show_etd: "Toon ETD", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Antigravity-gebruiksmonitor", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..982ac73 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -45,6 +45,9 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Antigravity Auth Error", antigravity_token_expired_body: "Open Antigravity and sign in again. After that, refresh or restart this app.", codex_window_title: "Codex Usage Monitor", + show_etd: "Show ETD", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Antigravity Usage Monitor", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..f699fe2 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -45,6 +45,9 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Erreur d'authentification Antigravity", antigravity_token_expired_body: "Ouvrez Antigravity et reconnectez-vous. Ensuite, actualisez ou redemarrez cette application.", codex_window_title: "Moniteur d'utilisation Codex", + show_etd: "Afficher l'ETD", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Moniteur d'utilisation Antigravity", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..f73ea08 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -45,6 +45,9 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Antigravity-Authentifizierungsfehler", antigravity_token_expired_body: "Offnen Sie Antigravity und melden Sie sich erneut an. Aktualisieren oder starten Sie diese App anschliessend neu.", codex_window_title: "Codex-Nutzungsmonitor", + show_etd: "ETD anzeigen", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Antigravity-Nutzungsmonitor", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..7097c5a 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -45,6 +45,9 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Antigravity 認証エラー", antigravity_token_expired_body: "Antigravity を開いて再度サインインしてください。その後、このアプリを更新するか再起動してください。", codex_window_title: "Codex 使用量モニター", + show_etd: "ETD を表示", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Antigravity 使用量モニター", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..143756e 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -45,6 +45,9 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Antigravity 인증 오류", antigravity_token_expired_body: "Antigravity를 열고 다시 로그인하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", + show_etd: "ETD 표시", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Antigravity 사용량 모니터", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2a06b04..053b109 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -180,6 +180,9 @@ pub struct Strings { pub antigravity_token_expired_title: &'static str, pub antigravity_token_expired_body: &'static str, pub codex_window_title: &'static str, + pub show_etd: &'static str, + pub etd_suffix: &'static str, + pub rem: &'static str, pub antigravity_window_title: &'static str, } diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..8f88407 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Erro de Autenticação do Antigravity", antigravity_token_expired_body: "Abra o Antigravity e entre novamente. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", + show_etd: "Mostrar ETD", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Monitor de uso do Antigravity", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..5a82163 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Ошибка авторизации Antigravity", antigravity_token_expired_body: "Откройте Antigravity и войдите снова. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", + show_etd: "Показывать ETD", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Монитор использования Antigravity", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..2b06168 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -45,6 +45,9 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Error de autenticacion de Antigravity", antigravity_token_expired_body: "Abre Antigravity e inicia sesion otra vez. Despues, actualiza o reinicia esta aplicacion.", codex_window_title: "Monitor de uso de Codex", + show_etd: "Mostrar ETD", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Monitor de uso de Antigravity", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..2a82b92 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -45,6 +45,9 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_title: "Antigravity 驗證錯誤", antigravity_token_expired_body: "請開啟 Antigravity 並重新登入。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", + show_etd: "顯示 ETD", + etd_suffix: "ETD", + rem: "rem", antigravity_window_title: "Antigravity 使用量監控", second_suffix: "秒", }; diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..7eb332a 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1733,3 +1733,138 @@ mod tests { assert!(usage.session.resets_at.is_some()); } } + +/// Rolling-quota window lengths, in seconds (5 hours and 7 days). Kept here, +/// next to the formatting that consumes them, so the ETD feature is +/// self-contained and does not depend on constants defined elsewhere. +pub const SESSION_WINDOW_SECS: u64 = 5 * 3600; +pub const WEEKLY_WINDOW_SECS: u64 = 7 * 86400; + +/// Estimated seconds until the quota is fully consumed, assuming the current +/// burn rate (quota-so-far / time-so-far) holds. Returns `None` unless the +/// projection lands *before* the window resets — i.e. only when the user is +/// genuinely on pace to deplete early. +fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option { + if actual_pct <= 0.0 || actual_pct >= 100.0 { + return None; + } + if remaining_secs == 0 || window_secs == 0 { + return None; + } + let elapsed_secs = window_secs.saturating_sub(remaining_secs); + if elapsed_secs == 0 { + return None; + } + let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct; + if !secs.is_finite() || secs < 0.0 { + return None; + } + let secs = secs as u64; + (secs < remaining_secs).then_some(secs) +} + +/// The trailing " rem · 45m ETD" segment for a usage cell, or `None` when the +/// section is not on pace to deplete before reset. The leading `rem` labels the +/// preceding countdown (which `format_line` left unlabeled) so the remaining +/// time and the depletion estimate are distinguishable. Reuses the same coarse, +/// single-unit duration format as the countdown. +pub fn etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option { + let reset = section.resets_at?; + let remaining_secs = reset.duration_since(SystemTime::now()).ok()?.as_secs(); + let secs = etd_secs(section.percentage, remaining_secs, window_secs)?; + let dur = format_countdown_from_secs(secs, strings); + Some(format!( + " {} \u{00b7} {dur} {}", + strings.rem, strings.etd_suffix + )) +} + +#[cfg(test)] +mod etd_tests { + use super::*; + use crate::localization::LanguageId; + use std::time::Duration; + + fn section(pct: f64, remaining: Duration) -> UsageSection { + UsageSection { + percentage: pct, + resets_at: Some(SystemTime::now() + remaining), + } + } + + #[test] + fn etd_suffix_present_when_at_risk() { + // 60% used, 1h remaining of a 2h window → at risk. + let s = section(60.0, Duration::from_secs(3600)); + let out = etd_suffix(&s, 2 * 3600, LanguageId::English.strings()); + let out = out.expect("expected a suffix when at risk"); + assert!(out.contains("ETD"), "suffix was: {out}"); + // Labels the preceding countdown with "rem", then the ETD segment. + assert!(out.starts_with(" rem \u{00b7} "), "suffix was: {out}"); + } + + #[test] + fn etd_suffix_absent_when_safe() { + // 10% used, 4h remaining of a 5h window → safe. + let s = section(10.0, Duration::from_secs(4 * 3600)); + assert_eq!(etd_suffix(&s, 5 * 3600, LanguageId::English.strings()), None); + } + + #[test] + fn etd_suffix_absent_without_reset() { + let s = UsageSection { percentage: 60.0, resets_at: None }; + assert_eq!(etd_suffix(&s, 2 * 3600, LanguageId::English.strings()), None); + } + + #[test] + fn etd_none_when_on_safe_pace() { + // 10% used, 1h elapsed of a 5h window (4h remaining): steady pace at 1h + // is 20%, so 10% is UNDER pace → would not deplete before reset. + // (50% in the first hour would be at-risk, not safe — see the invariant.) + assert_eq!(etd_secs(10.0, 4 * 3600, 5 * 3600), None); + } + + #[test] + fn etd_some_when_at_risk() { + // 60% used, 1h elapsed of a 2h window (1h remaining). + // Remaining 40% at 60%/h needs 40 min < 60 min remaining → at risk. + assert_eq!(etd_secs(60.0, 3600, 2 * 3600), Some(2400)); + } + + #[test] + fn etd_none_at_boundaries() { + assert_eq!(etd_secs(0.0, 3600, 5 * 3600), None); // nothing used + assert_eq!(etd_secs(100.0, 3600, 5 * 3600), None); // already full + assert_eq!(etd_secs(50.0, 5 * 3600, 5 * 3600), None); // elapsed = 0 + assert_eq!(etd_secs(50.0, 0, 5 * 3600), None); // no remaining + assert_eq!(etd_secs(50.0, 3600, 0), None); // no window + } + + #[test] + fn etd_invariant_matches_at_risk_rule() { + // etd_secs is Some iff burn rate exceeds steady pace: + // actual_pct > 100 * elapsed / window. + // Skip a small band around the exact boundary to avoid float flakiness. + let window = 5 * 3600u64; + for remaining in (0..=window).step_by(600) { + let elapsed = window - remaining; + for pct_x10 in 1..1000u64 { + let actual = pct_x10 as f64 / 10.0; + if elapsed == 0 || remaining == 0 { + assert_eq!(etd_secs(actual, remaining, window), None); + continue; + } + let boundary = 100.0 * elapsed as f64 / window as f64; + if (actual - boundary).abs() < 0.05 { + continue; // razor's edge — covered by explicit boundary test + } + let at_risk = actual > boundary; + assert_eq!( + etd_secs(actual, remaining, window).is_some(), + at_risk, + "actual={actual} remaining={remaining} window={window}" + ); + } + } + } +} diff --git a/src/window.rs b/src/window.rs index f6d261e..7e0d0c9 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicU32, Ordering}; use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -91,6 +91,7 @@ struct AppState { drag_start_offset: i32, widget_visible: bool, + show_etd: bool, } #[derive(Clone, Debug)] @@ -129,6 +130,7 @@ const IDM_LANG_TRADITIONAL_CHINESE: u16 = 48; const IDM_LANG_RUSSIAN: u16 = 49; const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; +const IDM_SHOW_ETD: u16 = 74; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; @@ -310,6 +312,8 @@ struct SettingsFile { last_update_check_unix: Option, #[serde(default = "default_widget_visible")] widget_visible: bool, + #[serde(default)] + show_etd: bool, #[serde(default = "default_show_claude_code")] show_claude_code: bool, #[serde(default = "default_show_codex")] @@ -327,6 +331,7 @@ impl Default for SettingsFile { language: None, last_update_check_unix: None, widget_visible: true, + show_etd: false, show_claude_code: true, show_codex: false, show_antigravity: false, @@ -388,6 +393,7 @@ fn save_state_settings() { .map(|language| language.code().to_string()), last_update_check_unix: s.last_update_check_unix, widget_visible: s.widget_visible, + show_etd: s.show_etd, show_claude_code: s.show_claude_code, show_codex: s.show_codex, show_antigravity: s.show_antigravity, @@ -647,6 +653,18 @@ 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); + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings) + { + state.session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&claude_code.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.weekly_text.push_str(&s); + } + } } else if state.show_claude_code { state.session_text = "!".to_string(); state.weekly_text = "!".to_string(); @@ -655,6 +673,18 @@ fn refresh_usage_texts(state: &mut AppState) { 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); + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&codex.session, poller::SESSION_WINDOW_SECS, strings) + { + state.codex_session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&codex.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.codex_weekly_text.push_str(&s); + } + } } else if state.show_codex { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); @@ -668,10 +698,24 @@ fn refresh_usage_texts(state: &mut AppState) { } else { poller::format_line(&antigravity.weekly, strings) }; + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&antigravity.session, poller::SESSION_WINDOW_SECS, strings) + { + state.antigravity_session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&antigravity.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.antigravity_weekly_text.push_str(&s); + } + } } else if state.show_antigravity { state.antigravity_session_text = "!".to_string(); state.antigravity_weekly_text = "!".to_string(); } + + update_measured_text_width(state); } fn set_window_title(hwnd: HWND, strings: Strings) { @@ -1083,6 +1127,76 @@ fn active_model_count(show_claude_code: bool, show_codex: bool, show_antigravity (show_claude_code as i32 + show_codex as i32 + show_antigravity as i32).max(1) } +/// Small trailing padding (device px, unscaled) added after measured text. +const TEXT_MEASURE_PAD: i32 = 6; + +/// Width (device px, already DPI-scaled) of the widest usage-cell text actually +/// shown, recomputed whenever the texts change. The cell column sizes to real +/// content (detailed time / ETD suffix only when present) instead of a fixed +/// worst-case reservation. Falls back to the base column before first measure. +static MEASURED_TEXT_WIDTH: AtomicI32 = AtomicI32::new(0); + +fn current_text_width() -> i32 { + MEASURED_TEXT_WIDTH.load(Ordering::Relaxed).max(sc(TEXT_WIDTH)) +} + +/// Measure a string's pixel width in the same font the widget renders with. +fn measure_text_px(text: &str) -> i32 { + if text.is_empty() { + return 0; + } + unsafe { + let hdc = GetDC(HWND::default()); + let mem = CreateCompatibleDC(hdc); + let font_name = native_interop::wide_str("Segoe UI"); + let font = CreateFontW( + sc(-12), + 0, + 0, + 0, + FW_MEDIUM.0 as i32, + 0, + 0, + 0, + DEFAULT_CHARSET.0 as u32, + OUT_TT_PRECIS.0 as u32, + CLIP_DEFAULT_PRECIS.0 as u32, + CLEARTYPE_QUALITY.0 as u32, + (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, + PCWSTR::from_raw(font_name.as_ptr()), + ); + let old = SelectObject(mem, font); + let wide: Vec = text.encode_utf16().collect(); + let mut size = SIZE::default(); + let _ = GetTextExtentPoint32W(mem, &wide, &mut size); + SelectObject(mem, old); + let _ = DeleteObject(font); + let _ = DeleteDC(mem); + ReleaseDC(HWND::default(), hdc); + size.cx + } +} + +/// Recompute the measured cell-text width from the currently-visible texts. +fn update_measured_text_width(state: &AppState) { + let mut max_w = 0; + if state.show_claude_code { + max_w = max_w.max(measure_text_px(&state.session_text)); + max_w = max_w.max(measure_text_px(&state.weekly_text)); + } + if state.show_codex { + max_w = max_w.max(measure_text_px(&state.codex_session_text)); + max_w = max_w.max(measure_text_px(&state.codex_weekly_text)); + } + if state.show_antigravity { + max_w = max_w.max(measure_text_px(&state.antigravity_session_text)); + max_w = max_w.max(measure_text_px(&state.antigravity_weekly_text)); + } + if max_w > 0 { + MEASURED_TEXT_WIDTH.store(max_w + sc(TEXT_MEASURE_PAD), Ordering::Relaxed); + } +} + fn row_bar_segment_count(active_models: i32) -> i32 { match active_models { 1 => SEGMENT_COUNT, @@ -1095,7 +1209,7 @@ fn total_widget_width_for(active_models: i32) -> i32 { let bar_segments = row_bar_segment_count(active_models); let model_width = (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * bar_segments - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(TEXT_WIDTH); + + current_text_width(); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -1125,6 +1239,13 @@ fn total_widget_width() -> i32 { total_widget_width_for(active_models) } +/// Whether the ETD suffix is enabled, read from shared state. Returns false +/// when state is not yet populated (startup) or the lock cannot be acquired. +/// Callers must not hold the state lock. +fn show_etd_enabled() -> bool { + lock_state().as_ref().map_or(false, |s| s.show_etd) +} + fn claude_accent_color() -> Color { Color::from_hex("#D97757") } @@ -1327,6 +1448,7 @@ pub fn run() { drag_start_client_x: 0, drag_start_offset: 0, widget_visible: settings.widget_visible, + show_etd: settings.show_etd, }); } @@ -2633,6 +2755,18 @@ unsafe extern "system" fn wnd_proc( do_poll(sh); }); } + IDM_SHOW_ETD => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.show_etd = !s.show_etd; + refresh_usage_texts(s); + } + } + save_state_settings(); + position_at_taskbar(); + render_layered(); + } IDM_LANG_SYSTEM | IDM_LANG_ENGLISH | IDM_LANG_DUTCH @@ -2908,6 +3042,19 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(language_label.as_ptr()), ); + let etd_str = native_interop::wide_str(strings.show_etd); + let etd_flags = if show_etd_enabled() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + settings_menu, + etd_flags, + IDM_SHOW_ETD as usize, + PCWSTR::from_raw(etd_str.as_ptr()), + ); + let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); let version_label = @@ -3188,7 +3335,7 @@ fn draw_row( fn model_usage_width(segment_count: i32) -> i32 { (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * segment_count - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(TEXT_WIDTH) + + current_text_width() } fn draw_usage_bar( @@ -3261,7 +3408,7 @@ fn draw_usage_bar( let mut text_rect = RECT { left: text_x, top: y, - right: text_x + sc(TEXT_WIDTH), + right: text_x + current_text_width(), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));