From 63421c5e5897c305a18591c87ca31596150ab18b Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 26 Jun 2026 10:16:36 +0800 Subject: [PATCH] feat: ETD on v1.4.8 --- .github/screenshots/etd.png | Bin 0 -> 6757 bytes README.md | 8 ++ 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/poller.rs | 135 +++++++++++++++++++++ src/window.rs | 155 +++++++++++++++++++++++- 15 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 .github/screenshots/etd.png diff --git a/.github/screenshots/etd.png b/.github/screenshots/etd.png new file mode 100644 index 0000000000000000000000000000000000000000..e200092c7f4265a7d656db4dbb8727e1dc99a90e GIT binary patch literal 6757 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 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()));