From 481c7eef9aec69c287e2eb95ff1d60e1e7766e44 Mon Sep 17 00:00:00 2001 From: A333 Date: Thu, 16 Apr 2026 16:26:37 +0300 Subject: [PATCH] Peak hours support --- .gitignore | 1 + Cargo.toml | 1 + DEVELOPMENT.md | 96 +++++++++ src/localization/english.rs | 15 ++ src/localization/french.rs | 14 ++ src/localization/german.rs | 14 ++ src/localization/japanese.rs | 15 ++ src/localization/korean.rs | 15 ++ src/localization/mod.rs | 14 ++ src/localization/spanish.rs | 14 ++ src/main.rs | 6 +- src/native_interop.rs | 1 + src/peak_dialog.rs | 391 +++++++++++++++++++++++++++++++++++ src/poller.rs | 5 +- src/tray_icon.rs | 13 +- src/window.rs | 299 ++++++++++++++++++++++++++- 16 files changed, 891 insertions(+), 23 deletions(-) create mode 100644 DEVELOPMENT.md create mode 100644 src/peak_dialog.rs diff --git a/.gitignore b/.gitignore index 0475229..0e6c269 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ workflow.cmd # Local WinGet manifest generation output /manifests/ +.claude/settings.local.json diff --git a/Cargo.toml b/Cargo.toml index 0648989..a239ce5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ features = [ "Win32_Security", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_HiDpi", + "Win32_System_SystemInformation", ] [build-dependencies] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..2933636 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,96 @@ +# Development Guide + +This document covers how to build and run **Claude Code Usage Monitor** locally. + +## Prerequisites + +- **Windows 10 or Windows 11** (the app uses Win32 APIs and can only be built and run on Windows) +- **Rust** (stable toolchain) — install via [rustup](https://rustup.rs/) +- **MSVC build tools** — required by the `windows` crate and `winres` + - Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **Desktop development with C++** workload, or install a full Visual Studio edition + +## Clone the Repository + +```powershell +git clone https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git +cd Claude-Code-Usage-Monitor +``` + +## Build + +### Debug build + +```powershell +`cargo build` +``` + +The binary is output to `target\debug\claude-code-usage-monitor.exe`. + +### Release build + +```powershell +cargo build --release +``` + +The binary is output to `target\release\claude-code-usage-monitor.exe`. The release profile applies aggressive size optimisations (`opt-level = "z"`, LTO, stripping, single codegen unit). + +> **Note:** `build.rs` embeds the app icon and Windows PE version metadata during compilation using `winres`. This requires the MSVC toolchain — it will not work with the GNU (`*-pc-windows-gnu`) target. + +## Run` + +```powershell +cargo run +``` + +Or run the compiled binary directly: + +```powershell +.\target\debug\claude-code-usage-monitor.exe +``` + +The app starts as a system tray icon. Left-click the tray icon to toggle the taskbar widget. Right-click for options such as refresh interval, language, launch-at-startup, and update checks. + +## Project Structure + +``` +src/ + main.rs # Entry point, message loop + models.rs # Data types and Claude API response structs + poller.rs # Background polling logic + window.rs # Win32 taskbar widget window + tray_icon.rs # System tray icon and context menu + theme.rs # Colour theming (light/dark mode) + updater.rs # Auto-update checker + diagnose.rs # Diagnostics / debug helpers + native_interop.rs # Low-level Win32 helpers + icons/ # Embedded icon assets + localization/ # Translated UI strings (en, fr, de, es, ja, ko) +build.rs # Embeds icon and version info into the PE binary +Cargo.toml # Manifest and dependencies +``` + +## Dependencies + +| Crate | Purpose | +|-------|---------| +| `windows` | Win32 API bindings | +| `ureq` | HTTP client for Claude usage API (native-tls) | +| `serde` / `serde_json` | JSON serialisation | +| `dirs` | Locate user credential files | +| `winres` *(build)* | Embed icon and version info into the `.exe` | + +## Useful Commands + +```powershell +# Check for compile errors without producing a binary +cargo check + +# Run Clippy lints +cargo clippy + +# Format code +cargo fmt + +# Run tests (if any) +cargo test +``` diff --git a/src/localization/english.rs b/src/localization/english.rs index d730842..a8f7f88 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -35,4 +35,19 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", second_suffix: "s", + peak_hours: "Peak Hours...", + peak_hours_title: "Peak Hours", + peak_start_label: "Start (HH:MM):", + peak_end_label: "End (HH:MM):", + peak_tz_label: "UTC offset (hours):", + peak_tz_hint: "(leave empty to use system timezone)", + peak_clear: "Clear", + peak_invalid_input: "Invalid Input", + peak_err_both_required: + "Both start and end times must be set, or both empty to disable peak hours.", + peak_err_start_format: "Start time must be HH:MM (e.g. 09:00).", + peak_err_end_format: "End time must be HH:MM (e.g. 17:00).", + peak_err_tz_format: "UTC offset must be a number of hours (e.g. 10 for UTC+10, -5 for UTC-5).", + peak_err_tz_range: "UTC offset must be between -12 and +14 hours.", + peak_err_start_equals_end: "Start and end times must be different.", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index e1d51b8..6d772af 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -35,4 +35,18 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", second_suffix: "s", + peak_hours: "Heures de pointe...", + peak_hours_title: "Heures de pointe", + peak_start_label: "Début (HH:MM) :", + peak_end_label: "Fin (HH:MM) :", + peak_tz_label: "Décalage UTC (heures) :", + peak_tz_hint: "(laisser vide pour utiliser le fuseau horaire système)", + peak_clear: "Effacer", + peak_invalid_input: "Entrée invalide", + peak_err_both_required: "Les heures de début et de fin doivent être définies, ou les deux vides pour désactiver les heures de pointe.", + peak_err_start_format: "L'heure de début doit être HH:MM (ex. 09:00).", + peak_err_end_format: "L'heure de fin doit être HH:MM (ex. 17:00).", + peak_err_tz_format: "Le décalage UTC doit être un nombre d'heures (ex. 10 pour UTC+10, -5 pour UTC-5).", + peak_err_tz_range: "Le décalage UTC doit être entre -12 et +14 heures.", + peak_err_start_equals_end: "Les heures de début et de fin doivent être différentes.", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 70de6fb..d7fc029 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -35,4 +35,18 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", second_suffix: "s", + peak_hours: "Stoßzeiten...", + peak_hours_title: "Stoßzeiten", + peak_start_label: "Beginn (HH:MM):", + peak_end_label: "Ende (HH:MM):", + peak_tz_label: "UTC-Abweichung (Stunden):", + peak_tz_hint: "(leer lassen für Systemzeitzone)", + peak_clear: "Löschen", + peak_invalid_input: "Ungültige Eingabe", + peak_err_both_required: "Sowohl Start- als auch Endzeit müssen angegeben werden, oder beide leer, um Stoßzeiten zu deaktivieren.", + peak_err_start_format: "Startzeit muss HH:MM sein (z.B. 09:00).", + peak_err_end_format: "Endzeit muss HH:MM sein (z.B. 17:00).", + peak_err_tz_format: "UTC-Abweichung muss eine Anzahl von Stunden sein (z.B. 10 für UTC+10, -5 für UTC-5).", + peak_err_tz_range: "UTC-Abweichung muss zwischen -12 und +14 Stunden liegen.", + peak_err_start_equals_end: "Start- und Endzeit müssen unterschiedlich sein.", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 5078a21..deb7b3b 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -35,4 +35,19 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "時間", minute_suffix: "分", second_suffix: "秒", + peak_hours: "ピーク時間...", + peak_hours_title: "ピーク時間", + peak_start_label: "開始 (HH:MM):", + peak_end_label: "終了 (HH:MM):", + peak_tz_label: "UTCオフセット (時間):", + peak_tz_hint: "(空欄でシステムのタイムゾーンを使用)", + peak_clear: "クリア", + peak_invalid_input: "無効な入力", + peak_err_both_required: + "ピーク時間を無効にするには、開始と終了の両方を設定するか、両方を空にしてください。", + peak_err_start_format: "開始時刻はHH:MM形式で入力してください(例: 09:00)。", + peak_err_end_format: "終了時刻はHH:MM形式で入力してください(例: 17:00)。", + peak_err_tz_format: "UTCオフセットは時間数で入力してください(例: UTC+10は10、UTC-5は-5)。", + peak_err_tz_range: "UTCオフセットは-12から+14時間の範囲で入力してください。", + peak_err_start_equals_end: "開始時刻と終了時刻は異なる必要があります。", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index ca2a88e..5d1e78e 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -35,4 +35,19 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "시간", minute_suffix: "분", second_suffix: "초", + peak_hours: "피크 시간...", + peak_hours_title: "피크 시간", + peak_start_label: "시작 (HH:MM):", + peak_end_label: "종료 (HH:MM):", + peak_tz_label: "UTC 오프셋 (시간):", + peak_tz_hint: "(비워두면 시스템 시간대 사용)", + peak_clear: "지우기", + peak_invalid_input: "잘못된 입력", + peak_err_both_required: + "피크 시간을 비활성화하려면 시작과 종료 시간을 모두 설정하거나 모두 비워야 합니다.", + peak_err_start_format: "시작 시간은 HH:MM 형식이어야 합니다 (예: 09:00).", + peak_err_end_format: "종료 시간은 HH:MM 형식이어야 합니다 (예: 17:00).", + peak_err_tz_format: "UTC 오프셋은 시간 단위의 숫자여야 합니다 (예: UTC+10은 10, UTC-5는 -5).", + peak_err_tz_range: "UTC 오프셋은 -12에서 +14 시간 사이여야 합니다.", + peak_err_start_equals_end: "시작 시간과 종료 시간은 달라야 합니다.", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2b741f6..147258a 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -127,6 +127,20 @@ pub struct Strings { pub hour_suffix: &'static str, pub minute_suffix: &'static str, pub second_suffix: &'static str, + pub peak_hours: &'static str, + pub peak_hours_title: &'static str, + pub peak_start_label: &'static str, + pub peak_end_label: &'static str, + pub peak_tz_label: &'static str, + pub peak_tz_hint: &'static str, + pub peak_clear: &'static str, + pub peak_invalid_input: &'static str, + pub peak_err_both_required: &'static str, + pub peak_err_start_format: &'static str, + pub peak_err_end_format: &'static str, + pub peak_err_tz_format: &'static str, + pub peak_err_tz_range: &'static str, + pub peak_err_start_equals_end: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index f3adace..134a6e3 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -35,4 +35,18 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", second_suffix: "s", + peak_hours: "Horas pico...", + peak_hours_title: "Horas pico", + peak_start_label: "Inicio (HH:MM):", + peak_end_label: "Fin (HH:MM):", + peak_tz_label: "Desfase UTC (horas):", + peak_tz_hint: "(dejar vacío para usar la zona horaria del sistema)", + peak_clear: "Borrar", + peak_invalid_input: "Entrada no válida", + peak_err_both_required: "Ambas horas de inicio y fin deben estar establecidas, o ambas vacías para desactivar las horas pico.", + peak_err_start_format: "La hora de inicio debe ser HH:MM (ej. 09:00).", + peak_err_end_format: "La hora de fin debe ser HH:MM (ej. 17:00).", + peak_err_tz_format: "El desfase UTC debe ser un número de horas (ej. 10 para UTC+10, -5 para UTC-5).", + peak_err_tz_range: "El desfase UTC debe estar entre -12 y +14 horas.", + peak_err_start_equals_end: "Las horas de inicio y fin deben ser diferentes.", }; diff --git a/src/main.rs b/src/main.rs index 88c363e..dbd8381 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod diagnose; mod localization; mod models; mod native_interop; +mod peak_dialog; mod poller; mod theme; mod tray_icon; @@ -15,10 +16,7 @@ fn main() { let diagnose_enabled = args.iter().any(|arg| arg == "--diagnose"); if diagnose_enabled { match diagnose::init() { - Ok(path) => diagnose::log(format!( - "startup args={args:?} log_path={}", - path.display() - )), + Ok(path) => diagnose::log(format!("startup args={args:?} log_path={}", path.display())), Err(error) => { // Logging may not be available yet, but keep startup behavior unchanged. let _ = error; diff --git a/src/native_interop.rs b/src/native_interop.rs index 9bccd18..3af54c6 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -18,6 +18,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_PEAK: usize = 5; // Custom messages pub const WM_APP: u32 = 0x8000; diff --git a/src/peak_dialog.rs b/src/peak_dialog.rs new file mode 100644 index 0000000..46f72cf --- /dev/null +++ b/src/peak_dialog.rs @@ -0,0 +1,391 @@ +//! Peak hours configuration dialog. +//! +//! Presents a small Win32 dialog (built from an in-memory DLGTEMPLATE) that lets +//! the user configure start/end times (HH:MM) and an optional UTC offset in hours. +//! Leaving the UTC offset empty means "use system local time". + +use std::sync::Mutex; + +use windows::core::PCWSTR; +use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, WPARAM}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; + +use crate::localization::Strings; +use crate::native_interop::wide_str; + +// ── Control IDs ─────────────────────────────────────────────────────────────── + +const IDC_START: i32 = 101; +const IDC_END: i32 = 102; +const IDC_TZ: i32 = 103; +const IDC_CLEAR: i32 = 104; + +// ── Thread-local dialog state (main thread only) ────────────────────────────── + +struct DialogState { + start: String, + end: String, + tz: String, + strings: Strings, +} + +static DIALOG_INPUT: Mutex> = Mutex::new(None); +static DIALOG_OUTPUT: Mutex> = Mutex::new(None); + +// ── Public API ──────────────────────────────────────────────────────────────── + +pub struct PeakDialogResult { + /// "HH:MM" or empty to disable + pub start: String, + /// "HH:MM" or empty to disable + pub end: String, + /// Hours from UTC (e.g. "10"), or empty for system timezone + pub tz: String, +} + +/// Show the peak-hours dialog modally over `parent`. +/// Returns `None` if the user cancelled; `Some(result)` on OK. +pub fn show( + parent: HWND, + start: &str, + end: &str, + tz: &str, + strings: Strings, +) -> Option { + unsafe { + *DIALOG_INPUT.lock().unwrap_or_else(|e| e.into_inner()) = Some(DialogState { + start: start.to_string(), + end: end.to_string(), + tz: tz.to_string(), + strings, + }); + *DIALOG_OUTPUT.lock().unwrap_or_else(|e| e.into_inner()) = None; + + let hmodule = GetModuleHandleW(PCWSTR::null()).unwrap_or_default(); + let hinstance = HINSTANCE(hmodule.0); + let template = build_template(&strings); + + let ret = DialogBoxIndirectParamW( + hinstance, + template.as_ptr() as *const DLGTEMPLATE, + parent, + Some(dlg_proc), + LPARAM(0), + ); + + if ret <= 0 { + return None; + } + + DIALOG_OUTPUT + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .map(|(s, e, t)| PeakDialogResult { + start: s, + end: e, + tz: t, + }) + } +} + +/// Parse an "HH:MM" string into (hour, minute). Public so window.rs can reuse it. +pub fn parse_time(s: &str) -> Result<(u8, u8), ()> { + let s = s.trim(); + let (h_str, m_str) = s.split_once(':').ok_or(())?; + let h: u8 = h_str.trim().parse().map_err(|_| ())?; + let m: u8 = m_str.trim().parse().map_err(|_| ())?; + if h > 23 || m > 59 { + return Err(()); + } + Ok((h, m)) +} + +// ── Dialog procedure ────────────────────────────────────────────────────────── + +unsafe extern "system" fn dlg_proc(hdlg: HWND, msg: u32, wparam: WPARAM, _lparam: LPARAM) -> isize { + match msg { + WM_INITDIALOG => { + let input = DIALOG_INPUT.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(state) = input.as_ref() { + set_text(hdlg, IDC_START, &state.start); + set_text(hdlg, IDC_END, &state.end); + set_text(hdlg, IDC_TZ, &state.tz); + } + 1 // TRUE: let dialog manager set initial focus + } + + WM_COMMAND => { + let id = (wparam.0 & 0xFFFF) as i32; + match id { + 1 /* IDOK */ => { + let start = get_text(hdlg, IDC_START); + let end = get_text(hdlg, IDC_END); + let tz = get_text(hdlg, IDC_TZ); + + let strings = { + let input = DIALOG_INPUT.lock().unwrap_or_else(|e| e.into_inner()); + input + .as_ref() + .map(|s| s.strings) + .unwrap_or_else(|| crate::localization::LanguageId::English.strings()) + }; + + if let Err(err_msg) = validate(&start, &end, &tz, &strings) { + let msg_w = wide_str(&err_msg); + let title_w = wide_str(strings.peak_invalid_input); + MessageBoxW( + hdlg, + PCWSTR::from_raw(msg_w.as_ptr()), + PCWSTR::from_raw(title_w.as_ptr()), + MB_OK | MB_ICONWARNING, + ); + return 1; + } + + *DIALOG_OUTPUT.lock().unwrap_or_else(|e| e.into_inner()) = + Some((start, end, tz)); + let _ = EndDialog(hdlg, 1); + 1 + } + 2 /* IDCANCEL */ => { + let _ = EndDialog(hdlg, 0); + 1 + } + IDC_CLEAR => { + set_text(hdlg, IDC_START, ""); + set_text(hdlg, IDC_END, ""); + set_text(hdlg, IDC_TZ, ""); + 1 + } + _ => 0, + } + } + + _ => 0, + } +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +fn validate(start: &str, end: &str, tz: &str, strings: &Strings) -> Result<(), String> { + if start.is_empty() != end.is_empty() { + return Err(strings.peak_err_both_required.to_string()); + } + if !start.is_empty() { + let s = parse_time(start).map_err(|_| strings.peak_err_start_format.to_string())?; + if !end.is_empty() { + let e = parse_time(end).map_err(|_| strings.peak_err_end_format.to_string())?; + if s == e { + return Err(strings.peak_err_start_equals_end.to_string()); + } + } + } + if !end.is_empty() { + parse_time(end).map_err(|_| strings.peak_err_end_format.to_string())?; + } + if !tz.is_empty() { + let v: i32 = tz + .trim() + .parse() + .map_err(|_| strings.peak_err_tz_format.to_string())?; + if !(-12..=14).contains(&v) { + return Err(strings.peak_err_tz_range.to_string()); + } + } + Ok(()) +} + +// ── Edit-control helpers ────────────────────────────────────────────────────── + +fn set_text(hdlg: HWND, id: i32, text: &str) { + unsafe { + let wide = wide_str(text); + let _ = SetDlgItemTextW(hdlg, id, PCWSTR::from_raw(wide.as_ptr())); + } +} + +fn get_text(hdlg: HWND, id: i32) -> String { + unsafe { + let mut buf = [0u16; 64]; + let len = GetDlgItemTextW(hdlg, id, &mut buf); + String::from_utf16_lossy(&buf[..len as usize]) + .trim() + .to_string() + } +} + +// ── In-memory DLGTEMPLATE builder ───────────────────────────────────────────── + +struct Buf(Vec); + +impl Buf { + fn new() -> Self { + Self(Vec::with_capacity(512)) + } + fn align4(&mut self) { + while !self.0.len().is_multiple_of(4) { + self.0.push(0); + } + } + fn u16v(&mut self, v: u16) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn u32v(&mut self, v: u32) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn i16v(&mut self, v: i16) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn wstr(&mut self, s: &str) { + for c in s.encode_utf16() { + self.u16v(c); + } + self.u16v(0); + } + fn patch_u16(&mut self, offset: usize, v: u16) { + self.0[offset] = (v & 0xFF) as u8; + self.0[offset + 1] = (v >> 8) as u8; + } +} + +/// Build the DLGTEMPLATE buffer for the peak-hours dialog. +fn build_template(strings: &Strings) -> Vec { + // Numeric style constants to avoid extra imports inside this file + const DIALOG_STYLE: u32 = 0x80000000 // WS_POPUP + | 0x00C00000 // WS_CAPTION + | 0x00080000 // WS_SYSMENU + | 0x0040 // DS_SETFONT + | 0x0080 // DS_MODALFRAME + | 0x0800; // DS_CENTER + + const WS_VISIBLE: u32 = 0x10000000; + const WS_CHILD: u32 = 0x40000000; + const WS_TABSTOP: u32 = 0x00010000; + const WS_BORDER: u32 = 0x00800000; + const ES_AUTOHSCROLL: u32 = 0x0080; + const BS_DEFPUSHBUTTON: u32 = 0x00000001; + + const ATOM_BUTTON: u16 = 0x0080; + const ATOM_EDIT: u16 = 0x0081; + const ATOM_STATIC: u16 = 0x0082; + + let mut b = Buf::new(); + + // ── DLGTEMPLATE header (18 bytes fixed) ───────────────────────────────── + b.u32v(DIALOG_STYLE); + b.u32v(0); // exStyle + let cdit_offset = b.0.len(); + b.u16v(0); // cdit – patched below + b.i16v(0); // x (DS_CENTER overrides) + b.i16v(0); // y + b.i16v(210); // cx in dialog units + b.i16v(100); // cy in dialog units + + b.u16v(0); // menu: none + b.u16v(0); // class: default dialog class + b.wstr(strings.peak_hours_title); + + // DS_SETFONT: point size + typeface name + b.u16v(9); + b.wstr("Segoe UI"); + + // ── Item helper macro ──────────────────────────────────────────────────── + let mut n: u16 = 0; + macro_rules! item { + ($style:expr, $x:expr, $y:expr, $cx:expr, $cy:expr, + $id:expr, $atom:expr, $text:expr) => {{ + b.align4(); + b.u32v($style); + b.u32v(0u32); // exStyle + b.i16v($x as i16); + b.i16v($y as i16); + b.i16v($cx as i16); + b.i16v($cy as i16); + b.u16v($id as u16); + b.u16v(0xFFFF_u16); // atom-class marker + b.u16v($atom); + b.wstr($text); + b.u16v(0u16); // cbExtra + n += 1; + }}; + } + + let lbl = WS_VISIBLE | WS_CHILD; + let edt = WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_BORDER | ES_AUTOHSCROLL; + let btn = WS_VISIBLE | WS_CHILD | WS_TABSTOP; + + // Row 1 – start time + item!( + lbl, + 7, + 14, + 83, + 8, + 0xFFFF_u16, + ATOM_STATIC, + strings.peak_start_label + ); + item!(edt, 93, 12, 55, 12, IDC_START as u16, ATOM_EDIT, ""); + // Row 2 – end time + item!( + lbl, + 7, + 30, + 83, + 8, + 0xFFFF_u16, + ATOM_STATIC, + strings.peak_end_label + ); + item!(edt, 93, 28, 55, 12, IDC_END as u16, ATOM_EDIT, ""); + // Row 3 – UTC offset + item!( + lbl, + 7, + 46, + 83, + 8, + 0xFFFF_u16, + ATOM_STATIC, + strings.peak_tz_label + ); + item!(edt, 93, 44, 55, 12, IDC_TZ as u16, ATOM_EDIT, ""); + // Hint text + item!( + lbl, + 7, + 59, + 196, + 8, + 0xFFFF_u16, + ATOM_STATIC, + strings.peak_tz_hint + ); + // Buttons + item!( + btn, + 7, + 80, + 42, + 14, + IDC_CLEAR as u16, + ATOM_BUTTON, + strings.peak_clear + ); + item!( + btn | BS_DEFPUSHBUTTON, + 105, + 80, + 45, + 14, + 1u16, + ATOM_BUTTON, + "OK" + ); + item!(btn, 155, 80, 45, 14, 2u16, ATOM_BUTTON, "Cancel"); + + b.patch_u16(cdit_offset, n); + b.0 +} diff --git a/src/poller.rs b/src/poller.rs index 938075c..0927abd 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -417,7 +417,10 @@ fn read_windows_credentials() -> Option { Err(error) => { if diagnose::is_enabled() { diagnose::log_error( - &format!("unable to read Windows credentials at {}", cred_path.display()), + &format!( + "unable to read Windows credentials at {}", + cred_path.display() + ), error, ); } diff --git a/src/tray_icon.rs b/src/tray_icon.rs index 1fc2593..084eb90 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -1,12 +1,12 @@ +use windows::core::PCWSTR; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; use windows::Win32::System::LibraryLoader::GetModuleFileNameW; use windows::Win32::UI::Shell::{ - ExtractIconExW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, - NOTIFYICONDATAW, Shell_NotifyIconW, + ExtractIconExW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, + NIM_MODIFY, NOTIFYICONDATAW, }; use windows::Win32::UI::WindowsAndMessaging::*; -use windows::core::PCWSTR; use crate::native_interop::{self, Color, WM_APP_TRAY}; @@ -107,8 +107,8 @@ pub fn create_icon(percent: Option) -> HICON { }; let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); - 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() { let _ = DeleteDC(mem_dc); @@ -119,8 +119,7 @@ pub fn create_icon(percent: Option) -> HICON { let old_bmp = SelectObject(mem_dc, dib); // Zero-fill (transparent background) - let pixel_data = - std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize); + let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize); for px in pixel_data.iter_mut() { *px = 0; } diff --git a/src/window.rs b/src/window.rs index 43024c7..a566403 100644 --- a/src/window.rs +++ b/src/window.rs @@ -9,6 +9,7 @@ use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}; use windows::Win32::System::Registry::*; +use windows::Win32::System::SystemInformation::GetLocalTime; use windows::Win32::System::Threading::CreateMutexW; use windows::Win32::UI::Accessibility::HWINEVENTHOOK; use windows::Win32::UI::HiDpi::*; @@ -20,12 +21,13 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::UsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + self, Color, TIMER_COUNTDOWN, TIMER_PEAK, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; -use crate::tray_icon; +use crate::peak_dialog; use crate::poller; use crate::theme; +use crate::tray_icon; use crate::updater::{self, InstallChannel, ReleaseDescriptor, UpdateCheckResult}; /// Wrapper to make HWND sendable across threads (safe for PostMessage usage) @@ -74,6 +76,13 @@ struct AppState { drag_start_offset: i32, widget_visible: bool, + + /// Peak hours start (hour, minute), or None if not configured. + peak_start: Option<(u8, u8)>, + /// Peak hours end (hour, minute), or None if not configured. + peak_end: Option<(u8, u8)>, + /// UTC offset override in hours. None = use system local time. + peak_tz_offset_hours: Option, } #[derive(Clone, Debug)] @@ -85,6 +94,19 @@ enum UpdateStatus { Available(ReleaseDescriptor), } +/// Peak-hours indicator state. +#[derive(Clone, Copy, PartialEq, Eq)] +enum PeakStatus { + /// Peak hours not configured – no icon shown. + NoPeakHours, + /// Current time is outside peak hours (more than 60 min away). + Outside, + /// Within 60 minutes of peak-hours start – warning colour. + Warning, + /// Currently within peak hours – alert colour. + Inside, +} + const RETRY_BASE_MS: u32 = 30_000; // 30 seconds const POLL_1_MIN: u32 = 60_000; @@ -107,9 +129,16 @@ const IDM_LANG_FRENCH: u16 = 43; const IDM_LANG_GERMAN: u16 = 44; const IDM_LANG_JAPANESE: u16 = 45; const IDM_LANG_KOREAN: u16 = 46; +const IDM_PEAK_HOURS: u16 = 50; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN +/// Diameter of the peak-hours status circle (px at 96 DPI). +/// Equals 2 × SEGMENT_H + row gap = 2 × 13 + 10 = 36, spanning both meter rows. +const PEAK_ICON_W: i32 = 36; +/// Gap between the right edge of the circle and the first meter label. +const PEAK_ICON_GAP: i32 = 6; + const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -192,6 +221,15 @@ struct SettingsFile { last_update_check_unix: Option, #[serde(default = "default_widget_visible")] widget_visible: bool, + /// Peak-hours start time in "HH:MM" format, or absent to disable. + #[serde(default, skip_serializing_if = "Option::is_none")] + peak_start: Option, + /// Peak-hours end time in "HH:MM" format. + #[serde(default, skip_serializing_if = "Option::is_none")] + peak_end: Option, + /// UTC offset override in hours (e.g. 10 = UTC+10). Absent = system TZ. + #[serde(default, skip_serializing_if = "Option::is_none")] + peak_tz_offset_hours: Option, } impl Default for SettingsFile { @@ -202,6 +240,9 @@ impl Default for SettingsFile { language: None, last_update_check_unix: None, widget_visible: true, + peak_start: None, + peak_end: None, + peak_tz_offset_hours: None, } } } @@ -243,6 +284,9 @@ fn save_state_settings() { .map(|language| language.code().to_string()), last_update_check_unix: s.last_update_check_unix, widget_visible: s.widget_visible, + peak_start: s.peak_start.map(|(h, m)| format!("{:02}:{:02}", h, m)), + peak_end: s.peak_end.map(|(h, m)| format!("{:02}:{:02}", h, m)), + peak_tz_offset_hours: s.peak_tz_offset_hours, }); } } @@ -727,9 +771,35 @@ const TEXT_WIDTH: i32 = 62; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; +/// Extra pixels reserved for the peak-hours status circle (0 when not configured). +fn peak_icon_extra_width(has_peak: bool) -> i32 { + if has_peak { + sc(PEAK_ICON_W) + sc(PEAK_ICON_GAP) + } else { + 0 + } +} + +/// Whether peak hours are configured. Acquires the state lock briefly. +fn has_peak_hours() -> bool { + let state = lock_state(); + state + .as_ref() + .map(|s| s.peak_start.is_some()) + .unwrap_or(false) +} + fn total_widget_width() -> i32 { + widget_width_with_peak(has_peak_hours()) +} + +/// Compute widget width given whether peak hours are configured. +/// Use this variant when the caller already knows the peak state +/// (e.g. while holding the state lock) to avoid re-acquiring it. +fn widget_width_with_peak(has_peak: bool) -> i32 { sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) + + peak_icon_extra_width(has_peak) + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN) + (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * SEGMENT_COUNT @@ -739,6 +809,64 @@ fn total_widget_width() -> i32 { + sc(RIGHT_MARGIN) } +/// Compute the current peak-hours status from state. +fn compute_peak_status(state: &AppState) -> PeakStatus { + let (start, end) = match (state.peak_start, state.peak_end) { + (Some(s), Some(e)) => (s, e), + _ => return PeakStatus::NoPeakHours, + }; + + let (cur_h, cur_m) = get_local_hm(state.peak_tz_offset_hours); + let cur = cur_h as i32 * 60 + cur_m as i32; + let s_m = start.0 as i32 * 60 + start.1 as i32; + let e_m = end.0 as i32 * 60 + end.1 as i32; + + // Helper: is `t` in the half-open range [a, b) wrapping midnight? + let in_range = |t: i32, a: i32, b: i32| -> bool { + if a <= b { + t >= a && t < b + } else { + t >= a || t < b + } + }; + + if in_range(cur, s_m, e_m) { + return PeakStatus::Inside; + } + + // Warning: within 60 minutes before peak start (wrapping midnight) + let warn_start = ((s_m - 60) + 1440) % 1440; + if in_range(cur, warn_start, s_m) { + return PeakStatus::Warning; + } + + PeakStatus::Outside +} + +/// Return (hour, minute) in local time adjusted by the given UTC offset in hours. +/// `None` means use the OS local time via GetLocalTime. +fn get_local_hm(tz_offset_hours: Option) -> (u8, u8) { + match tz_offset_hours { + None => { + // Use OS local time (handles DST automatically) + unsafe { + let st = GetLocalTime(); + (st.wHour as u8, st.wMinute as u8) + } + } + Some(offset_h) => { + use std::time::{SystemTime, UNIX_EPOCH}; + let utc_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let local_secs = utc_secs + offset_h as i64 * 3600; + let day_secs = ((local_secs % 86400) + 86400) % 86400; + ((day_secs / 3600) as u8, ((day_secs % 3600) / 60) as u8) + } + } +} + pub fn run() { // Enable Per-Monitor DPI Awareness V2 for crisp rendering at any scale factor unsafe { @@ -760,7 +888,10 @@ pub fn run() { h } Err(error) => { - diagnose::log_error("startup aborted: unable to create single-instance mutex", error); + diagnose::log_error( + "startup aborted: unable to create single-instance mutex", + error, + ); return; } } @@ -862,9 +993,23 @@ pub fn run() { drag_start_mouse_x: 0, drag_start_offset: 0, widget_visible: settings.widget_visible, + peak_start: settings + .peak_start + .as_deref() + .and_then(|s| peak_dialog::parse_time(s).ok()), + peak_end: settings + .peak_end + .as_deref() + .and_then(|s| peak_dialog::parse_time(s).ok()), + peak_tz_offset_hours: settings.peak_tz_offset_hours, }); } + diagnose::log(format!( + "peak hours config: start={:?} end={:?} tz_hours={:?}", + settings.peak_start, settings.peak_end, settings.peak_tz_offset_hours + )); + // Try to embed in taskbar if let Some(taskbar_hwnd) = native_interop::find_taskbar() { diagnose::log(format!("taskbar found hwnd={:?}", taskbar_hwnd)); @@ -943,6 +1088,9 @@ pub fn run() { do_poll(send_hwnd); }); + // 1-minute timer to refresh the peak-hours status circle colour. + SetTimer(hwnd, TIMER_PEAK, 60_000, None); + schedule_auto_update_check(hwnd); let should_check_updates = { let state = lock_state(); @@ -972,7 +1120,17 @@ 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, strings, session_pct, session_text, weekly_pct, weekly_text) = { + let ( + hwnd_val, + is_dark, + embedded, + strings, + session_pct, + session_text, + weekly_pct, + weekly_text, + peak_status, + ) = { let state = lock_state(); match state.as_ref() { Some(s) => ( @@ -984,6 +1142,7 @@ fn render_layered() { s.session_text.clone(), s.weekly_percent, s.weekly_text.clone(), + compute_peak_status(s), ), None => return, } @@ -1066,6 +1225,7 @@ fn render_layered() { &session_text, weekly_pct, &weekly_text, + peak_status, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1129,6 +1289,7 @@ fn paint_content( session_text: &str, weekly_pct: f64, weekly_text: &str, + peak_status: PeakStatus, ) { unsafe { let client_rect = RECT { @@ -1179,10 +1340,20 @@ fn paint_content( FillRect(hdc, &right_rect, right_brush); let _ = DeleteObject(right_brush); - let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN); + let icon_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN); + let peak_extra = if peak_status != PeakStatus::NoPeakHours { + sc(PEAK_ICON_W) + sc(PEAK_ICON_GAP) + } else { + 0 + }; + let content_x = icon_x + peak_extra; let row2_y = height - sc(5) - sc(SEGMENT_H); let row1_y = row2_y - sc(10) - sc(SEGMENT_H); + if peak_status != PeakStatus::NoPeakHours { + draw_peak_circle(hdc, icon_x, height, peak_status, is_dark); + } + let _ = SetBkMode(hdc, TRANSPARENT); let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -1552,6 +1723,10 @@ unsafe extern "system" fn wnd_proc( TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } + TIMER_PEAK => { + // Re-evaluate and repaint peak status every minute. + render_layered(); + } _ => {} } LRESULT(0) @@ -1634,6 +1809,7 @@ unsafe extern "system" fn wnd_proc( } // Clamp: don't go past left edge of taskbar + let has_peak = s.peak_start.is_some(); 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; @@ -1645,7 +1821,7 @@ unsafe extern "system" fn wnd_proc( tray_left = tray_rect.left; } } - let widget_width = total_widget_width(); + let widget_width = widget_width_with_peak(has_peak); let max_offset = if s.embedded { tray_left - taskbar_rect.left - widget_width } else { @@ -1675,7 +1851,7 @@ unsafe extern "system" fn wnd_proc( tray_left = tray_rect.left; } } - let widget_width = total_widget_width(); + let widget_width = widget_width_with_peak(has_peak); let widget_height = sc(WIDGET_HEIGHT); let y = compute_anchor_y(anchor_top, anchor_height, widget_height); if s.embedded { @@ -1799,6 +1975,51 @@ unsafe extern "system" fn wnd_proc( IDM_START_WITH_WINDOWS => { set_startup_enabled(!is_startup_enabled()); } + IDM_PEAK_HOURS => { + // Read current peak settings for pre-population + let (cur_start, cur_end, cur_tz, strings) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => ( + s.peak_start + .map(|(h, m)| format!("{:02}:{:02}", h, m)) + .unwrap_or_default(), + s.peak_end + .map(|(h, m)| format!("{:02}:{:02}", h, m)) + .unwrap_or_default(), + s.peak_tz_offset_hours + .map(|v| v.to_string()) + .unwrap_or_default(), + s.language.strings(), + ), + None => return LRESULT(0), + } + }; + + diagnose::log("opening peak hours dialog"); + if let Some(result) = + peak_dialog::show(hwnd, &cur_start, &cur_end, &cur_tz, strings) + { + let new_start = peak_dialog::parse_time(&result.start).ok(); + let new_end = peak_dialog::parse_time(&result.end).ok(); + let new_tz: Option = result.tz.trim().parse().ok(); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.peak_start = new_start; + s.peak_end = new_end; + s.peak_tz_offset_hours = new_tz; + } + } + diagnose::log(format!( + "peak hours updated: start={:?} end={:?} tz={:?}", + new_start, new_end, new_tz + )); + save_state_settings(); + position_at_taskbar(); + render_layered(); + } + } IDM_FREQ_1MIN | IDM_FREQ_5MIN | IDM_FREQ_15MIN | IDM_FREQ_1HOUR => { let new_interval = match id { IDM_FREQ_1MIN => POLL_1_MIN, @@ -1972,6 +2193,14 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + let peak_hours_str = native_interop::wide_str(strings.peak_hours); + let _ = AppendMenuW( + settings_menu, + MENU_ITEM_FLAGS(0), + IDM_PEAK_HOURS as usize, + PCWSTR::from_raw(peak_hours_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() { @@ -2022,8 +2251,10 @@ fn show_context_menu(hwnd: HWND) { let version_label = version_action_label(strings, language, install_channel, &update_status); let version_str = native_interop::wide_str(&version_label); - let version_flags = if matches!(update_status, UpdateStatus::Checking | UpdateStatus::Applying) - { + let version_flags = if matches!( + update_status, + UpdateStatus::Checking | UpdateStatus::Applying + ) { MF_GRAYED } else { MENU_ITEM_FLAGS(0) @@ -2044,7 +2275,11 @@ fn show_context_menu(hwnd: HWND) { ); let widget_label = native_interop::wide_str(strings.show_widget); - let widget_flags = if widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + let widget_flags = if widget_visible { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; let _ = AppendMenuW( menu, widget_flags, @@ -2072,7 +2307,7 @@ fn show_context_menu(hwnd: HWND) { /// Paint for non-embedded fallback (normal WM_PAINT path) fn paint(hdc: HDC, hwnd: HWND) { - let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text) = { + let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text, peak_status) = { let state = lock_state(); match state.as_ref() { Some(s) => ( @@ -2082,6 +2317,7 @@ fn paint(hdc: HDC, hwnd: HWND) { s.session_text.clone(), s.weekly_percent, s.weekly_text.clone(), + compute_peak_status(s), ), None => return, } @@ -2132,6 +2368,7 @@ fn paint(hdc: HDC, hwnd: HWND) { &session_text, weekly_pct, &weekly_text, + peak_status, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -2253,3 +2490,43 @@ fn draw_rounded_rect(hdc: HDC, rect: &RECT, color: &Color, radius: i32) { let _ = DeleteObject(brush); } } + +/// Draw the peak-hours status circle. +/// +/// The circle is a filled GDI ellipse whose diameter spans the full height of +/// both meter rows (= PEAK_ICON_W = 36 px at 96 DPI), centred vertically in +/// the widget. Colours adapt to the current theme: +/// - Outside (>60 min until peak): green +/// - Warning (<60 min until peak): amber +/// - Inside (within peak hours): red +fn draw_peak_circle(hdc: HDC, x: i32, height: i32, status: PeakStatus, is_dark: bool) { + let color = match (status, is_dark) { + (PeakStatus::Outside, true) => Color::from_hex("#22C55E"), + (PeakStatus::Outside, false) => Color::from_hex("#16A34A"), + (PeakStatus::Warning, true) => Color::from_hex("#F59E0B"), + (PeakStatus::Warning, false) => Color::from_hex("#D97706"), + (PeakStatus::Inside, true) => Color::from_hex("#EF4444"), + (PeakStatus::Inside, false) => Color::from_hex("#DC2626"), + (PeakStatus::NoPeakHours, _) => return, + }; + + // Align circle top/bottom with the meter rows. + let row2_y = height - sc(5) - sc(SEGMENT_H); + let row1_y = row2_y - sc(10) - sc(SEGMENT_H); + let top = row1_y; + let bottom = row2_y + sc(SEGMENT_H); + let right = x + sc(PEAK_ICON_W); + + unsafe { + let brush = CreateSolidBrush(COLORREF(color.to_colorref())); + let old_brush = SelectObject(hdc, brush); + let null_pen = GetStockObject(NULL_PEN); + let old_pen = SelectObject(hdc, null_pen); + + let _ = Ellipse(hdc, x, top, right, bottom); + + SelectObject(hdc, old_pen); + SelectObject(hdc, old_brush); + let _ = DeleteObject(brush); + } +}