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/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: "檢查更新", 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 {