mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
2600 lines
84 KiB
Rust
2600 lines
84 KiB
Rust
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(¤t_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);
|
||
}
|
||
}
|