From 5587905329e1366aee17a1f41ba19b2927348db4 Mon Sep 17 00:00:00 2001 From: usamaiqb <83345144+usamaiqb@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:19:33 +0500 Subject: [PATCH 1/3] source code --- .claude/settings.local.json | 7 + src/main.rs | 1 + src/native_interop.rs | 1 + src/tray_icon.rs | 285 ++++++++++++++++++++++++++++++++++++ src/window.rs | 87 ++++++++++- 5 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/tray_icon.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2641db6 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build:*)" + ] + } +} diff --git a/src/main.rs b/src/main.rs index 23b9c92..88c363e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod models; mod native_interop; mod poller; mod theme; +mod tray_icon; mod updater; mod window; diff --git a/src/native_interop.rs b/src/native_interop.rs index 8e5c29f..9bccd18 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -22,6 +22,7 @@ pub const TIMER_UPDATE_CHECK: usize = 4; // Custom messages pub const WM_APP: u32 = 0x8000; pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1; +pub const WM_APP_TRAY: u32 = WM_APP + 3; /// Get the taskbar window handle pub fn find_taskbar() -> Option { diff --git a/src/tray_icon.rs b/src/tray_icon.rs new file mode 100644 index 0000000..51f13ed --- /dev/null +++ b/src/tray_icon.rs @@ -0,0 +1,285 @@ +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::UI::Shell::{ + NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, + Shell_NotifyIconW, +}; +use windows::Win32::UI::WindowsAndMessaging::*; +use windows::core::PCWSTR; + +use crate::native_interop::{self, Color, WM_APP_TRAY}; + +const TRAY_ICON_ID: u32 = 1; + +/// Menu item ID for toggling widget visibility (used by window.rs context menu). +pub const IDM_TOGGLE_WIDGET: u16 = 50; + +/// Actions the tray message handler can request from the main window. +pub enum TrayAction { + None, + ToggleWidget, + ShowContextMenu, +} + +/// Create a rounded-rectangle tray icon badge showing the usage percentage. +/// `percent` = None means "no data" (gray "?"), Some(p) is the usage level. +pub fn create_icon(percent: Option) -> HICON { + let size = 64_i32; + let margin = 4_i32; + let radius = 14_i32; + let outline = 2_i32; + + let (fill, outline_col, text_col) = match percent { + None => ( + Color::from_hex("#6c757d"), + Color::from_hex("#495057"), + Color::from_hex("#FFFFFF"), + ), + Some(p) if p < 50.0 => ( + Color::from_hex("#28a745"), + Color::from_hex("#1e7e34"), + Color::from_hex("#FFFFFF"), + ), + Some(p) if p < 75.0 => ( + Color::from_hex("#ffc107"), + Color::from_hex("#e0a800"), + Color::from_hex("#1a1a1a"), + ), + Some(p) if p < 90.0 => ( + Color::from_hex("#fd7e14"), + Color::from_hex("#d9650a"), + Color::from_hex("#FFFFFF"), + ), + _ => ( + Color::from_hex("#dc3545"), + Color::from_hex("#bd2130"), + Color::from_hex("#FFFFFF"), + ), + }; + + let display_text = match percent { + None => "?".to_string(), + Some(p) => format!("{}", p as u32), + }; + + let font_h = match display_text.len() { + 1 => -50, + 2 => -42, + _ => -30, + }; + + unsafe { + let screen_dc = GetDC(HWND::default()); + let mem_dc = CreateCompatibleDC(screen_dc); + + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: size, + biHeight: -size, + biPlanes: 1, + biBitCount: 32, + biCompression: 0, + ..Default::default() + }, + ..Default::default() + }; + + let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); + let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) + .unwrap_or_default(); + + if dib.is_invalid() { + let _ = DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + return HICON::default(); + } + + let old_bmp = SelectObject(mem_dc, dib); + + // Zero-fill (transparent background) + let pixel_data = + std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize); + for px in pixel_data.iter_mut() { + *px = 0; + } + + // Draw rounded rectangle badge + let null_pen = GetStockObject(NULL_PEN); + let old_pen = SelectObject(mem_dc, null_pen); + + // Outer rounded rect = outline colour + let br_outline = CreateSolidBrush(COLORREF(outline_col.to_colorref())); + let old_brush = SelectObject(mem_dc, br_outline); + let _ = RoundRect( + mem_dc, + margin, + margin, + size - margin + 1, + size - margin + 1, + radius * 2, + radius * 2, + ); + + // Inner rounded rect = fill colour + let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref())); + SelectObject(mem_dc, br_fill); + let _ = RoundRect( + mem_dc, + margin + outline, + margin + outline, + size - margin - outline + 1, + size - margin - outline + 1, + (radius - 1) * 2, + (radius - 1) * 2, + ); + + SelectObject(mem_dc, old_brush); + SelectObject(mem_dc, old_pen); + let _ = DeleteObject(br_outline); + let _ = DeleteObject(br_fill); + + // Draw centered percentage text + let font_name = native_interop::wide_str("Arial Bold"); + let font = CreateFontW( + font_h, + 0, + 0, + 0, + FW_BOLD.0 as i32, + 0, + 0, + 0, + DEFAULT_CHARSET.0 as u32, + OUT_TT_PRECIS.0 as u32, + CLIP_DEFAULT_PRECIS.0 as u32, + ANTIALIASED_QUALITY.0 as u32, + (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, + PCWSTR::from_raw(font_name.as_ptr()), + ); + let old_font = SelectObject(mem_dc, font); + let _ = SetBkMode(mem_dc, TRANSPARENT); + let _ = SetTextColor(mem_dc, COLORREF(text_col.to_colorref())); + + let mut text_rect = RECT { + left: margin, + top: margin, + right: size - margin, + bottom: size - margin, + }; + let mut text_wide: Vec = display_text.encode_utf16().collect(); + let _ = DrawTextW( + mem_dc, + &mut text_wide, + &mut text_rect, + DT_CENTER | DT_VCENTER | DT_SINGLELINE, + ); + + SelectObject(mem_dc, old_font); + let _ = DeleteObject(font); + + // Set alpha: non-zero BGR pixel -> fully opaque; background stays transparent + for px in pixel_data.iter_mut() { + if *px != 0 { + *px = (*px & 0x00FF_FFFF) | 0xFF00_0000; + } + } + + // Monochrome mask (per-pixel alpha from colour bitmap) + let mask_bytes = vec![0u8; ((size * size + 7) / 8) as usize]; + let mask_bmp = CreateBitmap( + size, + size, + 1, + 1, + Some(mask_bytes.as_ptr() as *const std::ffi::c_void), + ); + + let icon_info = ICONINFO { + fIcon: TRUE, + xHotspot: 0, + yHotspot: 0, + hbmMask: mask_bmp, + hbmColor: dib, + }; + let hicon = CreateIconIndirect(&icon_info).unwrap_or_default(); + + let _ = DeleteObject(mask_bmp); + SelectObject(mem_dc, old_bmp); + let _ = DeleteObject(dib); + let _ = DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + + hicon + } +} + +/// Register the tray icon with the shell. +pub fn add(hwnd: HWND, percent: Option, tooltip: &str) { + let hicon = create_icon(percent); + unsafe { + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); + nid.cbSize = std::mem::size_of::() as u32; + nid.hWnd = hwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; + nid.uCallbackMessage = WM_APP_TRAY; + nid.hIcon = hicon; + copy_to_tip(tooltip, &mut nid.szTip); + let _ = Shell_NotifyIconW(NIM_ADD, &nid); + if !hicon.is_invalid() { + let _ = DestroyIcon(hicon); + } + } +} + +/// Update the tray icon colour and tooltip to reflect current usage. +pub fn update(hwnd: HWND, percent: Option, tooltip: &str) { + let hicon = create_icon(percent); + unsafe { + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); + nid.cbSize = std::mem::size_of::() as u32; + nid.hWnd = hwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_ICON | NIF_TIP; + nid.hIcon = hicon; + copy_to_tip(tooltip, &mut nid.szTip); + let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); + if !hicon.is_invalid() { + let _ = DestroyIcon(hicon); + } + } +} + +/// Remove the tray icon from the shell. +pub fn remove(hwnd: HWND) { + unsafe { + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); + nid.cbSize = std::mem::size_of::() as u32; + nid.hWnd = hwnd; + nid.uID = TRAY_ICON_ID; + let _ = Shell_NotifyIconW(NIM_DELETE, &nid); + } +} + +/// Interpret a tray callback message and return the action to take. +pub fn handle_message(lparam: LPARAM) -> TrayAction { + let mouse_msg = lparam.0 as u32; + match mouse_msg { + WM_LBUTTONUP => TrayAction::ToggleWidget, + WM_RBUTTONUP => TrayAction::ShowContextMenu, + _ => TrayAction::None, + } +} + +/// Copy a string into the fixed-size szTip field (max 127 chars + null). +fn copy_to_tip(s: &str, tip: &mut [u16; 128]) { + let wide: Vec = s.encode_utf16().collect(); + let mut len = wide.len().min(127); + // Don't leave a lone high surrogate at the truncation point + if len > 0 && (0xD800..=0xDBFF).contains(&wide[len - 1]) { + len -= 1; + } + tip[..len].copy_from_slice(&wide[..len]); + tip[len] = 0; +} diff --git a/src/window.rs b/src/window.rs index 7d76600..0ec8670 100644 --- a/src/window.rs +++ b/src/window.rs @@ -20,8 +20,9 @@ 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_USAGE_UPDATED, + WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; +use crate::tray_icon; use crate::poller; use crate::theme; use crate::updater::{self, InstallChannel, ReleaseDescriptor, UpdateCheckResult}; @@ -70,6 +71,8 @@ struct AppState { dragging: bool, drag_start_mouse_x: i32, drag_start_offset: i32, + + widget_visible: bool, } #[derive(Clone, Debug)] @@ -159,6 +162,8 @@ struct SettingsFile { language: Option, #[serde(default, skip_serializing_if = "Option::is_none")] last_update_check_unix: Option, + #[serde(default = "default_widget_visible")] + widget_visible: bool, } impl Default for SettingsFile { @@ -168,6 +173,7 @@ impl Default for SettingsFile { poll_interval_ms: default_poll_interval(), language: None, last_update_check_unix: None, + widget_visible: true, } } } @@ -176,6 +182,10 @@ 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, @@ -204,10 +214,44 @@ fn save_state_settings() { .language_override .map(|language| language.code().to_string()), last_update_check_unix: s.last_update_check_unix, + widget_visible: s.widget_visible, }); } } +fn tray_icon_data_from_state() -> (Option, 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) @@ -768,6 +812,7 @@ pub fn run() { dragging: false, drag_start_mouse_x: 0, drag_start_offset: 0, + widget_visible: settings.widget_visible, }); } @@ -818,9 +863,15 @@ pub fn run() { ); } - // Position and show + // 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(); - let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + if settings.widget_visible { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } diagnose::log("window shown"); // Initial render via UpdateLayeredWindow (for embedded) or InvalidateRect (fallback) @@ -1458,6 +1509,8 @@ unsafe extern "system" fn wnd_proc( 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 => { @@ -1736,10 +1789,25 @@ unsafe extern "system" fn wnd_proc( 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(); @@ -1748,6 +1816,7 @@ unsafe extern "system" fn wnd_proc( if let Some(h) = hook { native_interop::unhook_win_event(h); } + tray_icon::remove(hwnd); PostQuitMessage(0); LRESULT(0) } @@ -1764,6 +1833,7 @@ fn show_context_menu(hwnd: HWND) { language_override, install_channel, update_status, + widget_visible, ) = { let state = lock_state(); match state.as_ref() { @@ -1774,6 +1844,7 @@ fn show_context_menu(hwnd: HWND) { s.language_override, s.install_channel, s.update_status.clone(), + s.widget_visible, ), None => ( POLL_15_MIN, @@ -1782,6 +1853,7 @@ fn show_context_menu(hwnd: HWND) { None, InstallChannel::Portable, UpdateStatus::Idle, + true, ), } }; @@ -1921,6 +1993,15 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(settings_label.as_ptr()), ); + let widget_label = native_interop::wide_str("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); From 451c205de4cc13e70e417828a1568528d5e53b97 Mon Sep 17 00:00:00 2001 From: usamaiqb <83345144+usamaiqb@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:42:13 +0500 Subject: [PATCH 2/3] update readme --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4490432..ff3d317 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ It sits in your taskbar and shows how much of your Claude Code usage window you - A **7d** bar for your current 7-day window - A live countdown until each limit resets - A small native widget that lives directly in the Windows taskbar +- A **system tray icon** showing your usage percentage as a color-coded badge +- Left-click the tray icon to toggle the taskbar widget on or off - Right-click options for refresh, update frequency, language, startup, and updates ## Who This Is For @@ -48,12 +50,27 @@ After installing with WinGet, run: claude-code-usage-monitor ``` -Once running, it will appear in your taskbar. +Once running, it will appear in your taskbar and as a tray icon in the notification area. -- Drag the left divider to move it -- Right-click for refresh, update frequency, Start with Windows, reset position, language, updates, and exit +- Drag the left divider to move the taskbar widget +- Right-click the taskbar widget or tray icon for refresh, update frequency, Start with Windows, reset position, language, updates, and exit +- Left-click the tray icon to toggle the taskbar widget on or off - Enable `Start with Windows` from the right-click menu if you want it to launch automatically when you sign in +### System Tray Icon + +The tray icon shows your current 5-hour usage as a color-coded percentage badge: + +| Color | Meaning | +|--------|-------------------| +| Green | Under 50% used | +| Yellow | 50–75% used | +| Orange | 75–90% used | +| Red | 90% or more used | +| Gray | No data available | + +Hovering over the tray icon shows a tooltip with both your 5h and 7d usage. + ## Diagnostics If you need to troubleshoot startup or visibility issues, run: From 40d3d1f7e1e1d831cf4b030cd871ba789ca4edb5 Mon Sep 17 00:00:00 2001 From: usamaiqb <83345144+usamaiqb@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:46:02 +0500 Subject: [PATCH 3/3] misc --- .claude/settings.local.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 2641db6..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cargo build:*)" - ] - } -}