mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
Peak hours support
This commit is contained in:
parent
a407f69453
commit
481c7eef9a
1
.gitignore
vendored
1
.gitignore
vendored
@ -24,3 +24,4 @@ workflow.cmd
|
||||
|
||||
# Local WinGet manifest generation output
|
||||
/manifests/
|
||||
.claude/settings.local.json
|
||||
|
||||
@ -38,6 +38,7 @@ features = [
|
||||
"Win32_Security",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_System_SystemInformation",
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
96
DEVELOPMENT.md
Normal file
96
DEVELOPMENT.md
Normal file
@ -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
|
||||
```
|
||||
@ -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.",
|
||||
};
|
||||
|
||||
@ -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.",
|
||||
};
|
||||
|
||||
@ -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.",
|
||||
};
|
||||
|
||||
@ -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: "開始時刻と終了時刻は異なる必要があります。",
|
||||
};
|
||||
|
||||
@ -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: "시작 시간과 종료 시간은 달라야 합니다.",
|
||||
};
|
||||
|
||||
@ -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>) -> LanguageId {
|
||||
|
||||
@ -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.",
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
391
src/peak_dialog.rs
Normal file
391
src/peak_dialog.rs
Normal file
@ -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<Option<DialogState>> = Mutex::new(None);
|
||||
static DIALOG_OUTPUT: Mutex<Option<(String, String, String)>> = 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<PeakDialogResult> {
|
||||
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<u8>);
|
||||
|
||||
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<u8> {
|
||||
// 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
|
||||
}
|
||||
@ -417,7 +417,10 @@ fn read_windows_credentials() -> Option<Credentials> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<f64>) -> 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<f64>) -> 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;
|
||||
}
|
||||
|
||||
299
src/window.rs
299
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<i32>,
|
||||
}
|
||||
|
||||
#[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<u64>,
|
||||
#[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<String>,
|
||||
/// Peak-hours end time in "HH:MM" format.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
peak_end: Option<String>,
|
||||
/// 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<i32>,
|
||||
}
|
||||
|
||||
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<i32>) -> (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<i32> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user