From 7ee8e7d08096208fc43fbc63bd19faf672207d2a Mon Sep 17 00:00:00 2001 From: yure Date: Sat, 9 May 2026 21:39:21 +0800 Subject: [PATCH] fix: resolve drag deadlock and stuck cursor in taskbar widget Three related fixes: 1. Fix thread deadlock during drag (root cause of stuck cursor): WM_MOUSEMOVE held lock_state() mutex and called total_widget_width(), which also called lock_state(). Rust's std::sync::Mutex (SRWLOCK on Windows) is non-reentrant, causing the message thread to deadlock. Fixed by using total_widget_width_for() with active_models read from the already-held state. 2. Add WM_CAPTURECHANGED and WM_CANCELMODE handlers: Reset dragging state whenever mouse capture is transferred away for any reason, providing a safety net beyond WM_LBUTTONUP. 3. Add left_button_held() self-correction in WM_SETCURSOR and WM_MOUSEMOVE: If dragging=true but the left button is not physically held, cancel the drag immediately rather than waiting for a message that may never arrive. Co-Authored-By: Claude Sonnet 4.6 --- src/localization/dutch.rs | 10 +++ src/window.rs | 153 ++++++++++++++++++++++++++++++++++---- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 0eb486d..e3cb79e 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -43,4 +43,14 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", second_suffix: "s", + show_pacing: "Pacingindicator tonen", + quiet_hours: "Stille uren", + quiet_start: "Begin", + quiet_end: "Einde", + quiet_set_time: "Tijd instellen...", + quiet_clear: "Wissen", + ok: "OK", + cancel: "Annuleren", + quiet_time_hint: "Formaat: UU:MM (beide leeg laten om uit te schakelen)", + quiet_time_error: "Vul beide velden in of laat ze allebei leeg", }; diff --git a/src/window.rs b/src/window.rs index 5b840d3..67ce4ef 100644 --- a/src/window.rs +++ b/src/window.rs @@ -13,7 +13,9 @@ use windows::Win32::System::Threading::CreateMutexW; use windows::Win32::System::SystemInformation::GetLocalTime; use windows::Win32::UI::Accessibility::HWINEVENTHOOK; use windows::Win32::UI::HiDpi::*; -use windows::Win32::UI::Input::KeyboardAndMouse::{GetFocus, ReleaseCapture, SetCapture, SetFocus}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetFocus, GetKeyState, ReleaseCapture, SetCapture, SetFocus, VK_LBUTTON, +}; use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::WindowsAndMessaging::*; @@ -92,6 +94,8 @@ struct AppState { show_pacing: bool, session_pacing_pct: Option, weekly_pacing_pct: Option, + session_resets_at: Option, + weekly_resets_at: Option, } #[derive(Clone, Debug)] @@ -1531,6 +1535,8 @@ pub fn run() { show_pacing: settings.show_pacing, session_pacing_pct: None, weekly_pacing_pct: None, + session_resets_at: None, + weekly_resets_at: None, }); } @@ -1664,6 +1670,17 @@ fn render_layered() { Some(s) => { // Read quiet-hours state while holding the lock to avoid a deadlock from calling is_quiet_time() let quiet = quiet_now(s); + let now = std::time::SystemTime::now(); + + // During quiet hours, check if each window has already reset. + // If reset: show 0% usage and no pacing (window restarted, no conversation yet). + // If not reset: show frozen usage and time-based pacing. + let session_reset_done = quiet && s.session_resets_at.map(|t| t <= now).unwrap_or(false); + let weekly_reset_done = quiet && s.weekly_resets_at.map(|t| t <= now).unwrap_or(false); + + let eff_session_pct = if session_reset_done { 0.0 } else { s.session_percent }; + let eff_weekly_pct = if weekly_reset_done { 0.0 } else { s.weekly_percent }; + let session_text = if quiet { s.language.strings().quiet_hours.to_string() } else { @@ -1671,17 +1688,36 @@ fn render_layered() { }; // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) let weekly_text = s.weekly_text.clone(); - // Filter out pacing during quiet hours or when actual usage exceeds the expected pace - let session_pacing = if quiet { None } else { s.session_pacing_pct.filter(|&p| p > s.session_percent) }; - let weekly_pacing = if quiet { None } else { s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) }; + + let session_pacing = if quiet { + // Show pacing only before the window resets (time-based, no poll needed) + if !session_reset_done && s.show_pacing { + compute_pacing_pct(s.session_resets_at, 5.0 * 3600.0) + .filter(|&p| p > eff_session_pct) + } else { + None + } + } else { + s.session_pacing_pct.filter(|&p| p > s.session_percent) + }; + let weekly_pacing = if quiet { + if !weekly_reset_done && s.show_pacing { + compute_pacing_pct(s.weekly_resets_at, 7.0 * 24.0 * 3600.0) + .filter(|&p| p > eff_weekly_pct) + } else { + None + } + } else { + s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) + }; ( s.hwnd, s.is_dark, s.embedded, s.language.strings(), - s.session_percent, + eff_session_pct, session_text, - s.weekly_percent, + eff_weekly_pct, weekly_text, s.codex_session_percent, s.codex_session_text.clone(), @@ -1985,6 +2021,11 @@ fn paint_content( /// Computes the expected-usage percentage based on elapsed time within a rolling window. /// Returns None if `resets_at` is unavailable or the window has not yet started. +/// Returns true if the left mouse button is physically held down right now. +fn left_button_held() -> bool { + unsafe { (GetKeyState(VK_LBUTTON.0 as i32) & 0x8000u16 as i16) != 0 } +} + fn compute_pacing_pct(resets_at: Option, window_secs: f64) -> Option { let remaining = resets_at?.duration_since(std::time::SystemTime::now()).ok()?; let elapsed = 1.0 - remaining.as_secs_f64() / window_secs; @@ -2008,6 +2049,8 @@ fn do_poll(send_hwnd: SendHwnd) { if let Some(claude_code) = data.claude_code.as_ref() { s.session_percent = claude_code.session.percentage; s.weekly_percent = claude_code.weekly.percentage; + s.session_resets_at = claude_code.session.resets_at; + s.weekly_resets_at = claude_code.weekly.resets_at; if s.show_pacing { s.session_pacing_pct = compute_pacing_pct(claude_code.session.resets_at, 5.0 * 3600.0); s.weekly_pacing_pct = compute_pacing_pct(claude_code.weekly.resets_at, 7.0 * 24.0 * 3600.0); @@ -2521,8 +2564,18 @@ unsafe extern "system" fn wnd_proc( } WM_SETCURSOR => { let is_dragging = { - let state = lock_state(); - state.as_ref().map(|s| s.dragging).unwrap_or(false) + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + // If we think we're dragging but the button is no longer held, + // the WM_LBUTTONUP was missed — cancel the drag immediately. + if s.dragging && !left_button_held() { + s.dragging = false; + let _ = ReleaseCapture(); + } + s.dragging + } else { + false + } }; // Always show resize cursor while dragging or when hovering divider zone let hit_test = (lparam.0 & 0xFFFF) as u16; @@ -2565,6 +2618,19 @@ unsafe extern "system" fn wnd_proc( state.as_ref().map(|s| s.dragging).unwrap_or(false) }; if is_dragging { + // If the button was released outside our window, WM_LBUTTONUP may + // have been missed. Detect this here and cancel the drag. + if !left_button_held() { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.dragging = false; + } + } + let _ = ReleaseCapture(); + save_state_settings(); + return LRESULT(0); + } let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); let move_target = { @@ -2600,7 +2666,11 @@ unsafe extern "system" fn wnd_proc( tray_left = tray_rect.left; } } - let widget_width = total_widget_width(); + // Use _for() variant to avoid re-acquiring the state lock + // while we already hold it (total_widget_width calls lock_state). + let widget_width = total_widget_width_for( + active_model_count(s.show_claude_code, s.show_codex), + ); let max_offset = tray_left - taskbar_rect.left - widget_width; if new_offset > max_offset { new_offset = max_offset; @@ -2676,6 +2746,37 @@ unsafe extern "system" fn wnd_proc( } LRESULT(0) } + // WM_CAPTURECHANGED fires whenever mouse capture is transferred away from this + // window (including when we call ReleaseCapture ourselves). Resetting dragging + // here ensures the resize cursor never gets stuck if WM_LBUTTONUP is missed. + WM_CAPTURECHANGED => { + let was_dragging = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + let was = s.dragging; + s.dragging = false; + was + } else { + false + } + }; + if was_dragging { + save_state_settings(); + } + LRESULT(0) + } + // WM_CANCELMODE is sent when a modal operation begins (e.g. Alt+Tab, context + // menu on another window). Cancel the drag and release capture immediately. + WM_CANCELMODE => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.dragging = false; + } + } + let _ = ReleaseCapture(); + LRESULT(0) + } WM_RBUTTONUP => { show_context_menu(hwnd); LRESULT(0) @@ -3269,6 +3370,14 @@ fn paint(hdc: HDC, hwnd: HWND) { match state.as_ref() { Some(s) => { let quiet = quiet_now(s); + let now = std::time::SystemTime::now(); + + let session_reset_done = quiet && s.session_resets_at.map(|t| t <= now).unwrap_or(false); + let weekly_reset_done = quiet && s.weekly_resets_at.map(|t| t <= now).unwrap_or(false); + + let eff_session_pct = if session_reset_done { 0.0 } else { s.session_percent }; + let eff_weekly_pct = if weekly_reset_done { 0.0 } else { s.weekly_percent }; + let session_text = if quiet { s.language.strings().quiet_hours.to_string() } else { @@ -3276,14 +3385,32 @@ fn paint(hdc: HDC, hwnd: HWND) { }; // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) let weekly_text = s.weekly_text.clone(); - let session_pacing = if quiet { None } else { s.session_pacing_pct.filter(|&p| p > s.session_percent) }; - let weekly_pacing = if quiet { None } else { s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) }; + let session_pacing = if quiet { + if !session_reset_done && s.show_pacing { + compute_pacing_pct(s.session_resets_at, 5.0 * 3600.0) + .filter(|&p| p > eff_session_pct) + } else { + None + } + } else { + s.session_pacing_pct.filter(|&p| p > s.session_percent) + }; + let weekly_pacing = if quiet { + if !weekly_reset_done && s.show_pacing { + compute_pacing_pct(s.weekly_resets_at, 7.0 * 24.0 * 3600.0) + .filter(|&p| p > eff_weekly_pct) + } else { + None + } + } else { + s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) + }; ( s.is_dark, s.language.strings(), - s.session_percent, + eff_session_pct, session_text, - s.weekly_percent, + eff_weekly_pct, weekly_text, s.codex_session_percent, s.codex_session_text.clone(),