From d6ea143f0566eaf80527e13afa4520fb08b69d08 Mon Sep 17 00:00:00 2001 From: Craig Constable Date: Wed, 18 Mar 2026 11:15:07 +1000 Subject: [PATCH] v1.1.0 - Added Windows-aware UI localisation with language override --- Cargo.lock | 2 +- Cargo.toml | 3 +- build.rs | 4 +- src/localization.rs | 287 ++++++++++++++++++++++++++++++ src/main.rs | 1 + src/native_interop.rs | 12 +- src/poller.rs | 63 ++++--- src/theme.rs | 2 +- src/window.rs | 404 ++++++++++++++++++++++++++++++++---------- 9 files changed, 646 insertions(+), 132 deletions(-) create mode 100644 src/localization.rs diff --git a/Cargo.lock b/Cargo.lock index 5f08081..b5bfb4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "claude-code-usage-monitor" -version = "1.0.14" +version = "1.1.0" dependencies = [ "dirs", "native-tls", diff --git a/Cargo.toml b/Cargo.toml index 1cb967a..5953c52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-usage-monitor" -version = "1.0.15" +version = "1.1.0" edition = "2021" license = "MIT" description = "Windows taskbar widget for monitoring Claude Code usage and rate limits" @@ -27,6 +27,7 @@ dirs = "6" version = "0.58" features = [ "Win32_Foundation", + "Win32_Globalization", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Shell", diff --git a/build.rs b/build.rs index 8ec19cf..ce60573 100644 --- a/build.rs +++ b/build.rs @@ -18,9 +18,7 @@ fn main() { fn pack_version(version: &str) -> u64 { let core = version.split('-').next().unwrap_or(version); - let mut parts = core - .split('.') - .map(|part| part.parse::().unwrap_or(0)); + let mut parts = core.split('.').map(|part| part.parse::().unwrap_or(0)); let major = parts.next().unwrap_or(0).min(u16::MAX as u64); let minor = parts.next().unwrap_or(0).min(u16::MAX as u64); diff --git a/src/localization.rs b/src/localization.rs new file mode 100644 index 0000000..2dff8ab --- /dev/null +++ b/src/localization.rs @@ -0,0 +1,287 @@ +use windows::core::PWSTR; +use windows::Win32::Globalization::{ + GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages, + LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LanguageId { + English, + Spanish, + French, + German, + Japanese, +} + +impl LanguageId { + pub const ALL: [LanguageId; 5] = [ + LanguageId::English, + LanguageId::Spanish, + LanguageId::French, + LanguageId::German, + LanguageId::Japanese, + ]; + + pub fn code(self) -> &'static str { + match self { + Self::English => "en", + Self::Spanish => "es", + Self::French => "fr", + Self::German => "de", + Self::Japanese => "ja", + } + } + + pub fn native_name(self) -> &'static str { + match self { + Self::English => "English", + Self::Spanish => "Español", + Self::French => "Français", + Self::German => "Deutsch", + Self::Japanese => "日本語", + } + } + + pub fn strings(self) -> Strings { + match self { + Self::English => ENGLISH, + Self::Spanish => SPANISH, + Self::French => FRENCH, + Self::German => GERMAN, + Self::Japanese => JAPANESE, + } + } + + pub fn from_code(code: &str) -> Option { + let normalized = code.trim().replace('_', "-").to_ascii_lowercase(); + if normalized.is_empty() || normalized == "system" { + return None; + } + + match normalized.split('-').next().unwrap_or_default() { + "en" => Some(Self::English), + "es" => Some(Self::Spanish), + "fr" => Some(Self::French), + "de" => Some(Self::German), + "ja" => Some(Self::Japanese), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Strings { + pub window_title: &'static str, + pub refresh: &'static str, + pub update_frequency: &'static str, + pub one_minute: &'static str, + pub five_minutes: &'static str, + pub fifteen_minutes: &'static str, + pub one_hour: &'static str, + pub settings: &'static str, + pub start_with_windows: &'static str, + pub reset_position: &'static str, + pub language: &'static str, + pub system_default: &'static str, + pub exit: &'static str, + pub session_window: &'static str, + pub weekly_window: &'static str, + pub now: &'static str, + pub day_suffix: &'static str, + pub hour_suffix: &'static str, + pub minute_suffix: &'static str, + pub second_suffix: &'static str, +} + +const ENGLISH: Strings = Strings { + window_title: "Claude Code Usage Monitor", + refresh: "Refresh", + update_frequency: "Update Frequency", + one_minute: "1 Minute", + five_minutes: "5 Minutes", + fifteen_minutes: "15 Minutes", + one_hour: "1 Hour", + settings: "Settings", + start_with_windows: "Start with Windows", + reset_position: "Reset Position", + language: "Language", + system_default: "System Default", + exit: "Exit", + session_window: "5h", + weekly_window: "7d", + now: "now", + day_suffix: "d", + hour_suffix: "h", + minute_suffix: "m", + second_suffix: "s", +}; + +const SPANISH: Strings = Strings { + window_title: "Monitor de uso de Claude Code", + refresh: "Actualizar", + update_frequency: "Frecuencia de actualizacion", + one_minute: "1 minuto", + five_minutes: "5 minutos", + fifteen_minutes: "15 minutos", + one_hour: "1 hora", + settings: "Configuracion", + start_with_windows: "Iniciar con Windows", + reset_position: "Restablecer posicion", + language: "Idioma", + system_default: "Predeterminado del sistema", + exit: "Salir", + session_window: "5h", + weekly_window: "7d", + now: "ahora", + day_suffix: "d", + hour_suffix: "h", + minute_suffix: "m", + second_suffix: "s", +}; + +const FRENCH: Strings = Strings { + window_title: "Moniteur d'utilisation Claude Code", + refresh: "Actualiser", + update_frequency: "Frequence de mise a jour", + one_minute: "1 minute", + five_minutes: "5 minutes", + fifteen_minutes: "15 minutes", + one_hour: "1 heure", + settings: "Parametres", + start_with_windows: "Demarrer avec Windows", + reset_position: "Reinitialiser la position", + language: "Langue", + system_default: "Par defaut du systeme", + exit: "Quitter", + session_window: "5h", + weekly_window: "7d", + now: "maintenant", + day_suffix: "j", + hour_suffix: "h", + minute_suffix: "m", + second_suffix: "s", +}; + +const GERMAN: Strings = Strings { + window_title: "Claude Code Nutzungsmonitor", + refresh: "Aktualisieren", + update_frequency: "Aktualisierungsintervall", + one_minute: "1 Minute", + five_minutes: "5 Minuten", + fifteen_minutes: "15 Minuten", + one_hour: "1 Stunde", + settings: "Einstellungen", + start_with_windows: "Mit Windows starten", + reset_position: "Position zurucksetzen", + language: "Sprache", + system_default: "Systemstandard", + exit: "Beenden", + session_window: "5h", + weekly_window: "7d", + now: "jetzt", + day_suffix: "T", + hour_suffix: "h", + minute_suffix: "m", + second_suffix: "s", +}; + +const JAPANESE: Strings = Strings { + window_title: "Claude Code 使用量モニター", + refresh: "更新", + update_frequency: "更新間隔", + one_minute: "1分", + five_minutes: "5分", + fifteen_minutes: "15分", + one_hour: "1時間", + settings: "設定", + start_with_windows: "Windows と同時に開始", + reset_position: "位置をリセット", + language: "言語", + system_default: "システム既定", + exit: "終了", + session_window: "5h", + weekly_window: "7d", + now: "今", + day_suffix: "日", + hour_suffix: "時間", + minute_suffix: "分", + second_suffix: "秒", +}; + +pub fn resolve_language(language_override: Option) -> LanguageId { + language_override.unwrap_or_else(detect_system_language) +} + +pub fn detect_system_language() -> LanguageId { + preferred_ui_languages() + .into_iter() + .find_map(|locale| LanguageId::from_code(&locale)) + .or_else(default_ui_locale) + .or_else(default_locale_name) + .unwrap_or(LanguageId::English) +} + +fn preferred_ui_languages() -> Vec { + unsafe { + let mut num_languages = 0u32; + let mut buffer_len = 0u32; + if GetUserPreferredUILanguages( + MUI_LANGUAGE_NAME, + &mut num_languages, + PWSTR::null(), + &mut buffer_len, + ) + .is_err() + || buffer_len == 0 + { + return Vec::new(); + } + + let mut buffer = vec![0u16; buffer_len as usize]; + if GetUserPreferredUILanguages( + MUI_LANGUAGE_NAME, + &mut num_languages, + PWSTR(buffer.as_mut_ptr()), + &mut buffer_len, + ) + .is_err() + { + return Vec::new(); + } + + buffer + .split(|unit| *unit == 0) + .filter(|part| !part.is_empty()) + .map(String::from_utf16_lossy) + .collect() + } +} + +fn default_ui_locale() -> Option { + unsafe { + let lang_id = GetUserDefaultUILanguage(); + let mut buffer = [0u16; MAX_LOCALE_NAME as usize]; + let len = LCIDToLocaleName( + lang_id as u32, + Some(&mut buffer), + LOCALE_ALLOW_NEUTRAL_NAMES, + ); + if len <= 1 { + return None; + } + let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]); + LanguageId::from_code(&locale) + } +} + +fn default_locale_name() -> Option { + unsafe { + let mut buffer = [0u16; MAX_LOCALE_NAME as usize]; + let len = GetUserDefaultLocaleName(&mut buffer); + if len <= 1 { + return None; + } + let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]); + LanguageId::from_code(&locale) + } +} diff --git a/src/main.rs b/src/main.rs index 24bb14f..5fef428 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![windows_subsystem = "windows"] +mod localization; mod models; mod native_interop; mod poller; diff --git a/src/native_interop.rs b/src/native_interop.rs index 908049f..3f8f25e 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,7 +1,7 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{HWND, RECT}; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; -use windows::Win32::UI::Shell::{ABM_GETTASKBARPOS, APPBARDATA, SHAppBarMessage}; +use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; // Window style constants @@ -107,15 +107,7 @@ pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { /// Set up a WinEvent hook for tray location changes pub fn set_tray_event_hook( thread_id: u32, - callback: unsafe extern "system" fn( - HWINEVENTHOOK, - u32, - HWND, - i32, - i32, - u32, - u32, - ), + callback: unsafe extern "system" fn(HWINEVENTHOOK, u32, HWND, i32, i32, u32, u32), ) -> Option { unsafe { let hook = SetWinEventHook( diff --git a/src/poller.rs b/src/poller.rs index b95d9cf..ecf8402 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -2,19 +2,17 @@ use std::path::PathBuf; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use std::os::windows::process::CommandExt; use serde::Deserialize; +use std::os::windows::process::CommandExt; +use crate::localization::Strings; use crate::models::{UsageData, UsageSection}; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; const CREATE_NO_WINDOW: u32 = 0x08000000; -const MODEL_FALLBACK_CHAIN: &[&str] = &[ - "claude-3-haiku-20240307", - "claude-haiku-4-5-20251001", -]; +const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"]; #[derive(Debug)] pub enum PollError { @@ -167,7 +165,11 @@ fn resolve_windows_claude_path() -> String { } for name in &["claude.cmd", "claude"] { - if let Ok(output) = Command::new("where.exe").arg(name).creation_flags(CREATE_NO_WINDOW).output() { + if let Ok(output) = Command::new("where.exe") + .arg(name) + .creation_flags(CREATE_NO_WINDOW) + .output() + { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); if let Some(first_line) = stdout.lines().next() { @@ -280,11 +282,19 @@ fn fetch_usage_via_messages(token: &str) -> Result { fn parse_rate_limit_headers(response: &ureq::Response) -> UsageData { let mut data = UsageData::default(); - data.session.percentage = get_header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0; - data.session.resets_at = unix_to_system_time(get_header_i64(response, "anthropic-ratelimit-unified-5h-reset")); + data.session.percentage = + get_header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0; + data.session.resets_at = unix_to_system_time(get_header_i64( + response, + "anthropic-ratelimit-unified-5h-reset", + )); - data.weekly.percentage = get_header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0; - data.weekly.resets_at = unix_to_system_time(get_header_i64(response, "anthropic-ratelimit-unified-7d-reset")); + data.weekly.percentage = + get_header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0; + data.weekly.resets_at = unix_to_system_time(get_header_i64( + response, + "anthropic-ratelimit-unified-7d-reset", + )); let overall_reset = get_header_i64(response, "anthropic-ratelimit-unified-reset"); @@ -315,9 +325,7 @@ fn get_header_f64(response: &ureq::Response, name: &str) -> f64 { } fn get_header_i64(response: &ureq::Response, name: &str) -> Option { - response - .header(name) - .and_then(|s| s.parse::().ok()) + response.header(name).and_then(|s| s.parse::().ok()) } fn unix_to_system_time(unix_secs: Option) -> Option { @@ -404,7 +412,10 @@ fn parse_credentials(content: &str, source: CredentialSource) -> Option Result { // Extract date and time parts from "YYYY-MM-DDTHH:MM:SS[.frac]" let (date_str, time_str) = s.split_once('T').ok_or(())?; let date_parts: Vec<&str> = date_str.split('-').collect(); - if date_parts.len() != 3 { return Err(()); } + if date_parts.len() != 3 { + return Err(()); + } let year: u64 = date_parts[0].parse().map_err(|_| ())?; let month: u64 = date_parts[1].parse().map_err(|_| ())?; @@ -533,7 +546,9 @@ fn parse_datetime_to_unix(s: &str, _fmt: &str) -> Result { // Strip fractional seconds let time_base = time_str.split('.').next().unwrap_or(time_str); let time_parts: Vec<&str> = time_base.split(':').collect(); - if time_parts.len() != 3 { return Err(()); } + if time_parts.len() != 3 { + return Err(()); + } let hour: u64 = time_parts[0].parse().map_err(|_| ())?; let min: u64 = time_parts[1].parse().map_err(|_| ())?; @@ -562,9 +577,9 @@ fn is_leap(y: u64) -> bool { } /// Format a usage section as "X% · Yh" style text -pub fn format_line(section: &UsageSection) -> String { +pub fn format_line(section: &UsageSection, strings: Strings) -> String { let pct = format!("{:.0}%", section.percentage); - let cd = format_countdown(section.resets_at); + let cd = format_countdown(section.resets_at, strings); if cd.is_empty() { pct } else { @@ -572,7 +587,7 @@ pub fn format_line(section: &UsageSection) -> String { } } -fn format_countdown(resets_at: Option) -> String { +fn format_countdown(resets_at: Option, strings: Strings) -> String { let reset = match resets_at { Some(t) => t, None => return String::new(), @@ -580,7 +595,7 @@ fn format_countdown(resets_at: Option) -> String { let remaining = match reset.duration_since(SystemTime::now()) { Ok(d) => d, - Err(_) => return "now".to_string(), + Err(_) => return strings.now.to_string(), }; let total_secs = remaining.as_secs(); @@ -589,13 +604,13 @@ fn format_countdown(resets_at: Option) -> String { let total_days = total_secs / 86400; if total_days >= 1 { - format!("{total_days}d") + format!("{total_days}{}", strings.day_suffix) } else if total_mins > 61 { - format!("{total_hours}h") + format!("{total_hours}{}", strings.hour_suffix) } else if total_secs > 60 { - format!("{total_mins}m") + format!("{total_mins}{}", strings.minute_suffix) } else { - format!("{total_secs}") + format!("{total_secs}{}", strings.second_suffix) } } diff --git a/src/theme.rs b/src/theme.rs index 63d4bf9..05ad0bb 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,5 @@ -use windows::Win32::System::Registry::*; use windows::core::PCWSTR; +use windows::Win32::System::Registry::*; use crate::native_interop::wide_str; diff --git a/src/window.rs b/src/window.rs index fe3c17e..2804027 100644 --- a/src/window.rs +++ b/src/window.rs @@ -3,6 +3,8 @@ use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Mutex, MutexGuard}; use std::time::Duration; +use serde::{Deserialize, Serialize}; +use windows::core::PCWSTR; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}; @@ -12,10 +14,12 @@ use windows::Win32::UI::Accessibility::HWINEVENTHOOK; use windows::Win32::UI::HiDpi::*; use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture}; use windows::Win32::UI::WindowsAndMessaging::*; -use windows::core::PCWSTR; +use crate::localization::{self, LanguageId, Strings}; use crate::models::UsageData; -use crate::native_interop::{self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, WM_APP_USAGE_UPDATED}; +use crate::native_interop::{ + self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, WM_APP_USAGE_UPDATED, +}; use crate::poller; use crate::theme; @@ -42,6 +46,8 @@ struct AppState { win_event_hook: Option, is_dark: bool, embedded: bool, + language_override: Option, + language: LanguageId, session_percent: f64, session_text: String, @@ -74,6 +80,12 @@ 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; +const IDM_LANG_SYSTEM: u16 = 40; +const IDM_LANG_ENGLISH: u16 = 41; +const IDM_LANG_SPANISH: u16 = 42; +const IDM_LANG_FRENCH: u16 = 43; +const IDM_LANG_GERMAN: u16 = 44; +const IDM_LANG_JAPANESE: u16 = 45; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN @@ -113,7 +125,6 @@ fn lock_state() -> MutexGuard<'static, Option> { STATE.lock().unwrap_or_else(|e| e.into_inner()) } - fn settings_path() -> PathBuf { let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); PathBuf::from(appdata) @@ -121,50 +132,113 @@ fn settings_path() -> PathBuf { .join("settings.json") } -fn parse_json_i32(content: &str, key: &str) -> Option { - let needle = format!("\"{}\"", key); - let pos = content.find(&needle)?; - let rest = &content[pos + needle.len()..]; - let colon = rest.find(':')?; - let num_str: String = rest[colon + 1..] - .trim() - .chars() - .take_while(|c| c.is_ascii_digit() || *c == '-') - .collect(); - num_str.parse().ok() +#[derive(Debug, Serialize, Deserialize)] +struct SettingsFile { + #[serde(default)] + tray_offset: i32, + #[serde(default = "default_poll_interval")] + poll_interval_ms: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + language: Option, } -fn load_settings() -> (i32, u32) { +impl Default for SettingsFile { + fn default() -> Self { + Self { + tray_offset: 0, + poll_interval_ms: default_poll_interval(), + language: None, + } + } +} + +fn default_poll_interval() -> u32 { + POLL_15_MIN +} + +fn load_settings() -> SettingsFile { let content = match std::fs::read_to_string(settings_path()) { Ok(c) => c, - Err(_) => return (0, POLL_15_MIN), + Err(_) => return SettingsFile::default(), }; - let tray_offset = parse_json_i32(&content, "tray_offset").unwrap_or(0); - let poll_interval = parse_json_i32(&content, "poll_interval_ms") - .map(|v| v as u32) - .unwrap_or(POLL_15_MIN); - (tray_offset, poll_interval) + serde_json::from_str(&content).unwrap_or_default() } -fn save_settings(tray_offset: i32, poll_interval_ms: u32) { +fn save_settings(settings: &SettingsFile) { let path = settings_path(); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let json = format!( - "{{\n \"tray_offset\": {},\n \"poll_interval_ms\": {}\n}}", - tray_offset, poll_interval_ms - ); - let _ = std::fs::write(path, json); + if let Ok(json) = serde_json::to_string_pretty(settings) { + let _ = std::fs::write(path, json); + } } fn save_state_settings() { let state = lock_state(); if let Some(s) = state.as_ref() { - save_settings(s.tray_offset, s.poll_interval_ms); + save_settings(&SettingsFile { + tray_offset: s.tray_offset, + poll_interval_ms: s.poll_interval_ms, + language: s + .language_override + .map(|language| language.code().to_string()), + }); } } +fn refresh_usage_texts(state: &mut AppState) { + if !state.last_poll_ok { + return; + } + + let strings = state.language.strings(); + let Some((session_text, weekly_text)) = state.data.as_ref().map(|data| { + ( + poller::format_line(&data.session, strings), + poller::format_line(&data.weekly, strings), + ) + }) else { + return; + }; + + state.session_text = session_text; + state.weekly_text = weekly_text; +} + +fn set_window_title(hwnd: HWND, strings: Strings) { + unsafe { + let title = native_interop::wide_str(strings.window_title); + let _ = SetWindowTextW(hwnd, PCWSTR::from_raw(title.as_ptr())); + } +} + +fn apply_language_to_state(state: &mut AppState, language_override: Option) { + state.language_override = language_override; + state.language = localization::resolve_language(language_override); + set_window_title(state.hwnd.to_hwnd(), state.language.strings()); + refresh_usage_texts(state); +} + +fn update_language_change() -> bool { + let mut state = lock_state(); + let Some(app_state) = state.as_mut() else { + return false; + }; + + if app_state.language_override.is_some() { + return false; + } + + let new_language = localization::detect_system_language(); + if new_language == app_state.language { + return false; + } + + apply_language_to_state(app_state, None); + true +} + const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; const STARTUP_REGISTRY_KEY: &str = "ClaudeCodeUsageMonitor"; @@ -217,10 +291,8 @@ fn is_startup_enabled() -> bool { } // Convert the registry value (UTF-16) to a string - let wide_slice = std::slice::from_raw_parts( - buf.as_ptr() as *const u16, - data_size as usize / 2, - ); + let wide_slice = + std::slice::from_raw_parts(buf.as_ptr() as *const u16, data_size as usize / 2); let reg_value = String::from_utf16_lossy(wide_slice) .trim_end_matches('\0') .to_string(); @@ -349,8 +421,12 @@ pub fn run() { RegisterClassExW(&wc); + let settings = load_settings(); + let language_override = settings.language.as_deref().and_then(LanguageId::from_code); + let language = localization::resolve_language(language_override); + // Create as layered popup (will be reparented into taskbar) - let title = native_interop::wide_str("Claude Code Usage Monitor"); + let title = native_interop::wide_str(language.strings().window_title); let hwnd = CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_NOACTIVATE, PCWSTR::from_raw(class_name.as_ptr()), @@ -369,7 +445,6 @@ pub fn run() { let is_dark = theme::is_dark_mode(); let mut embedded = false; - let (saved_offset, saved_poll_interval) = load_settings(); { let mut state = lock_state(); @@ -380,15 +455,17 @@ pub fn run() { win_event_hook: None, is_dark, embedded: false, + language_override, + language, session_percent: 0.0, session_text: "--".to_string(), weekly_percent: 0.0, weekly_text: "--".to_string(), data: None, - poll_interval_ms: saved_poll_interval, + poll_interval_ms: settings.poll_interval_ms, retry_count: 0, last_poll_ok: false, - tray_offset: saved_offset, + tray_offset: settings.tray_offset, dragging: false, drag_start_mouse_x: 0, drag_start_offset: 0, @@ -410,8 +487,7 @@ pub fn run() { if let Some(tray_hwnd) = tray_notify { let thread_id = native_interop::get_window_thread_id(tray_hwnd); - let hook = - native_interop::set_tray_event_hook(thread_id, on_tray_location_changed); + let hook = native_interop::set_tray_event_hook(thread_id, on_tray_location_changed); s.win_event_hook = hook; } } @@ -440,7 +516,10 @@ pub fn run() { // Poll timer: 15 minutes let initial_poll_ms = { let state = lock_state(); - state.as_ref().map(|s| s.poll_interval_ms).unwrap_or(POLL_15_MIN) + state + .as_ref() + .map(|s| s.poll_interval_ms) + .unwrap_or(POLL_15_MIN) }; SetTimer(hwnd, TIMER_POLL, initial_poll_ms, None); @@ -467,13 +546,14 @@ pub fn run() { /// ClearType sub-pixel font rendering can be used for crisp, OS-native text. fn render_layered() { refresh_dpi(); - let (hwnd_val, is_dark, embedded, session_pct, session_text, weekly_pct, weekly_text) = { + let (hwnd_val, is_dark, embedded, strings, session_pct, session_text, weekly_pct, weekly_text) = { 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, @@ -531,8 +611,8 @@ fn render_layered() { let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); let mem_dc = CreateCompatibleDC(screen_dc); - let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) - .unwrap_or_default(); + let dib = + CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default(); if dib.is_invalid() || bits.is_null() { let _ = DeleteDC(mem_dc); @@ -546,9 +626,21 @@ fn render_layered() { // Render once with the actual taskbar background colour. // Using an opaque background lets us use CLEARTYPE_QUALITY for // sub-pixel font rendering that matches the rest of the OS. - paint_content(mem_dc, width, height, is_dark, &bg_color, - &text_color, &accent, &track, - session_pct, &session_text, weekly_pct, &weekly_text); + paint_content( + mem_dc, + width, + height, + is_dark, + &bg_color, + &text_color, + &accent, + &track, + strings, + session_pct, + &session_text, + weekly_pct, + &weekly_text, + ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). // Content pixels → fully opaque (preserves ClearType sub-pixel rendering). @@ -606,6 +698,7 @@ fn paint_content( text_color: &Color, accent: &Color, track: &Color, + strings: Strings, session_pct: f64, session_text: &str, weekly_pct: f64, @@ -634,7 +727,9 @@ fn paint_content( ((160, 160, 160), (230, 230, 230)) }; - let left_brush = CreateSolidBrush(COLORREF(native_interop::colorref(div_left.0, div_left.1, div_left.2))); + let left_brush = CreateSolidBrush(COLORREF(native_interop::colorref( + div_left.0, div_left.1, div_left.2, + ))); let left_rect = RECT { left: 0, top: divider_top, @@ -644,7 +739,11 @@ fn paint_content( FillRect(hdc, &left_rect, left_brush); let _ = DeleteObject(left_brush); - let right_brush = CreateSolidBrush(COLORREF(native_interop::colorref(div_right.0, div_right.1, div_right.2))); + let right_brush = CreateSolidBrush(COLORREF(native_interop::colorref( + div_right.0, + div_right.1, + div_right.2, + ))); let right_rect = RECT { left: sc(2), top: divider_top, @@ -680,8 +779,26 @@ fn paint_content( ); let old_font = SelectObject(hdc, font); - draw_row(hdc, content_x, row1_y, "5h", session_pct, session_text, accent, track); - draw_row(hdc, content_x, row2_y, "7d", weekly_pct, weekly_text, accent, track); + draw_row( + hdc, + content_x, + row1_y, + strings.session_window, + session_pct, + session_text, + accent, + track, + ); + draw_row( + hdc, + content_x, + row2_y, + strings.weekly_window, + weekly_pct, + weekly_text, + accent, + track, + ); SelectObject(hdc, old_font); let _ = DeleteObject(font); @@ -692,15 +809,10 @@ fn do_poll(send_hwnd: SendHwnd) { let hwnd = send_hwnd.to_hwnd(); match poller::poll() { Ok(data) => { - let session_text = poller::format_line(&data.session); - let weekly_text = poller::format_line(&data.weekly); - let mut state = lock_state(); if let Some(s) = state.as_mut() { s.session_percent = data.session.percentage; s.weekly_percent = data.weekly.percentage; - s.session_text = session_text; - s.weekly_text = weekly_text; // Stop fast-poll if reset data is now fresh if !poller::is_past_reset(&data) { unsafe { @@ -710,6 +822,7 @@ fn do_poll(send_hwnd: SendHwnd) { s.data = Some(data); s.last_poll_ok = true; + refresh_usage_texts(s); // Recovered from errors — restore normal poll interval if s.retry_count > 0 { @@ -735,9 +848,8 @@ fn do_poll(send_hwnd: SendHwnd) { // Exponential backoff retry: 30s, 60s, 120s, ... up to poll_interval 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), - ); + let backoff = RETRY_BASE_MS + .saturating_mul(1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX)); let retry_ms = backoff.min(s.poll_interval_ms); unsafe { @@ -815,6 +927,12 @@ fn check_theme_change() { } } +fn check_language_change() { + if update_language_change() { + render_layered(); + } +} + fn update_display() { let mut state = lock_state(); let s = match state.as_mut() { @@ -827,10 +945,7 @@ fn update_display() { return; } - if let Some(ref data) = s.data { - s.session_text = poller::format_line(&data.session); - s.weekly_text = poller::format_line(&data.weekly); - } + refresh_usage_texts(s); } fn position_at_taskbar() { @@ -960,6 +1075,10 @@ unsafe extern "system" fn wnd_proc( let new_dpi = (wparam.0 & 0xFFFF) as u32; CURRENT_DPI.store(new_dpi, Ordering::Relaxed); } + if msg == WM_SETTINGCHANGE { + check_theme_change(); + check_language_change(); + } refresh_dpi(); position_at_taskbar(); render_layered(); @@ -991,6 +1110,7 @@ unsafe extern "system" fn wnd_proc( } WM_APP_USAGE_UPDATED => { check_theme_change(); + check_language_change(); render_layered(); schedule_countdown_timer(); LRESULT(0) @@ -1003,18 +1123,17 @@ unsafe extern "system" fn wnd_proc( // Always show resize cursor while dragging or when hovering divider zone let hit_test = (lparam.0 & 0xFFFF) as u16; if is_dragging { - let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE) - .unwrap_or_default(); + let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); SetCursor(cursor); return LRESULT(1); } - if hit_test == 1 { // HTCLIENT + if hit_test == 1 { + // HTCLIENT let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); let _ = ScreenToClient(hwnd, &mut pt); if pt.x < sc(DIVIDER_HIT_ZONE) { - let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE) - .unwrap_or_default(); + let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); SetCursor(cursor); return LRESULT(1); } @@ -1064,8 +1183,11 @@ unsafe extern "system" fn wnd_proc( if let Some(taskbar_hwnd) = s.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) { + 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; } } @@ -1089,8 +1211,11 @@ unsafe extern "system" fn wnd_proc( if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { let taskbar_height = taskbar_rect.bottom - taskbar_rect.top; 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) { + 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; } } @@ -1099,11 +1224,23 @@ unsafe extern "system" fn wnd_proc( if s.embedded { let x = tray_left - taskbar_rect.left - widget_width - new_offset; let y = (taskbar_height - widget_height) / 2; - native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); + native_interop::move_window( + hwnd_val, + x, + y, + widget_width, + widget_height, + ); } else { let x = tray_left - widget_width - new_offset; let y = taskbar_rect.top + (taskbar_height - widget_height) / 2; - native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); + native_interop::move_window( + hwnd_val, + x, + y, + widget_width, + widget_height, + ); } } } @@ -1193,6 +1330,26 @@ unsafe extern "system" fn wnd_proc( // Reset the poll timer with the new interval SetTimer(hwnd, TIMER_POLL, new_interval, None); } + IDM_LANG_SYSTEM | IDM_LANG_ENGLISH | IDM_LANG_SPANISH | IDM_LANG_FRENCH + | IDM_LANG_GERMAN | IDM_LANG_JAPANESE => { + let language_override = match id { + IDM_LANG_SYSTEM => None, + IDM_LANG_ENGLISH => Some(LanguageId::English), + IDM_LANG_SPANISH => Some(LanguageId::Spanish), + IDM_LANG_FRENCH => Some(LanguageId::French), + IDM_LANG_GERMAN => Some(LanguageId::German), + IDM_LANG_JAPANESE => Some(LanguageId::Japanese), + _ => None, + }; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + apply_language_to_state(s, language_override); + } + } + save_state_settings(); + render_layered(); + } _ => {} } LRESULT(0) @@ -1214,14 +1371,21 @@ unsafe extern "system" fn wnd_proc( fn show_context_menu(hwnd: HWND) { unsafe { - let current_interval = { + let (current_interval, strings, language_override) = { let state = lock_state(); - state.as_ref().map(|s| s.poll_interval_ms).unwrap_or(POLL_15_MIN) + match state.as_ref() { + Some(s) => ( + s.poll_interval_ms, + s.language.strings(), + s.language_override, + ), + None => (POLL_15_MIN, LanguageId::English.strings(), None), + } }; let menu = CreatePopupMenu().unwrap(); - let refresh_str = native_interop::wide_str("Refresh"); + let refresh_str = native_interop::wide_str(strings.refresh); let _ = AppendMenuW( menu, MENU_ITEM_FLAGS(0), @@ -1231,13 +1395,13 @@ fn show_context_menu(hwnd: HWND) { // Update Frequency submenu let freq_menu = CreatePopupMenu().unwrap(); - let freq_items: &[(u16, u32, &str)] = &[ - (IDM_FREQ_1MIN, POLL_1_MIN, "1 Minute"), - (IDM_FREQ_5MIN, POLL_5_MIN, "5 Minutes"), - (IDM_FREQ_15MIN, POLL_15_MIN, "15 Minutes"), - (IDM_FREQ_1HOUR, POLL_1_HOUR, "1 Hour"), + let freq_items: [(u16, u32, &str); 4] = [ + (IDM_FREQ_1MIN, POLL_1_MIN, strings.one_minute), + (IDM_FREQ_5MIN, POLL_5_MIN, strings.five_minutes), + (IDM_FREQ_15MIN, POLL_15_MIN, strings.fifteen_minutes), + (IDM_FREQ_1HOUR, POLL_1_HOUR, strings.one_hour), ]; - for &(id, interval, label) in freq_items { + for (id, interval, label) in freq_items { let label_str = native_interop::wide_str(label); let flags = if interval == current_interval { MF_CHECKED @@ -1252,7 +1416,7 @@ fn show_context_menu(hwnd: HWND) { ); } - let freq_label = native_interop::wide_str("Update Frequency"); + let freq_label = native_interop::wide_str(strings.update_frequency); let _ = AppendMenuW( menu, MF_POPUP, @@ -1263,7 +1427,7 @@ fn show_context_menu(hwnd: HWND) { // Settings submenu let settings_menu = CreatePopupMenu().unwrap(); - let startup_str = native_interop::wide_str("Start with Windows"); + let startup_str = native_interop::wide_str(strings.start_with_windows); let startup_flags = if is_startup_enabled() { MF_CHECKED } else { @@ -1276,7 +1440,7 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(startup_str.as_ptr()), ); - let reset_pos_str = native_interop::wide_str("Reset Position"); + let reset_pos_str = native_interop::wide_str(strings.reset_position); let _ = AppendMenuW( settings_menu, MENU_ITEM_FLAGS(0), @@ -1284,6 +1448,50 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + let language_menu = CreatePopupMenu().unwrap(); + let system_label = native_interop::wide_str(strings.system_default); + let system_flags = if language_override.is_none() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + language_menu, + system_flags, + IDM_LANG_SYSTEM as usize, + PCWSTR::from_raw(system_label.as_ptr()), + ); + + for language in LanguageId::ALL { + let id = match language { + LanguageId::English => IDM_LANG_ENGLISH, + LanguageId::Spanish => IDM_LANG_SPANISH, + LanguageId::French => IDM_LANG_FRENCH, + LanguageId::German => IDM_LANG_GERMAN, + LanguageId::Japanese => IDM_LANG_JAPANESE, + }; + let label_str = native_interop::wide_str(language.native_name()); + let flags = if language_override == Some(language) { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + language_menu, + flags, + id as usize, + PCWSTR::from_raw(label_str.as_ptr()), + ); + } + + let language_label = native_interop::wide_str(strings.language); + let _ = AppendMenuW( + settings_menu, + MF_POPUP, + language_menu.0 as usize, + PCWSTR::from_raw(language_label.as_ptr()), + ); + let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); let version_str = native_interop::wide_str(&format!("v{}", env!("CARGO_PKG_VERSION"))); @@ -1294,7 +1502,7 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(version_str.as_ptr()), ); - let settings_label = native_interop::wide_str("Settings"); + let settings_label = native_interop::wide_str(strings.settings); let _ = AppendMenuW( menu, MF_POPUP, @@ -1304,7 +1512,7 @@ fn show_context_menu(hwnd: HWND) { let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); - let exit_str = native_interop::wide_str("Exit"); + let exit_str = native_interop::wide_str(strings.exit); let _ = AppendMenuW( menu, MENU_ITEM_FLAGS(0), @@ -1322,11 +1530,12 @@ fn show_context_menu(hwnd: HWND) { /// Paint for non-embedded fallback (normal WM_PAINT path) fn paint(hdc: HDC, hwnd: HWND) { - let (is_dark, session_pct, session_text, weekly_pct, weekly_text) = { + let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text) = { 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, @@ -1367,9 +1576,21 @@ fn paint(hdc: HDC, hwnd: HWND) { let mem_bmp = CreateCompatibleBitmap(hdc, width, height); let old_bmp = SelectObject(mem_dc, mem_bmp); - paint_content(mem_dc, width, height, is_dark, &bg_color, - &text_color, &accent, &track, - session_pct, &session_text, weekly_pct, &weekly_text); + paint_content( + mem_dc, + width, + height, + is_dark, + &bg_color, + &text_color, + &accent, + &track, + strings, + session_pct, + &session_text, + weekly_pct, + &weekly_text, + ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -1457,8 +1678,7 @@ fn draw_row( } } - let text_x = - bar_x + SEGMENT_COUNT * (seg_w + seg_gap) - seg_gap + sc(BAR_RIGHT_MARGIN); + 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,