From 3873e95fb50d8782174017ea7bf0f09b515c15c1 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Mon, 27 Jul 2026 22:15:35 +0800 Subject: [PATCH] fix(hook): report Windows pointer motion by differencing the cursor point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WH_MOUSE_LL never emitted MouseEvent::Moved, so the gesture accumulator saw no travel on Windows and a hold+swipe could never commit — only the plain click on release ever fired. Differencing the cursor position each WM_MOUSEMOVE against the previous one turns the hook's absolute point into the relative delta the accumulator expects, matching macOS MOUSE_EVENT_DELTA_X and Linux REL_X. The baseline lives in a thread-local: WH_MOUSE_LL callbacks run on the installing thread, so no synchronization is needed on the callback path. Injected moves are still filtered from the event stream but do advance the baseline, so a cursor jump can't be reported as the user's travel. Known limitation: the OS clamps the cursor point to the desktop bounds, so a swipe that runs into a screen edge stops producing deltas and may not reach the threshold. Reading the device's own relative counts needs raw input (WM_INPUT) and a second message pump. --- crates/openlogi-hook/src/lib.rs | 2 +- crates/openlogi-hook/src/windows.rs | 97 ++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/crates/openlogi-hook/src/lib.rs b/crates/openlogi-hook/src/lib.rs index 25f92269..44185a67 100644 --- a/crates/openlogi-hook/src/lib.rs +++ b/crates/openlogi-hook/src/lib.rs @@ -4,7 +4,7 @@ //! |----------|---------------| //! | macOS | `CGEventTap` (same primitive used by Logi Options+) | //! | Linux | `evdev` grab + `uinput` re-injection | -//! | Windows | `WH_MOUSE_LL` low-level mouse hook | +//! | Windows | `WH_MOUSE_LL` low-level mouse hook (motion is edge-clamped) | //! //! # Usage //! diff --git a/crates/openlogi-hook/src/windows.rs b/crates/openlogi-hook/src/windows.rs index 3c718a76..5d2f35fa 100644 --- a/crates/openlogi-hook/src/windows.rs +++ b/crates/openlogi-hook/src/windows.rs @@ -7,10 +7,11 @@ reason = "Win32 FFI uses raw pointer parameters and fixed-width message values" )] +use std::cell::Cell; use std::sync::{Arc, Mutex, mpsc}; use std::thread; -use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, LPARAM, LRESULT, WPARAM}; +use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, LPARAM, LRESULT, POINT, WPARAM}; use windows_sys::Win32::System::Threading::{ GetCurrentThreadId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, }; @@ -18,14 +19,26 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowThreadProcessId, HC_ACTION, LLMHF_INJECTED, MSG, MSLLHOOKSTRUCT, PM_NOREMOVE, PeekMessageW, PostThreadMessageW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, WH_MOUSE_LL, WM_LBUTTONDOWN, - WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEWHEEL, WM_QUIT, - WM_RBUTTONDOWN, WM_RBUTTONUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1, XBUTTON2, + WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, + WM_QUIT, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1, + XBUTTON2, }; use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; const WHEEL_DELTA: f32 = 120.0; +thread_local! { + /// Cursor position carried by the previous `WM_MOUSEMOVE`, differenced into + /// the relative delta [`MouseEvent::Moved`] carries — see [`motion_delta`]. + /// + /// Thread-local rather than a `static`: `WH_MOUSE_LL` callbacks run on the + /// thread that installed the hook, so this is single-threaded by + /// construction and needs no synchronization on the freeze-sensitive + /// callback path. + static LAST_POINT: Cell> = const { Cell::new(None) }; +} + type HookCallback = Arc EventDisposition + Send + Sync + 'static>; static CALLBACK: Mutex> = Mutex::new(None); @@ -224,6 +237,12 @@ unsafe fn hook_data(lparam: LPARAM) -> Option { fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option { if data.flags & LLMHF_INJECTED != 0 { + // Injected motion is not the user's, but it still moves the cursor: + // advance the baseline so the next real move reports its own travel + // rather than the jump, which could otherwise commit a phantom swipe. + if wparam as u32 == WM_MOUSEMOVE { + LAST_POINT.set(Some(data.pt)); + } return None; } @@ -268,10 +287,30 @@ fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option { from_trackpad: false, device: None, }), + WM_MOUSEMOVE => { + let (delta_x, delta_y) = motion_delta(data.pt)?; + Some(MouseEvent::Moved { delta_x, delta_y }) + } _ => None, } } +/// Relative motion since the previous `WM_MOUSEMOVE`, or `None` for the first +/// move seen (no baseline) and for a report that didn't move the cursor. +/// +/// # Limitation +/// `WH_MOUSE_LL` carries the cursor *position*, which the OS clamps to the +/// desktop bounds, so a swipe that runs into an edge stops producing deltas +/// even though the device keeps reporting counts. A gesture started near an +/// edge can therefore fail to reach the swipe threshold. Reading the device's +/// own counts needs raw input (`WM_INPUT`), a separate message pump. +fn motion_delta(pt: POINT) -> Option<(i32, i32)> { + let previous = LAST_POINT.replace(Some(pt))?; + let delta_x = pt.x - previous.x; + let delta_y = pt.y - previous.y; + (delta_x != 0 || delta_y != 0).then_some((delta_x, delta_y)) +} + fn high_word(value: u32) -> u16 { (value >> 16) as u16 } @@ -333,6 +372,58 @@ fn last_error(context: &str) -> HookError { mod tests { use super::*; + /// The accumulator downstream sums these deltas and tests a threshold, so + /// the first move of a session must not report the cursor's absolute + /// position as travel, and a report that didn't move the cursor must not + /// count at all. + #[test] + fn motion_delta_differences_successive_points() { + let at = |x, y| POINT { x, y }; + // Explicit, so the test doesn't depend on running on a fresh thread + // (`--test-threads=1` shares one with every other test). + LAST_POINT.set(None); + + assert!( + motion_delta(at(800, 600)).is_none(), + "the first move has no baseline to difference against" + ); + assert_eq!(motion_delta(at(860, 595)), Some((60, -5))); + assert_eq!(motion_delta(at(900, 595)), Some((40, 0))); + assert!( + motion_delta(at(900, 595)).is_none(), + "a repeated point is not motion" + ); + } + + /// Injected motion is filtered out of the event stream, but it still moves + /// the cursor — if it didn't advance the baseline, the next real move would + /// report the injected jump as the user's own travel and could commit a + /// swipe from a single event. + #[test] + fn injected_motion_advances_the_baseline_without_reporting() { + let injected = MSLLHOOKSTRUCT { + flags: LLMHF_INJECTED, + pt: POINT { x: 100, y: 100 }, + ..MSLLHOOKSTRUCT::default() + }; + let real = MSLLHOOKSTRUCT { + pt: POINT { x: 110, y: 100 }, + ..MSLLHOOKSTRUCT::default() + }; + + assert!(translate_event(WM_MOUSEMOVE as WPARAM, injected).is_none()); + assert!( + matches!( + translate_event(WM_MOUSEMOVE as WPARAM, real), + Some(MouseEvent::Moved { + delta_x: 10, + delta_y: 0 + }) + ), + "the delta spans the injected point, not the pre-injection one" + ); + } + #[test] fn translate_event_ignores_injected_mouse_input() { let data = MSLLHOOKSTRUCT {