From 71ddfe3ccfebf0e5b8aa87fad15c55657b97d598 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 15 May 2026 15:08:51 +0800 Subject: [PATCH 1/8] feat: add "Show detailed remaining time" localization Adds the localized label that the upcoming Settings toggle will use. --- 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/spanish.rs | 1 + src/localization/traditional_chinese.rs | 1 + 9 files changed, 9 insertions(+) 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 39ee702..89a77b0 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -150,6 +150,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: "檢查更新", From 8f8d5d072da4eaccd42540d590dbe30d0ba5ec17 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 15 May 2026 15:09:01 +0800 Subject: [PATCH 2/8] refactor: thread detailed flag through countdown formatter Adds a `detailed: bool` parameter to `format_line`, `format_countdown`, `format_countdown_from_secs`, `time_until_display_change`, and `time_until_display_change_from_secs`. When `true`, sub-units are surfaced (Xh Ym for hours, Xd Yh for days) and the display-change tick fires on minute/hour boundaries to match. All callers pass `false`, so behavior is unchanged. The toggle that flips this is added in a later commit. --- src/poller.rs | 54 +++++++++++++++++++++++++++++++++++++++------------ src/window.rs | 16 +++++++-------- 2 files changed, 50 insertions(+), 20 deletions(-) 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 7ce1dea..c9ab697 100644 --- a/src/window.rs +++ b/src/window.rs @@ -420,16 +420,16 @@ 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); + state.session_text = poller::format_line(&claude_code.session, strings, false); + state.weekly_text = poller::format_line(&claude_code.weekly, strings, false); } 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, false); + state.codex_weekly_text = poller::format_line(&codex.weekly, strings, false); } else if state.show_codex { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); @@ -1634,16 +1634,16 @@ fn schedule_countdown_timer() { 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, false)), 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, false)), 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, false)), 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, false)), ]; let min_delay = delays.into_iter().flatten().min(); From af31a5a58c3d4d8503ed945051da6dc2967a47e7 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 15 May 2026 15:10:54 +0800 Subject: [PATCH 3/8] refactor: thread detailed flag through widget width and rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `TEXT_WIDTH_DETAILED` (95 px, the narrowest value that fits the worst-case `100% · 23h 59m` weekly display) alongside the existing 62 px default, plus a `text_width_for(detailed)` helper. Threads a `detailed: bool` parameter through `total_widget_width_for`, `model_usage_width`, `draw_usage_bar`, `draw_row`, and `paint_content` so the widget can size and clip text appropriately. All callers pass `false`, so behavior is unchanged. The toggle that flips this is added in a later commit. --- src/window.rs | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/window.rs b/src/window.rs index c9ab697..95337fc 100644 --- a/src/window.rs +++ b/src/window.rs @@ -818,6 +818,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 +827,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 @@ -834,11 +843,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) @@ -850,7 +859,10 @@ 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), + false, + ) } fn total_widget_width() -> i32 { @@ -861,7 +873,7 @@ fn total_widget_width() -> i32 { .map(|s| active_model_count(s.show_claude_code, s.show_codex)) .unwrap_or(1) }; - total_widget_width_for(active_models) + total_widget_width_for(active_models, false) } fn claude_accent_color() -> Color { @@ -962,7 +974,7 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width_for(initial_model_count), + total_widget_width_for(initial_model_count, false), sc(WIDGET_HEIGHT), HWND::default(), HMENU::default(), @@ -1262,6 +1274,7 @@ fn render_layered() { show_claude_code, show_codex, &codex_accent, + false, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1332,6 +1345,7 @@ fn paint_content( show_claude_code: bool, show_codex: bool, codex_accent: &Color, + detailed: bool, ) { unsafe { let client_rect = RECT { @@ -1424,6 +1438,7 @@ fn paint_content( accent, codex_accent, track, + detailed, ); draw_row( hdc, @@ -1441,6 +1456,7 @@ fn paint_content( accent, codex_accent, track, + detailed, ); SelectObject(hdc, old_font); @@ -2659,6 +2675,7 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, &codex_accent, + false, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -2685,6 +2702,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); @@ -2729,8 +2747,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( @@ -2743,15 +2762,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( @@ -2764,6 +2784,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); @@ -2824,7 +2845,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())); From d7f7cca2b3658b0846a442a7e4756e39d81ab12b Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 15 May 2026 15:14:17 +0800 Subject: [PATCH 4/8] feat: add Settings toggle to show detailed remaining time Adds a new "Show detailed remaining time" entry under the right-click Settings submenu, off by default. When on, the 5h session window shows minutes alongside hours (e.g. "4h 12m") and the 7d weekly window shows hours alongside days (e.g. "3d 5h"). The widget grows to TEXT_WIDTH_DETAILED (95 px per row) while toggled, and the countdown timer repaints on the finer-unit boundary so the displayed value is never stale. The preference is persisted in settings.json as `show_detailed_remaining` and is loaded on startup. Existing settings.json files without the field deserialize to false. --- README.md | 1 + src/window.rs | 79 ++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 16 deletions(-) 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/window.rs b/src/window.rs index 95337fc..d122fd8 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, @@ -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, }); } } @@ -415,21 +421,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, false); - state.weekly_text = poller::format_line(&claude_code.weekly, strings, false); + 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, false); - state.codex_weekly_text = poller::format_line(&codex.weekly, strings, false); + 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(); @@ -861,19 +868,24 @@ fn total_widget_width_for(active_models: i32, detailed: bool) -> i32 { fn total_widget_width_for_state(state: &AppState) -> i32 { total_widget_width_for( active_model_count(state.show_claude_code, state.show_codex), - false, + 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, false) + total_widget_width_for(active_models, detailed) } fn claude_accent_color() -> Color { @@ -974,7 +986,7 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width_for(initial_model_count, false), + total_widget_width_for(initial_model_count, settings.show_detailed_remaining), sc(WIDGET_HEIGHT), HWND::default(), HMENU::default(), @@ -1027,6 +1039,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, @@ -1166,6 +1179,7 @@ fn render_layered() { codex_weekly_text, show_claude_code, show_codex, + show_detailed_remaining, ) = { let state = lock_state(); match state.as_ref() { @@ -1184,6 +1198,7 @@ fn render_layered() { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, + s.show_detailed_remaining, ), None => return, } @@ -1274,7 +1289,7 @@ fn render_layered() { show_claude_code, show_codex, &codex_accent, - false, + show_detailed_remaining, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1647,19 +1662,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, false)), + .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, false)), + .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, false)), + .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, false)), + .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at, detailed)), ]; let min_delay = delays.into_iter().flatten().min(); @@ -2236,6 +2252,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(); @@ -2349,6 +2378,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() { @@ -2362,6 +2392,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, @@ -2373,6 +2404,7 @@ fn show_context_menu(hwnd: HWND) { true, true, false, + false, ), } }; @@ -2478,6 +2510,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() { @@ -2601,6 +2646,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() { @@ -2617,6 +2663,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, } @@ -2675,7 +2722,7 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, &codex_accent, - false, + show_detailed_remaining, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); From 8f2c6eb7d2a8f273c37f3cd4161970b79fe96dc7 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Mon, 18 May 2026 12:20:37 +0800 Subject: [PATCH 5/8] docs: add detailed remaining time screenshots --- .github/screenshots/detailed-time-off.png | Bin 0 -> 5193 bytes .github/screenshots/detailed-time-on.png | Bin 0 -> 6069 bytes 2 files changed, 0 insertions(+), 0 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$* Date: Fri, 19 Jun 2026 19:23:44 +0800 Subject: [PATCH 6/8] feat: add detailed-remaining strings for Russian and Brazilian Portuguese --- src/localization/portuguese_brazil.rs | 1 + src/localization/russian.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 691ee77..631c6b5 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -43,4 +43,5 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Erro de Autenticação do Codex", codex_token_expired_body: "Execute 'codex' em um terminal e siga as instruções de login. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", + show_detailed_remaining: "Mostrar tempo restante detalhado", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index 786d0fa..7cfa3df 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -43,4 +43,5 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Ошибка авторизации Codex", codex_token_expired_body: "Запустите 'codex' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", + show_detailed_remaining: "Показывать подробное оставшееся время", }; \ No newline at end of file From 39a9166109bb017c39da46f871cffa450f7e8412 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 20:10:20 +0800 Subject: [PATCH 7/8] refactor: read display flags from state for merge compatibility Revert shared window.rs/poller.rs signatures to base. Detailed-remaining is read from a lock-free poller atomic (countdown formatting) and a shared window-level text-width slot (layout). The slot mechanism block is identical to the ETD branch's so any merge subset stays conflict-free; each branch wires only its own slot. Relocate the localization field to a distinct anchor (after exit). --- README.md | 2 +- src/localization/dutch.rs | 2 +- src/localization/english.rs | 2 +- src/localization/french.rs | 2 +- src/localization/german.rs | 2 +- src/localization/japanese.rs | 2 +- src/localization/korean.rs | 2 +- src/localization/mod.rs | 2 +- src/localization/portuguese_brazil.rs | 2 +- src/localization/russian.rs | 2 +- src/localization/spanish.rs | 2 +- src/localization/traditional_chinese.rs | 2 +- src/poller.rs | 40 +++++--- src/window.rs | 131 +++++++++++++----------- 14 files changed, 111 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index eb20097..be1cc27 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ 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 `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) +- 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 be4f3bd..bbdc849 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -16,7 +16,6 @@ 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", @@ -31,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 32c81a9..848dd7e 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -16,7 +16,6 @@ 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", @@ -31,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 195b1a3..988067f 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -16,7 +16,6 @@ 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", @@ -31,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 d86dc2c..f714d83 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -16,7 +16,6 @@ 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", @@ -31,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 3b7b269..e40cd57 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -16,7 +16,6 @@ pub(super) const STRINGS: Strings = Strings { settings: "設定", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", - show_detailed_remaining: "残り時間を詳細表示", language: "言語", system_default: "システム既定", check_for_updates: "更新を確認", @@ -31,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 0fc9bb0..61a5ace 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -16,7 +16,6 @@ pub(super) const STRINGS: Strings = Strings { settings: "설정", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", - show_detailed_remaining: "남은 시간 상세 표시", language: "언어", system_default: "시스템 기본값", check_for_updates: "업데이트 확인", @@ -31,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 89a77b0..b660917 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -150,7 +150,6 @@ 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, @@ -165,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 631c6b5..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", @@ -43,5 +44,4 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Erro de Autenticação do Codex", codex_token_expired_body: "Execute 'codex' em um terminal e siga as instruções de login. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", - show_detailed_remaining: "Mostrar tempo restante detalhado", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index 7cfa3df..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д", @@ -43,5 +44,4 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Ошибка авторизации Codex", codex_token_expired_body: "Запустите 'codex' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", - show_detailed_remaining: "Показывать подробное оставшееся время", }; \ No newline at end of file diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 0538516..f43d49a 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -16,7 +16,6 @@ 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", @@ -31,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 4f74537..fd94ddd 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -16,7 +16,6 @@ pub(super) const STRINGS: Strings = Strings { settings: "設定", start_with_windows: "開機時啟動", reset_position: "重置位置", - show_detailed_remaining: "顯示詳細剩餘時間", language: "語言", system_default: "系統預設", check_for_updates: "檢查更新", @@ -31,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 d379722..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,10 +1021,25 @@ 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, detailed: bool) -> String { +pub fn format_line(section: &UsageSection, strings: Strings) -> String { let pct = format!("{:.0}%", section.percentage); - let cd = format_countdown(section.resets_at, strings, detailed); + let cd = format_countdown(section.resets_at, strings); if cd.is_empty() { pct } else { @@ -1031,7 +1047,7 @@ pub fn format_line(section: &UsageSection, strings: Strings, detailed: bool) -> } } -fn format_countdown(resets_at: Option, strings: Strings, detailed: bool) -> String { +fn format_countdown(resets_at: Option, strings: Strings) -> String { let reset = match resets_at { Some(t) => t, None => return String::new(), @@ -1042,23 +1058,18 @@ fn format_countdown(resets_at: Option, strings: Strings, detailed: b Err(_) => return strings.now.to_string(), }; - format_countdown_from_secs(remaining.as_secs(), strings, detailed) + format_countdown_from_secs(remaining.as_secs(), strings) } /// Calculate how long until the display text would change -pub fn time_until_display_change( - resets_at: Option, - detailed: bool, -) -> Option { +pub fn time_until_display_change(resets_at: Option) -> Option { let reset = resets_at?; let remaining = reset.duration_since(SystemTime::now()).ok()?; - Some(time_until_display_change_from_secs( - remaining.as_secs(), - detailed, - )) + Some(time_until_display_change_from_secs(remaining.as_secs())) } -fn format_countdown_from_secs(total_secs: u64, strings: Strings, detailed: bool) -> String { +fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String { + let detailed = detailed_remaining_enabled(); let total_mins = total_secs / 60; let total_hours = total_secs / 3600; let total_days = total_secs / 86400; @@ -1090,7 +1101,8 @@ fn format_countdown_from_secs(total_secs: u64, strings: Strings, detailed: bool) } } -fn time_until_display_change_from_secs(total_secs: u64, detailed: bool) -> Duration { +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; diff --git a/src/window.rs b/src/window.rs index d122fd8..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}; @@ -421,22 +421,21 @@ 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, detailed); - state.weekly_text = poller::format_line(&claude_code.weekly, strings, detailed); + state.session_text = poller::format_line(&claude_code.session, strings); + state.weekly_text = poller::format_line(&claude_code.weekly, strings); } 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, detailed); - state.codex_weekly_text = poller::format_line(&codex.weekly, strings, detailed); + state.codex_session_text = poller::format_line(&codex.session, strings); + state.codex_weekly_text = poller::format_line(&codex.weekly, strings); } else if state.show_codex { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); @@ -834,12 +833,31 @@ 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 - } +// 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 { @@ -850,11 +868,20 @@ fn row_bar_segment_count(active_models: i32) -> i32 { } } -fn total_widget_width_for(active_models: i32, detailed: bool) -> 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_for(detailed)); + + sc(effective_text_width()); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -865,27 +892,15 @@ fn total_widget_width_for(active_models: i32, detailed: bool) -> 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), - state.show_detailed_remaining, - ) -} - fn total_widget_width() -> i32 { - let (active_models, detailed) = { + let active_models = { let state = lock_state(); state .as_ref() - .map(|s| { - ( - active_model_count(s.show_claude_code, s.show_codex), - s.show_detailed_remaining, - ) - }) - .unwrap_or((1, false)) + .map(|s| active_model_count(s.show_claude_code, s.show_codex)) + .unwrap_or(1) }; - total_widget_width_for(active_models, detailed) + total_widget_width_for(active_models) } fn claude_accent_color() -> Color { @@ -986,7 +1001,7 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width_for(initial_model_count, settings.show_detailed_remaining), + total_widget_width_for(initial_model_count), sc(WIDGET_HEIGHT), HWND::default(), HMENU::default(), @@ -1058,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)); @@ -1179,7 +1203,6 @@ fn render_layered() { codex_weekly_text, show_claude_code, show_codex, - show_detailed_remaining, ) = { let state = lock_state(); match state.as_ref() { @@ -1198,7 +1221,6 @@ fn render_layered() { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, - s.show_detailed_remaining, ), None => return, } @@ -1289,7 +1311,6 @@ 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). @@ -1360,7 +1381,6 @@ fn paint_content( show_claude_code: bool, show_codex: bool, codex_accent: &Color, - detailed: bool, ) { unsafe { let client_rect = RECT { @@ -1453,7 +1473,6 @@ fn paint_content( accent, codex_accent, track, - detailed, ); draw_row( hdc, @@ -1471,7 +1490,6 @@ fn paint_content( accent, codex_accent, track, - detailed, ); SelectObject(hdc, old_font); @@ -1662,20 +1680,19 @@ 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, detailed)), + .and_then(|usage| poller::time_until_display_change(usage.session.resets_at)), data.claude_code .as_ref() - .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at, detailed)), + .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), data.codex .as_ref() - .and_then(|usage| poller::time_until_display_change(usage.session.resets_at, detailed)), + .and_then(|usage| poller::time_until_display_change(usage.session.resets_at)), data.codex .as_ref() - .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at, detailed)), + .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), ]; let min_delay = delays.into_iter().flatten().min(); @@ -2046,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() { @@ -2079,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; @@ -2257,6 +2276,12 @@ unsafe extern "system" fn wnd_proc( 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); } } @@ -2378,7 +2403,6 @@ fn show_context_menu(hwnd: HWND) { widget_visible, show_claude_code, show_codex, - show_detailed_remaining, ) = { let state = lock_state(); match state.as_ref() { @@ -2392,7 +2416,6 @@ fn show_context_menu(hwnd: HWND) { s.widget_visible, s.show_claude_code, s.show_codex, - s.show_detailed_remaining, ), None => ( POLL_15_MIN, @@ -2404,7 +2427,6 @@ fn show_context_menu(hwnd: HWND) { true, true, false, - false, ), } }; @@ -2511,7 +2533,7 @@ fn show_context_menu(hwnd: HWND) { ); let detailed_str = native_interop::wide_str(strings.show_detailed_remaining); - let detailed_flags = if show_detailed_remaining { + let detailed_flags = if show_detailed_remaining_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) @@ -2646,7 +2668,6 @@ 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() { @@ -2663,7 +2684,6 @@ fn paint(hdc: HDC, hwnd: HWND) { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, - s.show_detailed_remaining, ), None => return, } @@ -2722,7 +2742,6 @@ 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); @@ -2749,7 +2768,6 @@ 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); @@ -2794,9 +2812,8 @@ fn draw_row( claude_accent, track, &claude_value_color, - detailed, ); - model_x += model_usage_width(segment_count, detailed) + sc(MODEL_RIGHT_MARGIN); + model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); } if show_codex { draw_usage_bar( @@ -2809,16 +2826,15 @@ fn draw_row( codex_accent, track, &codex_value_color, - detailed, ); } } } -fn model_usage_width(segment_count: i32, detailed: bool) -> i32 { +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_for(detailed)) + + sc(effective_text_width()) } fn draw_usage_bar( @@ -2831,7 +2847,6 @@ 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); @@ -2892,7 +2907,7 @@ fn draw_usage_bar( let mut text_rect = RECT { left: text_x, top: y, - right: text_x + sc(text_width_for(detailed)), + right: text_x + sc(effective_text_width()), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); From 9699b62f4dc0f1473b3bd618d034e60beeff7db1 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Mon, 22 Jun 2026 12:33:40 +0800 Subject: [PATCH 8/8] refactor: size usage-cell column to measured text width --- src/window.rs | 106 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 69 insertions(+), 37 deletions(-) diff --git a/src/window.rs b/src/window.rs index 30eec8e..8a080f3 100644 --- a/src/window.rs +++ b/src/window.rs @@ -440,6 +440,8 @@ fn refresh_usage_texts(state: &mut AppState) { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); } + + update_measured_text_width(state); } fn set_window_title(hwnd: HWND, strings: Strings) { @@ -824,7 +826,6 @@ 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; @@ -833,31 +834,70 @@ 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); +/// 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); -#[allow(dead_code)] -fn set_extra_text_width_detailed(px: i32) { - EXTRA_TEXT_WIDTH_DETAILED.store(px, Ordering::Relaxed); +fn current_text_width() -> i32 { + MEASURED_TEXT_WIDTH.load(Ordering::Relaxed).max(sc(TEXT_WIDTH)) } -#[allow(dead_code)] -fn set_extra_text_width_etd(px: i32) { - EXTRA_TEXT_WIDTH_ETD.store(px, Ordering::Relaxed); +/// 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 + } } -/// 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) +/// 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 max_w > 0 { + MEASURED_TEXT_WIDTH.store(max_w + sc(TEXT_MEASURE_PAD), Ordering::Relaxed); + } } fn row_bar_segment_count(active_models: i32) -> i32 { @@ -881,7 +921,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(effective_text_width()); + + current_text_width(); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -892,6 +932,10 @@ 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(); @@ -1076,11 +1120,6 @@ 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() { @@ -2063,9 +2102,6 @@ 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() { @@ -2099,6 +2135,7 @@ 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; @@ -2277,11 +2314,6 @@ unsafe extern "system" fn wnd_proc( 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); } } @@ -2834,7 +2866,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(effective_text_width()) + + current_text_width() } fn draw_usage_bar( @@ -2907,7 +2939,7 @@ fn draw_usage_bar( let mut text_rect = RECT { left: text_x, top: y, - right: text_x + sc(effective_text_width()), + right: text_x + current_text_width(), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));