diff --git a/.github/animation.gif b/.github/animation.gif new file mode 100644 index 0000000..a95fd26 Binary files /dev/null and b/.github/animation.gif differ diff --git a/.github/screenshot.png b/.github/screenshot.png deleted file mode 100644 index 32e658a..0000000 Binary files a/.github/screenshot.png and /dev/null differ diff --git a/Cargo.lock b/Cargo.lock index 214df5d..c537015 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "claude-code-usage-monitor" -version = "1.2.9" +version = "1.2.11" dependencies = [ "dirs", "native-tls", diff --git a/Cargo.toml b/Cargo.toml index 2bbc81f..71ac04f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,16 @@ [package] name = "claude-code-usage-monitor" -version = "1.2.9" +version = "1.2.11" edition = "2021" license = "MIT" -description = "Windows taskbar widget for monitoring Claude Code usage and rate limits" +description = "Claude Code Usage Monitor" homepage = "https://codezeno.com.au" repository = "https://github.com/CodeZeno/Claude-Code-Usage-Monitor" [package.metadata.winres] CompanyName = "Code Zeno Pty Ltd" ProductName = "Claude Code Usage Monitor" -FileDescription = "Windows taskbar widget for monitoring Claude Code usage and rate limits" +FileDescription = "Claude Code Usage Monitor" OriginalFilename = "claude-code-usage-monitor.exe" InternalName = "ClaudeCodeUsageMonitor" Comments = "https://codezeno.com.au" diff --git a/README.md b/README.md index ff3d317..5a28eda 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ It sits in your taskbar and shows how much of your Claude Code usage window you ![Windows](https://img.shields.io/badge/platform-Windows-blue) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -![Screenshot](.github/screenshot.png) +![Screenshot](.github/animation.gif) ## What You Get diff --git a/src/tray_icon.rs b/src/tray_icon.rs index 51f13ed..1fc2593 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -1,8 +1,9 @@ use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; +use windows::Win32::System::LibraryLoader::GetModuleFileNameW; use windows::Win32::UI::Shell::{ - NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, - Shell_NotifyIconW, + ExtractIconExW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, + NOTIFYICONDATAW, Shell_NotifyIconW, }; use windows::Win32::UI::WindowsAndMessaging::*; use windows::core::PCWSTR; @@ -21,45 +22,65 @@ pub enum TrayAction { 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; +fn lerp_channel(start: u8, end: u8, t: f64) -> u8 { + (start as f64 + (end as f64 - start as f64) * t.clamp(0.0, 1.0)).round() as u8 +} - 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"), - ), - }; +fn lerp_color(start: Color, end: Color, t: f64) -> Color { + Color::new( + lerp_channel(start.r, end.r, t), + lerp_channel(start.g, end.g, t), + lerp_channel(start.b, end.b, t), + ) +} + +fn interpolated_fill(percent: f64) -> Color { + if percent <= 50.0 { + return Color::from_hex("#D97757"); + } + + let stops = [ + (50.0, Color::from_hex("#D97757")), + (70.0, Color::from_hex("#D08540")), + (85.0, Color::from_hex("#CC8C20")), + (95.0, Color::from_hex("#C45020")), + (100.0, Color::from_hex("#B82020")), + ]; + + for pair in stops.windows(2) { + let (start_pct, start_color) = pair[0]; + let (end_pct, end_color) = pair[1]; + if percent <= end_pct { + let span = (end_pct - start_pct).max(f64::EPSILON); + let t = (percent - start_pct) / span; + return lerp_color(start_color, end_color, t); + } + } + + stops[stops.len() - 1].1 +} + +/// Create a rounded-rectangle tray icon badge showing the usage percentage. +/// `percent` = None means "no data/loading" and uses the embedded app icon. +pub fn create_icon(percent: Option) -> HICON { + if percent.is_none() { + let app_icon = load_embedded_app_icon(); + if !app_icon.is_invalid() { + return app_icon; + } + } + + let size = 64_i32; + let margin = 0_i32; + let radius = 2_i32; + let outline = 0_i32; + + let fill = interpolated_fill(percent.unwrap_or(0.0)); + let text_col = Color::from_hex("#FFFFFF"); let display_text = match percent { - None => "?".to_string(), Some(p) => format!("{}", p as u32), + None => String::new(), }; let font_h = match display_text.len() { @@ -108,22 +129,9 @@ pub fn create_icon(percent: Option) -> HICON { 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 + // Fill rounded rect let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref())); - SelectObject(mem_dc, br_fill); + let old_brush = SelectObject(mem_dc, br_fill); let _ = RoundRect( mem_dc, margin + outline, @@ -136,7 +144,6 @@ pub fn create_icon(percent: Option) -> HICON { SelectObject(mem_dc, old_brush); SelectObject(mem_dc, old_pen); - let _ = DeleteObject(br_outline); let _ = DeleteObject(br_fill); // Draw centered percentage text @@ -214,6 +221,34 @@ pub fn create_icon(percent: Option) -> HICON { } } +fn load_embedded_app_icon() -> HICON { + unsafe { + let mut exe_buf = [0u16; 260]; + let len = GetModuleFileNameW(None, &mut exe_buf) as usize; + if len == 0 { + return HICON::default(); + } + + let mut small_icon = HICON::default(); + let mut large_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() + } else if !small_icon.is_invalid() { + small_icon + } else { + large_icon + } + } +} + /// Register the tray icon with the shell. pub fn add(hwnd: HWND, percent: Option, tooltip: &str) { let hicon = create_icon(percent); diff --git a/src/window.rs b/src/window.rs index 0ec8670..c094127 100644 --- a/src/window.rs +++ b/src/window.rs @@ -13,6 +13,7 @@ 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; @@ -136,6 +137,32 @@ fn refresh_dpi() { } } +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> = Mutex::new(None); @@ -742,12 +769,15 @@ pub fn run() { unsafe { let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap(); + let (large_icon, small_icon) = load_embedded_app_icons(); let wc = WNDCLASSEXW { cbSize: std::mem::size_of::() 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()), @@ -781,6 +811,24 @@ pub fn run() { 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(); @@ -1374,18 +1422,18 @@ fn position_at_taskbar() { 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; - let y = (anchor_top - taskbar_rect.top) + (anchor_height - widget_height) / 2; - native_interop::move_window(hwnd, x, y, widget_width, widget_height); + native_interop::move_window(hwnd, x, y - taskbar_rect.top, widget_width, widget_height); diagnose::log(format!( - "positioned embedded widget at x={x} y={y} w={widget_width} h={widget_height}" + "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; - let y = anchor_top + (anchor_height - widget_height) / 2; 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}" @@ -1393,6 +1441,12 @@ fn position_at_taskbar() { } } +fn compute_anchor_y(anchor_top: i32, anchor_height: i32, widget_height: i32) -> i32 { + let anchor_bottom = anchor_top + anchor_height; + let bottom_padding = (anchor_height - widget_height).clamp(0, sc(6)); + (anchor_bottom - widget_height - bottom_padding).max(anchor_top) +} + /// WinEvent callback for tray icon location changes unsafe extern "system" fn on_tray_location_changed( _hook: HWINEVENTHOOK, @@ -1627,20 +1681,18 @@ unsafe extern "system" fn wnd_proc( } 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; - let y = - (anchor_top - taskbar_rect.top) + (anchor_height - widget_height) / 2; native_interop::move_window( hwnd_val, x, - y, + y - taskbar_rect.top, widget_width, widget_height, ); } else { let x = tray_left - widget_width - new_offset; - let y = anchor_top + (anchor_height - widget_height) / 2; native_interop::move_window( hwnd_val, x,