1+ use std:: path:: PathBuf ;
12use std:: sync:: { Mutex , MutexGuard } ;
23use std:: time:: Duration ;
34
@@ -7,6 +8,7 @@ use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW
78use windows:: Win32 :: System :: Registry :: * ;
89use windows:: Win32 :: System :: Threading :: CreateMutexW ;
910use windows:: Win32 :: UI :: Accessibility :: HWINEVENTHOOK ;
11+ use windows:: Win32 :: UI :: Input :: KeyboardAndMouse :: { ReleaseCapture , SetCapture } ;
1012use windows:: Win32 :: UI :: WindowsAndMessaging :: * ;
1113use 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
5461const RETRY_BASE_MS : u32 = 30_000 ; // 30 seconds
@@ -64,6 +71,9 @@ const IDM_FREQ_5MIN: u16 = 11;
6471const IDM_FREQ_15MIN : u16 = 12 ;
6572const IDM_FREQ_1HOUR : u16 = 13 ;
6673const 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
6878unsafe 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+
78139const STARTUP_REGISTRY_PATH : & str = r"Software\Microsoft\Windows\CurrentVersion\Run" ;
79140const 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