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 08c8a28..be1cc27 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,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 - 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 0eb486d..bbdc849 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -30,6 +30,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 2b92f36..848dd7e 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -30,6 +30,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 fa448fb..988067f 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -30,6 +30,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 5c7bb23..f714d83 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -30,6 +30,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 0020018..e40cd57 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -30,6 +30,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 59e3829..61a5ace 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -30,6 +30,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 39ee702..b660917 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -164,6 +164,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 691ee77..9df7f3e 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -30,6 +30,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 786d0fa..816d53d 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -30,6 +30,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 8e6513e..f43d49a 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -30,6 +30,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 809ebba..fd94ddd 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -30,6 +30,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 5bd02bc..75e32af 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Deserialize; @@ -1020,6 +1021,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); @@ -1053,14 +1069,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 { @@ -1069,14 +1102,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 7ce1dea..30eec8e 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}; @@ -65,6 +65,7 @@ struct AppState { codex_weekly_text: String, show_claude_code: bool, show_codex: bool, + show_detailed_remaining: bool, data: Option, @@ -123,6 +124,7 @@ const IDM_LANG_RUSSIAN: u16 = 49; const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; +const IDM_SHOW_DETAILED_REMAINING: u16 = 70; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN @@ -215,6 +217,8 @@ struct SettingsFile { show_claude_code: bool, #[serde(default = "default_show_codex")] show_codex: bool, + #[serde(default)] + show_detailed_remaining: bool, } impl Default for SettingsFile { @@ -227,6 +231,7 @@ impl Default for SettingsFile { widget_visible: true, show_claude_code: true, show_codex: false, + show_detailed_remaining: false, } } } @@ -282,6 +287,7 @@ fn save_state_settings() { widget_visible: s.widget_visible, show_claude_code: s.show_claude_code, show_codex: s.show_codex, + show_detailed_remaining: s.show_detailed_remaining, }); } } @@ -818,6 +824,7 @@ const LABEL_WIDTH: i32 = 18; const LABEL_RIGHT_MARGIN: i32 = 10; const BAR_RIGHT_MARGIN: i32 = 4; const TEXT_WIDTH: i32 = 62; +const TEXT_WIDTH_DETAILED: i32 = 95; const MODEL_RIGHT_MARGIN: i32 = 5; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; @@ -826,6 +833,33 @@ fn active_model_count(show_claude_code: bool, show_codex: bool) -> i32 { (show_claude_code as i32 + show_codex as i32).max(1) } +// Optional display features may widen the usage text column. Each feature +// stores its own extra width (in base 96-DPI px) in a dedicated slot, read +// lock-free by the width/layout helpers below. Slots are summed so multiple +// features stack. This block is identical across the display-feature branches +// so they merge without conflict; a branch only wires up its own slot. +#[allow(dead_code)] +static EXTRA_TEXT_WIDTH_DETAILED: AtomicI32 = AtomicI32::new(0); +#[allow(dead_code)] +static EXTRA_TEXT_WIDTH_ETD: AtomicI32 = AtomicI32::new(0); + +#[allow(dead_code)] +fn set_extra_text_width_detailed(px: i32) { + EXTRA_TEXT_WIDTH_DETAILED.store(px, Ordering::Relaxed); +} + +#[allow(dead_code)] +fn set_extra_text_width_etd(px: i32) { + EXTRA_TEXT_WIDTH_ETD.store(px, Ordering::Relaxed); +} + +/// Usage text-column width including any enabled display-feature widening. +fn effective_text_width() -> i32 { + TEXT_WIDTH + + EXTRA_TEXT_WIDTH_DETAILED.load(Ordering::Relaxed) + + EXTRA_TEXT_WIDTH_ETD.load(Ordering::Relaxed) +} + fn row_bar_segment_count(active_models: i32) -> i32 { if active_models > 1 { 5 @@ -834,11 +868,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); + + sc(effective_text_width()); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -849,10 +892,6 @@ fn total_widget_width_for(active_models: i32) -> i32 { + sc(RIGHT_MARGIN) } -fn total_widget_width_for_state(state: &AppState) -> i32 { - total_widget_width_for(active_model_count(state.show_claude_code, state.show_codex)) -} - fn total_widget_width() -> i32 { let active_models = { let state = lock_state(); @@ -1015,6 +1054,7 @@ pub fn run() { codex_weekly_text: "--".to_string(), show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, + show_detailed_remaining: settings.show_detailed_remaining, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1033,6 +1073,15 @@ 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); + set_extra_text_width_detailed(if settings.show_detailed_remaining { + TEXT_WIDTH_DETAILED - TEXT_WIDTH + } else { + 0 + }); + // Try to embed in taskbar if let Some(taskbar_hwnd) = native_interop::find_taskbar() { diagnose::log(format!("taskbar found hwnd={:?}", taskbar_hwnd)); @@ -2014,6 +2063,9 @@ unsafe extern "system" fn wnd_proc( if is_dragging { let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); + // Compute the widget width before locking; the width helpers read + // shared state themselves and must not run while the lock is held. + let widget_width = total_widget_width(); let move_target = { let mut state = lock_state(); let s = match state.as_mut() { @@ -2047,7 +2099,6 @@ unsafe extern "system" fn wnd_proc( tray_left = tray_rect.left; } } - let widget_width = total_widget_width_for_state(s); let max_offset = (tray_left - taskbar_rect.left - widget_width).max(0); if new_offset > max_offset { new_offset = max_offset; @@ -2220,6 +2271,25 @@ 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); + set_extra_text_width_detailed(if s.show_detailed_remaining { + TEXT_WIDTH_DETAILED - TEXT_WIDTH + } else { + 0 + }); + refresh_usage_texts(s); + } + } + save_state_settings(); + position_at_taskbar(); + render_layered(); + schedule_countdown_timer(); + } IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX => { { let mut state = lock_state(); @@ -2462,6 +2532,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() { @@ -2751,7 +2834,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) + + sc(effective_text_width()) } fn draw_usage_bar( @@ -2824,7 +2907,7 @@ fn draw_usage_bar( let mut text_rect = RECT { left: text_x, top: y, - right: text_x + sc(TEXT_WIDTH), + right: text_x + sc(effective_text_width()), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));