mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
e361b38cc5
commit
7ee8e7d080
@ -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",
|
||||
};
|
||||
|
||||
153
src/window.rs
153
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<f64>,
|
||||
weekly_pacing_pct: Option<f64>,
|
||||
session_resets_at: Option<std::time::SystemTime>,
|
||||
weekly_resets_at: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
#[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<std::time::SystemTime>, window_secs: f64) -> Option<f64> {
|
||||
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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user