mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
v1.1.0
- Added Windows-aware UI localisation with language override
This commit is contained in:
parent
ec4177f7f6
commit
d6ea143f05
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-monitor"
|
||||
version = "1.0.14"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"native-tls",
|
||||
|
||||
@ -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",
|
||||
|
||||
4
build.rs
4
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::<u64>().unwrap_or(0));
|
||||
let mut parts = core.split('.').map(|part| part.parse::<u64>().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);
|
||||
|
||||
287
src/localization.rs
Normal file
287
src/localization.rs
Normal file
@ -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<Self> {
|
||||
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>) -> 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<String> {
|
||||
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<LanguageId> {
|
||||
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<LanguageId> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
mod localization;
|
||||
mod models;
|
||||
mod native_interop;
|
||||
mod poller;
|
||||
|
||||
@ -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<HWINEVENTHOOK> {
|
||||
unsafe {
|
||||
let hook = SetWinEventHook(
|
||||
|
||||
@ -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<UsageData, PollError> {
|
||||
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<i64> {
|
||||
response
|
||||
.header(name)
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
response.header(name).and_then(|s| s.parse::<i64>().ok())
|
||||
}
|
||||
|
||||
fn unix_to_system_time(unix_secs: Option<i64>) -> Option<SystemTime> {
|
||||
@ -404,7 +412,10 @@ fn parse_credentials(content: &str, source: CredentialSource) -> Option<Credenti
|
||||
let json: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||
|
||||
let oauth = json.get("claudeAiOauth")?;
|
||||
let access_token = oauth.get("accessToken").and_then(|v| v.as_str())?.to_string();
|
||||
let access_token = oauth
|
||||
.get("accessToken")
|
||||
.and_then(|v| v.as_str())?
|
||||
.to_string();
|
||||
let expires_at = oauth.get("expiresAt").and_then(|v| v.as_i64());
|
||||
|
||||
Some(Credentials {
|
||||
@ -524,7 +535,9 @@ fn parse_datetime_to_unix(s: &str, _fmt: &str) -> Result<u64, ()> {
|
||||
// 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<u64, ()> {
|
||||
// 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<SystemTime>) -> String {
|
||||
fn format_countdown(resets_at: Option<SystemTime>, 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<SystemTime>) -> 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<SystemTime>) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use windows::Win32::System::Registry::*;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::System::Registry::*;
|
||||
|
||||
use crate::native_interop::wide_str;
|
||||
|
||||
|
||||
404
src/window.rs
404
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<HWINEVENTHOOK>,
|
||||
is_dark: bool,
|
||||
embedded: bool,
|
||||
language_override: Option<LanguageId>,
|
||||
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<AppState>> {
|
||||
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<i32> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<LanguageId>) {
|
||||
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<u16> = text.encode_utf16().collect();
|
||||
let mut text_rect = RECT {
|
||||
left: text_x,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user