diff --git a/.github/screenshots/detailed-time-off.png b/.github/screenshots/detailed-time-off.png new file mode 100644 index 0000000..35cc3ef Binary files /dev/null and b/.github/screenshots/detailed-time-off.png differ diff --git a/.github/screenshots/detailed-time-on.png b/.github/screenshots/detailed-time-on.png new file mode 100644 index 0000000..2df50e0 Binary files /dev/null and b/.github/screenshots/detailed-time-on.png differ diff --git a/README.md b/README.md index f8b3c2b..26f94bf 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Once running, it will appear in your taskbar and as one or more tray icons in th - 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 - Left-click the tray icon to toggle the taskbar widget on or off +- Enable `Show detailed remaining time` under right-click `Settings` to add minutes alongside hours (5h window) and hours alongside days (7d window) - Enable `Start with Windows` from the right-click menu if you want it to launch automatically when you sign in ### Models diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..8f3030a 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "Update beschikbaar", update_prompt_now: "Versie {version} is beschikbaar. Wil je nu bijwerken?", exit: "Afsluiten", + show_detailed_remaining: "Gedetailleerde resterende tijd tonen", show_widget: "Widget tonen", session_window: "5u", weekly_window: "7d", diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..5d64e26 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "Update available", update_prompt_now: "Version {version} is available. Do you want to update now?", exit: "Exit", + show_detailed_remaining: "Show detailed remaining time", show_widget: "Show Widget", session_window: "5h", weekly_window: "7d", diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..0c56071 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "Mise à jour disponible", update_prompt_now: "La version {version} est disponible. Voulez-vous mettre à jour maintenant ?", exit: "Quitter", + show_detailed_remaining: "Afficher le temps restant détaillé", show_widget: "Afficher le widget", session_window: "5h", weekly_window: "7d", diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..df4d76c 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "Update verfügbar", update_prompt_now: "Version {version} ist verfügbar. Möchten Sie jetzt aktualisieren?", exit: "Beenden", + show_detailed_remaining: "Detaillierte Restzeit anzeigen", show_widget: "Widget anzeigen", session_window: "5h", weekly_window: "7d", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..6b9c601 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "更新が利用可能です", update_prompt_now: "バージョン {version} が利用可能です。今すぐ更新しますか?", exit: "終了", + show_detailed_remaining: "残り時間を詳細表示", show_widget: "ウィジェットを表示", session_window: "5h", weekly_window: "7d", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..f39ef12 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "업데이트 사용 가능", update_prompt_now: "버전 {version}을 사용할 수 있습니다. 지금 업데이트하시겠습니까?", exit: "종료", + show_detailed_remaining: "남은 시간 상세 표시", show_widget: "위젯 표시", session_window: "5시간", weekly_window: "7일", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2a06b04..a37f34d 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -165,6 +165,7 @@ pub struct Strings { pub update_available: &'static str, pub update_prompt_now: &'static str, pub exit: &'static str, + pub show_detailed_remaining: &'static str, pub show_widget: &'static str, pub session_window: &'static str, pub weekly_window: &'static str, diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..0e50bf2 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "Atualização disponível", update_prompt_now: "Versão {version} está disponível. Deseja atualizar agora?", exit: "Sair", + show_detailed_remaining: "Mostrar tempo restante detalhado", show_widget: "Exibir Widget", session_window: "5h", weekly_window: "7d", diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..29a7b29 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "Доступно обновление", update_prompt_now: "Доступна версия {version}. Обновить сейчас?", exit: "Выход", + show_detailed_remaining: "Показывать подробное оставшееся время", show_widget: "Показать виджет", session_window: "5ч", weekly_window: "7д", diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..1a2e761 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "Actualización disponible", update_prompt_now: "La versión {version} está disponible. ¿Quieres actualizar ahora?", exit: "Salir", + show_detailed_remaining: "Mostrar tiempo restante detallado", show_widget: "Mostrar widget", session_window: "5h", weekly_window: "7d", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..059f031 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -31,6 +31,7 @@ pub(super) const STRINGS: Strings = Strings { update_available: "有可用更新", update_prompt_now: "版本 {version} 已可用。是否立即更新?", exit: "結束", + show_detailed_remaining: "顯示詳細剩餘時間", show_widget: "顯示小工具", session_window: "5h", weekly_window: "7d", diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..2a05085 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -4,6 +4,7 @@ use std::ffi::c_void; use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Deserialize; @@ -1521,6 +1522,21 @@ fn is_leap(y: u64) -> 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()));