diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..6c15812 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.x86_64-pc-windows-gnu] +linker = "C:/msys64/mingw64/bin/gcc.exe" +ar = "C:/msys64/mingw64/bin/ar.exe" diff --git a/.gitignore b/.gitignore index e021482..312476f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ AGENTS.md # Local WinGet manifest generation output /manifests/ + +# Cursor IDE agent rules - hard-linked, not repo content +.cursor/ diff --git a/src/main.rs b/src/main.rs index a17bc0b..cef58d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod localization; mod models; mod native_interop; mod poller; +mod spend_pace; mod theme; mod tray_icon; mod updater; diff --git a/src/models.rs b/src/models.rs index da49ef1..98062b4 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,6 +4,7 @@ use std::time::SystemTime; pub struct UsageSection { pub percentage: f64, pub resets_at: Option, + pub has_bucket: bool, } #[derive(Clone, Debug, Default)] @@ -12,9 +13,42 @@ pub struct UsageData { pub weekly: UsageSection, } +#[derive(Clone, Debug, Default)] +pub struct AccountUsage { + pub credit_pct: f64, + pub credit_expiry: Option, + pub spend_used: f64, + pub spend_limit: f64, +} + +#[derive(Clone, Debug, Default)] +pub struct SpendPaceSlots { + pub month_actual: f64, + pub month_cap: f64, + pub month_expected: f64, + pub month_level: u8, + pub week_actual: f64, + pub week_cap: f64, + pub week_expected: f64, + pub week_level: u8, + pub day_actual: f64, + pub day_cap: f64, + pub day_expected: f64, + pub day_level: u8, +} + +#[derive(Clone, Debug)] +pub struct SpendPaceView { + pub credit_pct: f64, + pub credit_expiry: Option, + pub slots: SpendPaceSlots, +} + #[derive(Clone, Debug, Default)] pub struct AppUsageData { pub claude_code: Option, pub codex: Option, pub antigravity: Option, + pub account: Option, + pub spend_pace: Option, } diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d08..5635b71 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,9 +1,14 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; +use windows::Win32::Globalization::GetLocaleInfoW; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; +const LOCALE_USER_DEFAULT: u32 = 0x0400; +// Short date format pattern (e.g. "M/d/yyyy") +const LOCALE_SSHORTDATE: u32 = 0x001F; + // Window style constants pub const WS_POPUP_STYLE: u32 = 0x80000000; pub const WS_CHILD_STYLE: u32 = 0x40000000; @@ -18,6 +23,7 @@ pub const TIMER_POLL: usize = 1; pub const TIMER_COUNTDOWN: usize = 2; pub const TIMER_RESET_POLL: usize = 3; pub const TIMER_UPDATE_CHECK: usize = 4; +pub const TIMER_DRAG: usize = 5; // Custom messages pub const WM_APP: u32 = 0x8000; @@ -134,6 +140,41 @@ pub fn embed_in_taskbar(hwnd: HWND, taskbar_hwnd: HWND) { } } +/// Detach our window from the taskbar, restoring popup style and topmost z-order +pub fn detach_from_taskbar(hwnd: HWND) { + unsafe { + let style = GetWindowLongW(hwnd, GWL_STYLE) as u32; + let new_style = (style & !(WS_CHILD_STYLE | WS_CLIPSIBLINGS_STYLE)) | WS_POPUP_STYLE; + let _ = SetWindowLongW(hwnd, GWL_STYLE, new_style as i32); + let _ = SetParent(hwnd, None); + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } +} + +/// Re-assert HWND_TOPMOST so the window sits above Shell_TrayWnd (which is also topmost). +/// MoveWindow preserves z-order but doesn't lift us to the front of the topmost band. +pub fn raise_above_taskbar(hwnd: HWND) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } +} + /// Move the window pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { unsafe { @@ -141,6 +182,22 @@ pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { } } +/// Move the window asynchronously — posts a move request to the owning thread's queue +/// instead of blocking cross-process. Required for WS_CHILD windows embedded in Explorer. +pub fn move_window_async(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND::default(), + x, + y, + w, + h, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS, + ); + } +} + /// Set up a WinEvent hook for tray location changes pub fn set_tray_event_hook( thread_id: u32, @@ -181,6 +238,41 @@ pub fn wide_str(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } +/// Format a month/day pair respecting the Windows system locale +/// (separator, and whether day or month comes first). +/// Returns e.g. "9/15" (en-US), "15/9" (en-GB), "15.9" (de-DE). +pub fn format_month_day_locale(month: u8, day: u8) -> String { + if let Some(pattern) = locale_short_date_pattern() { + let lower = pattern.to_lowercase(); + // Find the separator: first non-alphabetic, non-quote character + let sep = lower + .chars() + .find(|c| !c.is_alphabetic() && *c != '\'') + .unwrap_or('/'); + // day-first when 'd' appears before 'm' in the pattern (e.g. "dd/MM/yyyy") + let d_pos = lower.find('d'); + let m_pos = lower.find('m'); + return match (d_pos, m_pos) { + (Some(d), Some(m)) if d < m => format!("{}{}{}", day, sep, month), + (Some(_), Some(_)) => format!("{}{}{}", month, sep, day), + _ => format!("{}/{}", month, day), // malformed pattern — safe fallback + }; + } + format!("{}/{}", month, day) +} + +fn locale_short_date_pattern() -> Option { + unsafe { + let mut buf = [0u16; 256]; + let len = GetLocaleInfoW(LOCALE_USER_DEFAULT, LOCALE_SSHORTDATE, Some(&mut buf)); + if len > 1 && (len as usize) <= buf.len() { + Some(String::from_utf16_lossy(&buf[..len as usize - 1]).to_string()) + } else { + None + } + } +} + /// COLORREF wrapper (RGB packed into u32) pub fn colorref(r: u8, g: u8, b: u8) -> u32 { r as u32 | (g as u32) << 8 | (b as u32) << 16 diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..58943ef 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -6,12 +6,86 @@ use std::path::PathBuf; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::os::windows::process::CommandExt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use crate::diagnose; use crate::localization::Strings; -use crate::models::{AppUsageData, UsageData, UsageSection}; +use crate::models::{AccountUsage, AppUsageData, UsageData, UsageSection}; + +// In-memory cache: survives transient 429s within a single session. +static LAST_KNOWN_ACCOUNT: Mutex> = Mutex::new(None); +// Ensures disk cache is read at most once per process lifetime. +static DISK_CACHE_LOADED: AtomicBool = AtomicBool::new(false); + +#[derive(Serialize, Deserialize, Default)] +struct CachedAccountDisk { + credit_pct: f64, + credit_expiry_unix: Option, + spend_used: f64, + spend_limit: f64, +} + +fn account_cache_path() -> PathBuf { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(appdata) + .join("ClaudeCodeUsageMonitor") + .join("account_cache.json") +} + +fn save_account_to_disk(account: &AccountUsage) { + let path = account_cache_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let credit_expiry_unix = account + .credit_expiry + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + let disk = CachedAccountDisk { + credit_pct: account.credit_pct, + credit_expiry_unix, + spend_used: account.spend_used, + spend_limit: account.spend_limit, + }; + if let Ok(json) = serde_json::to_string(&disk) { + let _ = std::fs::write(&path, json); + } +} + +fn load_account_from_disk() -> Option { + let content = std::fs::read_to_string(account_cache_path()).ok()?; + let disk: CachedAccountDisk = serde_json::from_str(&content).ok()?; + let credit_expiry = disk.credit_expiry_unix.filter(|&s| s > 0).map(|secs| { + UNIX_EPOCH + Duration::from_secs(secs) + }); + Some(AccountUsage { + credit_pct: disk.credit_pct, + credit_expiry, + spend_used: disk.spend_used, + spend_limit: disk.spend_limit, + }) +} + +/// Pre-populate in-memory cache from disk on first call (no-op afterwards). +fn ensure_disk_cache_loaded() { + if DISK_CACHE_LOADED.swap(true, Ordering::Relaxed) { + return; + } + if let Some(account) = load_account_from_disk() { + if let Ok(mut cached) = LAST_KNOWN_ACCOUNT.lock() { + if cached.is_none() { + diagnose::log(format!( + "loaded account cache from disk credit_pct={}", + account.credit_pct + )); + *cached = Some(account); + } + } + } +} const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; @@ -47,6 +121,20 @@ pub type CredentialWatchSnapshot = Vec; struct UsageResponse { five_hour: Option, seven_day: Option, + cinder_cove: Option, + spend: Option, +} + +#[derive(Deserialize)] +struct SpendData { + used: SpendAmount, + limit: SpendAmount, +} + +#[derive(Deserialize)] +struct SpendAmount { + amount_minor: i64, + exponent: u32, } #[derive(Deserialize)] @@ -190,7 +278,7 @@ fn poll_with( show_claude_code: bool, show_codex: bool, show_antigravity: bool, - mut poll_claude_code: impl FnMut() -> Result, + mut poll_claude_code: impl FnMut() -> Result<(UsageData, Option), PollError>, mut poll_codex: impl FnMut() -> Result, mut poll_antigravity: impl FnMut() -> Result, ) -> Result { @@ -200,7 +288,13 @@ fn poll_with( if show_claude_code { match poll_claude_code() { - Ok(claude_code) => data.claude_code = Some(claude_code), + Ok((usage, account)) => { + data.claude_code = Some(usage); + data.account = account; + if let Some(ref account) = data.account { + data.spend_pace = crate::spend_pace::compute_spend_pace(account); + } + } Err(error) => { if active_provider_count > 1 { diagnose::log(format!("Claude Code usage poll failed: {error:?}")); @@ -241,7 +335,7 @@ fn poll_with( } } -fn poll_claude_code() -> Result { +fn poll_claude_code() -> Result<(UsageData, Option), PollError> { let creds = match read_first_credentials() { Some(c) => c, None => { @@ -252,7 +346,20 @@ fn poll_claude_code() -> Result { let creds = refresh_or_fallback(creds)?; - fetch_usage_with_fallback(&creds.access_token) + match fetch_usage_with_fallback(&creds.access_token) { + Ok(result) => Ok(result), + Err(PollError::AuthRequired) => { + diagnose::log("Claude usage auth error; attempting CLI token refresh and retry"); + cli_refresh_token(&creds.source); + if let Some(refreshed) = read_credentials_from_source(&creds.source) { + if let Ok(result) = fetch_usage_with_fallback(&refreshed.access_token) { + return Ok(result); + } + } + Err(PollError::AuthRequired) + } + Err(error) => Err(error), + } } fn poll_codex() -> Result { @@ -654,10 +761,10 @@ fn wsl_credential_watch_signature(distro: &str) -> Option { Some(format!("wsl:{distro}|{state}")) } -fn fetch_usage_with_fallback(token: &str) -> Result { +fn fetch_usage_with_fallback(token: &str) -> Result<(UsageData, Option), PollError> { // Try the dedicated usage endpoint first match try_usage_endpoint(token)? { - Some(data) => { + Some((data, account)) => { // If reset timers are missing, fill them in from the Messages API if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() { if let Ok(fallback) = fetch_usage_via_messages(token) { @@ -668,10 +775,10 @@ fn fetch_usage_with_fallback(token: &str) -> Result { if merged.weekly.resets_at.is_none() { merged.weekly.resets_at = fallback.weekly.resets_at; } - return Ok(merged); + return Ok((merged, account)); } } - return Ok(data); + return Ok((data, account)); } None => {} } @@ -679,12 +786,26 @@ fn fetch_usage_with_fallback(token: &str) -> Result { // Fall back to Messages API with rate limit headers let result = fetch_usage_via_messages(token); if result.is_err() { - diagnose::log("usage endpoint and Messages API fallback both failed"); + diagnose::log("usage endpoint and messages API both unavailable"); + } + // Load disk cache once per process, then use in-memory cache + ensure_disk_cache_loaded(); + let cached_account = LAST_KNOWN_ACCOUNT.lock().ok().and_then(|g| g.clone()); + match result { + Ok(d) => Ok((d, cached_account)), + // Both endpoints down but we have cached enterprise data: return it with empty + // UsageData so refresh_usage_texts can still render the Cr/Sp rows. This keeps + // poll() returning Ok and prevents the transient-error handler from wiping + // session_text / weekly_text with "...". + Err(_) if cached_account.is_some() => { + diagnose::log("using cached account data (usage endpoint unavailable)"); + Ok((UsageData::default(), cached_account)) + } + Err(e) => Err(e), } - result } -fn try_usage_endpoint(token: &str) -> Result, PollError> { +fn try_usage_endpoint(token: &str) -> Result)>, PollError> { let agent = build_agent()?; let resp = match agent @@ -700,26 +821,79 @@ fn try_usage_endpoint(token: &str) -> Result, PollError> { )); return Err(PollError::AuthRequired); } - Err(_) => return Ok(None), + Err(ureq::Error::Status(code, _)) => { + diagnose::log(format!("usage endpoint returned non-auth error status {code}")); + return Ok(None); + } + Err(e) => { + diagnose::log(format!("usage endpoint request failed: {e}")); + return Ok(None); + } }; let response: UsageResponse = match resp.into_json() { Ok(response) => response, - Err(_) => return Ok(None), + Err(e) => { + diagnose::log(format!("usage endpoint json parse failed: {e}")); + return Ok(None); + } }; + diagnose::log("usage endpoint json parsed ok"); let mut data = UsageData::default(); if let Some(bucket) = &response.five_hour { data.session.percentage = bucket.utilization; data.session.resets_at = parse_iso8601(bucket.resets_at.as_deref()); + data.session.has_bucket = true; } if let Some(bucket) = &response.seven_day { data.weekly.percentage = bucket.utilization; data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref()); + data.weekly.has_bucket = true; } - Ok(Some(data)) + let account = extract_account_usage(&response); + // Update both in-memory cache and disk to reflect the current plan. + // When account is None (non-enterprise plan), clear the disk cache too so + // stale enterprise rows don't reappear after a 429 later in the session. + if let Some(ref a) = account { + if let Ok(mut cached) = LAST_KNOWN_ACCOUNT.lock() { + *cached = Some(a.clone()); + } + save_account_to_disk(a); + } else { + if let Ok(mut cached) = LAST_KNOWN_ACCOUNT.lock() { + *cached = None; + } + let _ = std::fs::remove_file(account_cache_path()); + } + Ok(Some((data, account))) +} + +fn extract_account_usage(response: &UsageResponse) -> Option { + diagnose::log(format!( + "extract_account_usage: cinder_cove={} spend={}", + response.cinder_cove.is_some(), + response.spend.is_some() + )); + let credit_bucket = response.cinder_cove.as_ref()?; + let spend = response.spend.as_ref()?; + + let used_divisor = 10f64.powi(spend.used.exponent as i32); + let limit_divisor = 10f64.powi(spend.limit.exponent as i32); + + let result = Some(AccountUsage { + credit_pct: credit_bucket.utilization, + credit_expiry: parse_iso8601(credit_bucket.resets_at.as_deref()), + spend_used: spend.used.amount_minor as f64 / used_divisor, + spend_limit: spend.limit.amount_minor as f64 / limit_divisor, + }); + diagnose::log(format!( + "extract_account_usage: returning Some with credit_pct={}", + credit_bucket.utilization + )); + result } fn fetch_usage_via_messages(token: &str) -> Result { @@ -855,6 +1029,7 @@ fn codex_section_from_window(window: &CodexRateLimitWindow) -> UsageSection { UsageSection { percentage: window.used_percent, resets_at: unix_to_system_time(Some(window.reset_at)), + has_bucket: true, } } @@ -1047,6 +1222,7 @@ fn antigravity_section_from_quota(quota: AntigravityQuotaInfo) -> Option) -> bool { /// Parse an ISO 8601 timestamp string into a SystemTime. fn parse_iso8601(s: Option<&str>) -> Option { let s = s?; - // Strip timezone offset to get "YYYY-MM-DDTHH:MM:SS" or with fractional seconds - // The API returns formats like "2026-03-05T08:00:00.321598+00:00" - let datetime_part = s.split('+').next().unwrap_or(s); - let datetime_part = datetime_part.split('Z').next().unwrap_or(datetime_part); + // Strip timezone: "2026-03-05T08:00:00.321598+00:00" or "-05:00" + // First strip trailing 'Z', then find +/- timezone offset after the 'T' separator. + let datetime_part = s.split('Z').next().unwrap_or(s); + let datetime_part = if let Some(t_pos) = datetime_part.find('T') { + let after_t = &datetime_part[t_pos + 1..]; + match after_t.find(|c: char| c == '+' || c == '-') { + Some(tz) => &datetime_part[..t_pos + 1 + tz], + None => datetime_part, + } + } else { + datetime_part + }; // Try parsing with and without fractional seconds let formats = ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S"]; @@ -1609,6 +1794,7 @@ mod tests { session: UsageSection { percentage, resets_at: None, + has_bucket: true, }, weekly: UsageSection::default(), } @@ -1636,7 +1822,7 @@ mod tests { true, true, false, - || Ok(usage_with_session_percent(64.0)), + || Ok((usage_with_session_percent(64.0), None)), || Err(PollError::RequestFailed), || unreachable!("antigravity is disabled"), ) @@ -1733,3 +1919,92 @@ mod tests { assert!(usage.session.resets_at.is_some()); } } + +pub fn format_credit_text(credit_pct: f64, expiry: Option) -> String { + match expiry { + Some(t) => { + let suffix = format_expiry_locale(t); + if suffix.is_empty() { + // Expiry in the past or invalid — show percentage only + format!("{:.0}%", credit_pct) + } else { + // Drop decimal when expiry suffix present: "NN%·D/M" must fit 62px + format!("{:.0}%\u{00b7}{}", credit_pct, suffix) + } + } + // No expiry: keep one decimal for sub-10% precision + None => { + if credit_pct < 10.0 { + format!("{:.1}%", credit_pct) + } else { + format!("{:.0}%", credit_pct) + } + } + } +} + +pub fn format_spend_text(spend_used: f64, spend_limit: f64) -> String { + if spend_limit <= 0.0 { + return format_usd(spend_used); + } + format!("{}/{}", format_usd(spend_used), format_usd(spend_limit)) +} + +fn format_expiry_locale(t: std::time::SystemTime) -> String { + let secs = match t.duration_since(UNIX_EPOCH) { + Ok(d) => d.as_secs(), + Err(_) => return String::new(), + }; + let (month, day) = unix_secs_to_month_day(secs); + crate::native_interop::format_month_day_locale(month, day) +} + +fn unix_secs_to_month_day(secs: u64) -> (u8, u8) { + let days = secs / 86400; + let mut remaining = days; + let mut year = 1970u32; + loop { + let year_days = if is_leap_year(year) { 366u64 } else { 365u64 }; + if remaining < year_days { + break; + } + remaining -= year_days; + year += 1; + } + let month_lengths: [u64; 12] = [ + 31, if is_leap_year(year) { 29 } else { 28 }, + 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, + ]; + let mut month = 1u8; + let mut rem = remaining; + for &days_in_month in &month_lengths { + if rem < days_in_month { + break; + } + rem -= days_in_month; + month += 1; + } + let month = month.min(12); + let day = (rem + 1).min(31) as u8; + (month, day) +} + +fn is_leap_year(year: u32) -> bool { + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +} + +fn format_usd(amount: f64) -> String { + let dollars = amount as u64; + if dollars >= 10_000 { + format!("${:.0}K", amount / 1000.0) + } else if dollars >= 1_000 { + let k = amount / 1000.0; + if (k - k.floor()).abs() < 0.05 { + format!("${:.0}K", k) + } else { + format!("${:.1}K", k) + } + } else { + format!("${}", dollars) + } +} diff --git a/src/spend_pace.rs b/src/spend_pace.rs new file mode 100644 index 0000000..06dc955 --- /dev/null +++ b/src/spend_pace.rs @@ -0,0 +1,425 @@ +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::models::{AccountUsage, SpendPaceSlots, SpendPaceView}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PaceLevel { + Ok, + High, + Critical, +} + +impl PaceLevel { + pub fn as_bar_level(self) -> u8 { + match self { + Self::Ok => 0, + Self::High => 1, + Self::Critical => 2, + } + } +} + +#[derive(Serialize, Deserialize, Default)] +struct SpendAnchorsDisk { + day_key: String, + day_spend_start: f64, + week_key: String, + week_spend_start: f64, + last_spend: f64, +} + +fn anchors_path() -> PathBuf { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(appdata) + .join("ClaudeCodeUsageMonitor") + .join("spend_anchors.json") +} + +fn load_anchors() -> SpendAnchorsDisk { + std::fs::read_to_string(anchors_path()) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() +} + +fn save_anchors(anchors: &SpendAnchorsDisk) { + let path = anchors_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(anchors) { + let _ = std::fs::write(path, json); + } +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn local_day_key(secs: u64) -> String { + let (year, month, day, _) = unix_to_ymd_hms(secs); + format!("{year:04}-{month:02}-{day:02}") +} + +fn local_week_key(secs: u64) -> String { + let (year, month, day, _) = unix_to_ymd_hms(secs); + let weekday = unix_weekday_monday_zero(secs); + let day = day.saturating_sub(weekday as u32); + let (year, month, day) = normalize_ymd(year, month, day); + format!("{year:04}-{month:02}-{day:02}") +} + +fn unix_weekday_monday_zero(secs: u64) -> u64 { + let days = secs / 86_400; + (days + 3) % 7 +} + +fn normalize_ymd(mut year: u32, mut month: u32, mut day: u32) -> (u32, u32, u32) { + while day == 0 { + month = month.saturating_sub(1); + if month == 0 { + year -= 1; + month = 12; + } + day += days_in_month(year, month); + } + (year, month, day) +} + +fn days_in_month(year: u32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if is_leap_year(year) => 29, + _ => 28, + } +} + +fn is_leap_year(year: u32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +fn unix_to_ymd_hms(secs: u64) -> (u32, u32, u32, u32) { + let days = secs / 86_400; + let time = secs % 86_400; + let hour = (time / 3600) as u32; + + let mut remaining = days; + let mut year = 1970u32; + loop { + let year_days = if is_leap_year(year) { 366 } else { 365 }; + if remaining < year_days { + break; + } + remaining -= year_days; + year += 1; + } + + let month_lengths = [ + 31, + if is_leap_year(year) { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut month = 1u32; + for &len in &month_lengths { + if remaining < len { + break; + } + remaining -= len; + month += 1; + } + (year, month, (remaining + 1) as u32, hour) +} + +fn cycle_bounds(account: &AccountUsage, now: SystemTime) -> (SystemTime, SystemTime) { + if let Some(end) = account.credit_expiry { + let start = end + .checked_sub(Duration::from_secs(30 * 86_400)) + .unwrap_or(UNIX_EPOCH); + return (start, end); + } + + let secs = now.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (year, month, _, _) = unix_to_ymd_hms(secs); + let start_secs = month_start_unix(year, month); + let next_month = if month == 12 { (year + 1, 1) } else { (year, month + 1) }; + let end_secs = month_start_unix(next_month.0, next_month.1); + ( + UNIX_EPOCH + Duration::from_secs(start_secs), + UNIX_EPOCH + Duration::from_secs(end_secs), + ) +} + +fn month_start_unix(year: u32, month: u32) -> u64 { + let mut days = 0u64; + for y in 1970..year { + days += if is_leap_year(y) { 366 } else { 365 }; + } + for m in 1..month { + days += days_in_month(year, m) as u64; + } + days * 86_400 +} + +pub fn evaluate_pace(actual: f64, expected: f64) -> PaceLevel { + if expected <= 0.0 || actual <= 0.0 { + return PaceLevel::Ok; + } + let ratio = actual / expected; + if ratio <= 1.0 { + PaceLevel::Ok + } else if ratio <= 1.15 { + PaceLevel::High + } else { + PaceLevel::Critical + } +} + +fn elapsed_fraction(now: SystemTime, start: SystemTime, end: SystemTime) -> f64 { + let total = end.duration_since(start).map(|d| d.as_secs_f64()).unwrap_or(1.0); + if total <= 0.0 { + return 1.0; + } + let elapsed = now + .duration_since(start) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) + .clamp(0.0, total); + (elapsed / total).clamp(0.0, 1.0) +} + +fn day_fraction(secs: u64) -> f64 { + let seconds_into_day = secs % 86_400; + let fraction = seconds_into_day as f64 / 86_400.0; + fraction.clamp(1.0 / 24.0, 1.0) +} + +fn week_fraction(secs: u64) -> f64 { + let weekday = unix_weekday_monday_zero(secs); + let seconds_into_day = secs % 86_400; + let elapsed = weekday as f64 * 86_400.0 + seconds_into_day as f64; + let fraction = elapsed / (7.0 * 86_400.0); + fraction.clamp(1.0 / (7.0 * 24.0), 1.0) +} + +pub fn pace_accent(level: u8) -> crate::native_interop::Color { + match level { + 2 => crate::native_interop::Color::from_hex("#ef4444"), + 1 => crate::native_interop::Color::from_hex("#eab308"), + _ => crate::native_interop::Color::from_hex("#22c55e"), + } +} + +pub fn bar_fill_percent(actual: f64, cap: f64) -> f64 { + if cap <= 0.0 { + return 0.0; + } + (actual / cap * 100.0).clamp(0.0, 100.0) +} + +pub fn format_pace_fraction(spent: f64, cap: f64) -> String { + if cap <= 0.0 { + return format_usd(spent); + } + format!("{}/{}", format_usd(spent), format_usd(cap)) +} + +fn format_usd(amount: f64) -> String { + if amount >= 100.0 { + format!("${:.0}", amount.round()) + } else if amount >= 10.0 { + format!("${:.0}", amount) + } else if amount >= 1.0 { + format!("${:.1}", amount) + } else { + format!("${:.2}", amount) + } +} + +fn spend_close(a: f64, b: f64) -> bool { + (a - b).abs() < 0.01 +} + +/// Week/day pace uses cumulative billing spend minus anchors at period start. +/// If both anchors are pinned to the current total, week/day incorrectly read $0. +fn repair_stuck_anchors(anchors: &mut SpendAnchorsDisk, spend_used: f64) { + if spend_used <= 0.0 { + return; + } + // Both period baselines pinned to the live total => zero delta for week and day. + // Legitimate day rollover only pins day_spend_start, so leave that case alone. + if spend_close(anchors.week_spend_start, spend_used) + && spend_close(anchors.day_spend_start, spend_used) + { + anchors.week_spend_start = 0.0; + anchors.day_spend_start = 0.0; + } +} + +fn update_anchors(spend_used: f64) -> SpendAnchorsDisk { + let secs = now_secs(); + let day_key = local_day_key(secs); + let week_key = local_week_key(secs); + let mut anchors = load_anchors(); + + repair_stuck_anchors(&mut anchors, spend_used); + + // Billing cycle reset: spend dropped — re-anchor from zero, not from the new total. + if spend_used + 0.01 < anchors.last_spend { + anchors = SpendAnchorsDisk { + day_key: day_key.clone(), + day_spend_start: 0.0, + week_key: week_key.clone(), + week_spend_start: 0.0, + last_spend: spend_used, + }; + save_anchors(&anchors); + return anchors; + } + + if anchors.day_key.is_empty() { + anchors.day_key = day_key.clone(); + anchors.day_spend_start = 0.0; + } else if anchors.day_key != day_key { + anchors.day_key = day_key; + anchors.day_spend_start = anchors.last_spend; + } + + if anchors.week_key.is_empty() { + anchors.week_key = week_key.clone(); + anchors.week_spend_start = 0.0; + } else if anchors.week_key != week_key { + anchors.week_key = week_key; + anchors.week_spend_start = anchors.last_spend; + } + + anchors.last_spend = spend_used; + save_anchors(&anchors); + anchors +} + +pub fn compute_spend_pace(account: &AccountUsage) -> Option { + if account.spend_limit <= 0.0 { + return None; + } + + let now = SystemTime::now(); + let secs = now_secs(); + let anchors = update_anchors(account.spend_used); + + let month_actual = account.spend_used; + let week_actual = (account.spend_used - anchors.week_spend_start).max(0.0); + let day_actual = (account.spend_used - anchors.day_spend_start).max(0.0); + + let (cycle_start, cycle_end) = cycle_bounds(account, now); + let cycle_days = cycle_end + .duration_since(cycle_start) + .map(|d| d.as_secs_f64() / 86_400.0) + .unwrap_or(30.0) + .max(1.0); + + let linear_daily = account.spend_limit / cycle_days; + let linear_week = linear_daily * 7.0; + let month_fraction = elapsed_fraction(now, cycle_start, cycle_end); + let month_expected = account.spend_limit * month_fraction; + let week_expected = linear_week * week_fraction(secs); + let day_expected = linear_daily * day_fraction(secs); + + let month_level = evaluate_pace(month_actual, month_expected); + let week_level = evaluate_pace(week_actual, week_expected); + let day_level = evaluate_pace(day_actual, day_expected); + + Some(SpendPaceView { + credit_pct: account.credit_pct, + credit_expiry: account.credit_expiry, + slots: SpendPaceSlots { + month_actual, + month_cap: account.spend_limit, + month_expected, + month_level: month_level.as_bar_level(), + week_actual, + week_cap: linear_week, + week_expected, + week_level: week_level.as_bar_level(), + day_actual, + day_cap: linear_daily, + day_expected, + day_level: day_level.as_bar_level(), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evaluate_pace_thresholds() { + assert_eq!(evaluate_pace(90.0, 100.0), PaceLevel::Ok); + assert_eq!(evaluate_pace(110.0, 100.0), PaceLevel::High); + assert_eq!(evaluate_pace(120.0, 100.0), PaceLevel::Critical); + } + + #[test] + fn format_pace_fraction_rounds_dollars() { + assert_eq!(format_pace_fraction(57.2, 400.0), "$57/$400"); + assert_eq!(format_pace_fraction(13.4, 13.3), "$13/$13"); + } + + #[test] + fn repair_stuck_anchors_unpins_week_and_day() { + let mut anchors = SpendAnchorsDisk { + day_key: "2026-07-01".to_string(), + day_spend_start: 27.41, + week_key: "2026-06-30".to_string(), + week_spend_start: 27.41, + last_spend: 27.41, + }; + repair_stuck_anchors(&mut anchors, 27.41); + assert_eq!(anchors.week_spend_start, 0.0); + assert_eq!(anchors.day_spend_start, 0.0); + } + + #[test] + fn repair_stuck_anchors_leaves_day_rollover_alone() { + let mut anchors = SpendAnchorsDisk { + day_key: "2026-07-01".to_string(), + day_spend_start: 27.41, + week_key: "2026-06-30".to_string(), + week_spend_start: 0.0, + last_spend: 27.41, + }; + repair_stuck_anchors(&mut anchors, 27.41); + assert_eq!(anchors.week_spend_start, 0.0); + assert_eq!(anchors.day_spend_start, 27.41); + } + + #[test] + fn repair_stuck_anchors_without_last_spend_match() { + let mut anchors = SpendAnchorsDisk { + day_key: "2026-07-01".to_string(), + day_spend_start: 27.41, + week_key: "2026-06-30".to_string(), + week_spend_start: 27.41, + last_spend: 26.0, + }; + repair_stuck_anchors(&mut anchors, 27.41); + assert_eq!(anchors.week_spend_start, 0.0); + assert_eq!(anchors.day_spend_start, 0.0); + } +} diff --git a/src/tray_icon.rs b/src/tray_icon.rs index e2502e2..d2234fa 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -256,12 +256,14 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { bottom: size - margin, }; let mut text_wide: Vec = display_text.encode_utf16().collect(); - let _ = DrawTextW( - mem_dc, - &mut text_wide, - &mut text_rect, - DT_CENTER | DT_VCENTER | DT_SINGLELINE, - ); + if !text_wide.is_empty() { + let _ = DrawTextW( + mem_dc, + &mut text_wide, + &mut text_rect, + DT_CENTER | DT_VCENTER | DT_SINGLELINE, + ); + } SelectObject(mem_dc, old_font); let _ = DeleteObject(font); diff --git a/src/window.rs b/src/window.rs index f6d261e..ee58556 100644 --- a/src/window.rs +++ b/src/window.rs @@ -12,7 +12,7 @@ use windows::Win32::System::Registry::*; use windows::Win32::System::Threading::{CreateMutexW, WaitForSingleObject}; use windows::Win32::UI::Accessibility::HWINEVENTHOOK; use windows::Win32::UI::HiDpi::*; -use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture}; +use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, ReleaseCapture, SetCapture, VK_LBUTTON}; use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::WindowsAndMessaging::*; @@ -20,10 +20,11 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::AppUsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, - WM_APP_USAGE_UPDATED, + self, Color, TIMER_COUNTDOWN, TIMER_DRAG, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; +use crate::spend_pace; use crate::theme; use crate::tray_icon; use crate::updater::{self, InstallChannel, ReleaseDescriptor, UpdateCheckResult}; @@ -57,8 +58,21 @@ struct AppState { session_percent: f64, session_text: String, + session_label: String, weekly_percent: f64, weekly_text: String, + weekly_label: String, + account_pace_mode: bool, + show_credit_row: bool, + credit_percent: f64, + credit_text: String, + credit_label: String, + session_pace_level: u8, + weekly_pace_level: u8, + day_percent: f64, + day_text: String, + day_label: String, + day_pace_level: u8, codex_session_percent: f64, codex_session_text: String, codex_weekly_percent: f64, @@ -103,6 +117,7 @@ enum UpdateStatus { } const RETRY_BASE_MS: u32 = 30_000; // 30 seconds +const AUTH_RETRY_BASE_MS: u32 = 120_000; // 2 minutes — keep retrying auth recovery in background const POLL_1_MIN: u32 = 60_000; const POLL_5_MIN: u32 = 300_000; @@ -116,6 +131,8 @@ const IDM_FREQ_15MIN: u16 = 12; const IDM_FREQ_1HOUR: u16 = 13; const IDM_START_WITH_WINDOWS: u16 = 20; const IDM_RESET_POSITION: u16 = 30; +/// Persisted in settings.json; resolved to max_offset at layout time. +const TRAY_OFFSET_LEFTMOST: i32 = -1; const IDM_VERSION_ACTION: u16 = 31; const IDM_LANG_SYSTEM: u16 = 40; const IDM_LANG_ENGLISH: u16 = 41; @@ -134,13 +151,16 @@ const IDM_MODEL_ANTIGRAVITY: u16 = 62; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; +const WM_APP_RECOVER_TASKBAR: u32 = WM_APP + 4; const TRAY_ICON_UPDATE_REPOSITION_SUPPRESS_MS: u64 = 750; /// How often the watchdog thread polls for an explorer.exe restart (which /// recreates the taskbar and wipes our tray-icon registration). -const TASKBAR_WATCH_INTERVAL_SECS: u64 = 2; +const TASKBAR_WATCH_INTERVAL_SECS: u64 = 1; +const TASKBAR_RECOVER_MAX_ATTEMPTS: u32 = 3; static SUPPRESS_TRAY_REPOSITION_UNTIL: Mutex> = Mutex::new(None); +static TASKBAR_RECOVER_FAILURES: AtomicU32 = AtomicU32::new(0); /// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.) static CURRENT_DPI: AtomicU32 = AtomicU32::new(96); @@ -225,6 +245,35 @@ fn relaunch_self() { } } +/// True when our widget HWND is still a live child of the given taskbar. +/// HWND reuse after an explorer restart can make stale handle comparisons lie; +/// parentage is the reliable signal. +fn is_widget_embedded_in_taskbar(widget_hwnd: HWND, taskbar_hwnd: HWND) -> bool { + unsafe { + IsWindow(widget_hwnd).as_bool() + && IsWindow(taskbar_hwnd).as_bool() + && GetParent(widget_hwnd).ok() == Some(taskbar_hwnd) + } +} + +fn recover_taskbar_embed(hwnd: HWND) { + let taskbar_index = { + let state = lock_state(); + state.as_ref().map(|s| s.taskbar_index).unwrap_or(0) + }; + diagnose::log("recover_taskbar_embed: re-attaching to taskbar"); + if attach_to_taskbar(hwnd, taskbar_index) { + position_at_taskbar(); + sync_tray_icons(hwnd); + render_layered(); + TASKBAR_RECOVER_FAILURES.store(0, Ordering::Relaxed); + diagnose::log("recover_taskbar_embed: success"); + } else { + diagnose::log("recover_taskbar_embed: attach failed, relaunching"); + relaunch_self(); + } +} + /// Detect explorer.exe restarts and recover from them. /// /// Once explorer destroys the taskbar, our embedded child window is destroyed @@ -234,22 +283,50 @@ fn relaunch_self() { fn spawn_taskbar_watchdog() { std::thread::spawn(move || loop { std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS)); - let stored = { + let (widget_hwnd, old_taskbar, embedded, widget_visible) = { let state = lock_state(); - state.as_ref().and_then(|s| s.taskbar_hwnd) + match state.as_ref() { + Some(s) => ( + s.hwnd.to_hwnd(), + s.taskbar_hwnd, + s.embedded, + s.widget_visible, + ), + None => continue, + } }; - // Only relevant once we have embedded into a taskbar at least once. - let Some(old) = stored else { + if !embedded || !widget_visible { continue; - }; - let taskbars = native_interop::find_taskbars(); - if !taskbars.is_empty() && !taskbars.iter().any(|taskbar| taskbar.hwnd == old) { - let new = taskbars[0].hwnd; + } + let intact = old_taskbar + .is_some_and(|taskbar| is_widget_embedded_in_taskbar(widget_hwnd, taskbar)); + if intact { + TASKBAR_RECOVER_FAILURES.store(0, Ordering::Relaxed); + continue; + } + + let widget_alive = unsafe { IsWindow(widget_hwnd).as_bool() }; + diagnose::log(format!( + "watchdog: embed broken widget_alive={widget_alive} taskbar={:?}", + old_taskbar.map(|h| h.0) + )); + + if !widget_alive { + relaunch_self(); + continue; + } + + let failures = TASKBAR_RECOVER_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; + if failures >= TASKBAR_RECOVER_MAX_ATTEMPTS { diagnose::log(format!( - "watchdog: taskbar changed old={:?} new={:?} -> relaunching", - old.0, new.0 + "watchdog: {failures} in-process recoveries failed, relaunching" )); relaunch_self(); + continue; + } + + unsafe { + let _ = PostMessageW(widget_hwnd, WM_APP_RECOVER_TASKBAR, WPARAM(0), LPARAM(0)); } }); } @@ -298,7 +375,7 @@ fn settings_path() -> PathBuf { #[derive(Debug, Serialize, Deserialize)] struct SettingsFile { - #[serde(default)] + #[serde(default = "default_tray_offset")] tray_offset: i32, #[serde(default)] taskbar_index: usize, @@ -318,10 +395,32 @@ struct SettingsFile { show_antigravity: bool, } +fn default_tray_offset() -> i32 { + TRAY_OFFSET_LEFTMOST +} + +fn resolve_tray_offset(stored: i32, max_offset: i32) -> i32 { + if stored < 0 { + max_offset + } else { + stored.clamp(0, max_offset) + } +} + +fn resolved_tray_offset_for_taskbar( + taskbar_hwnd: HWND, + taskbar_rect: RECT, + stored: i32, +) -> i32 { + let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); + let max_offset = (tray_left - taskbar_rect.left - total_widget_width()).max(0); + resolve_tray_offset(stored, max_offset) +} + impl Default for SettingsFile { fn default() -> Self { Self { - tray_offset: 0, + tray_offset: TRAY_OFFSET_LEFTMOST, taskbar_index: 0, poll_interval_ms: default_poll_interval(), language: None, @@ -401,15 +500,35 @@ fn tray_icon_data_from_state() -> Vec { Some(s) if s.last_poll_ok => { let mut icons = Vec::new(); if s.show_claude_code { - icons.push(tray_icon::TrayIconData { - kind: tray_icon::TrayIconKind::Claude, - percent: Some(s.session_percent), - tooltip: format!( - "{} 5h: {} | 7d: {}", + let tooltip = if s.account_pace_mode { + let credit_pct = s + .data + .as_ref() + .and_then(|d| d.spend_pace.as_ref()) + .map(|p| p.credit_pct) + .unwrap_or(0.0); + format!( + "{} Mo: {} | Wk: {} | Dy: {} | Cr: {:.0}%", + s.language.strings().claude_code_model, + s.session_text, + s.weekly_text, + s.day_text, + credit_pct + ) + } else { + format!( + "{} {}: {} | {}: {}", s.language.strings().claude_code_model, + s.session_label, s.session_text, + s.weekly_label, s.weekly_text - ), + ) + }; + icons.push(tray_icon::TrayIconData { + kind: tray_icon::TrayIconKind::Claude, + percent: Some(s.session_percent), + tooltip, }); } if s.show_codex { @@ -494,6 +613,28 @@ fn toggle_widget_visibility(hwnd: HWND) { } } +/// Pick a taskbar that actually hosts the notification area. On multi-monitor +/// setups Windows can expose a spanning primary bar (often at a virtual top +/// edge) that has no TrayNotifyWnd; embedding there hides the widget. +fn resolve_taskbar_index(requested_index: usize, taskbars: &[native_interop::TaskbarWindow]) -> usize { + if taskbars.is_empty() { + return 0; + } + let capped = requested_index.min(taskbars.len() - 1); + if native_interop::find_child_window(taskbars[capped].hwnd, "TrayNotifyWnd").is_some() { + return capped; + } + for (index, taskbar) in taskbars.iter().enumerate() { + if native_interop::find_child_window(taskbar.hwnd, "TrayNotifyWnd").is_some() { + diagnose::log(format!( + "taskbar index {requested_index} has no TrayNotifyWnd; using index {index}" + )); + return index; + } + } + capped +} + fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { let taskbars = native_interop::find_taskbars(); if taskbars.is_empty() { @@ -501,7 +642,7 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { return false; } - let index = requested_index.min(taskbars.len().saturating_sub(1)); + let index = resolve_taskbar_index(requested_index, &taskbars); let taskbar = taskbars[index]; diagnose::log(format!( "taskbar selected index={index} count={} hwnd={:?} rect=({}, {}, {}, {})", @@ -540,14 +681,18 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { diagnose::log("tray event hook could not be installed"); } - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.taskbar_hwnd = Some(taskbar.hwnd); - s.tray_notify_hwnd = tray_notify; - s.win_event_hook = hook; - s.taskbar_index = index; - s.embedded = true; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.taskbar_hwnd = Some(taskbar.hwnd); + s.tray_notify_hwnd = tray_notify; + s.win_event_hook = hook; + s.taskbar_index = index; + s.embedded = true; + s.dragging = false; + } } + save_state_settings(); true } @@ -634,6 +779,41 @@ fn schedule_auto_update_check(hwnd: HWND) { } } +fn waiting_usage_text() -> String { + "...".to_string() +} + +/// When a poll fails, keep the last successful values on screen when we have them. +/// Only show a waiting indicator when there is no cached data yet. +fn apply_poll_failure_display(state: &mut AppState) { + if state.data.is_some() { + return; + } + + let waiting = waiting_usage_text(); + if state.show_claude_code { + state.session_text = waiting.clone(); + state.weekly_text = waiting.clone(); + state.day_text = waiting.clone(); + state.credit_text = waiting.clone(); + } + if state.show_codex { + state.codex_session_text = waiting.clone(); + state.codex_weekly_text = waiting.clone(); + } + if state.show_antigravity { + state.antigravity_session_text = waiting.clone(); + state.antigravity_weekly_text = waiting.clone(); + } +} + +fn auth_retry_delay_ms(retry_count: u32, poll_interval_ms: u32) -> u32 { + let backoff = AUTH_RETRY_BASE_MS.saturating_mul( + 1u32.checked_shl(retry_count.saturating_sub(1)).unwrap_or(1), + ); + backoff.min(poll_interval_ms) +} + fn refresh_usage_texts(state: &mut AppState) { if !state.last_poll_ok { return; @@ -644,20 +824,95 @@ fn refresh_usage_texts(state: &mut AppState) { return; }; + // Reset labels to defaults before potentially overriding below + state.session_label = strings.session_window.to_string(); + state.weekly_label = strings.weekly_window.to_string(); + state.account_pace_mode = false; + state.show_credit_row = false; + state.credit_text.clear(); + state.credit_label.clear(); + state.day_text.clear(); + state.day_label.clear(); + if let Some(claude_code) = data.claude_code.as_ref() { + state.session_percent = claude_code.session.percentage; + state.weekly_percent = claude_code.weekly.percentage; state.session_text = poller::format_line(&claude_code.session, strings); state.weekly_text = poller::format_line(&claude_code.weekly, strings); + + // When the usage endpoint returned no rate-limit buckets (enterprise), show account rows + let has_rate_limit = claude_code.session.has_bucket || claude_code.weekly.has_bucket; + + diagnose::log(format!("refresh_usage_texts: has_rate_limit={has_rate_limit} account={}", data.account.is_some())); + if !has_rate_limit { + if let Some(pace) = data.spend_pace.as_ref() { + let slots = &pace.slots; + state.account_pace_mode = true; + state.session_label = "Mo".to_string(); + state.session_text = + spend_pace::format_pace_fraction(slots.month_actual, slots.month_cap); + state.session_percent = + spend_pace::bar_fill_percent(slots.month_actual, slots.month_cap); + state.session_pace_level = slots.month_level; + + state.weekly_label = "Wk".to_string(); + state.weekly_text = + spend_pace::format_pace_fraction(slots.week_actual, slots.week_cap); + state.weekly_percent = + spend_pace::bar_fill_percent(slots.week_actual, slots.week_cap); + state.weekly_pace_level = slots.week_level; + + state.day_label = "Dy".to_string(); + state.day_text = + spend_pace::format_pace_fraction(slots.day_actual, slots.day_cap); + state.day_percent = + spend_pace::bar_fill_percent(slots.day_actual, slots.day_cap); + state.day_pace_level = slots.day_level; + + if let Some(account) = data.account.as_ref() { + state.show_credit_row = false; // credit shown in tooltip; row omitted to stay within taskbar + state.credit_percent = account.credit_pct; + state.credit_text = + poller::format_credit_text(account.credit_pct, account.credit_expiry); + state.credit_label = "Cr".to_string(); + } + } else if let Some(account) = data.account.as_ref() { + diagnose::log(format!( + "refresh_usage_texts: setting Cr/Sp rows credit_pct={}", + account.credit_pct + )); + state.session_percent = account.credit_pct; + state.session_text = + poller::format_credit_text(account.credit_pct, account.credit_expiry); + state.session_label = "Cr".to_string(); + + let spend_pct = if account.spend_limit > 0.0 { + (account.spend_used / account.spend_limit * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + state.weekly_percent = spend_pct; + state.weekly_text = + poller::format_spend_text(account.spend_used, account.spend_limit); + state.weekly_label = "Sp".to_string(); + } + } } else if state.show_claude_code { - state.session_text = "!".to_string(); - state.weekly_text = "!".to_string(); + // Provider enabled but this poll returned no Claude data — keep prior text if any. + if state.session_text.is_empty() || state.session_text == "!" { + state.session_text = waiting_usage_text(); + state.weekly_text = waiting_usage_text(); + } } 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); } else if state.show_codex { - state.codex_session_text = "!".to_string(); - state.codex_weekly_text = "!".to_string(); + if state.codex_session_text.is_empty() || state.codex_session_text == "!" { + state.codex_session_text = waiting_usage_text(); + state.codex_weekly_text = waiting_usage_text(); + } } if let Some(antigravity) = data.antigravity.as_ref() { @@ -669,8 +924,10 @@ fn refresh_usage_texts(state: &mut AppState) { poller::format_line(&antigravity.weekly, strings) }; } else if state.show_antigravity { - state.antigravity_session_text = "!".to_string(); - state.antigravity_weekly_text = "!".to_string(); + if state.antigravity_session_text.is_empty() || state.antigravity_session_text == "!" { + state.antigravity_session_text = waiting_usage_text(); + state.antigravity_weekly_text = waiting_usage_text(); + } } } @@ -1051,6 +1308,8 @@ const SEGMENT_COUNT: i32 = 10; const CORNER_RADIUS: i32 = 2; const LEFT_DIVIDER_W: i32 = 3; +/// Hit-test width for the drag handle — wider than the visual divider for usability. +const LEFT_DIVIDER_HIT_W: i32 = 10; const DIVIDER_RIGHT_MARGIN: i32 = 10; const LABEL_WIDTH: i32 = 18; const LABEL_RIGHT_MARGIN: i32 = 10; @@ -1059,23 +1318,62 @@ const TEXT_WIDTH: i32 = 62; const MODEL_RIGHT_MARGIN: i32 = 3; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; +const WIDGET_HEIGHT_PACE: i32 = 48; +const WIDGET_HEIGHT_PACE_CREDIT: i32 = 88; + +fn widget_height(account_pace_mode: bool, show_credit_row: bool) -> i32 { + if account_pace_mode { + if show_credit_row { + WIDGET_HEIGHT_PACE_CREDIT + } else { + WIDGET_HEIGHT_PACE + } + } else { + WIDGET_HEIGHT + } +} + +fn widget_height_for_state(state: &AppState) -> i32 { + widget_height(state.account_pace_mode, state.show_credit_row) +} + +fn is_drag_handle_point(client_x: i32, client_y: i32, state: &AppState) -> bool { + is_drag_handle_point_inner(client_x, client_y, state, false) +} -fn is_drag_handle_point(client_x: i32, client_y: i32) -> bool { +fn is_drag_handle_point_verbose(client_x: i32, client_y: i32, state: &AppState) -> bool { + is_drag_handle_point_inner(client_x, client_y, state, true) +} + +fn is_drag_handle_point_inner(client_x: i32, client_y: i32, state: &AppState, verbose: bool) -> bool { let divider_h = sc(25); - let divider_top = (sc(WIDGET_HEIGHT) - divider_h) / 2; + let widget_h = sc(widget_height_for_state(state)); + let divider_top = (widget_h - divider_h) / 2; + let hit_w = sc(LEFT_DIVIDER_HIT_W); + if verbose { + diagnose::log(&format!( + "is_drag_handle_point: client=({},{}) widget_h={} divider_top={} divider_h={} hit_w={} dpi={}", + client_x, client_y, widget_h, divider_top, divider_h, hit_w, + CURRENT_DPI.load(Ordering::Relaxed) + )); + } client_x >= 0 - && client_x < sc(LEFT_DIVIDER_W) + && client_x < hit_w && client_y >= divider_top && client_y < divider_top + divider_h } fn cursor_is_on_drag_handle(hwnd: HWND) -> bool { + let state = lock_state(); + let Some(s) = state.as_ref() else { + return false; + }; unsafe { let mut pt = POINT::default(); if GetCursorPos(&mut pt).is_err() || !ScreenToClient(hwnd, &mut pt).as_bool() { return false; } - is_drag_handle_point(pt.x, pt.y) + is_drag_handle_point(pt.x, pt.y, s) } } @@ -1125,6 +1423,14 @@ fn total_widget_width() -> i32 { total_widget_width_for(active_models) } +/// Width/height used for both MoveWindow and the layered bitmap so they always match. +fn resolved_widget_size(account_pace_mode: bool, show_credit_row: bool) -> (i32, i32) { + refresh_dpi(); + let width = total_widget_width(); + let height = sc(widget_height(account_pace_mode, show_credit_row)); + (width, height) +} + fn claude_accent_color() -> Color { Color::from_hex("#D97757") } @@ -1297,8 +1603,21 @@ pub fn run() { install_channel, session_percent: 0.0, session_text: "--".to_string(), + session_label: language.strings().session_window.to_string(), weekly_percent: 0.0, weekly_text: "--".to_string(), + weekly_label: language.strings().weekly_window.to_string(), + account_pace_mode: false, + show_credit_row: false, + credit_percent: 0.0, + credit_text: String::new(), + credit_label: String::new(), + session_pace_level: 0, + weekly_pace_level: 0, + day_percent: 0.0, + day_text: String::new(), + day_label: String::new(), + day_pace_level: 0, codex_session_percent: 0.0, codex_session_text: "--".to_string(), codex_weekly_percent: 0.0, @@ -1350,10 +1669,14 @@ pub fn run() { } // Register system tray icon(s) + diagnose::log("before sync_tray_icons"); sync_tray_icons(hwnd); + diagnose::log("after sync_tray_icons"); // Position and show (only if widget_visible preference is true) + diagnose::log("before position_at_taskbar"); position_at_taskbar(); + diagnose::log("before ShowWindow"); if settings.widget_visible { let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); } @@ -1422,8 +1745,10 @@ fn render_layered() { strings, session_pct, session_text, + session_label, weekly_pct, weekly_text, + weekly_label, codex_session_pct, codex_session_text, codex_weekly_pct, @@ -1435,6 +1760,17 @@ fn render_layered() { show_claude_code, show_codex, show_antigravity, + account_pace_mode, + show_credit_row, + credit_pct, + credit_text, + credit_label, + day_pct, + day_text, + day_label, + session_pace_level, + weekly_pace_level, + day_pace_level, ) = { let state = lock_state(); match state.as_ref() { @@ -1445,8 +1781,10 @@ fn render_layered() { s.language.strings(), s.session_percent, s.session_text.clone(), + s.session_label.clone(), s.weekly_percent, s.weekly_text.clone(), + s.weekly_label.clone(), s.codex_session_percent, s.codex_session_text.clone(), s.codex_weekly_percent, @@ -1458,6 +1796,17 @@ fn render_layered() { s.show_claude_code, s.show_codex, s.show_antigravity, + s.account_pace_mode, + s.show_credit_row, + s.credit_percent, + s.credit_text.clone(), + s.credit_label.clone(), + s.day_percent, + s.day_text.clone(), + s.day_label.clone(), + s.session_pace_level, + s.weekly_pace_level, + s.day_pace_level, ), None => return, } @@ -1465,16 +1814,7 @@ fn render_layered() { let hwnd = hwnd_val.to_hwnd(); - // For non-embedded fallback, just invalidate and let WM_PAINT handle it - if !embedded { - unsafe { - let _ = InvalidateRect(hwnd, None, false); - } - return; - } - - let width = total_widget_width(); - let height = sc(WIDGET_HEIGHT); + let (width, height) = resolved_widget_size(account_pace_mode, show_credit_row); let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); @@ -1540,8 +1880,10 @@ fn render_layered() { strings, session_pct, &session_text, + &session_label, weekly_pct, &weekly_text, + &weekly_label, codex_session_pct, &codex_session_text, codex_weekly_pct, @@ -1553,17 +1895,28 @@ fn render_layered() { show_claude_code, show_codex, show_antigravity, + account_pace_mode, + show_credit_row, + credit_pct, + &credit_text, + &credit_label, + day_pct, + &day_text, + &day_label, + session_pace_level, + weekly_pace_level, + day_pace_level, &codex_accent, &antigravity_accent, ); - // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). - // Content pixels → fully opaque (preserves ClearType sub-pixel rendering). + // Embedded: background pixels nearly invisible (blends with taskbar), content fully opaque. + // Popup: all pixels fully opaque (solid standalone window). let bg_bgr = bg_color.to_colorref(); let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); for px in pixel_data.iter_mut() { let rgb = *px & 0x00FFFFFF; - if rgb == bg_bgr { + if embedded && rgb == bg_bgr { *px = 0x01000000; } else { *px = rgb | 0xFF000000; @@ -1613,11 +1966,13 @@ fn paint_content( text_color: &Color, accent: &Color, track: &Color, - strings: Strings, + _strings: Strings, session_pct: f64, session_text: &str, + session_label: &str, weekly_pct: f64, weekly_text: &str, + weekly_label: &str, codex_session_pct: f64, codex_session_text: &str, codex_weekly_pct: f64, @@ -1629,6 +1984,17 @@ fn paint_content( show_claude_code: bool, show_codex: bool, show_antigravity: bool, + account_pace_mode: bool, + show_credit_row: bool, + credit_pct: f64, + credit_text: &str, + credit_label: &str, + day_pct: f64, + day_text: &str, + day_label: &str, + session_pace_level: u8, + weekly_pace_level: u8, + day_pace_level: u8, codex_accent: &Color, antigravity_accent: &Color, ) { @@ -1682,8 +2048,35 @@ fn paint_content( let _ = DeleteObject(right_brush); let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN); - let row2_y = height - sc(5) - sc(SEGMENT_H); - let row1_y = row2_y - sc(10) - sc(SEGMENT_H); + let bottom_y = height - sc(5) - sc(SEGMENT_H); + let (mo_y, wk_y, dy_y, credit_y) = if account_pace_mode { + let row_gap = sc(1); + let dy_y = bottom_y; + let wk_y = dy_y - row_gap - sc(SEGMENT_H); + let mo_y = wk_y - row_gap - sc(SEGMENT_H); + let credit_y = if show_credit_row { + Some(mo_y - row_gap - sc(SEGMENT_H)) + } else { + None + }; + (mo_y, wk_y, dy_y, credit_y) + } else { + let wk_y = bottom_y; + let mo_y = wk_y - sc(10) - sc(SEGMENT_H); + (mo_y, wk_y, bottom_y, None) + }; + + let claude_session_accent = if account_pace_mode { + spend_pace::pace_accent(session_pace_level) + } else { + *accent + }; + let claude_weekly_accent = if account_pace_mode { + spend_pace::pace_accent(weekly_pace_level) + } else { + *accent + }; + let claude_day_accent = spend_pace::pace_accent(day_pace_level); let _ = SetBkMode(hdc, TRANSPARENT); let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -1707,13 +2100,37 @@ fn paint_content( ); let old_font = SelectObject(hdc, font); + if let Some(credit_y) = credit_y { + draw_row( + hdc, + content_x, + credit_y, + is_dark, + text_color, + credit_label, + credit_pct, + credit_text, + codex_session_pct, + codex_session_text, + antigravity_session_pct, + antigravity_session_text, + show_claude_code, + show_codex, + show_antigravity, + accent, + codex_accent, + antigravity_accent, + track, + ); + } + draw_row( hdc, content_x, - row1_y, + mo_y, is_dark, text_color, - strings.session_window, + session_label, session_pct, session_text, codex_session_pct, @@ -1723,7 +2140,7 @@ fn paint_content( show_claude_code, show_codex, show_antigravity, - accent, + &claude_session_accent, codex_accent, antigravity_accent, track, @@ -1731,10 +2148,10 @@ fn paint_content( draw_row( hdc, content_x, - row2_y, + wk_y, is_dark, text_color, - strings.weekly_window, + weekly_label, weekly_pct, weekly_text, codex_weekly_pct, @@ -1744,11 +2161,34 @@ fn paint_content( show_claude_code, show_codex, show_antigravity, - accent, + &claude_weekly_accent, codex_accent, antigravity_accent, track, ); + if account_pace_mode { + draw_row( + hdc, + content_x, + dy_y, + is_dark, + text_color, + day_label, + day_pct, + day_text, + codex_weekly_pct, + codex_weekly_text, + antigravity_weekly_pct, + antigravity_weekly_text, + show_claude_code, + show_codex, + show_antigravity, + &claude_day_accent, + codex_accent, + antigravity_accent, + track, + ); + } SelectObject(hdc, old_font); let _ = DeleteObject(font); @@ -1852,35 +2292,35 @@ fn do_poll(send_hwnd: SendHwnd) { should_notify = true; } s.force_notify_auth_error = false; + // Still watch credential files for fast recovery after re-login, + // but also keep TIMER_POLL actively retrying so a silent token + // refresh (or transient 401) self-heals without waiting for a + // file change — otherwise the widget sticks on "!" until restart. s.auth_error_paused_polling = true; s.auth_watch_mode = watch_mode; s.auth_watch_snapshot = watch_snapshot; - s.session_text = "!".to_string(); - s.weekly_text = "!".to_string(); - s.codex_session_text = "!".to_string(); - s.codex_weekly_text = "!".to_string(); - s.antigravity_session_text = "!".to_string(); - s.antigravity_weekly_text = "!".to_string(); + apply_poll_failure_display(s); s.retry_count = s.retry_count.saturating_add(1); + let retry_ms = + auth_retry_delay_ms(s.retry_count, s.poll_interval_ms); + diagnose::log(format!( + "auth poll failed; keeping last data and retrying in {retry_ms}ms" + )); unsafe { let _ = KillTimer(hwnd, TIMER_POLL); let _ = KillTimer(hwnd, TIMER_RESET_POLL); let _ = KillTimer(hwnd, TIMER_COUNTDOWN); - SetTimer(hwnd, TIMER_POLL, s.poll_interval_ms, None); + SetTimer(hwnd, TIMER_POLL, retry_ms, None); } } _ => { - // Transient network / credential-missing errors: exponential backoff. + // Transient network errors: exponential backoff. + // Keep last good values on screen when available. s.force_notify_auth_error = false; s.auth_error_paused_polling = false; s.auth_watch_mode = poller::CredentialWatchMode::ActiveSource; s.auth_watch_snapshot.clear(); - s.session_text = "...".to_string(); - s.weekly_text = "...".to_string(); - s.codex_session_text = "...".to_string(); - s.codex_weekly_text = "...".to_string(); - s.antigravity_session_text = "...".to_string(); - s.antigravity_weekly_text = "...".to_string(); + apply_poll_failure_display(s); s.retry_count = s.retry_count.saturating_add(1); let backoff = RETRY_BASE_MS.saturating_mul( 1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX), @@ -2060,6 +2500,185 @@ fn tray_reposition_is_suppressed() -> bool { } } +fn drag_button_held() -> bool { + unsafe { (GetAsyncKeyState(VK_LBUTTON.0 as i32) as u16 & 0x8000) != 0 } +} + +fn update_drag_reposition_from_cursor() { + let mut pt = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut pt); + } + let move_target = { + let mut state = lock_state(); + let s = match state.as_mut() { + Some(s) => s, + None => return, + }; + + let delta = s.drag_start_mouse_x - pt.x; + let mut new_offset = s.drag_start_offset + delta; + if new_offset < 0 { + new_offset = 0; + } + + let taskbar_hwnd = s.taskbar_hwnd; + let embedded = s.embedded; + let hwnd_val = s.hwnd.to_hwnd(); + + if let Some(taskbar_hwnd) = taskbar_hwnd { + if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { + let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); + 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; + } + + s.tray_offset = new_offset; + + let taskbar_height = taskbar_rect.bottom - taskbar_rect.top; + let anchor_top = taskbar_rect.top; + let anchor_height = taskbar_height; + let widget_height = sc(widget_height(s.account_pace_mode, s.show_credit_row)); + let y = anchor_top + anchor_height - widget_height; + let mut x = if embedded { + tray_left - taskbar_rect.left - widget_width - new_offset + } else { + tray_left - widget_width - new_offset + }; + diagnose::log(&format!("update_drag: pt.x={} delta={} new_offset={} x={} embedded={}", pt.x, s.drag_start_mouse_x - pt.x, new_offset, x, embedded)); + if embedded { + let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); + x = x.clamp(0, max_x); + } + Some(( + hwnd_val, + embedded, + x, + y, + taskbar_rect.top, + widget_width, + widget_height, + )) + } else { + s.tray_offset = new_offset; + None + } + } else { + s.tray_offset = new_offset; + None + } + }; + + if let Some((hwnd_val, embedded, x, y, taskbar_top, widget_width, widget_height)) = move_target + { + if embedded { + native_interop::move_window_async( + hwnd_val, + x, + y - taskbar_top, + widget_width, + widget_height, + ); + } else { + native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); + native_interop::raise_above_taskbar(hwnd_val); + } + } +} + +fn start_drag_reposition(hwnd: HWND, pt: POINT, client_x: i32) { + let embedded = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.dragging = true; + s.drag_start_mouse_x = pt.x; + s.drag_start_client_x = client_x; + s.drag_start_offset = s + .taskbar_hwnd + .and_then(|taskbar_hwnd| { + native_interop::get_taskbar_rect(taskbar_hwnd) + .map(|rect| resolved_tray_offset_for_taskbar(taskbar_hwnd, rect, s.tray_offset)) + }) + .unwrap_or(0); + s.embedded + } else { + return; + } + }; + diagnose::log(&format!("start_drag_reposition embedded={} pt=({},{}) client_x={}", embedded, pt.x, pt.y, client_x)); + unsafe { + if embedded { + // SetCapture on a taskbar child freezes Explorer; poll via timer instead. + let _ = SetTimer(hwnd, TIMER_DRAG, 16, None); + } else { + let _ = SetCapture(hwnd); + } + } +} + +fn finalize_drag_reposition(hwnd: HWND, pt: POINT) { + let drag_result = { + let state = lock_state(); + if let Some(s) = state.as_ref() { + if s.dragging { + Some((s.taskbar_index, s.drag_start_client_x)) + } else { + None + } + } else { + None + } + }; + if let Some((current_taskbar_index, drag_start_client_x)) = drag_result { + if let Some((target_index, target_taskbar)) = taskbar_at_point(pt) { + if target_index != current_taskbar_index { + let new_offset = offset_for_drop_point( + target_taskbar.hwnd, + target_taskbar.rect, + pt, + drag_start_client_x, + ); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.tray_offset = new_offset; + } + } + if attach_to_taskbar(hwnd, target_index) { + position_at_taskbar(); + render_layered(); + } + } + } + } + finish_drag_reposition(); +} + +fn finish_drag_reposition() -> bool { + let (was_dragging, hwnd) = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + let was = s.dragging; + s.dragging = false; + (was, s.hwnd.to_hwnd()) + } else { + (false, HWND::default()) + } + }; + unsafe { + let _ = KillTimer(hwnd, TIMER_DRAG); + let _ = ReleaseCapture(); + } + if was_dragging { + save_state_settings(); + position_at_taskbar(); + render_layered(); + } + was_dragging +} + fn position_at_taskbar() { refresh_dpi(); // Drop the app-state lock before any Win32 call that may synchronously @@ -2106,44 +2725,76 @@ fn position_at_taskbar() { } } - let widget_width = total_widget_width(); + let account_pace_mode = lock_state() + .as_ref() + .map(|s| (s.account_pace_mode, s.show_credit_row)) + .unwrap_or((false, false)); + let (widget_width, widget_height) = + resolved_widget_size(account_pace_mode.0, account_pace_mode.1); let max_offset = (tray_left - taskbar_rect.left - widget_width).max(0); - let tray_offset = tray_offset.clamp(0, max_offset); - let offset_changed = { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - if s.tray_offset != tray_offset { - s.tray_offset = tray_offset; - true - } else { - false + let stored_tray_offset = tray_offset; + let tray_offset = resolve_tray_offset(tray_offset, max_offset); + let y = anchor_top + anchor_height - widget_height; + let widget_visible = lock_state() + .as_ref() + .map(|s| s.widget_visible) + .unwrap_or(true); + + // If the widget is taller than the taskbar, it cannot be fully shown as a child window + // (child windows are clipped to the parent's client area). Detach to popup mode so all + // rows remain visible — the popup path already positions correctly above the taskbar. + let embedded = if embedded && widget_height > taskbar_height { + native_interop::detach_from_taskbar(hwnd); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.embedded = false; } - } else { - false } + diagnose::log(format!( + "detached from taskbar: widget_height={widget_height} > taskbar_height={taskbar_height}" + )); + false + } else { + embedded }; - if offset_changed { - save_state_settings(); + if embedded && stored_tray_offset < 0 { + // Persist the resolved max_offset so drags start from the correct position. + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.tray_offset = tray_offset; + } } - let widget_height = sc(WIDGET_HEIGHT); - let y = compute_anchor_y(anchor_top, anchor_height, widget_height); if embedded { // Child window: coordinates relative to parent (taskbar) - let x = tray_left - taskbar_rect.left - widget_width - tray_offset; - native_interop::move_window(hwnd, x, y - taskbar_rect.top, widget_width, widget_height); + let mut x = tray_left - taskbar_rect.left - widget_width - tray_offset; + let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); + x = x.clamp(0, max_x); + let y_child = compute_anchor_y(anchor_top, anchor_height, widget_height) - anchor_top; + native_interop::move_window(hwnd, x, y_child, widget_width, widget_height); diagnose::log(format!( - "positioned embedded widget at x={x} y={} w={widget_width} h={widget_height}", + "positioned embedded widget at x={x} y={y_child} w={widget_width} h={widget_height} (raw_y={})", y - taskbar_rect.top )); } else { - // Topmost popup: screen coordinates - let x = tray_left - widget_width - tray_offset; + // Topmost popup: screen coordinates, aligned flush with taskbar bottom (overlapping). + // Re-assert HWND_TOPMOST after MoveWindow so we appear above Shell_TrayWnd. + let mut x = tray_left - widget_width - tray_offset; + let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); + x = (x - taskbar_rect.left).clamp(0, max_x) + taskbar_rect.left; native_interop::move_window(hwnd, x, y, widget_width, widget_height); + native_interop::raise_above_taskbar(hwnd); diagnose::log(format!( "positioned fallback widget at x={x} y={y} w={widget_width} h={widget_height}" )); } + if widget_visible { + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + render_layered(); + } } fn compute_anchor_y(anchor_top: i32, anchor_height: i32, widget_height: i32) -> i32 { @@ -2243,19 +2894,24 @@ unsafe extern "system" fn wnd_proc( let timer_id = wparam.0; match timer_id { TIMER_POLL => { - let auth_watch = { - let state = lock_state(); - state.as_ref().map(|s| { - ( - s.auth_error_paused_polling, - s.auth_watch_mode, - s.auth_watch_snapshot.clone(), - ) - }) - }; - match auth_watch { - Some((true, watch_mode, previous_snapshot)) => { - let current_snapshot = poller::credential_watch_snapshot(watch_mode); + // Always poll on the timer — including during auth-error recovery. + // Credential-file watches still update the snapshot when it changes so + // a re-login is detected immediately on the next tick, but we no longer + // skip the poll when the snapshot is unchanged (that left "!" stuck). + { + let auth_watch = { + let state = lock_state(); + state.as_ref().map(|s| { + ( + s.auth_error_paused_polling, + s.auth_watch_mode, + s.auth_watch_snapshot.clone(), + ) + }) + }; + if let Some((true, watch_mode, previous_snapshot)) = auth_watch { + let current_snapshot = + poller::credential_watch_snapshot(watch_mode); if current_snapshot != previous_snapshot { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -2265,21 +2921,13 @@ unsafe extern "system" fn wnd_proc( s.auth_watch_snapshot = current_snapshot; } } - drop(state); - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); } } - Some((false, _, _)) => { - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); - } - None => {} } + let sh = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + do_poll(sh); + }); } TIMER_COUNTDOWN => { update_display(); @@ -2304,6 +2952,26 @@ unsafe extern "system" fn wnd_proc( TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } + TIMER_DRAG => { + let (dragging, tray_offset) = { + let state = lock_state(); + state.as_ref().map(|s| (s.dragging, s.tray_offset)).unwrap_or((false, 0)) + }; + if !dragging { + unsafe { + let _ = KillTimer(hwnd, TIMER_DRAG); + } + } else if !drag_button_held() { + let mut pt = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut pt); + } + diagnose::log(&format!("TIMER_DRAG: button released, finalizing at offset={}", tray_offset)); + finalize_drag_reposition(hwnd, pt); + } else { + update_drag_reposition_from_cursor(); + } + } _ => {} } LRESULT(0) @@ -2311,6 +2979,7 @@ unsafe extern "system" fn wnd_proc( WM_APP_USAGE_UPDATED => { check_theme_change(); check_language_change(); + position_at_taskbar(); render_layered(); schedule_countdown_timer(); suppress_tray_reposition_for(Duration::from_millis( @@ -2323,17 +2992,19 @@ unsafe extern "system" fn wnd_proc( schedule_auto_update_check(hwnd); LRESULT(0) } + WM_APP_RECOVER_TASKBAR => { + recover_taskbar_embed(hwnd); + LRESULT(0) + } WM_SETCURSOR => { let is_dragging = { let state = lock_state(); - state.as_ref().map(|s| s.dragging).unwrap_or(false) + state + .as_ref() + .map(|s| s.dragging) + .unwrap_or(false) }; - if is_dragging { - let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); - SetCursor(cursor); - return LRESULT(1); - } - if cursor_is_on_drag_handle(hwnd) { + if is_dragging || cursor_is_on_drag_handle(hwnd) { let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); SetCursor(cursor); return LRESULT(1); @@ -2343,160 +3014,55 @@ unsafe extern "system" fn wnd_proc( WM_LBUTTONDOWN => { let client_x = (lparam.0 & 0xFFFF) as i16 as i32; let client_y = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32; - if !is_drag_handle_point(client_x, client_y) { - return LRESULT(0); - } - + diagnose::log(&format!("WM_LBUTTONDOWN client_x={} client_y={}", client_x, client_y)); + { + let state = lock_state(); + let Some(s) = state.as_ref() else { + return LRESULT(0); + }; + let on_handle = is_drag_handle_point_verbose(client_x, client_y, s); + diagnose::log(&format!("WM_LBUTTONDOWN on_drag_handle={} embedded={}", on_handle, s.embedded)); + if !on_handle { + return LRESULT(0); + } + } // drop state before start_drag_reposition re-acquires it let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.dragging = true; - s.drag_start_mouse_x = pt.x; - s.drag_start_client_x = client_x; - s.drag_start_offset = s.tray_offset; - } - SetCapture(hwnd); + start_drag_reposition(hwnd, pt, client_x); LRESULT(0) } WM_MOUSEMOVE => { let is_dragging = { let state = lock_state(); - state.as_ref().map(|s| s.dragging).unwrap_or(false) + state + .as_ref() + .map(|s| s.dragging && !s.embedded) + .unwrap_or(false) }; if is_dragging { - let mut pt = POINT::default(); - let _ = GetCursorPos(&mut pt); - let move_target = { - let mut state = lock_state(); - let s = match state.as_mut() { - Some(s) => s, - None => return LRESULT(0), - }; - - // Moving mouse left = positive delta = larger offset (further left) - let delta = s.drag_start_mouse_x - pt.x; - let mut new_offset = s.drag_start_offset + delta; - - // Clamp: offset >= 0 (can't go right of default) - if new_offset < 0 { - new_offset = 0; - } - - let taskbar_hwnd = s.taskbar_hwnd; - let embedded = s.embedded; - let hwnd_val = s.hwnd.to_hwnd(); - - // Clamp: don't go past left edge of taskbar - if let Some(taskbar_hwnd) = taskbar_hwnd { - if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { - let mut tray_left = taskbar_rect.right; - if let Some(tray_hwnd) = - native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") - { - if let Some(tray_rect) = - native_interop::get_window_rect_safe(tray_hwnd) - { - 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; - } - - s.tray_offset = new_offset; - - let taskbar_height = taskbar_rect.bottom - taskbar_rect.top; - let anchor_top = taskbar_rect.top; - let anchor_height = taskbar_height; - let widget_height = sc(WIDGET_HEIGHT); - let y = compute_anchor_y(anchor_top, anchor_height, widget_height); - let x = if embedded { - tray_left - taskbar_rect.left - widget_width - new_offset - } else { - tray_left - widget_width - new_offset - }; - Some(( - hwnd_val, - embedded, - x, - y, - taskbar_rect.top, - widget_width, - widget_height, - )) - } else { - s.tray_offset = new_offset; - None - } - } else { - s.tray_offset = new_offset; - None - } - }; - - if let Some((hwnd_val, embedded, x, y, taskbar_top, widget_width, widget_height)) = - move_target - { - if embedded { - native_interop::move_window( - hwnd_val, - x, - y - taskbar_top, - widget_width, - widget_height, - ); - } else { - native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); - } - } + update_drag_reposition_from_cursor(); } LRESULT(0) } WM_LBUTTONUP => { let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); - let drag_result = { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - if s.dragging { - s.dragging = false; - Some((s.taskbar_index, s.drag_start_client_x)) - } else { - None - } - } else { - None - } + let dragging = { + let state = lock_state(); + state.as_ref().map(|s| s.dragging).unwrap_or(false) }; - if let Some((current_taskbar_index, drag_start_client_x)) = drag_result { - let _ = ReleaseCapture(); - if let Some((target_index, target_taskbar)) = taskbar_at_point(pt) { - if target_index != current_taskbar_index { - let new_offset = offset_for_drop_point( - target_taskbar.hwnd, - target_taskbar.rect, - pt, - drag_start_client_x, - ); - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.tray_offset = new_offset; - } - } - if attach_to_taskbar(hwnd, target_index) { - position_at_taskbar(); - render_layered(); - } - } - } - save_state_settings(); + if dragging { + finalize_drag_reposition(hwnd, pt); } LRESULT(0) } + WM_CAPTURECHANGED => { + let losing = HWND(lparam.0 as *mut _); + if losing == hwnd { + finish_drag_reposition(); + } + DefWindowProcW(hwnd, msg, wparam, lparam) + } WM_RBUTTONUP => { show_context_menu(hwnd); LRESULT(0) @@ -2510,6 +3076,14 @@ unsafe extern "system" fn wnd_proc( if let Some(s) = state.as_mut() { s.session_text = "...".to_string(); s.weekly_text = "...".to_string(); + if !s.account_pace_mode { + s.session_label = + s.language.strings().session_window.to_string(); + s.weekly_label = + s.language.strings().weekly_window.to_string(); + } + s.day_text = "...".to_string(); + s.credit_text = "...".to_string(); s.codex_session_text = "...".to_string(); s.codex_weekly_text = "...".to_string(); s.force_notify_auth_error = true; @@ -2567,7 +3141,7 @@ unsafe extern "system" fn wnd_proc( { let mut state = lock_state(); if let Some(s) = state.as_mut() { - s.tray_offset = 0; + s.tray_offset = TRAY_OFFSET_LEFTMOST; } } save_state_settings(); @@ -2618,6 +3192,8 @@ unsafe extern "system" fn wnd_proc( } s.session_text = "...".to_string(); s.weekly_text = "...".to_string(); + s.day_text = "...".to_string(); + s.credit_text = "...".to_string(); s.codex_session_text = "...".to_string(); s.codex_weekly_text = "...".to_string(); s.antigravity_session_text = "...".to_string(); @@ -2974,8 +3550,10 @@ fn paint(hdc: HDC, hwnd: HWND) { strings, session_pct, session_text, + session_label, weekly_pct, weekly_text, + weekly_label, codex_session_pct, codex_session_text, codex_weekly_pct, @@ -2987,6 +3565,17 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + account_pace_mode, + show_credit_row, + credit_pct, + credit_text, + credit_label, + day_pct, + day_text, + day_label, + session_pace_level, + weekly_pace_level, + day_pace_level, ) = { let state = lock_state(); match state.as_ref() { @@ -2995,8 +3584,10 @@ fn paint(hdc: HDC, hwnd: HWND) { s.language.strings(), s.session_percent, s.session_text.clone(), + s.session_label.clone(), s.weekly_percent, s.weekly_text.clone(), + s.weekly_label.clone(), s.codex_session_percent, s.codex_session_text.clone(), s.codex_weekly_percent, @@ -3008,11 +3599,29 @@ fn paint(hdc: HDC, hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.account_pace_mode, + s.show_credit_row, + s.credit_percent, + s.credit_text.clone(), + s.credit_label.clone(), + s.day_percent, + s.day_text.clone(), + s.day_label.clone(), + s.session_pace_level, + s.weekly_pace_level, + s.day_pace_level, ), None => return, } }; + let mut rect = RECT::default(); + unsafe { + let _ = GetClientRect(hwnd, &mut rect); + } + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); let antigravity_accent = antigravity_accent_color(); @@ -3058,8 +3667,10 @@ fn paint(hdc: HDC, hwnd: HWND) { strings, session_pct, &session_text, + &session_label, weekly_pct, &weekly_text, + &weekly_label, codex_session_pct, &codex_session_text, codex_weekly_pct, @@ -3071,6 +3682,17 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + account_pace_mode, + show_credit_row, + credit_pct, + &credit_text, + &credit_label, + day_pct, + &day_text, + &day_label, + session_pace_level, + weekly_pace_level, + day_pace_level, &codex_accent, &antigravity_accent, );