From e9d95547447ad8cf1d2326b42606d84d46296630 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 15 May 2026 16:23:46 +0800 Subject: [PATCH 1/2] feat: add pace indicator localization Adds the pace indicator strings to the Strings struct and provides translations for all eight supported locales: the show_pace_indicator menu item, the pace_indicator_style submenu label, and the Tick and Solid style names. The strings are wired into the renderer and the right-click menu in the following commit. --- src/localization/dutch.rs | 4 ++++ src/localization/english.rs | 4 ++++ src/localization/french.rs | 4 ++++ src/localization/german.rs | 4 ++++ src/localization/japanese.rs | 4 ++++ src/localization/korean.rs | 4 ++++ src/localization/mod.rs | 4 ++++ src/localization/spanish.rs | 4 ++++ src/localization/traditional_chinese.rs | 4 ++++ 9 files changed, 36 insertions(+) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 0eb486d..d684a17 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -16,6 +16,10 @@ pub(super) const STRINGS: Strings = Strings { settings: "Instellingen", start_with_windows: "Opstarten met Windows", reset_position: "Positie herstellen", + show_pace_indicator: "Tempo-indicator tonen", + pace_indicator_style: "Stijl van tempo-indicator", + pace_style_tick: "Streepje", + pace_style_solid: "Gevuld", 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..f84b054 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -16,6 +16,10 @@ pub(super) const STRINGS: Strings = Strings { settings: "Settings", start_with_windows: "Start with Windows", reset_position: "Reset Position", + show_pace_indicator: "Show pace indicator", + pace_indicator_style: "Pace indicator style", + pace_style_tick: "Tick", + pace_style_solid: "Solid", 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..a7fa382 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -16,6 +16,10 @@ pub(super) const STRINGS: Strings = Strings { settings: "Paramètres", start_with_windows: "Démarrer avec Windows", reset_position: "Réinitialiser la position", + show_pace_indicator: "Afficher l'indicateur de cadence", + pace_indicator_style: "Style de l'indicateur de cadence", + pace_style_tick: "Repère", + pace_style_solid: "Plein", 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..580d31d 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -16,6 +16,10 @@ pub(super) const STRINGS: Strings = Strings { settings: "Einstellungen", start_with_windows: "Mit Windows starten", reset_position: "Position zurücksetzen", + show_pace_indicator: "Tempo-Anzeige einblenden", + pace_indicator_style: "Stil der Tempo-Anzeige", + pace_style_tick: "Strich", + pace_style_solid: "Gefüllt", 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..2e734f1 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -16,6 +16,10 @@ pub(super) const STRINGS: Strings = Strings { settings: "設定", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", + show_pace_indicator: "ペース表示", + pace_indicator_style: "ペース表示スタイル", + pace_style_tick: "目盛り", + pace_style_solid: "塗りつぶし", language: "言語", system_default: "システム既定", check_for_updates: "更新を確認", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 59e3829..0168ee5 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -16,6 +16,10 @@ pub(super) const STRINGS: Strings = Strings { settings: "설정", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", + show_pace_indicator: "사용 속도 표시", + pace_indicator_style: "사용 속도 표시 스타일", + pace_style_tick: "눈금", + pace_style_solid: "채우기", language: "언어", system_default: "시스템 기본값", check_for_updates: "업데이트 확인", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 146b419..188ab08 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -134,6 +134,10 @@ pub struct Strings { pub settings: &'static str, pub start_with_windows: &'static str, pub reset_position: &'static str, + pub show_pace_indicator: &'static str, + pub pace_indicator_style: &'static str, + pub pace_style_tick: &'static str, + pub pace_style_solid: &'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..fa829ba 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -16,6 +16,10 @@ pub(super) const STRINGS: Strings = Strings { settings: "Configuración", start_with_windows: "Iniciar con Windows", reset_position: "Restablecer posición", + show_pace_indicator: "Mostrar indicador de ritmo", + pace_indicator_style: "Estilo del indicador de ritmo", + pace_style_tick: "Marca", + pace_style_solid: "Relleno", 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..4f45fbd 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -16,6 +16,10 @@ pub(super) const STRINGS: Strings = Strings { settings: "設定", start_with_windows: "開機時啟動", reset_position: "重置位置", + show_pace_indicator: "顯示使用步調", + pace_indicator_style: "使用步調樣式", + pace_style_tick: "刻度線", + pace_style_solid: "實心", language: "語言", system_default: "系統預設", check_for_updates: "檢查更新", From 5489a85fce26bcb8facf76c4229c09959ae8dd31 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 15 May 2026 16:23:58 +0800 Subject: [PATCH 2/2] feat: add a pace indicator to the usage bars Adds an opt-in pace indicator that marks where usage should be if it were spread evenly across the window, so the bar shows whether the current burn rate is ahead of or behind pace. The expected position is derived from each section's resets_at timestamp and the fixed window length (5h or 7d). It is drawn red when actual usage is ahead of pace and green when behind, in one of two styles: - Tick: a thin vertical bar at the expected-pace position. - Solid: a filled band spanning the gap between actual usage and the expected-pace position. Two right-click Settings controls drive it: a 'Show pace indicator' checkbox (off by default) and a 'Pace indicator style' submenu with Tick and Solid options (Solid by default). Both persist to settings.json via #[serde(default)] fields so existing files migrate cleanly. The indicator is drawn inside the existing bar segments, so the widget width is unchanged. --- README.md | 1 + src/window.rs | 341 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 312 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 08c8a28..a171ccb 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 pace indicator` under right-click `Settings` to mark where your usage should be for the time elapsed, coloured red when you are ahead of pace and green when you are behind; choose a `Tick` or `Solid` style under `Pace indicator style` ### Models diff --git a/src/window.rs b/src/window.rs index 31955f5..476a7fe 100644 --- a/src/window.rs +++ b/src/window.rs @@ -65,6 +65,8 @@ struct AppState { codex_weekly_text: String, show_claude_code: bool, show_codex: bool, + show_pace_indicator: bool, + pace_indicator_solid: bool, data: Option, @@ -121,6 +123,14 @@ 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_PACE_INDICATOR: u16 = 71; +const IDM_PACE_STYLE_TICK: u16 = 72; +const IDM_PACE_STYLE_SOLID: u16 = 73; + +// 5 hours and 7 days, in seconds. Used to compute "where pace says you +// should be" by comparing remaining time against the full window length. +const SESSION_WINDOW_SECS: u64 = 5 * 3600; +const WEEKLY_WINDOW_SECS: u64 = 7 * 86400; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN @@ -213,6 +223,10 @@ struct SettingsFile { show_claude_code: bool, #[serde(default = "default_show_codex")] show_codex: bool, + #[serde(default)] + show_pace_indicator: bool, + #[serde(default = "default_pace_indicator_solid")] + pace_indicator_solid: bool, } impl Default for SettingsFile { @@ -225,6 +239,8 @@ impl Default for SettingsFile { widget_visible: true, show_claude_code: true, show_codex: false, + show_pace_indicator: false, + pace_indicator_solid: default_pace_indicator_solid(), } } } @@ -245,6 +261,10 @@ fn default_show_codex() -> bool { false } +fn default_pace_indicator_solid() -> bool { + true +} + fn load_settings() -> SettingsFile { let content = match std::fs::read_to_string(settings_path()) { Ok(c) => c, @@ -280,10 +300,47 @@ fn save_state_settings() { widget_visible: s.widget_visible, show_claude_code: s.show_claude_code, show_codex: s.show_codex, + show_pace_indicator: s.show_pace_indicator, + pace_indicator_solid: s.pace_indicator_solid, }); } } +/// Where pace says you should be, as a 0-100 percentage of the window +/// consumed by now. `None` if there is no reset timestamp yet, if the +/// window has already reset (data is stale), or if the remaining time +/// exceeds the window length (unexpected, but guarded against). +fn expected_pace_pct(resets_at: Option, window_secs: u64) -> Option { + let reset = resets_at?; + let remaining = reset.duration_since(SystemTime::now()).ok()?; + let remaining_secs = remaining.as_secs(); + if remaining_secs > window_secs { + return None; + } + let elapsed = window_secs - remaining_secs; + Some(elapsed as f64 / window_secs as f64 * 100.0) +} + +/// Pace values for the four usage cells, in the order +/// (claude session, claude weekly, codex session, codex weekly). +/// Returns all `None` when the indicator is disabled, when there is no +/// usage data yet, or when individual reset timestamps are missing. +fn pace_values_from_state( + s: &AppState, +) -> (Option, Option, Option, Option) { + if !s.show_pace_indicator { + return (None, None, None, None); + } + let claude = s.data.as_ref().and_then(|d| d.claude_code.as_ref()); + let codex = s.data.as_ref().and_then(|d| d.codex.as_ref()); + ( + claude.and_then(|c| expected_pace_pct(c.session.resets_at, SESSION_WINDOW_SECS)), + claude.and_then(|c| expected_pace_pct(c.weekly.resets_at, WEEKLY_WINDOW_SECS)), + codex.and_then(|c| expected_pace_pct(c.session.resets_at, SESSION_WINDOW_SECS)), + codex.and_then(|c| expected_pace_pct(c.weekly.resets_at, WEEKLY_WINDOW_SECS)), + ) +} + fn tray_icon_data_from_state() -> Vec { let state = lock_state(); match state.as_ref() { @@ -1013,6 +1070,8 @@ pub fn run() { codex_weekly_text: "--".to_string(), show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, + show_pace_indicator: settings.show_pace_indicator, + pace_indicator_solid: settings.pace_indicator_solid, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1152,25 +1211,39 @@ fn render_layered() { codex_weekly_text, show_claude_code, show_codex, + session_pace_pct, + weekly_pace_pct, + codex_session_pace_pct, + codex_weekly_pace_pct, + pace_solid, ) = { let state = lock_state(); match state.as_ref() { - Some(s) => ( - s.hwnd, - s.is_dark, - s.embedded, - s.language.strings(), - s.session_percent, - s.session_text.clone(), - s.weekly_percent, - s.weekly_text.clone(), - s.codex_session_percent, - s.codex_session_text.clone(), - s.codex_weekly_percent, - s.codex_weekly_text.clone(), - s.show_claude_code, - s.show_codex, - ), + Some(s) => { + let (session_pace, weekly_pace, codex_session_pace, codex_weekly_pace) = + pace_values_from_state(s); + ( + s.hwnd, + s.is_dark, + s.embedded, + s.language.strings(), + s.session_percent, + s.session_text.clone(), + s.weekly_percent, + s.weekly_text.clone(), + s.codex_session_percent, + s.codex_session_text.clone(), + s.codex_weekly_percent, + s.codex_weekly_text.clone(), + s.show_claude_code, + s.show_codex, + session_pace, + weekly_pace, + codex_session_pace, + codex_weekly_pace, + s.pace_indicator_solid, + ) + } None => return, } }; @@ -1260,6 +1333,11 @@ fn render_layered() { show_claude_code, show_codex, &codex_accent, + session_pace_pct, + weekly_pace_pct, + codex_session_pace_pct, + codex_weekly_pace_pct, + pace_solid, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1330,6 +1408,11 @@ fn paint_content( show_claude_code: bool, show_codex: bool, codex_accent: &Color, + session_pace_pct: Option, + weekly_pace_pct: Option, + codex_session_pace_pct: Option, + codex_weekly_pace_pct: Option, + pace_solid: bool, ) { unsafe { let client_rect = RECT { @@ -1422,6 +1505,9 @@ fn paint_content( accent, codex_accent, track, + session_pace_pct, + codex_session_pace_pct, + pace_solid, ); draw_row( hdc, @@ -1439,6 +1525,9 @@ fn paint_content( accent, codex_accent, track, + weekly_pace_pct, + codex_weekly_pace_pct, + pace_solid, ); SelectObject(hdc, old_font); @@ -2200,6 +2289,26 @@ unsafe extern "system" fn wnd_proc( IDM_START_WITH_WINDOWS => { set_startup_enabled(!is_startup_enabled()); } + IDM_SHOW_PACE_INDICATOR => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.show_pace_indicator = !s.show_pace_indicator; + } + } + save_state_settings(); + render_layered(); + } + IDM_PACE_STYLE_TICK | IDM_PACE_STYLE_SOLID => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.pace_indicator_solid = id == IDM_PACE_STYLE_SOLID; + } + } + save_state_settings(); + render_layered(); + } IDM_FREQ_1MIN | IDM_FREQ_5MIN | IDM_FREQ_15MIN | IDM_FREQ_1HOUR => { let new_interval = match id { IDM_FREQ_1MIN => POLL_1_MIN, @@ -2327,6 +2436,8 @@ fn show_context_menu(hwnd: HWND) { widget_visible, show_claude_code, show_codex, + show_pace_indicator, + pace_indicator_solid, ) = { let state = lock_state(); match state.as_ref() { @@ -2340,6 +2451,8 @@ fn show_context_menu(hwnd: HWND) { s.widget_visible, s.show_claude_code, s.show_codex, + s.show_pace_indicator, + s.pace_indicator_solid, ), None => ( POLL_15_MIN, @@ -2351,6 +2464,8 @@ fn show_context_menu(hwnd: HWND) { true, true, false, + false, + true, ), } }; @@ -2456,6 +2571,52 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + let pace_str = native_interop::wide_str(strings.show_pace_indicator); + let pace_flags = if show_pace_indicator { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + settings_menu, + pace_flags, + IDM_SHOW_PACE_INDICATOR as usize, + PCWSTR::from_raw(pace_str.as_ptr()), + ); + + let pace_style_menu = CreatePopupMenu().unwrap(); + let pace_tick_str = native_interop::wide_str(strings.pace_style_tick); + let pace_tick_flags = if pace_indicator_solid { + MENU_ITEM_FLAGS(0) + } else { + MF_CHECKED + }; + let _ = AppendMenuW( + pace_style_menu, + pace_tick_flags, + IDM_PACE_STYLE_TICK as usize, + PCWSTR::from_raw(pace_tick_str.as_ptr()), + ); + let pace_solid_str = native_interop::wide_str(strings.pace_style_solid); + let pace_solid_flags = if pace_indicator_solid { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + pace_style_menu, + pace_solid_flags, + IDM_PACE_STYLE_SOLID as usize, + PCWSTR::from_raw(pace_solid_str.as_ptr()), + ); + let pace_style_label = native_interop::wide_str(strings.pace_indicator_style); + let _ = AppendMenuW( + settings_menu, + MF_POPUP, + pace_style_menu.0 as usize, + PCWSTR::from_raw(pace_style_label.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,23 +2738,37 @@ fn paint(hdc: HDC, hwnd: HWND) { codex_weekly_text, show_claude_code, show_codex, + session_pace_pct, + weekly_pace_pct, + codex_session_pace_pct, + codex_weekly_pace_pct, + pace_solid, ) = { let state = lock_state(); match state.as_ref() { - Some(s) => ( - s.is_dark, - s.language.strings(), - s.session_percent, - s.session_text.clone(), - s.weekly_percent, - s.weekly_text.clone(), - s.codex_session_percent, - s.codex_session_text.clone(), - s.codex_weekly_percent, - s.codex_weekly_text.clone(), - s.show_claude_code, - s.show_codex, - ), + Some(s) => { + let (session_pace, weekly_pace, codex_session_pace, codex_weekly_pace) = + pace_values_from_state(s); + ( + s.is_dark, + s.language.strings(), + s.session_percent, + s.session_text.clone(), + s.weekly_percent, + s.weekly_text.clone(), + s.codex_session_percent, + s.codex_session_text.clone(), + s.codex_weekly_percent, + s.codex_weekly_text.clone(), + s.show_claude_code, + s.show_codex, + session_pace, + weekly_pace, + codex_session_pace, + codex_weekly_pace, + s.pace_indicator_solid, + ) + } None => return, } }; @@ -2651,6 +2826,11 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, &codex_accent, + session_pace_pct, + weekly_pace_pct, + codex_session_pace_pct, + codex_weekly_pace_pct, + pace_solid, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -2677,6 +2857,9 @@ fn draw_row( claude_accent: &Color, codex_accent: &Color, track: &Color, + claude_pace_pct: Option, + codex_pace_pct: Option, + pace_solid: bool, ) { let seg_h = sc(SEGMENT_H); let active_models = active_model_count(show_claude_code, show_codex); @@ -2721,6 +2904,9 @@ fn draw_row( claude_accent, track, &claude_value_color, + claude_pace_pct, + is_dark, + pace_solid, ); model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); } @@ -2735,6 +2921,9 @@ fn draw_row( codex_accent, track, &codex_value_color, + codex_pace_pct, + is_dark, + pace_solid, ); } } @@ -2756,6 +2945,9 @@ fn draw_usage_bar( accent: &Color, track: &Color, text_color: &Color, + pace_pct: Option, + is_dark: bool, + pace_solid: bool, ) { let seg_w = sc(SEGMENT_W); let seg_h = sc(SEGMENT_H); @@ -2811,6 +3003,95 @@ fn draw_usage_bar( } } + // Pace indicator. Two styles, selected by pace_solid: + // solid — a filled band spanning the gap between actual usage and + // where pace says the user should be (red overage over the + // orange accent, green headroom over the grey track). + // tick — a thick vertical bar at the expected-pace position. + // Red means actual usage is ahead of pace, green means behind it. + if let Some(pace) = pace_pct { + let expected = pace.clamp(0.0, 100.0); + let actual = percent_clamped; + let _ = is_dark; + let pace_color = if expected < actual { + Color::from_hex("#E53935") // red — ahead of pace + } else { + Color::from_hex("#43A047") // green — behind pace + }; + + if pace_solid { + if (actual - expected).abs() > 0.01 { + let (band_lo, band_hi) = if actual > expected { + (expected, actual) + } else { + (actual, expected) + }; + let band_brush = CreateSolidBrush(COLORREF(pace_color.to_colorref())); + for i in 0..segment_count { + let seg_x = bar_x + i * (seg_w + seg_gap); + let seg_start = (i as f64) * segment_percent; + let seg_end = seg_start + segment_percent; + + let overlap_start = band_lo.max(seg_start); + let overlap_end = band_hi.min(seg_end); + if overlap_end <= overlap_start { + continue; + } + + let frac_start = (overlap_start - seg_start) / segment_percent; + let frac_end = (overlap_end - seg_start) / segment_percent; + let fill_left = seg_x + (seg_w as f64 * frac_start) as i32; + let fill_right = seg_x + (seg_w as f64 * frac_end) as i32; + if fill_right <= fill_left { + continue; + } + + let band_rect = RECT { + left: fill_left, + top: y, + right: fill_right, + bottom: y + seg_h, + }; + let rgn = CreateRoundRectRgn( + seg_x, + y, + seg_x + seg_w + 1, + y + seg_h + 1, + corner_r * 2, + corner_r * 2, + ); + let _ = SelectClipRgn(hdc, rgn); + FillRect(hdc, &band_rect, band_brush); + let _ = SelectClipRgn(hdc, HRGN::default()); + let _ = DeleteObject(rgn); + } + let _ = DeleteObject(band_brush); + } + } else { + let seg_idx = + ((expected / segment_percent).floor() as i32).clamp(0, segment_count - 1); + let frac_in_seg = + (expected - (seg_idx as f64) * segment_percent) / segment_percent; + let seg_x = bar_x + seg_idx * (seg_w + seg_gap); + let tick_center = seg_x + (seg_w as f64 * frac_in_seg).round() as i32; + + let bar_left = bar_x; + let bar_right = bar_x + segment_count * (seg_w + seg_gap) - seg_gap; + let tick_w = sc(3).max(2); + let tick_left = (tick_center - tick_w / 2).clamp(bar_left, bar_right - tick_w); + + let tick_rect = RECT { + left: tick_left, + top: y, + right: tick_left + tick_w, + bottom: y + seg_h, + }; + let tick_brush = CreateSolidBrush(COLORREF(pace_color.to_colorref())); + FillRect(hdc, &tick_rect, tick_brush); + let _ = DeleteObject(tick_brush); + } + } + let text_x = bar_x + segment_count * (seg_w + seg_gap) - seg_gap + sc(BAR_RIGHT_MARGIN); let mut text_wide: Vec = text.encode_utf16().collect(); let mut text_rect = RECT {