From e1c91c3483c8be11815f7ffbfa767cbe8d2ce4d2 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 26 Jun 2026 10:14:45 +0800 Subject: [PATCH] feat: detailed remaining time on v1.4.8 --- .github/screenshots/detailed-time-off.png | Bin 0 -> 5193 bytes .github/screenshots/detailed-time-on.png | Bin 0 -> 6069 bytes README.md | 1 + src/localization/dutch.rs | 1 + src/localization/english.rs | 1 + src/localization/french.rs | 1 + src/localization/german.rs | 1 + src/localization/japanese.rs | 1 + src/localization/korean.rs | 1 + src/localization/mod.rs | 1 + src/localization/portuguese_brazil.rs | 1 + src/localization/russian.rs | 1 + src/localization/spanish.rs | 1 + src/localization/traditional_chinese.rs | 1 + src/poller.rs | 50 ++++++++- src/window.rs | 127 +++++++++++++++++++++- 16 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 .github/screenshots/detailed-time-off.png create mode 100644 .github/screenshots/detailed-time-on.png diff --git a/.github/screenshots/detailed-time-off.png b/.github/screenshots/detailed-time-off.png new file mode 100644 index 0000000000000000000000000000000000000000..35cc3ef7477def45bfb2bb5577448347d5d2a5ed GIT binary patch literal 5193 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 WOT|Bh$* bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 } +/// Detailed remaining-time flag, mirrored from window state. Read lock-free +/// here so the countdown formatters (which run while the window state lock is +/// held) never re-lock shared state. Kept at base signatures so other features +/// that call these formatters stay source-compatible. +static DETAILED_REMAINING: AtomicBool = AtomicBool::new(false); + +/// Update the detailed remaining-time flag the formatters read. +pub fn set_detailed_remaining(enabled: bool) { + DETAILED_REMAINING.store(enabled, Ordering::Relaxed); +} + +fn detailed_remaining_enabled() -> bool { + DETAILED_REMAINING.load(Ordering::Relaxed) +} + /// Format a usage section as "X% · Yh" style text pub fn format_line(section: &UsageSection, strings: Strings) -> String { let pct = format!("{:.0}%", section.percentage); @@ -1554,14 +1570,31 @@ pub fn time_until_display_change(resets_at: Option) -> Option String { + let detailed = detailed_remaining_enabled(); let total_mins = total_secs / 60; let total_hours = total_secs / 3600; let total_days = total_secs / 86400; if total_days >= 1 { - format!("{total_days}{}", strings.day_suffix) + if detailed { + let hours = total_hours % 24; + format!( + "{total_days}{} {hours}{}", + strings.day_suffix, strings.hour_suffix + ) + } else { + format!("{total_days}{}", strings.day_suffix) + } } else if total_hours >= 1 { - format!("{total_hours}{}", strings.hour_suffix) + if detailed { + let mins = total_mins % 60; + format!( + "{total_hours}{} {mins}{}", + strings.hour_suffix, strings.minute_suffix + ) + } else { + format!("{total_hours}{}", strings.hour_suffix) + } } else if total_mins >= 1 { format!("{total_mins}{}", strings.minute_suffix) } else { @@ -1570,14 +1603,23 @@ fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String { } fn time_until_display_change_from_secs(total_secs: u64) -> Duration { + let detailed = detailed_remaining_enabled(); let total_mins = total_secs / 60; let total_hours = total_secs / 3600; let total_days = total_secs / 86400; let current_bucket_start = if total_days >= 1 { - total_days * 86400 + if detailed { + total_hours * 3600 + } else { + total_days * 86400 + } } else if total_hours >= 1 { - total_hours * 3600 + if detailed { + total_mins * 60 + } else { + total_hours * 3600 + } } else if total_mins >= 1 { total_mins * 60 } else { diff --git a/src/window.rs b/src/window.rs index f6d261e..1d50ad6 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}; @@ -70,6 +70,7 @@ struct AppState { show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_detailed_remaining: bool, data: Option, @@ -131,6 +132,7 @@ 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_SHOW_DETAILED_REMAINING: u16 = 70; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -316,6 +318,8 @@ struct SettingsFile { show_codex: bool, #[serde(default = "default_show_antigravity")] show_antigravity: bool, + #[serde(default)] + show_detailed_remaining: bool, } impl Default for SettingsFile { @@ -330,6 +334,7 @@ impl Default for SettingsFile { show_claude_code: true, show_codex: false, show_antigravity: false, + show_detailed_remaining: false, } } } @@ -391,6 +396,7 @@ fn save_state_settings() { show_claude_code: s.show_claude_code, show_codex: s.show_codex, show_antigravity: s.show_antigravity, + show_detailed_remaining: s.show_detailed_remaining, }); } } @@ -672,6 +678,8 @@ fn refresh_usage_texts(state: &mut AppState) { 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 +1091,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, @@ -1091,11 +1169,20 @@ fn row_bar_segment_count(active_models: i32) -> i32 { } } +/// Whether the detailed remaining-time display 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_detailed_remaining_enabled() -> bool { + lock_state() + .as_ref() + .map_or(false, |s| s.show_detailed_remaining) +} + 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) @@ -1310,6 +1397,7 @@ pub fn run() { show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, show_antigravity: settings.show_antigravity, + show_detailed_remaining: settings.show_detailed_remaining, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1330,6 +1418,10 @@ pub fn run() { }); } + // Mirror the detailed-remaining flag into the poller, which reads it + // lock-free while formatting countdowns (avoids re-locking shared state). + poller::set_detailed_remaining(settings.show_detailed_remaining); + // Try to embed in taskbar if attach_to_taskbar(hwnd, settings.taskbar_index) { embedded = true; @@ -2594,6 +2686,20 @@ unsafe extern "system" fn wnd_proc( // Reset the poll timer with the new interval SetTimer(hwnd, TIMER_POLL, new_interval, None); } + IDM_SHOW_DETAILED_REMAINING => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.show_detailed_remaining = !s.show_detailed_remaining; + poller::set_detailed_remaining(s.show_detailed_remaining); + refresh_usage_texts(s); + } + } + save_state_settings(); + position_at_taskbar(); + render_layered(); + schedule_countdown_timer(); + } IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX | IDM_MODEL_ANTIGRAVITY => { { let mut state = lock_state(); @@ -2859,6 +2965,19 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + let detailed_str = native_interop::wide_str(strings.show_detailed_remaining); + let detailed_flags = if show_detailed_remaining_enabled() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + settings_menu, + detailed_flags, + IDM_SHOW_DETAILED_REMAINING as usize, + PCWSTR::from_raw(detailed_str.as_ptr()), + ); + let language_menu = CreatePopupMenu().unwrap(); let system_label = native_interop::wide_str(strings.system_default); let system_flags = if language_override.is_none() { @@ -3188,7 +3307,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 +3380,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()));