Skip to content

Commit 55d3a88

Browse files
committed
v1.0.7
- Added ability to drag widget to desired position - Using C:\Users\CraigConstable\AppData\Roaming\ClaudeCodeUsageMonitor\settings.json to store position and update frequency - Updated README.md
1 parent 9941c9a commit 55d3a88

File tree

3 files changed

+238
-8
lines changed

3 files changed

+238
-8
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ features = [
2323
"Win32_System_Registry",
2424
"Win32_System_Threading",
2525
"Win32_Security",
26+
"Win32_UI_Input_KeyboardAndMouse",
2627
]
2728

2829
[profile.release]

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ Each bar shows the current utilization percentage and a countdown until the rate
2222
2. Sends a minimal API request to the Anthropic Messages API
2323
3. Parses rate limit headers (`anthropic-ratelimit-unified-*`) from the response
2424
4. Renders the widget using Win32 GDI, embedded as a child window of the taskbar
25-
5. Polls every 15 minutes (adjustable) and updates countdown timers between polls
25+
5. Polls every 15 minutes by default (adjustable via context menu) and updates countdown timers between polls
2626

27-
The widget automatically detects dark/light mode from Windows system settings.
27+
The widget automatically detects dark/light mode from Windows system settings. You can drag the left divider to reposition the widget along the taskbar. Settings (position and poll frequency) are persisted to `%APPDATA%\ClaudeCodeUsageMonitor\settings.json`.
2828

2929
## Requirements
3030

@@ -42,7 +42,10 @@ The binary will be at `target/release/claude-code-usage-monitor.exe`.
4242

4343
## Usage
4444

45-
Run the executable — the widget appears in your taskbar. Right-click for a context menu with **Refresh**, **Update Frequency** and **Exit** options.
45+
Run the executable — the widget appears in your taskbar.
46+
47+
- **Drag** the left divider to reposition the widget along the taskbar
48+
- **Right-click** for a context menu with **Refresh**, **Update Frequency**, **Settings** (Start with Windows, Reset Position), and **Exit**
4649

4750
## Project structure
4851

@@ -63,6 +66,6 @@ Pre-built Windows executables are available on the [Releases](../../releases) pa
6366
New releases are published automatically when a version tag is pushed:
6467

6568
```bash
66-
git tag v0.1.0
67-
git push origin v0.1.0
69+
git tag v1.0.0
70+
git push origin v1.0.0
6871
```

src/window.rs

Lines changed: 229 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::path::PathBuf;
12
use std::sync::{Mutex, MutexGuard};
23
use std::time::Duration;
34

@@ -7,6 +8,7 @@ use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW
78
use windows::Win32::System::Registry::*;
89
use windows::Win32::System::Threading::CreateMutexW;
910
use windows::Win32::UI::Accessibility::HWINEVENTHOOK;
11+
use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture};
1012
use windows::Win32::UI::WindowsAndMessaging::*;
1113
use windows::core::PCWSTR;
1214

@@ -49,6 +51,11 @@ struct AppState {
4951
poll_interval_ms: u32,
5052
retry_count: u32,
5153
last_poll_ok: bool,
54+
55+
tray_offset: i32,
56+
dragging: bool,
57+
drag_start_mouse_x: i32,
58+
drag_start_offset: i32,
5259
}
5360

5461
const RETRY_BASE_MS: u32 = 30_000; // 30 seconds
@@ -64,6 +71,9 @@ const IDM_FREQ_5MIN: u16 = 11;
6471
const IDM_FREQ_15MIN: u16 = 12;
6572
const IDM_FREQ_1HOUR: u16 = 13;
6673
const IDM_START_WITH_WINDOWS: u16 = 20;
74+
const IDM_RESET_POSITION: u16 = 30;
75+
76+
const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN
6777

6878
unsafe impl Send for AppState {}
6979

@@ -75,6 +85,57 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
7585
}
7686

7787

88+
fn settings_path() -> PathBuf {
89+
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string());
90+
PathBuf::from(appdata)
91+
.join("ClaudeCodeUsageMonitor")
92+
.join("settings.json")
93+
}
94+
95+
fn parse_json_i32(content: &str, key: &str) -> Option<i32> {
96+
let needle = format!("\"{}\"", key);
97+
let pos = content.find(&needle)?;
98+
let rest = &content[pos + needle.len()..];
99+
let colon = rest.find(':')?;
100+
let num_str: String = rest[colon + 1..]
101+
.trim()
102+
.chars()
103+
.take_while(|c| c.is_ascii_digit() || *c == '-')
104+
.collect();
105+
num_str.parse().ok()
106+
}
107+
108+
fn load_settings() -> (i32, u32) {
109+
let content = match std::fs::read_to_string(settings_path()) {
110+
Ok(c) => c,
111+
Err(_) => return (0, POLL_15_MIN),
112+
};
113+
let tray_offset = parse_json_i32(&content, "tray_offset").unwrap_or(0);
114+
let poll_interval = parse_json_i32(&content, "poll_interval_ms")
115+
.map(|v| v as u32)
116+
.unwrap_or(POLL_15_MIN);
117+
(tray_offset, poll_interval)
118+
}
119+
120+
fn save_settings(tray_offset: i32, poll_interval_ms: u32) {
121+
let path = settings_path();
122+
if let Some(parent) = path.parent() {
123+
let _ = std::fs::create_dir_all(parent);
124+
}
125+
let json = format!(
126+
"{{\n \"tray_offset\": {},\n \"poll_interval_ms\": {}\n}}",
127+
tray_offset, poll_interval_ms
128+
);
129+
let _ = std::fs::write(path, json);
130+
}
131+
132+
fn save_state_settings() {
133+
let state = lock_state();
134+
if let Some(s) = state.as_ref() {
135+
save_settings(s.tray_offset, s.poll_interval_ms);
136+
}
137+
}
138+
78139
const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
79140
const STARTUP_REGISTRY_KEY: &str = "ClaudeCodeUsageMonitor";
80141

@@ -273,6 +334,7 @@ pub fn run() {
273334

274335
let is_dark = theme::is_dark_mode();
275336
let mut embedded = false;
337+
let (saved_offset, saved_poll_interval) = load_settings();
276338

277339
{
278340
let mut state = lock_state();
@@ -288,9 +350,13 @@ pub fn run() {
288350
weekly_percent: 0.0,
289351
weekly_text: "--".to_string(),
290352
data: None,
291-
poll_interval_ms: POLL_15_MIN,
353+
poll_interval_ms: saved_poll_interval,
292354
retry_count: 0,
293355
last_poll_ok: false,
356+
tray_offset: saved_offset,
357+
dragging: false,
358+
drag_start_mouse_x: 0,
359+
drag_start_offset: 0,
294360
});
295361
}
296362

@@ -737,8 +803,14 @@ fn position_at_taskbar() {
737803
None => return,
738804
};
739805

806+
// Don't fight the user's drag
807+
if s.dragging {
808+
return;
809+
}
810+
740811
let hwnd = s.hwnd.to_hwnd();
741812
let embedded = s.embedded;
813+
let tray_offset = s.tray_offset;
742814

743815
let taskbar_hwnd = match s.taskbar_hwnd {
744816
Some(h) => h,
@@ -763,12 +835,12 @@ fn position_at_taskbar() {
763835

764836
if embedded {
765837
// Child window: coordinates relative to parent (taskbar)
766-
let x = tray_left - taskbar_rect.left - widget_width;
838+
let x = tray_left - taskbar_rect.left - widget_width - tray_offset;
767839
let y = (taskbar_height - WIDGET_HEIGHT) / 2;
768840
native_interop::move_window(hwnd, x, y, widget_width, WIDGET_HEIGHT);
769841
} else {
770842
// Topmost popup: screen coordinates
771-
let x = tray_left - widget_width;
843+
let x = tray_left - widget_width - tray_offset;
772844
let y = taskbar_rect.top + (taskbar_height - WIDGET_HEIGHT) / 2;
773845
native_interop::move_window(hwnd, x, y, widget_width, WIDGET_HEIGHT);
774846
}
@@ -877,6 +949,141 @@ unsafe extern "system" fn wnd_proc(
877949
schedule_countdown_timer();
878950
LRESULT(0)
879951
}
952+
WM_SETCURSOR => {
953+
let is_dragging = {
954+
let state = lock_state();
955+
state.as_ref().map(|s| s.dragging).unwrap_or(false)
956+
};
957+
// Always show resize cursor while dragging or when hovering divider zone
958+
let hit_test = (lparam.0 & 0xFFFF) as u16;
959+
if is_dragging {
960+
let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE)
961+
.unwrap_or_default();
962+
SetCursor(cursor);
963+
return LRESULT(1);
964+
}
965+
if hit_test == 1 { // HTCLIENT
966+
let mut pt = POINT::default();
967+
let _ = GetCursorPos(&mut pt);
968+
let _ = ScreenToClient(hwnd, &mut pt);
969+
if pt.x < DIVIDER_HIT_ZONE {
970+
let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE)
971+
.unwrap_or_default();
972+
SetCursor(cursor);
973+
return LRESULT(1);
974+
}
975+
}
976+
DefWindowProcW(hwnd, msg, wparam, lparam)
977+
}
978+
WM_LBUTTONDOWN => {
979+
let client_x = (lparam.0 & 0xFFFF) as i16 as i32;
980+
if client_x < DIVIDER_HIT_ZONE {
981+
let mut pt = POINT::default();
982+
let _ = GetCursorPos(&mut pt);
983+
let mut state = lock_state();
984+
if let Some(s) = state.as_mut() {
985+
s.dragging = true;
986+
s.drag_start_mouse_x = pt.x;
987+
s.drag_start_offset = s.tray_offset;
988+
}
989+
SetCapture(hwnd);
990+
}
991+
LRESULT(0)
992+
}
993+
WM_MOUSEMOVE => {
994+
let is_dragging = {
995+
let state = lock_state();
996+
state.as_ref().map(|s| s.dragging).unwrap_or(false)
997+
};
998+
if is_dragging {
999+
let mut pt = POINT::default();
1000+
let _ = GetCursorPos(&mut pt);
1001+
1002+
let mut state = lock_state();
1003+
let s = match state.as_mut() {
1004+
Some(s) => s,
1005+
None => return LRESULT(0),
1006+
};
1007+
1008+
// Moving mouse left = positive delta = larger offset (further left)
1009+
let delta = s.drag_start_mouse_x - pt.x;
1010+
let mut new_offset = s.drag_start_offset + delta;
1011+
1012+
// Clamp: offset >= 0 (can't go right of default)
1013+
if new_offset < 0 {
1014+
new_offset = 0;
1015+
}
1016+
1017+
// Clamp: don't go past left edge of taskbar
1018+
if let Some(taskbar_hwnd) = s.taskbar_hwnd {
1019+
if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) {
1020+
let mut tray_left = taskbar_rect.right;
1021+
if let Some(tray_hwnd) = native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") {
1022+
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) {
1023+
tray_left = tray_rect.left;
1024+
}
1025+
}
1026+
let widget_width = total_widget_width();
1027+
let max_offset = if s.embedded {
1028+
tray_left - taskbar_rect.left - widget_width
1029+
} else {
1030+
tray_left - taskbar_rect.left - widget_width
1031+
};
1032+
if new_offset > max_offset {
1033+
new_offset = max_offset;
1034+
}
1035+
}
1036+
}
1037+
1038+
s.tray_offset = new_offset;
1039+
1040+
// Move window directly
1041+
let hwnd_val = s.hwnd.to_hwnd();
1042+
if let Some(taskbar_hwnd) = s.taskbar_hwnd {
1043+
if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) {
1044+
let taskbar_height = taskbar_rect.bottom - taskbar_rect.top;
1045+
let mut tray_left = taskbar_rect.right;
1046+
if let Some(tray_hwnd) = native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") {
1047+
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) {
1048+
tray_left = tray_rect.left;
1049+
}
1050+
}
1051+
let widget_width = total_widget_width();
1052+
if s.embedded {
1053+
let x = tray_left - taskbar_rect.left - widget_width - new_offset;
1054+
let y = (taskbar_height - WIDGET_HEIGHT) / 2;
1055+
native_interop::move_window(hwnd_val, x, y, widget_width, WIDGET_HEIGHT);
1056+
} else {
1057+
let x = tray_left - widget_width - new_offset;
1058+
let y = taskbar_rect.top + (taskbar_height - WIDGET_HEIGHT) / 2;
1059+
native_interop::move_window(hwnd_val, x, y, widget_width, WIDGET_HEIGHT);
1060+
}
1061+
}
1062+
}
1063+
}
1064+
LRESULT(0)
1065+
}
1066+
WM_LBUTTONUP => {
1067+
let was_dragging = {
1068+
let mut state = lock_state();
1069+
if let Some(s) = state.as_mut() {
1070+
if s.dragging {
1071+
s.dragging = false;
1072+
let offset = s.tray_offset;
1073+
Some(offset)
1074+
} else {
1075+
None
1076+
}
1077+
} else {
1078+
None
1079+
}
1080+
};
1081+
if was_dragging.is_some() {
1082+
let _ = ReleaseCapture();
1083+
save_state_settings();
1084+
}
1085+
LRESULT(0)
1086+
}
8801087
WM_RBUTTONUP => {
8811088
show_context_menu(hwnd);
8821089
LRESULT(0)
@@ -908,6 +1115,16 @@ unsafe extern "system" fn wnd_proc(
9081115
}
9091116
PostQuitMessage(0);
9101117
}
1118+
IDM_RESET_POSITION => {
1119+
{
1120+
let mut state = lock_state();
1121+
if let Some(s) = state.as_mut() {
1122+
s.tray_offset = 0;
1123+
}
1124+
}
1125+
save_state_settings();
1126+
position_at_taskbar();
1127+
}
9111128
IDM_START_WITH_WINDOWS => {
9121129
set_startup_enabled(!is_startup_enabled());
9131130
}
@@ -925,6 +1142,7 @@ unsafe extern "system" fn wnd_proc(
9251142
s.poll_interval_ms = new_interval;
9261143
}
9271144
}
1145+
save_state_settings();
9281146
// Reset the poll timer with the new interval
9291147
SetTimer(hwnd, TIMER_POLL, new_interval, None);
9301148
}
@@ -1011,6 +1229,14 @@ fn show_context_menu(hwnd: HWND) {
10111229
PCWSTR::from_raw(startup_str.as_ptr()),
10121230
);
10131231

1232+
let reset_pos_str = native_interop::wide_str("Reset Position");
1233+
let _ = AppendMenuW(
1234+
settings_menu,
1235+
MENU_ITEM_FLAGS(0),
1236+
IDM_RESET_POSITION as usize,
1237+
PCWSTR::from_raw(reset_pos_str.as_ptr()),
1238+
);
1239+
10141240
let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null());
10151241

10161242
let version_str = native_interop::wide_str(&format!("v{}", env!("CARGO_PKG_VERSION")));

0 commit comments

Comments
 (0)