From c316a7e20715114d850aaaa07fe1cbbd57f5f1b4 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Mon, 27 Jul 2026 22:15:35 +0800 Subject: [PATCH 1/2] 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 { From 6673e218c3ecf0478add6a0992757190a2fb87c6 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 1 Aug 2026 10:14:28 +0800 Subject: [PATCH 2/2] fix(hook): seed the Windows motion baseline from every mouse message A gesture button pressed before the hook had seen any WM_MOUSEMOVE left the baseline empty, so the swipe's first move was consumed as the initial sample and never reached the accumulator - a short swipe then resolved as the fallback click, the exact failure this hook exists to fix. Every MSLLHOOKSTRUCT carries the cursor point, so advance the baseline on all mouse messages instead of only on reported motion: the button-down that starts a gesture now seeds it. This also subsumes the injected-motion special case, which no longer needs its own branch. --- crates/openlogi-hook/src/windows.rs | 88 +++++++++++------------------ 1 file changed, 32 insertions(+), 56 deletions(-) diff --git a/crates/openlogi-hook/src/windows.rs b/crates/openlogi-hook/src/windows.rs index 5d2f35fa..781e1e01 100644 --- a/crates/openlogi-hook/src/windows.rs +++ b/crates/openlogi-hook/src/windows.rs @@ -29,8 +29,9 @@ 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`]. + /// Cursor position carried by the previous mouse message of any kind, + /// differenced into the relative delta [`MouseEvent::Moved`] carries — see + /// [`translate_event`] and [`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 @@ -236,13 +237,12 @@ unsafe fn hook_data(lparam: LPARAM) -> Option { } fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option { + // Every mouse message carries the cursor point, so the baseline advances on + // all of them: injected motion is dropped below but still moves the cursor, + // and a gesture's button-down seeds the baseline for its own first move. + let previous = LAST_POINT.replace(Some(data.pt)); + 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; } @@ -288,15 +288,16 @@ fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option { device: None, }), WM_MOUSEMOVE => { - let (delta_x, delta_y) = motion_delta(data.pt)?; + let (delta_x, delta_y) = motion_delta(previous?, 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. +/// Relative motion between two cursor points, or `None` for a report that +/// didn't move the cursor — the accumulator downstream sums these deltas +/// against a threshold, so a non-move must not count. /// /// # Limitation /// `WH_MOUSE_LL` carries the cursor *position*, which the OS clamps to the @@ -304,8 +305,7 @@ fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option { /// 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))?; +fn motion_delta(previous: POINT, pt: POINT) -> Option<(i32, i32)> { 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)) @@ -372,58 +372,33 @@ 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. + /// A gesture button pressed before the hook has seen any motion used to + /// leave the baseline empty, so the swipe's first move was swallowed as the + /// initial sample and a short swipe fell back to a click. #[test] - fn motion_delta_differences_successive_points() { - let at = |x, y| POINT { x, y }; + fn button_down_seeds_the_baseline_for_the_first_move() { + let at = |x, y| MSLLHOOKSTRUCT { + pt: POINT { x, y }, + ..MSLLHOOKSTRUCT::default() + }; // 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); - + translate_event(WM_LBUTTONDOWN as WPARAM, at(500, 400)); + + assert!(matches!( + translate_event(WM_MOUSEMOVE as WPARAM, at(560, 395)), + Some(MouseEvent::Moved { + delta_x: 60, + delta_y: -5 + }) + )); 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(), + translate_event(WM_MOUSEMOVE as WPARAM, at(560, 395)).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 { @@ -432,6 +407,7 @@ mod tests { }; assert!(translate_event(WM_LBUTTONDOWN as WPARAM, data).is_none()); + assert!(translate_event(WM_MOUSEMOVE as WPARAM, data).is_none()); } /// Wheel-forward (away from the user) must produce a positive `delta_y`, the