2026-04-12 09:07:41 -07:00

2290 lines
73 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::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_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK,
WM_APP_TRAY, WM_APP_USAGE_UPDATED,
};
use crate::tray_icon;
use crate::poller;
use crate::theme;
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,
show_decimals: bool,
widget_visible: bool,
}
#[derive(Clone, Debug)]
enum UpdateStatus {
Idle,
Checking,
Applying,
UpToDate,
Available(ReleaseDescriptor),
}
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_SHOW_DECIMALS: u16 = 50;
const IDM_LANG_KOREAN: u16 = 46;
const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN
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)]
show_decimals: bool,
#[serde(default = "default_widget_visible")]
widget_visible: bool,
}
impl Default for SettingsFile {
fn default() -> Self {
Self {
tray_offset: 0,
poll_interval_ms: default_poll_interval(),
language: None,
last_update_check_unix: None,
show_decimals: true,
widget_visible: true,
}
}
}
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,
show_decimals: s.show_decimals,
widget_visible: s.widget_visible,
});
}
}
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, state.show_decimals),
poller::format_line(&data.weekly, strings, state.show_decimals),
)
}) 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 = 80;
const RIGHT_MARGIN: i32 = 1;
const WIDGET_HEIGHT: i32 = 46;
fn total_widget_width() -> i32 {
sc(LEFT_DIVIDER_W)
+ sc(DIVIDER_RIGHT_MARGIN)
+ 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)
}
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,
show_decimals: settings.show_decimals,
widget_visible: settings.widget_visible,
});
}
// 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");
}
// 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
let (tray_pct, tray_tooltip) = tray_icon_data_from_state();
tray_icon::add(hwnd, tray_pct, &tray_tooltip);
// Position and show (only if widget_visible preference is true)
position_at_taskbar();
if settings.widget_visible {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
diagnose::log("window shown");
// Initial render via UpdateLayeredWindow (for embedded) or InvalidateRect (fallback)
render_layered();
// 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);
});
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();
// 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) = {
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(),
),
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,
);
// 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,
) {
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 content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN);
let row2_y = height - sc(5) - sc(SEGMENT_H);
let row1_y = row2_y - sc(10) - sc(SEGMENT_H);
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();
let state = lock_state();
let s = match state.as_ref() {
Some(s) => s,
None => return,
};
// Don't fight the user's drag
if s.dragging {
return;
}
let hwnd = s.hwnd.to_hwnd();
let embedded = s.embedded;
let tray_offset = s.tray_offset;
let taskbar_hwnd = match s.taskbar_hwnd {
Some(h) => h,
None => {
diagnose::log("position_at_taskbar skipped: no taskbar handle");
return;
}
};
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);
}
_ => {}
}
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
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 = total_widget_width();
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 = total_widget_width();
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_SHOW_DECIMALS => {
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.show_decimals = !s.show_decimals;
}
}
save_state_settings();
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()),
);
// Show Decimals toggle
let show_decimals_str = native_interop::wide_str(strings.show_decimals);
let show_checked_flags = if {
let state = lock_state();
state.as_ref().map(|s| s.show_decimals).unwrap_or(true)
} {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
let _ = AppendMenuW(
settings_menu,
show_checked_flags,
IDM_SHOW_DECIMALS as usize,
PCWSTR::from_raw(show_decimals_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) = {
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(),
),
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,
);
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);
}
}