diff --git a/.github/screenshots/pace-solid-green.png b/.github/screenshots/pace-solid-green.png new file mode 100644 index 0000000..ccb954d Binary files /dev/null and b/.github/screenshots/pace-solid-green.png differ diff --git a/.github/screenshots/pace-solid-red.png b/.github/screenshots/pace-solid-red.png new file mode 100644 index 0000000..afc4a27 Binary files /dev/null and b/.github/screenshots/pace-solid-red.png differ diff --git a/.github/screenshots/pace-tick-green.png b/.github/screenshots/pace-tick-green.png new file mode 100644 index 0000000..8acb3b2 Binary files /dev/null and b/.github/screenshots/pace-tick-green.png differ diff --git a/.github/screenshots/pace-tick-red.png b/.github/screenshots/pace-tick-red.png new file mode 100644 index 0000000..9547716 Binary files /dev/null and b/.github/screenshots/pace-tick-red.png differ diff --git a/README.md b/README.md index f8b3c2b..c625720 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,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 +- Under right-click `Settings` → `Show pace indicator`, pick a `Tick` or `Solid` style to mark where your usage should be for the time elapsed: green means you have headroom to ramp up, red means you are on a trajectory to exceed your quota before it resets (stop work or pay for extra usage); pick `Off` to turn it off ### Models diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..520e322 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5u", weekly_window: "7d", now: "nu", + show_pace_indicator: "Tempo-indicator tonen", + pace_style_off: "Uit (standaard)", + pace_style_tick: "Streepje", + pace_style_solid: "Gevuld", day_suffix: "d", hour_suffix: "u", minute_suffix: "m", diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..8636dff 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5h", weekly_window: "7d", now: "now", + show_pace_indicator: "Show pace indicator", + pace_style_off: "Off (default)", + pace_style_tick: "Tick", + pace_style_solid: "Solid", day_suffix: "d", hour_suffix: "h", minute_suffix: "m", diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..67fcb4b 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5h", weekly_window: "7d", now: "maintenant", + show_pace_indicator: "Afficher l'indicateur de cadence", + pace_style_off: "Désactivé (par défaut)", + pace_style_tick: "Repère", + pace_style_solid: "Plein", day_suffix: "j", hour_suffix: "h", minute_suffix: "m", diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..a8f34ca 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5h", weekly_window: "7d", now: "jetzt", + show_pace_indicator: "Tempo-Anzeige einblenden", + pace_style_off: "Aus (Standard)", + pace_style_tick: "Strich", + pace_style_solid: "Gefüllt", day_suffix: "T", hour_suffix: "h", minute_suffix: "m", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..65c7156 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5h", weekly_window: "7d", now: "今", + show_pace_indicator: "ペース表示", + pace_style_off: "オフ(デフォルト)", + pace_style_tick: "目盛り", + pace_style_solid: "塗りつぶし", day_suffix: "日", hour_suffix: "時間", minute_suffix: "分", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..dba00c4 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5시간", weekly_window: "7일", now: "지금", + show_pace_indicator: "사용 속도 표시", + pace_style_off: "끄기(기본값)", + pace_style_tick: "눈금", + pace_style_solid: "채우기", day_suffix: "일", hour_suffix: "시간", minute_suffix: "분", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2a06b04..d1e8507 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -169,6 +169,10 @@ pub struct Strings { pub session_window: &'static str, pub weekly_window: &'static str, pub now: &'static str, + pub show_pace_indicator: &'static str, + pub pace_style_off: &'static str, + pub pace_style_tick: &'static str, + pub pace_style_solid: &'static str, pub day_suffix: &'static str, pub hour_suffix: &'static str, pub minute_suffix: &'static str, diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..79d1fb4 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5h", weekly_window: "7d", now: "agora", + show_pace_indicator: "Mostrar indicador de ritmo", + pace_style_off: "Desligado (padrão)", + pace_style_tick: "Traço", + pace_style_solid: "Sólido", day_suffix: "d", hour_suffix: "h", minute_suffix: "m", diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..459c16a 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5ч", weekly_window: "7д", now: "сейчас", + show_pace_indicator: "Показывать индикатор темпа", + pace_style_off: "Выкл. (по умолчанию)", + pace_style_tick: "Штрих", + pace_style_solid: "Заливка", day_suffix: "д", hour_suffix: "ч", minute_suffix: "м", diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..9786ec3 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5h", weekly_window: "7d", now: "ahora", + show_pace_indicator: "Mostrar indicador de ritmo", + pace_style_off: "Desactivado (predeterminado)", + pace_style_tick: "Marca", + pace_style_solid: "Relleno", day_suffix: "d", hour_suffix: "h", minute_suffix: "m", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..d434f94 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -35,6 +35,10 @@ pub(super) const STRINGS: Strings = Strings { session_window: "5h", weekly_window: "7d", now: "現在", + show_pace_indicator: "顯示使用步調", + pace_style_off: "關閉(預設)", + pace_style_tick: "刻度線", + pace_style_solid: "實心", day_suffix: "天", hour_suffix: "時", minute_suffix: "分", diff --git a/src/window.rs b/src/window.rs index f6d261e..965db05 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}; @@ -68,6 +68,8 @@ struct AppState { antigravity_weekly_percent: f64, antigravity_weekly_text: String, show_claude_code: bool, + show_pace_indicator: bool, + pace_indicator_solid: bool, show_codex: bool, show_antigravity: bool, @@ -128,10 +130,18 @@ const IDM_LANG_KOREAN: u16 = 47; const IDM_LANG_TRADITIONAL_CHINESE: u16 = 48; const IDM_LANG_RUSSIAN: u16 = 49; const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; +const IDM_PACE_STYLE_OFF: u16 = 71; +const IDM_PACE_STYLE_TICK: u16 = 72; +const IDM_PACE_STYLE_SOLID: u16 = 73; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +// 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 WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; const TRAY_ICON_UPDATE_REPOSITION_SUPPRESS_MS: u64 = 750; @@ -312,6 +322,10 @@ struct SettingsFile { widget_visible: bool, #[serde(default = "default_show_claude_code")] show_claude_code: bool, + #[serde(default)] + show_pace_indicator: bool, + #[serde(default = "default_pace_indicator_solid")] + pace_indicator_solid: bool, #[serde(default = "default_show_codex")] show_codex: bool, #[serde(default = "default_show_antigravity")] @@ -328,6 +342,8 @@ impl Default for SettingsFile { last_update_check_unix: None, widget_visible: true, show_claude_code: true, + show_pace_indicator: false, + pace_indicator_solid: default_pace_indicator_solid(), show_codex: false, show_antigravity: false, } @@ -354,6 +370,10 @@ fn default_show_antigravity() -> 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, @@ -389,12 +409,60 @@ fn save_state_settings() { last_update_check_unix: s.last_update_check_unix, widget_visible: s.widget_visible, show_claude_code: s.show_claude_code, + show_pace_indicator: s.show_pace_indicator, + pace_indicator_solid: s.pace_indicator_solid, show_codex: s.show_codex, show_antigravity: s.show_antigravity, }); } } +/// 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 six usage cells, in the order +/// (claude session, claude weekly, codex session, codex weekly, +/// antigravity session, antigravity 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, + Option, + Option, +) { + if !s.show_pace_indicator { + return (None, None, 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()); + let antigravity = s.data.as_ref().and_then(|d| d.antigravity.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)), + antigravity.and_then(|c| expected_pace_pct(c.session.resets_at, SESSION_WINDOW_SECS)), + antigravity.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() { @@ -672,6 +740,8 @@ fn refresh_usage_texts(state: &mut AppState) { state.antigravity_session_text = "!".to_string(); state.antigravity_weekly_text = "!".to_string(); } + + update_measured_text_width(state); } fn set_window_title(hwnd: HWND, strings: Strings) { @@ -1083,6 +1153,76 @@ fn active_model_count(show_claude_code: bool, show_codex: bool, show_antigravity (show_claude_code as i32 + show_codex as i32 + show_antigravity as i32).max(1) } +/// 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); + +fn current_text_width() -> i32 { + MEASURED_TEXT_WIDTH.load(Ordering::Relaxed).max(sc(TEXT_WIDTH)) +} + +/// 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 + } +} + +/// 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 state.show_antigravity { + max_w = max_w.max(measure_text_px(&state.antigravity_session_text)); + max_w = max_w.max(measure_text_px(&state.antigravity_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 { match active_models { 1 => SEGMENT_COUNT, @@ -1095,7 +1235,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(TEXT_WIDTH); + + current_text_width(); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -1308,6 +1448,8 @@ pub fn run() { antigravity_weekly_percent: 0.0, antigravity_weekly_text: "--".to_string(), show_claude_code: settings.show_claude_code, + show_pace_indicator: settings.show_pace_indicator, + pace_indicator_solid: settings.pace_indicator_solid, show_codex: settings.show_codex, show_antigravity: settings.show_antigravity, data: None, @@ -1435,30 +1577,54 @@ fn render_layered() { show_claude_code, show_codex, show_antigravity, + session_pace_pct, + weekly_pace_pct, + codex_session_pace_pct, + codex_weekly_pace_pct, + antigravity_session_pace_pct, + antigravity_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.antigravity_session_percent, - s.antigravity_session_text.clone(), - s.antigravity_weekly_percent, - s.antigravity_weekly_text.clone(), - s.show_claude_code, - s.show_codex, - s.show_antigravity, - ), + Some(s) => { + let ( + session_pace, + weekly_pace, + codex_session_pace, + codex_weekly_pace, + antigravity_session_pace, + antigravity_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.antigravity_session_percent, + s.antigravity_session_text.clone(), + s.antigravity_weekly_percent, + s.antigravity_weekly_text.clone(), + s.show_claude_code, + s.show_codex, + s.show_antigravity, + session_pace, + weekly_pace, + codex_session_pace, + codex_weekly_pace, + antigravity_session_pace, + antigravity_weekly_pace, + s.pace_indicator_solid, + ) + } None => return, } }; @@ -1555,6 +1721,13 @@ fn render_layered() { show_antigravity, &codex_accent, &antigravity_accent, + session_pace_pct, + weekly_pace_pct, + codex_session_pace_pct, + codex_weekly_pace_pct, + antigravity_session_pace_pct, + antigravity_weekly_pace_pct, + pace_solid, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1631,6 +1804,13 @@ fn paint_content( show_antigravity: bool, codex_accent: &Color, antigravity_accent: &Color, + session_pace_pct: Option, + weekly_pace_pct: Option, + codex_session_pace_pct: Option, + codex_weekly_pace_pct: Option, + antigravity_session_pace_pct: Option, + antigravity_weekly_pace_pct: Option, + pace_solid: bool, ) { unsafe { let client_rect = RECT { @@ -1727,6 +1907,10 @@ fn paint_content( codex_accent, antigravity_accent, track, + session_pace_pct, + codex_session_pace_pct, + antigravity_session_pace_pct, + pace_solid, ); draw_row( hdc, @@ -1748,6 +1932,10 @@ fn paint_content( codex_accent, antigravity_accent, track, + weekly_pace_pct, + codex_weekly_pace_pct, + antigravity_weekly_pace_pct, + pace_solid, ); SelectObject(hdc, old_font); @@ -2576,6 +2764,21 @@ unsafe extern "system" fn wnd_proc( IDM_START_WITH_WINDOWS => { set_startup_enabled(!is_startup_enabled()); } + IDM_PACE_STYLE_OFF | IDM_PACE_STYLE_TICK | IDM_PACE_STYLE_SOLID => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + if id == IDM_PACE_STYLE_OFF { + s.show_pace_indicator = false; + } else { + s.show_pace_indicator = true; + 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, @@ -2715,6 +2918,8 @@ fn show_context_menu(hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + show_pace_indicator, + pace_indicator_solid, ) = { let state = lock_state(); match state.as_ref() { @@ -2729,6 +2934,8 @@ fn show_context_menu(hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.show_pace_indicator, + s.pace_indicator_solid, ), None => ( POLL_15_MIN, @@ -2741,6 +2948,8 @@ fn show_context_menu(hwnd: HWND) { true, false, false, + false, + true, ), } }; @@ -2851,6 +3060,63 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(startup_str.as_ptr()), ); + // "Show pace indicator" is a submenu whose Off/Tick/Solid items form a + // radio group. The parent entry shows a check while a style is active; + // selecting Off clears that check and turns the indicator off. + let pace_menu = CreatePopupMenu().unwrap(); + + let pace_off_str = native_interop::wide_str(strings.pace_style_off); + let pace_off_flags = if show_pace_indicator { + MENU_ITEM_FLAGS(0) + } else { + MF_CHECKED + }; + let _ = AppendMenuW( + pace_menu, + pace_off_flags, + IDM_PACE_STYLE_OFF as usize, + PCWSTR::from_raw(pace_off_str.as_ptr()), + ); + + let pace_tick_str = native_interop::wide_str(strings.pace_style_tick); + let pace_tick_flags = if show_pace_indicator && !pace_indicator_solid { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + pace_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 show_pace_indicator && pace_indicator_solid { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + pace_menu, + pace_solid_flags, + IDM_PACE_STYLE_SOLID as usize, + PCWSTR::from_raw(pace_solid_str.as_ptr()), + ); + + let pace_label = native_interop::wide_str(strings.show_pace_indicator); + let pace_parent_flags = if show_pace_indicator { + MF_POPUP | MF_CHECKED + } else { + MF_POPUP + }; + let _ = AppendMenuW( + settings_menu, + pace_parent_flags, + pace_menu.0 as usize, + PCWSTR::from_raw(pace_label.as_ptr()), + ); + let reset_pos_str = native_interop::wide_str(strings.reset_position); let _ = AppendMenuW( settings_menu, @@ -2987,28 +3253,52 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + session_pace_pct, + weekly_pace_pct, + codex_session_pace_pct, + codex_weekly_pace_pct, + antigravity_session_pace_pct, + antigravity_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.antigravity_session_percent, - s.antigravity_session_text.clone(), - s.antigravity_weekly_percent, - s.antigravity_weekly_text.clone(), - s.show_claude_code, - s.show_codex, - s.show_antigravity, - ), + Some(s) => { + let ( + session_pace, + weekly_pace, + codex_session_pace, + codex_weekly_pace, + antigravity_session_pace, + antigravity_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.antigravity_session_percent, + s.antigravity_session_text.clone(), + s.antigravity_weekly_percent, + s.antigravity_weekly_text.clone(), + s.show_claude_code, + s.show_codex, + s.show_antigravity, + session_pace, + weekly_pace, + codex_session_pace, + codex_weekly_pace, + antigravity_session_pace, + antigravity_weekly_pace, + s.pace_indicator_solid, + ) + } None => return, } }; @@ -3073,6 +3363,13 @@ fn paint(hdc: HDC, hwnd: HWND) { show_antigravity, &codex_accent, &antigravity_accent, + session_pace_pct, + weekly_pace_pct, + codex_session_pace_pct, + codex_weekly_pace_pct, + antigravity_session_pace_pct, + antigravity_weekly_pace_pct, + pace_solid, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -3103,6 +3400,10 @@ fn draw_row( codex_accent: &Color, antigravity_accent: &Color, track: &Color, + claude_pace_pct: Option, + codex_pace_pct: Option, + antigravity_pace_pct: Option, + pace_solid: bool, ) { let seg_h = sc(SEGMENT_H); let active_models = active_model_count(show_claude_code, show_codex, show_antigravity); @@ -3152,6 +3453,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); } @@ -3166,6 +3470,9 @@ fn draw_row( codex_accent, track, &codex_value_color, + codex_pace_pct, + is_dark, + pace_solid, ); model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); } @@ -3180,6 +3487,9 @@ fn draw_row( antigravity_accent, track, &antigravity_value_color, + antigravity_pace_pct, + is_dark, + pace_solid, ); } } @@ -3188,7 +3498,7 @@ fn draw_row( fn model_usage_width(segment_count: i32) -> i32 { (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * segment_count - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(TEXT_WIDTH) + + current_text_width() } fn draw_usage_bar( @@ -3201,6 +3511,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); @@ -3256,12 +3569,102 @@ 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. + // Green means usage is behind pace — headroom to ramp up. Red means + // usage is ahead of pace — on a trajectory to exhaust the quota early. + 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, may exhaust quota early + } else { + Color::from_hex("#43A047") // green — behind pace, headroom to ramp up + }; + + 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 { left: text_x, top: y, - right: text_x + sc(TEXT_WIDTH), + right: text_x + current_text_width(), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));