2026-04-16 21:44:27 +03:00

2600 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use windows::core::PCWSTR;
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::*;
use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture};
use windows::Win32::UI::Shell::ExtractIconExW;
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::diagnose;
use crate::localization::{self, LanguageId, Strings};
use crate::models::UsageData;
use crate::native_interop::{
self, Color, TIMER_COUNTDOWN, TIMER_PEAK, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK,
WM_APP_TRAY, WM_APP_USAGE_UPDATED,
};
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)
#[derive(Clone, Copy)]
struct SendHwnd(isize);
unsafe impl Send for SendHwnd {}
impl SendHwnd {
fn from_hwnd(hwnd: HWND) -> Self {
Self(hwnd.0 as isize)
}
fn to_hwnd(self) -> HWND {
HWND(self.0 as *mut _)
}
}
/// Shared application state
struct AppState {
hwnd: SendHwnd,
taskbar_hwnd: Option<HWND>,
tray_notify_hwnd: Option<HWND>,
win_event_hook: Option<HWINEVENTHOOK>,
is_dark: bool,
embedded: bool,
language_override: Option<LanguageId>,
language: LanguageId,
install_channel: InstallChannel,
session_percent: f64,
session_text: String,
weekly_percent: f64,
weekly_text: String,
data: Option<UsageData>,
poll_interval_ms: u32,
retry_count: u32,
last_poll_ok: bool,
update_status: UpdateStatus,
last_update_check_unix: Option<u64>,
tray_offset: i32,
dragging: bool,
drag_start_mouse_x: i32,
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)]
enum UpdateStatus {
Idle,
Checking,
Applying,
UpToDate,
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;
const POLL_5_MIN: u32 = 300_000;
const POLL_15_MIN: u32 = 900_000;
const POLL_1_HOUR: u32 = 3_600_000;
// Menu item IDs for update frequency
const IDM_FREQ_1MIN: u16 = 10;
const IDM_FREQ_5MIN: u16 = 11;
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_VERSION_ACTION: u16 = 31;
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 IDM_LANG_KOREAN: u16 = 46;
const IDM_PEAK_HOURS: u16 = 50;
const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN
/// Width of the peak-status indicator column (px at 96 DPI).
/// Contains a colored rectangle on row 1 and a short text label on row 2.
const PEAK_ICON_W: i32 = 36;
/// Gap between the right edge of the indicator 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;
/// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.)
static CURRENT_DPI: AtomicU32 = AtomicU32::new(96);
/// Scale a base pixel value (designed at 96 DPI) to the current DPI.
fn sc(px: i32) -> i32 {
let dpi = CURRENT_DPI.load(Ordering::Relaxed);
(px as f64 * dpi as f64 / 96.0).round() as i32
}
/// Re-query the monitor DPI for our window and update the cached value.
/// Uses GetDpiForWindow which returns the live DPI (unlike GetDpiForSystem
/// which is cached at process startup and never changes).
fn refresh_dpi() {
let hwnd = {
let state = lock_state();
state.as_ref().map(|s| s.hwnd.to_hwnd())
};
if let Some(hwnd) = hwnd {
let dpi = unsafe { GetDpiForWindow(hwnd) };
if dpi > 0 {
CURRENT_DPI.store(dpi, Ordering::Relaxed);
}
}
}
fn load_embedded_app_icons() -> (HICON, HICON) {
unsafe {
let mut exe_buf = [0u16; 260];
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
if len == 0 {
return (HICON::default(), HICON::default());
}
let mut large_icon = HICON::default();
let mut small_icon = HICON::default();
let extracted = ExtractIconExW(
PCWSTR::from_raw(exe_buf.as_ptr()),
0,
Some(&mut large_icon),
Some(&mut small_icon),
1,
);
if extracted == 0 {
(HICON::default(), HICON::default())
} else {
(large_icon, small_icon)
}
}
}
unsafe impl Send for AppState {}
static STATE: Mutex<Option<AppState>> = Mutex::new(None);
/// Lock STATE safely, recovering from poisoned mutex
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)
.join("ClaudeCodeUsageMonitor")
.join("settings.json")
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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 {
fn default() -> Self {
Self {
tray_offset: 0,
poll_interval_ms: default_poll_interval(),
language: None,
last_update_check_unix: None,
widget_visible: true,
peak_start: None,
peak_end: None,
peak_tz_offset_hours: None,
}
}
}
fn default_poll_interval() -> u32 {
POLL_15_MIN
}
fn default_widget_visible() -> bool {
true
}
fn load_settings() -> SettingsFile {
let content = match std::fs::read_to_string(settings_path()) {
Ok(c) => c,
Err(_) => return SettingsFile::default(),
};
serde_json::from_str(&content).unwrap_or_default()
}
fn save_settings(settings: &SettingsFile) {
let path = settings_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
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(&SettingsFile {
tray_offset: s.tray_offset,
poll_interval_ms: s.poll_interval_ms,
language: s
.language_override
.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,
});
}
}
fn tray_icon_data_from_state() -> (Option<f64>, String) {
let state = lock_state();
match state.as_ref() {
Some(s) if s.last_poll_ok => {
let tooltip = format!("5h: {} | 7d: {}", s.session_text, s.weekly_text);
(Some(s.session_percent), tooltip)
}
_ => (None, "Claude Code Usage Monitor".to_string()),
}
}
fn toggle_widget_visibility(hwnd: HWND) {
let new_visible = {
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.widget_visible = !s.widget_visible;
s.widget_visible
} else {
return;
}
};
save_state_settings();
unsafe {
if new_visible {
position_at_taskbar();
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
render_layered();
} else {
let _ = ShowWindow(hwnd, SW_HIDE);
}
}
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn update_check_interval() -> Duration {
Duration::from_secs(24 * 60 * 60)
}
fn auto_update_check_due(last_update_check_unix: Option<u64>) -> bool {
let Some(last_update_check_unix) = last_update_check_unix else {
return true;
};
now_unix_secs().saturating_sub(last_update_check_unix) >= update_check_interval().as_secs()
}
fn schedule_auto_update_check(hwnd: HWND) {
let delay_ms = {
let state = lock_state();
let Some(s) = state.as_ref() else {
return;
};
if auto_update_check_due(s.last_update_check_unix) {
None
} else {
let elapsed = now_unix_secs().saturating_sub(s.last_update_check_unix.unwrap_or(0));
let remaining_secs = update_check_interval().as_secs().saturating_sub(elapsed);
Some((remaining_secs.saturating_mul(1000)).min(u32::MAX as u64) as u32)
}
};
unsafe {
let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK);
if let Some(delay_ms) = delay_ms {
SetTimer(hwnd, TIMER_UPDATE_CHECK, delay_ms.max(1), None);
}
}
}
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 show_info_message(hwnd: HWND, title: &str, message: &str) {
unsafe {
let title_wide = native_interop::wide_str(title);
let message_wide = native_interop::wide_str(message);
let _ = MessageBoxW(
hwnd,
PCWSTR::from_raw(message_wide.as_ptr()),
PCWSTR::from_raw(title_wide.as_ptr()),
MB_OK | MB_ICONINFORMATION,
);
}
}
fn show_error_message(hwnd: HWND, title: &str, message: &str) {
unsafe {
let title_wide = native_interop::wide_str(title);
let message_wide = native_interop::wide_str(message);
let _ = MessageBoxW(
hwnd,
PCWSTR::from_raw(message_wide.as_ptr()),
PCWSTR::from_raw(title_wide.as_ptr()),
MB_OK | MB_ICONERROR,
);
}
}
fn show_update_prompt(hwnd: HWND, strings: Strings, release: &ReleaseDescriptor) -> bool {
let message = strings
.update_prompt_now
.replace("{version}", &release.latest_version);
unsafe {
let title_wide = native_interop::wide_str(strings.update_available);
let message_wide = native_interop::wide_str(&message);
MessageBoxW(
hwnd,
PCWSTR::from_raw(message_wide.as_ptr()),
PCWSTR::from_raw(title_wide.as_ptr()),
MB_YESNO | MB_ICONQUESTION,
) == IDYES
}
}
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
}
fn version_action_label(
strings: Strings,
language: LanguageId,
install_channel: InstallChannel,
status: &UpdateStatus,
) -> String {
let current = env!("CARGO_PKG_VERSION");
match status {
UpdateStatus::Idle => format!("v{current} - {}", strings.check_for_updates),
UpdateStatus::Checking => format!("v{current} - {}", strings.checking_for_updates),
UpdateStatus::Applying => format!("v{current} - {}", strings.applying_update),
UpdateStatus::UpToDate => format!("v{current} - {}", strings.up_to_date_short),
UpdateStatus::Available(release) => match install_channel {
InstallChannel::Portable => {
format!(
"v{current} - {} v{}",
strings.update_to, release.latest_version
)
}
InstallChannel::Winget => format!(
"v{current} - {} v{}",
localization::update_via_winget(language),
release.latest_version
),
},
}
}
fn begin_update_check(hwnd: HWND, interactive: bool) {
let send_hwnd = SendHwnd::from_hwnd(hwnd);
let (strings, install_channel) = {
let mut state = lock_state();
let Some(app_state) = state.as_mut() else {
return;
};
if matches!(
app_state.update_status,
UpdateStatus::Checking | UpdateStatus::Applying
) {
if interactive {
show_info_message(
hwnd,
app_state.language.strings().updates,
app_state.language.strings().update_in_progress,
);
}
return;
}
app_state.update_status = UpdateStatus::Checking;
(app_state.language.strings(), app_state.install_channel)
};
std::thread::spawn(move || {
let hwnd = send_hwnd.to_hwnd();
let checked_at = now_unix_secs();
match updater::check_for_updates() {
Ok(UpdateCheckResult::UpToDate) => {
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.update_status = UpdateStatus::UpToDate;
s.last_update_check_unix = Some(checked_at);
}
}
save_state_settings();
if interactive {
show_info_message(hwnd, strings.updates, strings.up_to_date);
}
unsafe {
let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0));
}
}
Ok(UpdateCheckResult::Available(release)) => {
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.update_status = UpdateStatus::Available(release.clone());
s.last_update_check_unix = Some(checked_at);
}
}
save_state_settings();
if interactive && show_update_prompt(hwnd, strings, &release) {
match install_channel {
InstallChannel::Portable => begin_update_apply(hwnd, release),
InstallChannel::Winget => begin_winget_update(hwnd),
}
}
unsafe {
let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0));
}
}
Err(error) => {
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.update_status = UpdateStatus::Idle;
s.last_update_check_unix = Some(checked_at);
}
}
save_state_settings();
if interactive {
let message = format!("{}.\n\n{}", strings.update_failed, error);
show_error_message(hwnd, strings.updates, &message);
}
unsafe {
let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0));
}
}
}
});
}
fn begin_update_apply(hwnd: HWND, release: ReleaseDescriptor) {
let send_hwnd = SendHwnd::from_hwnd(hwnd);
let strings = {
let mut state = lock_state();
let Some(app_state) = state.as_mut() else {
return;
};
if matches!(
app_state.update_status,
UpdateStatus::Checking | UpdateStatus::Applying
) {
show_info_message(
hwnd,
app_state.language.strings().updates,
app_state.language.strings().update_in_progress,
);
return;
}
app_state.update_status = UpdateStatus::Applying;
app_state.language.strings()
};
std::thread::spawn(move || {
let hwnd = send_hwnd.to_hwnd();
match updater::begin_self_update(&release) {
Ok(()) => unsafe {
let _ = PostMessageW(hwnd, WM_CLOSE, WPARAM(0), LPARAM(0));
},
Err(error) => {
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.update_status = UpdateStatus::Available(release);
}
}
let message = format!("{}.\n\n{}", strings.update_failed, error);
show_error_message(hwnd, strings.updates, &message);
unsafe {
let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0));
}
}
}
});
}
fn begin_winget_update(hwnd: HWND) {
let strings = {
let state = lock_state();
state.as_ref().map(|s| s.language.strings())
}
.unwrap_or(LanguageId::English.strings());
match updater::begin_winget_update() {
Ok(()) => unsafe {
let _ = PostMessageW(hwnd, WM_CLOSE, WPARAM(0), LPARAM(0));
},
Err(error) => {
let message = format!("{}.\n\n{}", strings.update_failed, error);
show_error_message(hwnd, strings.updates, &message);
}
}
}
const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
const STARTUP_REGISTRY_KEY: &str = "ClaudeCodeUsageMonitor";
/// Returns true only if the startup registry value points to this executable.
fn is_startup_enabled() -> bool {
unsafe {
let path = native_interop::wide_str(STARTUP_REGISTRY_PATH);
let key_name = native_interop::wide_str(STARTUP_REGISTRY_KEY);
let mut hkey = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(path.as_ptr()),
0,
KEY_READ,
&mut hkey,
);
if result.is_err() {
return false;
}
// Query the size of the value
let mut data_size: u32 = 0;
let result = RegQueryValueExW(
hkey,
PCWSTR::from_raw(key_name.as_ptr()),
None,
None,
None,
Some(&mut data_size),
);
if result.is_err() || data_size == 0 {
let _ = RegCloseKey(hkey);
return false;
}
// Read the value
let mut buf = vec![0u8; data_size as usize];
let result = RegQueryValueExW(
hkey,
PCWSTR::from_raw(key_name.as_ptr()),
None,
None,
Some(buf.as_mut_ptr()),
Some(&mut data_size),
);
let _ = RegCloseKey(hkey);
if result.is_err() {
return false;
}
// 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 reg_value = String::from_utf16_lossy(wide_slice)
.trim_end_matches('\0')
.to_string();
// Get the current executable path
let mut exe_buf = [0u16; 260];
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
if len == 0 {
return false;
}
let current_exe = String::from_utf16_lossy(&exe_buf[..len]);
// Case-insensitive comparison (Windows paths are case-insensitive)
reg_value.eq_ignore_ascii_case(&current_exe)
}
}
fn set_startup_enabled(enable: bool) {
unsafe {
let path = native_interop::wide_str(STARTUP_REGISTRY_PATH);
let mut hkey = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(path.as_ptr()),
0,
KEY_SET_VALUE,
&mut hkey,
);
if result.is_err() {
return;
}
let key_name = native_interop::wide_str(STARTUP_REGISTRY_KEY);
if enable {
let mut exe_buf = [0u16; 260];
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
if len > 0 {
// Write the wide string including null terminator
let byte_len = ((len + 1) * 2) as u32;
let _ = RegSetValueExW(
hkey,
PCWSTR::from_raw(key_name.as_ptr()),
0,
REG_SZ,
Some(std::slice::from_raw_parts(
exe_buf.as_ptr() as *const u8,
byte_len as usize,
)),
);
}
} else {
let _ = RegDeleteValueW(hkey, PCWSTR::from_raw(key_name.as_ptr()));
}
let _ = RegCloseKey(hkey);
}
}
// Dimensions matching the C# version
const SEGMENT_W: i32 = 10;
const SEGMENT_H: i32 = 13;
const SEGMENT_GAP: i32 = 1;
const SEGMENT_COUNT: i32 = 10;
const CORNER_RADIUS: i32 = 2;
const LEFT_DIVIDER_W: i32 = 3;
const DIVIDER_RIGHT_MARGIN: i32 = 10;
const LABEL_WIDTH: i32 = 18;
const LABEL_RIGHT_MARGIN: i32 = 10;
const BAR_RIGHT_MARGIN: i32 = 4;
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
- sc(SEGMENT_GAP)
+ sc(BAR_RIGHT_MARGIN)
+ sc(TEXT_WIDTH)
+ 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 {
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
CURRENT_DPI.store(GetDpiForSystem(), Ordering::Relaxed);
}
diagnose::log("window::run started");
// Single-instance guard: silently exit if another instance is running
let mutex_name = native_interop::wide_str("Global\\ClaudeCodeUsageMonitor");
let _mutex = unsafe {
let handle = CreateMutexW(None, false, PCWSTR::from_raw(mutex_name.as_ptr()));
match handle {
Ok(h) => {
if GetLastError() == ERROR_ALREADY_EXISTS {
diagnose::log("startup aborted: another instance is already running");
return;
}
h
}
Err(error) => {
diagnose::log_error(
"startup aborted: unable to create single-instance mutex",
error,
);
return;
}
}
};
let class_name = native_interop::wide_str("ClaudeCodeUsageMonitor");
unsafe {
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap();
let (large_icon, small_icon) = load_embedded_app_icons();
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(wnd_proc),
hInstance: HINSTANCE(hinstance.0),
hIcon: large_icon,
hIconSm: small_icon,
hCursor: LoadCursorW(HINSTANCE::default(), IDC_ARROW).unwrap_or_default(),
hbrBackground: HBRUSH(std::ptr::null_mut()),
lpszClassName: PCWSTR::from_raw(class_name.as_ptr()),
..Default::default()
};
let atom = RegisterClassExW(&wc);
if atom == 0 {
diagnose::log("RegisterClassExW returned 0");
}
let settings = load_settings();
let language_override = settings.language.as_deref().and_then(LanguageId::from_code);
let language = localization::resolve_language(language_override);
let install_channel = updater::current_install_channel();
// Create as layered popup (will be reparented into taskbar)
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()),
PCWSTR::from_raw(title.as_ptr()),
WS_POPUP,
0,
0,
total_widget_width(),
sc(WIDGET_HEIGHT),
HWND::default(),
HMENU::default(),
hinstance,
None,
)
.unwrap();
if !large_icon.is_invalid() {
let _ = SendMessageW(
hwnd,
WM_SETICON,
WPARAM(ICON_BIG as usize),
LPARAM(large_icon.0 as isize),
);
}
if !small_icon.is_invalid() {
let _ = SendMessageW(
hwnd,
WM_SETICON,
WPARAM(ICON_SMALL as usize),
LPARAM(small_icon.0 as isize),
);
}
diagnose::log(format!("main window created hwnd={:?}", hwnd));
let is_dark = theme::is_dark_mode();
let mut embedded = false;
{
let mut state = lock_state();
*state = Some(AppState {
hwnd: SendHwnd::from_hwnd(hwnd),
taskbar_hwnd: None,
tray_notify_hwnd: None,
win_event_hook: None,
is_dark,
embedded: false,
language_override,
language,
install_channel,
session_percent: 0.0,
session_text: "--".to_string(),
weekly_percent: 0.0,
weekly_text: "--".to_string(),
data: None,
poll_interval_ms: settings.poll_interval_ms,
retry_count: 0,
last_poll_ok: false,
update_status: UpdateStatus::Idle,
last_update_check_unix: settings.last_update_check_unix,
tray_offset: settings.tray_offset,
dragging: false,
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));
native_interop::embed_in_taskbar(hwnd, taskbar_hwnd);
embedded = true;
let mut state = lock_state();
let s = state.as_mut().unwrap();
s.taskbar_hwnd = Some(taskbar_hwnd);
s.embedded = true;
let tray_notify = native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd");
s.tray_notify_hwnd = tray_notify;
if tray_notify.is_some() {
diagnose::log("TrayNotifyWnd found");
} else {
diagnose::log("TrayNotifyWnd not found");
}
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);
s.win_event_hook = hook;
if hook.is_some() {
diagnose::log("tray event hook installed");
} else {
diagnose::log("tray event hook could not be installed");
}
}
} else {
diagnose::log("taskbar not found; using fallback popup window");
}
diagnose::log("taskbar embedding complete");
// If not embedded, fall back to topmost popup with SetLayeredWindowAttributes
if !embedded {
let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA);
let _ = SetWindowPos(
hwnd,
HWND_TOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
);
}
// Register system tray icon
diagnose::log("adding tray icon...");
let (tray_pct, tray_tooltip) = tray_icon_data_from_state();
tray_icon::add(hwnd, tray_pct, &tray_tooltip);
diagnose::log("tray icon added");
// Position and show (only if widget_visible preference is true)
diagnose::log("positioning at taskbar...");
position_at_taskbar();
diagnose::log("position_at_taskbar done");
if settings.widget_visible {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
diagnose::log(format!("window shown (visible={})", settings.widget_visible));
// Initial render via UpdateLayeredWindow (for embedded) or InvalidateRect (fallback)
diagnose::log("initial render_layered...");
render_layered();
diagnose::log("initial render_layered done");
// 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)
};
SetTimer(hwnd, TIMER_POLL, initial_poll_ms, None);
// Initial poll
let send_hwnd = SendHwnd::from_hwnd(hwnd);
std::thread::spawn(move || {
diagnose::log("initial poll thread started");
do_poll(send_hwnd);
});
// 1-minute timer to refresh the peak-hours status circle colour.
SetTimer(hwnd, TIMER_PEAK, 60_000, None);
diagnose::log("scheduling auto-update check");
schedule_auto_update_check(hwnd);
let should_check_updates = {
let state = lock_state();
state
.as_ref()
.map(|s| auto_update_check_due(s.last_update_check_unix))
.unwrap_or(false)
};
if should_check_updates {
begin_update_check(hwnd, false);
}
// Initial theme check
check_theme_change();
diagnose::log("entering message loop");
// Message loop
let mut msg = MSG::default();
while GetMessageW(&mut msg, HWND::default(), 0, 0).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
/// Render widget content and push to the layered window via UpdateLayeredWindow.
/// Renders fully opaque with the actual taskbar background colour so that
/// 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,
peak_status,
) = {
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,
s.weekly_text.clone(),
compute_peak_status(s),
),
None => return,
}
};
let hwnd = hwnd_val.to_hwnd();
// For non-embedded fallback, just invalidate and let WM_PAINT handle it
if !embedded {
unsafe {
let _ = InvalidateRect(hwnd, None, false);
}
return;
}
let width = total_widget_width();
let height = sc(WIDGET_HEIGHT);
let accent = Color::from_hex("#D97757");
let track = if is_dark {
Color::from_hex("#444444")
} else {
Color::from_hex("#AAAAAA")
};
let text_color = if is_dark {
Color::from_hex("#888888")
} else {
Color::from_hex("#404040")
};
let bg_color = if is_dark {
Color::from_hex("#1C1C1C")
} else {
Color::from_hex("#F3F3F3")
};
unsafe {
let screen_dc = GetDC(hwnd);
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: width,
biHeight: -height, // top-down
biPlanes: 1,
biBitCount: 32,
biCompression: 0, // BI_RGB
..Default::default()
},
..Default::default()
};
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();
if dib.is_invalid() || bits.is_null() {
let _ = DeleteDC(mem_dc);
ReleaseDC(hwnd, screen_dc);
return;
}
let old_bmp = SelectObject(mem_dc, dib);
let pixel_count = (width * height) as usize;
// 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,
strings,
session_pct,
&session_text,
weekly_pct,
&weekly_text,
peak_status,
);
// Background pixels → alpha 1 (nearly invisible but still hittable for right-click).
// Content pixels → fully opaque (preserves ClearType sub-pixel rendering).
let bg_bgr = bg_color.to_colorref();
let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
for px in pixel_data.iter_mut() {
let rgb = *px & 0x00FFFFFF;
if rgb == bg_bgr {
*px = 0x01000000;
} else {
*px = rgb | 0xFF000000;
}
}
// Push to window via UpdateLayeredWindow
let pt_src = POINT { x: 0, y: 0 };
let sz = SIZE {
cx: width,
cy: height,
};
let blend = BLENDFUNCTION {
BlendOp: 0, // AC_SRC_OVER
BlendFlags: 0,
SourceConstantAlpha: 255,
AlphaFormat: 1, // AC_SRC_ALPHA
};
let _ = UpdateLayeredWindow(
hwnd,
screen_dc,
None,
Some(&sz),
mem_dc,
Some(&pt_src),
COLORREF(0),
Some(&blend),
ULW_ALPHA,
);
// Cleanup
SelectObject(mem_dc, old_bmp);
let _ = DeleteObject(dib);
let _ = DeleteDC(mem_dc);
ReleaseDC(hwnd, screen_dc);
}
}
/// Paint all widget content onto a DC with a given background color.
fn paint_content(
hdc: HDC,
width: i32,
height: i32,
is_dark: bool,
bg: &Color,
text_color: &Color,
accent: &Color,
track: &Color,
strings: Strings,
session_pct: f64,
session_text: &str,
weekly_pct: f64,
weekly_text: &str,
peak_status: PeakStatus,
) {
unsafe {
let client_rect = RECT {
left: 0,
top: 0,
right: width,
bottom: height,
};
let bg_brush = CreateSolidBrush(COLORREF(bg.to_colorref()));
FillRect(hdc, &client_rect, bg_brush);
let _ = DeleteObject(bg_brush);
// Left divider
let divider_h = sc(25);
let divider_top = (height - divider_h) / 2;
let divider_bottom = divider_top + divider_h;
let (div_left, div_right) = if is_dark {
((80, 80, 80), (40, 40, 40))
} else {
((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_rect = RECT {
left: 0,
top: divider_top,
right: sc(2),
bottom: divider_bottom,
};
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_rect = RECT {
left: sc(2),
top: divider_top,
right: sc(3),
bottom: divider_bottom,
};
FillRect(hdc, &right_rect, right_brush);
let _ = DeleteObject(right_brush);
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_indicator(hdc, icon_x, height, peak_status, is_dark, text_color, strings);
}
let _ = SetBkMode(hdc, TRANSPARENT);
let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));
let font_name = native_interop::wide_str("Segoe UI");
let font = CreateFontW(
sc(-12),
0,
0,
0,
FW_MEDIUM.0 as i32,
0,
0,
0,
DEFAULT_CHARSET.0 as u32,
OUT_TT_PRECIS.0 as u32,
CLIP_DEFAULT_PRECIS.0 as u32,
CLEARTYPE_QUALITY.0 as u32,
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
PCWSTR::from_raw(font_name.as_ptr()),
);
let old_font = SelectObject(hdc, font);
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);
}
}
fn do_poll(send_hwnd: SendHwnd) {
let hwnd = send_hwnd.to_hwnd();
match poller::poll() {
Ok(data) => {
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.session_percent = data.session.percentage;
s.weekly_percent = data.weekly.percentage;
// Stop fast-poll if reset data is now fresh
if !poller::is_past_reset(&data) {
unsafe {
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
}
}
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 {
s.retry_count = 0;
let interval = s.poll_interval_ms;
unsafe {
SetTimer(hwnd, TIMER_POLL, interval, None);
}
}
}
unsafe {
let _ = PostMessageW(hwnd, WM_APP_USAGE_UPDATED, WPARAM(0), LPARAM(0));
}
}
Err(_e) => {
// Show refresh indicator — retry will recover silently
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.session_text = "...".to_string();
s.weekly_text = "...".to_string();
s.last_poll_ok = false;
// 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 retry_ms = backoff.min(s.poll_interval_ms);
unsafe {
// Kill the 5-second reset poll so it doesn't bypass backoff
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
SetTimer(hwnd, TIMER_POLL, retry_ms, None);
}
}
unsafe {
let _ = PostMessageW(hwnd, WM_APP_USAGE_UPDATED, WPARAM(0), LPARAM(0));
}
}
}
}
fn schedule_countdown_timer() {
let state = lock_state();
let s = match state.as_ref() {
Some(s) => s,
None => return,
};
let data = match &s.data {
Some(d) => d,
None => return,
};
let hwnd = s.hwnd.to_hwnd();
// If a reset time has passed, poll every 5s to pick up fresh data
if poller::is_past_reset(data) {
unsafe {
SetTimer(hwnd, TIMER_RESET_POLL, 5_000, None);
}
}
let session_delay = poller::time_until_display_change(data.session.resets_at);
let weekly_delay = poller::time_until_display_change(data.weekly.resets_at);
let min_delay = match (session_delay, weekly_delay) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
let ms = min_delay
.unwrap_or(Duration::from_secs(60))
.as_millis()
.max(1000) as u32;
unsafe {
SetTimer(hwnd, TIMER_COUNTDOWN, ms, None);
}
}
fn check_theme_change() {
let new_dark = theme::is_dark_mode();
let changed = {
let mut state = lock_state();
if let Some(s) = state.as_mut() {
if s.is_dark != new_dark {
s.is_dark = new_dark;
true
} else {
false
}
} else {
false
}
};
if changed {
render_layered();
}
}
fn check_language_change() {
if update_language_change() {
render_layered();
}
}
fn update_display() {
let mut state = lock_state();
let s = match state.as_mut() {
Some(s) => s,
None => return,
};
// Don't overwrite error text with stale cached data
if !s.last_poll_ok {
return;
}
refresh_usage_texts(s);
}
fn position_at_taskbar() {
refresh_dpi();
// Extract everything we need from state, then DROP the lock before
// making any Win32 calls. MoveWindow dispatches WM_PAINT synchronously
// and our wnd_proc also calls lock_state() — Rust's Mutex is not
// reentrant, so holding it across Win32 calls causes a deadlock.
let (hwnd, embedded, tray_offset, taskbar_hwnd) = {
let state = lock_state();
let s = match state.as_ref() {
Some(s) => s,
None => return,
};
if s.dragging {
return;
}
let tb = match s.taskbar_hwnd {
Some(h) => h,
None => {
diagnose::log("position_at_taskbar skipped: no taskbar handle");
return;
}
};
(s.hwnd.to_hwnd(), s.embedded, s.tray_offset, tb)
};
// STATE lock is released here
let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) {
Some(r) => r,
None => {
diagnose::log("position_at_taskbar skipped: unable to query taskbar rect");
return;
}
};
let taskbar_height = taskbar_rect.bottom - taskbar_rect.top;
let mut tray_left = taskbar_rect.right;
let anchor_top = taskbar_rect.top;
let anchor_height = taskbar_height;
if let Some(tray_hwnd) = native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") {
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) {
tray_left = tray_rect.left;
}
}
let widget_width = total_widget_width();
let widget_height = sc(WIDGET_HEIGHT);
let y = compute_anchor_y(anchor_top, anchor_height, widget_height);
if embedded {
// Child window: coordinates relative to parent (taskbar)
let x = tray_left - taskbar_rect.left - widget_width - tray_offset;
native_interop::move_window(hwnd, x, y - taskbar_rect.top, widget_width, widget_height);
diagnose::log(format!(
"positioned embedded widget at x={x} y={} w={widget_width} h={widget_height}",
y - taskbar_rect.top
));
} else {
// Topmost popup: screen coordinates
let x = tray_left - widget_width - tray_offset;
native_interop::move_window(hwnd, x, y, widget_width, widget_height);
diagnose::log(format!(
"positioned fallback widget at x={x} y={y} w={widget_width} h={widget_height}"
));
}
}
fn compute_anchor_y(anchor_top: i32, anchor_height: i32, widget_height: i32) -> i32 {
let anchor_bottom = anchor_top + anchor_height;
(anchor_bottom - widget_height).max(anchor_top)
}
/// WinEvent callback for tray icon location changes
unsafe extern "system" fn on_tray_location_changed(
_hook: HWINEVENTHOOK,
_event: u32,
hwnd: HWND,
_id_object: i32,
_id_child: i32,
_thread: u32,
_time: u32,
) {
static LAST_REPOSITION: Mutex<Option<std::time::Instant>> = Mutex::new(None);
let is_tray = {
let state = lock_state();
state
.as_ref()
.and_then(|s| s.tray_notify_hwnd)
.map(|h| h == hwnd)
.unwrap_or(false)
};
if is_tray {
let should_reposition = {
let mut last = LAST_REPOSITION.lock().unwrap_or_else(|e| e.into_inner());
let now = std::time::Instant::now();
if last
.map(|t| now.duration_since(t).as_millis() > 500)
.unwrap_or(true)
{
*last = Some(now);
true
} else {
false
}
};
if should_reposition {
position_at_taskbar();
render_layered();
}
}
}
/// Main window procedure
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_PAINT => {
// For non-embedded fallback, paint normally
let embedded = {
let state = lock_state();
state.as_ref().map(|s| s.embedded).unwrap_or(false)
};
if embedded {
// Layered windows don't use WM_PAINT; just validate the region
let mut ps = PAINTSTRUCT::default();
let _ = BeginPaint(hwnd, &mut ps);
let _ = EndPaint(hwnd, &ps);
} else {
let mut ps = PAINTSTRUCT::default();
let hdc = BeginPaint(hwnd, &mut ps);
paint(hdc, hwnd);
let _ = EndPaint(hwnd, &ps);
}
LRESULT(0)
}
WM_ERASEBKGND => LRESULT(1),
WM_DISPLAYCHANGE | WM_DPICHANGED_MSG | WM_SETTINGCHANGE => {
if msg == WM_DPICHANGED_MSG {
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();
LRESULT(0)
}
WM_TIMER => {
let timer_id = wparam.0;
match timer_id {
TIMER_POLL => {
let sh = SendHwnd::from_hwnd(hwnd);
std::thread::spawn(move || {
do_poll(sh);
});
}
TIMER_COUNTDOWN => {
update_display();
render_layered();
schedule_countdown_timer();
}
TIMER_RESET_POLL => {
let sh = SendHwnd::from_hwnd(hwnd);
std::thread::spawn(move || {
do_poll(sh);
});
}
TIMER_UPDATE_CHECK => {
begin_update_check(hwnd, false);
}
TIMER_PEAK => {
// Re-evaluate and repaint peak status every minute.
render_layered();
}
_ => {}
}
LRESULT(0)
}
WM_APP_USAGE_UPDATED => {
check_theme_change();
check_language_change();
render_layered();
schedule_countdown_timer();
let (pct, tooltip) = tray_icon_data_from_state();
tray_icon::update(hwnd, pct, &tooltip);
LRESULT(0)
}
WM_APP_UPDATE_CHECK_COMPLETE => {
schedule_auto_update_check(hwnd);
LRESULT(0)
}
WM_SETCURSOR => {
let is_dragging = {
let state = lock_state();
state.as_ref().map(|s| s.dragging).unwrap_or(false)
};
// 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();
SetCursor(cursor);
return LRESULT(1);
}
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();
SetCursor(cursor);
return LRESULT(1);
}
}
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_LBUTTONDOWN => {
let client_x = (lparam.0 & 0xFFFF) as i16 as i32;
if client_x < sc(DIVIDER_HIT_ZONE) {
let mut pt = POINT::default();
let _ = GetCursorPos(&mut pt);
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.dragging = true;
s.drag_start_mouse_x = pt.x;
s.drag_start_offset = s.tray_offset;
}
SetCapture(hwnd);
}
LRESULT(0)
}
WM_MOUSEMOVE => {
let is_dragging = {
let state = lock_state();
state.as_ref().map(|s| s.dragging).unwrap_or(false)
};
if is_dragging {
let mut pt = POINT::default();
let _ = GetCursorPos(&mut pt);
let mut state = lock_state();
let s = match state.as_mut() {
Some(s) => s,
None => return LRESULT(0),
};
// Moving mouse left = positive delta = larger offset (further left)
let delta = s.drag_start_mouse_x - pt.x;
let mut new_offset = s.drag_start_offset + delta;
// Clamp: offset >= 0 (can't go right of default)
if new_offset < 0 {
new_offset = 0;
}
// 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;
if let Some(tray_hwnd) =
native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd")
{
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd)
{
tray_left = tray_rect.left;
}
}
let widget_width = widget_width_with_peak(has_peak);
let max_offset = if s.embedded {
tray_left - taskbar_rect.left - widget_width
} else {
tray_left - taskbar_rect.left - widget_width
};
if new_offset > max_offset {
new_offset = max_offset;
}
}
}
s.tray_offset = new_offset;
// Move window directly
let hwnd_val = s.hwnd.to_hwnd();
if let Some(taskbar_hwnd) = s.taskbar_hwnd {
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;
let anchor_top = taskbar_rect.top;
let anchor_height = taskbar_height;
if let Some(tray_hwnd) =
native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd")
{
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd)
{
tray_left = tray_rect.left;
}
}
let widget_width = 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 {
let x = tray_left - taskbar_rect.left - widget_width - new_offset;
native_interop::move_window(
hwnd_val,
x,
y - taskbar_rect.top,
widget_width,
widget_height,
);
} else {
let x = tray_left - widget_width - new_offset;
native_interop::move_window(
hwnd_val,
x,
y,
widget_width,
widget_height,
);
}
}
}
}
LRESULT(0)
}
WM_LBUTTONUP => {
let was_dragging = {
let mut state = lock_state();
if let Some(s) = state.as_mut() {
if s.dragging {
s.dragging = false;
let offset = s.tray_offset;
Some(offset)
} else {
None
}
} else {
None
}
};
if was_dragging.is_some() {
let _ = ReleaseCapture();
save_state_settings();
}
LRESULT(0)
}
WM_RBUTTONUP => {
show_context_menu(hwnd);
LRESULT(0)
}
WM_COMMAND => {
let id = wparam.0 as u16;
match id {
1 => {
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.session_text = "...".to_string();
s.weekly_text = "...".to_string();
}
}
render_layered();
let sh = SendHwnd::from_hwnd(hwnd);
std::thread::spawn(move || {
do_poll(sh);
});
}
IDM_VERSION_ACTION => {
let (install_channel, release) = {
let state = lock_state();
match state.as_ref() {
Some(s) => (
s.install_channel,
match &s.update_status {
UpdateStatus::Available(release) => Some(release.clone()),
_ => None,
},
),
None => (InstallChannel::Portable, None),
}
};
match install_channel {
InstallChannel::Winget => {
if release.is_some() {
begin_winget_update(hwnd);
} else {
begin_update_check(hwnd, true);
}
}
InstallChannel::Portable => {
if let Some(release) = release {
begin_update_apply(hwnd, release);
} else {
begin_update_check(hwnd, true);
}
}
}
}
2 => {
let hook = {
let state = lock_state();
state.as_ref().and_then(|s| s.win_event_hook)
};
if let Some(h) = hook {
native_interop::unhook_win_event(h);
}
PostQuitMessage(0);
}
IDM_RESET_POSITION => {
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.tray_offset = 0;
}
}
save_state_settings();
position_at_taskbar();
}
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,
IDM_FREQ_5MIN => POLL_5_MIN,
IDM_FREQ_15MIN => POLL_15_MIN,
IDM_FREQ_1HOUR => POLL_1_HOUR,
_ => POLL_15_MIN,
};
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.poll_interval_ms = new_interval;
}
}
save_state_settings();
// 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 | IDM_LANG_KOREAN => {
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),
IDM_LANG_KOREAN => Some(LanguageId::Korean),
_ => 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();
}
id if id == tray_icon::IDM_TOGGLE_WIDGET => {
toggle_widget_visibility(hwnd);
}
_ => {}
}
LRESULT(0)
}
_ if msg == WM_APP_TRAY => {
match tray_icon::handle_message(lparam) {
tray_icon::TrayAction::ToggleWidget => {
toggle_widget_visibility(hwnd);
}
tray_icon::TrayAction::ShowContextMenu => {
show_context_menu(hwnd);
}
tray_icon::TrayAction::None => {}
}
LRESULT(0)
}
WM_DESTROY => {
let hook = {
let state = lock_state();
state.as_ref().and_then(|s| s.win_event_hook)
};
if let Some(h) = hook {
native_interop::unhook_win_event(h);
}
tray_icon::remove(hwnd);
PostQuitMessage(0);
LRESULT(0)
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
fn show_context_menu(hwnd: HWND) {
unsafe {
let (
current_interval,
strings,
language,
language_override,
install_channel,
update_status,
widget_visible,
) = {
let state = lock_state();
match state.as_ref() {
Some(s) => (
s.poll_interval_ms,
s.language.strings(),
s.language,
s.language_override,
s.install_channel,
s.update_status.clone(),
s.widget_visible,
),
None => (
POLL_15_MIN,
LanguageId::English.strings(),
LanguageId::English,
None,
InstallChannel::Portable,
UpdateStatus::Idle,
true,
),
}
};
let menu = CreatePopupMenu().unwrap();
let refresh_str = native_interop::wide_str(strings.refresh);
let _ = AppendMenuW(
menu,
MENU_ITEM_FLAGS(0),
1,
PCWSTR::from_raw(refresh_str.as_ptr()),
);
// Update Frequency submenu
let freq_menu = CreatePopupMenu().unwrap();
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 {
let label_str = native_interop::wide_str(label);
let flags = if interval == current_interval {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
let _ = AppendMenuW(
freq_menu,
flags,
id as usize,
PCWSTR::from_raw(label_str.as_ptr()),
);
}
let freq_label = native_interop::wide_str(strings.update_frequency);
let _ = AppendMenuW(
menu,
MF_POPUP,
freq_menu.0 as usize,
PCWSTR::from_raw(freq_label.as_ptr()),
);
// Settings submenu
let settings_menu = CreatePopupMenu().unwrap();
let startup_str = native_interop::wide_str(strings.start_with_windows);
let startup_flags = if is_startup_enabled() {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
let _ = AppendMenuW(
settings_menu,
startup_flags,
IDM_START_WITH_WINDOWS as usize,
PCWSTR::from_raw(startup_str.as_ptr()),
);
let reset_pos_str = native_interop::wide_str(strings.reset_position);
let _ = AppendMenuW(
settings_menu,
MENU_ITEM_FLAGS(0),
IDM_RESET_POSITION as usize,
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() {
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,
LanguageId::Korean => IDM_LANG_KOREAN,
};
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_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
) {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
};
let _ = AppendMenuW(
settings_menu,
version_flags,
IDM_VERSION_ACTION as usize,
PCWSTR::from_raw(version_str.as_ptr()),
);
let settings_label = native_interop::wide_str(strings.settings);
let _ = AppendMenuW(
menu,
MF_POPUP,
settings_menu.0 as usize,
PCWSTR::from_raw(settings_label.as_ptr()),
);
let widget_label = native_interop::wide_str(strings.show_widget);
let widget_flags = if widget_visible {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
let _ = AppendMenuW(
menu,
widget_flags,
tray_icon::IDM_TOGGLE_WIDGET as usize,
PCWSTR::from_raw(widget_label.as_ptr()),
);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
let exit_str = native_interop::wide_str(strings.exit);
let _ = AppendMenuW(
menu,
MENU_ITEM_FLAGS(0),
2,
PCWSTR::from_raw(exit_str.as_ptr()),
);
let mut pt = POINT::default();
let _ = GetCursorPos(&mut pt);
let _ = SetForegroundWindow(hwnd);
let _ = TrackPopupMenu(menu, TPM_RIGHTBUTTON, pt.x, pt.y, 0, hwnd, None);
let _ = DestroyMenu(menu);
}
}
/// 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, peak_status) = {
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,
s.weekly_text.clone(),
compute_peak_status(s),
),
None => return,
}
};
let accent = Color::from_hex("#D97757");
let track = if is_dark {
Color::from_hex("#444444")
} else {
Color::from_hex("#AAAAAA")
};
let text_color = if is_dark {
Color::from_hex("#888888")
} else {
Color::from_hex("#404040")
};
let bg_color = if is_dark {
Color::from_hex("#1C1C1C")
} else {
Color::from_hex("#F3F3F3")
};
unsafe {
let mut client_rect = RECT::default();
let _ = GetClientRect(hwnd, &mut client_rect);
let width = client_rect.right - client_rect.left;
let height = client_rect.bottom - client_rect.top;
if width <= 0 || height <= 0 {
return;
}
let mem_dc = CreateCompatibleDC(hdc);
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,
strings,
session_pct,
&session_text,
weekly_pct,
&weekly_text,
peak_status,
);
let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY);
SelectObject(mem_dc, old_bmp);
let _ = DeleteObject(mem_bmp);
let _ = DeleteDC(mem_dc);
}
}
fn draw_row(
hdc: HDC,
x: i32,
y: i32,
label: &str,
percent: f64,
text: &str,
accent: &Color,
track: &Color,
) {
let seg_w = sc(SEGMENT_W);
let seg_h = sc(SEGMENT_H);
let seg_gap = sc(SEGMENT_GAP);
let corner_r = sc(CORNER_RADIUS);
unsafe {
let mut label_wide: Vec<u16> = label.encode_utf16().collect();
let mut label_rect = RECT {
left: x,
top: y,
right: x + sc(LABEL_WIDTH),
bottom: y + seg_h,
};
let _ = DrawTextW(
hdc,
&mut label_wide,
&mut label_rect,
DT_LEFT | DT_VCENTER | DT_SINGLELINE,
);
let bar_x = x + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN);
let percent_clamped = percent.clamp(0.0, 100.0);
for i in 0..SEGMENT_COUNT {
let seg_x = bar_x + i * (seg_w + seg_gap);
let seg_start = (i as f64) * 10.0;
let seg_end = seg_start + 10.0;
let seg_rect = RECT {
left: seg_x,
top: y,
right: seg_x + seg_w,
bottom: y + seg_h,
};
if percent_clamped >= seg_end {
draw_rounded_rect(hdc, &seg_rect, accent, corner_r);
} else if percent_clamped <= seg_start {
draw_rounded_rect(hdc, &seg_rect, track, corner_r);
} else {
draw_rounded_rect(hdc, &seg_rect, track, corner_r);
let fraction = (percent_clamped - seg_start) / 10.0;
let fill_width = (seg_w as f64 * fraction) as i32;
if fill_width > 0 {
let fill_rect = RECT {
left: seg_x,
top: y,
right: seg_x + fill_width,
bottom: y + seg_h,
};
let rgn = CreateRoundRectRgn(
seg_rect.left,
seg_rect.top,
seg_rect.right + 1,
seg_rect.bottom + 1,
corner_r * 2,
corner_r * 2,
);
let _ = SelectClipRgn(hdc, rgn);
let brush = CreateSolidBrush(COLORREF(accent.to_colorref()));
FillRect(hdc, &fill_rect, brush);
let _ = DeleteObject(brush);
let _ = SelectClipRgn(hdc, HRGN::default());
let _ = DeleteObject(rgn);
}
}
}
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,
top: y,
right: text_x + sc(TEXT_WIDTH),
bottom: y + seg_h,
};
let _ = DrawTextW(
hdc,
&mut text_wide,
&mut text_rect,
DT_LEFT | DT_VCENTER | DT_SINGLELINE,
);
}
}
fn draw_rounded_rect(hdc: HDC, rect: &RECT, color: &Color, radius: i32) {
unsafe {
let brush = CreateSolidBrush(COLORREF(color.to_colorref()));
let rgn = CreateRoundRectRgn(
rect.left,
rect.top,
rect.right + 1,
rect.bottom + 1,
radius * 2,
radius * 2,
);
let _ = FillRgn(hdc, rgn, brush);
let _ = DeleteObject(rgn);
let _ = DeleteObject(brush);
}
}
/// Draw the peak-hours status indicator as two rows matching the meter layout.
///
/// Row 1: a colored rounded rectangle (same height as a bar segment).
/// Row 2: a short text label ("OK", "WARN", "PEAK" or localised equivalent).
/// 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_indicator(
hdc: HDC,
x: i32,
height: i32,
status: PeakStatus,
is_dark: bool,
text_color: &Color,
strings: Strings,
) {
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,
};
let label = match status {
PeakStatus::Outside => strings.peak_status_ok,
PeakStatus::Warning => strings.peak_status_warn,
PeakStatus::Inside => strings.peak_status_peak,
PeakStatus::NoPeakHours => return,
};
let seg_h = sc(SEGMENT_H);
let row2_y = height - sc(5) - seg_h;
let row1_y = row2_y - sc(10) - seg_h;
let w = sc(PEAK_ICON_W);
// Row 1: colored rectangle
let rect = RECT {
left: x,
top: row1_y,
right: x + w,
bottom: row1_y + seg_h,
};
draw_rounded_rect(hdc, &rect, &color, sc(CORNER_RADIUS));
// Row 2: text label
unsafe {
let _ = SetBkMode(hdc, TRANSPARENT);
let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));
let font_name = native_interop::wide_str("Segoe UI");
let font = CreateFontW(
sc(-10),
0,
0,
0,
FW_MEDIUM.0 as i32,
0,
0,
0,
DEFAULT_CHARSET.0 as u32,
OUT_TT_PRECIS.0 as u32,
CLIP_DEFAULT_PRECIS.0 as u32,
CLEARTYPE_QUALITY.0 as u32,
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
PCWSTR::from_raw(font_name.as_ptr()),
);
let old_font = SelectObject(hdc, font);
let mut label_wide: Vec<u16> = label.encode_utf16().collect();
let mut label_rect = RECT {
left: x,
top: row2_y,
right: x + w,
bottom: row2_y + seg_h,
};
let _ = DrawTextW(
hdc,
&mut label_wide,
&mut label_rect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
);
SelectObject(hdc, old_font);
let _ = DeleteObject(font);
}
}