diff --git a/README.md b/README.md index 08c8a28..eb20097 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Once running, it will appear in your taskbar and as one or more tray icons in th - 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 `Start with Windows` from the right-click menu if you want it to launch automatically when you sign in +- Enable `Show detailed remaining time` under right-click `Settings` to add minutes alongside hours (5h window) and hours alongside days (7d window) ### Models diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 0eb486d..be4f3bd 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -16,6 +16,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Instellingen", start_with_windows: "Opstarten met Windows", reset_position: "Positie herstellen", + show_detailed_remaining: "Gedetailleerde resterende tijd tonen", language: "Taal", system_default: "Systeemstandaard", check_for_updates: "Controleren op updates", diff --git a/src/localization/english.rs b/src/localization/english.rs index 2b92f36..32c81a9 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -16,6 +16,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Settings", start_with_windows: "Start with Windows", reset_position: "Reset Position", + show_detailed_remaining: "Show detailed remaining time", language: "Language", system_default: "System Default", check_for_updates: "Check for Updates", diff --git a/src/localization/french.rs b/src/localization/french.rs index fa448fb..195b1a3 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -16,6 +16,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Paramètres", start_with_windows: "Démarrer avec Windows", reset_position: "Réinitialiser la position", + show_detailed_remaining: "Afficher le temps restant détaillé", language: "Langue", system_default: "Par défaut du système", check_for_updates: "Vérifier les mises à jour", diff --git a/src/localization/german.rs b/src/localization/german.rs index 5c7bb23..d86dc2c 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -16,6 +16,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Einstellungen", start_with_windows: "Mit Windows starten", reset_position: "Position zurücksetzen", + show_detailed_remaining: "Detaillierte Restzeit anzeigen", language: "Sprache", system_default: "Systemstandard", check_for_updates: "Nach Updates suchen", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 0020018..3b7b269 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -16,6 +16,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "設定", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", + show_detailed_remaining: "残り時間を詳細表示", language: "言語", system_default: "システム既定", check_for_updates: "更新を確認", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 59e3829..0fc9bb0 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -16,6 +16,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "설정", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", + show_detailed_remaining: "남은 시간 상세 표시", language: "언어", system_default: "시스템 기본값", check_for_updates: "업데이트 확인", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 146b419..27d5cec 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -134,6 +134,7 @@ pub struct Strings { pub settings: &'static str, pub start_with_windows: &'static str, pub reset_position: &'static str, + pub show_detailed_remaining: &'static str, pub language: &'static str, pub system_default: &'static str, pub check_for_updates: &'static str, diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 8e6513e..0538516 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -16,6 +16,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Configuración", start_with_windows: "Iniciar con Windows", reset_position: "Restablecer posición", + show_detailed_remaining: "Mostrar tiempo restante detallado", language: "Idioma", system_default: "Predeterminado del sistema", check_for_updates: "Buscar actualizaciones", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 809ebba..4f74537 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -16,6 +16,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "設定", start_with_windows: "開機時啟動", reset_position: "重置位置", + show_detailed_remaining: "顯示詳細剩餘時間", language: "語言", system_default: "系統預設", check_for_updates: "檢查更新", diff --git a/src/poller.rs b/src/poller.rs index 5bd02bc..d379722 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1021,9 +1021,9 @@ fn is_leap(y: u64) -> bool { } /// Format a usage section as "X% · Yh" style text -pub fn format_line(section: &UsageSection, strings: Strings) -> String { +pub fn format_line(section: &UsageSection, strings: Strings, detailed: bool) -> String { let pct = format!("{:.0}%", section.percentage); - let cd = format_countdown(section.resets_at, strings); + let cd = format_countdown(section.resets_at, strings, detailed); if cd.is_empty() { pct } else { @@ -1031,7 +1031,7 @@ pub fn format_line(section: &UsageSection, strings: Strings) -> String { } } -fn format_countdown(resets_at: Option, strings: Strings) -> String { +fn format_countdown(resets_at: Option, strings: Strings, detailed: bool) -> String { let reset = match resets_at { Some(t) => t, None => return String::new(), @@ -1042,25 +1042,47 @@ fn format_countdown(resets_at: Option, strings: Strings) -> String { Err(_) => return strings.now.to_string(), }; - format_countdown_from_secs(remaining.as_secs(), strings) + format_countdown_from_secs(remaining.as_secs(), strings, detailed) } /// Calculate how long until the display text would change -pub fn time_until_display_change(resets_at: Option) -> Option { +pub fn time_until_display_change( + resets_at: Option, + detailed: bool, +) -> Option { let reset = resets_at?; let remaining = reset.duration_since(SystemTime::now()).ok()?; - Some(time_until_display_change_from_secs(remaining.as_secs())) + Some(time_until_display_change_from_secs( + remaining.as_secs(), + detailed, + )) } -fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String { +fn format_countdown_from_secs(total_secs: u64, strings: Strings, detailed: bool) -> String { 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 { @@ -1068,15 +1090,23 @@ fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String { } } -fn time_until_display_change_from_secs(total_secs: u64) -> Duration { +fn time_until_display_change_from_secs(total_secs: u64, detailed: bool) -> Duration { 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 31955f5..9a7ee5f 100644 --- a/src/window.rs +++ b/src/window.rs @@ -65,6 +65,7 @@ struct AppState { codex_weekly_text: String, show_claude_code: bool, show_codex: bool, + show_detailed_remaining: bool, data: Option, @@ -121,6 +122,7 @@ const IDM_LANG_KOREAN: u16 = 47; const IDM_LANG_TRADITIONAL_CHINESE: u16 = 48; 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 @@ -213,6 +215,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 { @@ -225,6 +229,7 @@ impl Default for SettingsFile { widget_visible: true, show_claude_code: true, show_codex: false, + show_detailed_remaining: false, } } } @@ -280,6 +285,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, }); } } @@ -413,21 +419,22 @@ fn refresh_usage_texts(state: &mut AppState) { } let strings = state.language.strings(); + let detailed = state.show_detailed_remaining; let Some(data) = state.data.as_ref() else { return; }; 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); + state.session_text = poller::format_line(&claude_code.session, strings, detailed); + state.weekly_text = poller::format_line(&claude_code.weekly, strings, detailed); } else if state.show_claude_code { state.session_text = "!".to_string(); state.weekly_text = "!".to_string(); } 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); + state.codex_session_text = poller::format_line(&codex.session, strings, detailed); + state.codex_weekly_text = poller::format_line(&codex.weekly, strings, detailed); } else if state.show_codex { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); @@ -816,6 +823,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; @@ -824,6 +832,14 @@ fn active_model_count(show_claude_code: bool, show_codex: bool) -> i32 { (show_claude_code as i32 + show_codex as i32).max(1) } +fn text_width_for(detailed: bool) -> i32 { + if detailed { + TEXT_WIDTH_DETAILED + } else { + TEXT_WIDTH + } +} + fn row_bar_segment_count(active_models: i32) -> i32 { if active_models > 1 { 5 @@ -832,11 +848,11 @@ fn row_bar_segment_count(active_models: i32) -> i32 { } } -fn total_widget_width_for(active_models: i32) -> i32 { +fn total_widget_width_for(active_models: i32, detailed: bool) -> 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(text_width_for(detailed)); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -848,18 +864,26 @@ fn total_widget_width_for(active_models: i32) -> i32 { } fn total_widget_width_for_state(state: &AppState) -> i32 { - total_widget_width_for(active_model_count(state.show_claude_code, state.show_codex)) + total_widget_width_for( + active_model_count(state.show_claude_code, state.show_codex), + state.show_detailed_remaining, + ) } fn total_widget_width() -> i32 { - let active_models = { + let (active_models, detailed) = { let state = lock_state(); state .as_ref() - .map(|s| active_model_count(s.show_claude_code, s.show_codex)) - .unwrap_or(1) + .map(|s| { + ( + active_model_count(s.show_claude_code, s.show_codex), + s.show_detailed_remaining, + ) + }) + .unwrap_or((1, false)) }; - total_widget_width_for(active_models) + total_widget_width_for(active_models, detailed) } fn claude_accent_color() -> Color { @@ -960,7 +984,7 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width_for(initial_model_count), + total_widget_width_for(initial_model_count, settings.show_detailed_remaining), sc(WIDGET_HEIGHT), HWND::default(), HMENU::default(), @@ -1013,6 +1037,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, @@ -1152,6 +1177,7 @@ fn render_layered() { codex_weekly_text, show_claude_code, show_codex, + show_detailed_remaining, ) = { let state = lock_state(); match state.as_ref() { @@ -1170,6 +1196,7 @@ fn render_layered() { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, + s.show_detailed_remaining, ), None => return, } @@ -1260,6 +1287,7 @@ fn render_layered() { show_claude_code, show_codex, &codex_accent, + show_detailed_remaining, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1330,6 +1358,7 @@ fn paint_content( show_claude_code: bool, show_codex: bool, codex_accent: &Color, + detailed: bool, ) { unsafe { let client_rect = RECT { @@ -1422,6 +1451,7 @@ fn paint_content( accent, codex_accent, track, + detailed, ); draw_row( hdc, @@ -1439,6 +1469,7 @@ fn paint_content( accent, codex_accent, track, + detailed, ); SelectObject(hdc, old_font); @@ -1629,19 +1660,20 @@ fn schedule_countdown_timer() { } } + let detailed = s.show_detailed_remaining; let delays = [ data.claude_code .as_ref() - .and_then(|usage| poller::time_until_display_change(usage.session.resets_at)), + .and_then(|usage| poller::time_until_display_change(usage.session.resets_at, detailed)), data.claude_code .as_ref() - .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), + .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at, detailed)), data.codex .as_ref() - .and_then(|usage| poller::time_until_display_change(usage.session.resets_at)), + .and_then(|usage| poller::time_until_display_change(usage.session.resets_at, detailed)), data.codex .as_ref() - .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), + .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at, detailed)), ]; let min_delay = delays.into_iter().flatten().min(); @@ -2218,6 +2250,19 @@ 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; + 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(); @@ -2327,6 +2372,7 @@ fn show_context_menu(hwnd: HWND) { widget_visible, show_claude_code, show_codex, + show_detailed_remaining, ) = { let state = lock_state(); match state.as_ref() { @@ -2340,6 +2386,7 @@ fn show_context_menu(hwnd: HWND) { s.widget_visible, s.show_claude_code, s.show_codex, + s.show_detailed_remaining, ), None => ( POLL_15_MIN, @@ -2351,6 +2398,7 @@ fn show_context_menu(hwnd: HWND) { true, true, false, + false, ), } }; @@ -2456,6 +2504,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 { + 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() { @@ -2577,6 +2638,7 @@ fn paint(hdc: HDC, hwnd: HWND) { codex_weekly_text, show_claude_code, show_codex, + show_detailed_remaining, ) = { let state = lock_state(); match state.as_ref() { @@ -2593,6 +2655,7 @@ fn paint(hdc: HDC, hwnd: HWND) { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, + s.show_detailed_remaining, ), None => return, } @@ -2651,6 +2714,7 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, &codex_accent, + show_detailed_remaining, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -2677,6 +2741,7 @@ fn draw_row( claude_accent: &Color, codex_accent: &Color, track: &Color, + detailed: bool, ) { let seg_h = sc(SEGMENT_H); let active_models = active_model_count(show_claude_code, show_codex); @@ -2721,8 +2786,9 @@ fn draw_row( claude_accent, track, &claude_value_color, + detailed, ); - model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); + model_x += model_usage_width(segment_count, detailed) + sc(MODEL_RIGHT_MARGIN); } if show_codex { draw_usage_bar( @@ -2735,15 +2801,16 @@ fn draw_row( codex_accent, track, &codex_value_color, + detailed, ); } } } -fn model_usage_width(segment_count: i32) -> i32 { +fn model_usage_width(segment_count: i32, detailed: bool) -> i32 { (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * segment_count - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(TEXT_WIDTH) + + sc(text_width_for(detailed)) } fn draw_usage_bar( @@ -2756,6 +2823,7 @@ fn draw_usage_bar( accent: &Color, track: &Color, text_color: &Color, + detailed: bool, ) { let seg_w = sc(SEGMENT_W); let seg_h = sc(SEGMENT_H); @@ -2816,7 +2884,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(text_width_for(detailed)), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));