From 8f0eca7591c7ca5ca9acab02042f66bd6cfd4f2d Mon Sep 17 00:00:00 2001 From: Sudheer Date: Mon, 8 Jun 2026 01:57:53 +0200 Subject: [PATCH 1/4] Fix spurious Noop MouseGesture firing after KeyIsDown + wheel rule When a button is set to Mouse Gestures mode and used in a KeyIsDown + thumb wheel combo rule, releasing the button would spuriously fire any Noop MouseGesture rule for that button. Add gesture_suppressed flag that persists across all rule evaluations of the same MOUSE_GESTURE notification, reset at the start of each new notification in process_notification. --- lib/logitech_receiver/diversion.py | 112 +++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 6 deletions(-) diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index e933f5ad..bd33013b 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -73,10 +73,9 @@ logger = logging.getLogger(__name__) # KeyPress action gets the current keyboard group using XkbGetState from libX11.so using ctypes definitions # under Wayland the keyboard group is None resulting in using the first keyboard group # KeyPress action translates keysyms to keycodes using the GDK keymap -# KeyPress, MouseScroll, and MouseClick actions use uinput. +# KeyPress, MouseScroll, and MouseClick actions use XTest (under X11) or uinput. # For uinput to work the user must have write access for /dev/uinput. -# The Solaar udev rule should set this up -# Otherwise run sudo setfacl -m u:${user}:rw /dev/uinput +# To get this access run sudo setfacl -m u:${user}:rw /dev/uinput # # Rule GUI keyname determination uses a local file generated # from http://cgit.freedesktop.org/xorg/proto/x11proto/plain/keysymdef.h @@ -86,7 +85,8 @@ logger = logging.getLogger(__name__) # Setting up is complex because there are several systems that each provide partial facilities: # GDK - always available (when running with a window system) but only provides access to keymap # X11 - provides access to active process and process with window under mouse and current modifier keys -# uinput and evdev - provides input simulation +# Xtest extension to X11 - provides input simulation, partly works under Wayland +# Wayland - provides input simulation XK_KEYS: Dict[str, int] = keysymdef.key_symbols @@ -110,12 +110,16 @@ if wayland: "accessing process only works on GNOME with Solaar Gnome extension installed" ) +# import Xlib was missing here; the try/except was a no-op and x11_setup() never attempted to load X11 try: + import Xlib + _x11 = None # X11 might be available except Exception: _x11 = False # X11 is not available # Globals +xtest_available = True # Xtest might be available xdisplay = None @@ -139,6 +143,11 @@ g_keys_down = 0 m_keys_down = 0 mr_key_down = False thumb_wheel_displacement = 0 +# When a button fires a KeyIsDown action while held, the Noop gesture generated on release is spurious. +# These three track that state so MouseGesture.evaluate can swallow the unwanted Noop. +keys_used_while_held = set() # control IDs of buttons that had a KeyIsDown action fire while held +suppress_noop_for_buttons = set() # control IDs whose next single-element MOUSE_GESTURE should be suppressed +_suppress_current_noop = False # reset each notification in evaluate_rules, checked in MouseGesture.evaluate _dbus_interface = None @@ -167,7 +176,7 @@ class XkbStateRec(ctypes.Structure): def x11_setup(): - global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS + global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS, xtest_available if _x11 is not None: return _x11 try: @@ -184,6 +193,7 @@ def x11_setup(): except Exception: logger.warning("X11 not available - some rule capabilities inoperable", exc_info=sys.exc_info()) _x11 = False + xtest_available = False return _x11 @@ -268,6 +278,10 @@ def setup_uinput(): logger.warning("cannot create uinput device: %s", e) +if wayland: # Wayland can't use xtest so may as well set up uinput now + setup_uinput() + + def kbdgroup(): if xkb_setup(): state = XkbStateRec() @@ -316,6 +330,33 @@ def xy_direction(_x, _y): return "noop" +# simulate_xtest and click_xtest were removed in a prior refactor; restored as the preferred +# input-simulation path on X11 (uinput is the fallback for Wayland and when xtest fails). +def simulate_xtest(code, event): + global xtest_available + if x11_setup() and xtest_available: + try: + event = ( + Xlib.X.KeyPress + if event == _KEY_PRESS + else Xlib.X.KeyRelease + if event == _KEY_RELEASE + else Xlib.X.ButtonPress + if event == _BUTTON_PRESS + else Xlib.X.ButtonRelease + if event == _BUTTON_RELEASE + else None + ) + Xlib.ext.xtest.fake_input(xdisplay, event, code) + xdisplay.sync() + if logger.isEnabledFor(logging.DEBUG): + logger.debug("xtest simulated input %s %s %s", xdisplay, event, code) + return True + except Exception as e: + xtest_available = False + logger.warning("xtest fake input failed: %s", e) + + def simulate_uinput(what, code, arg): global udevice if setup_uinput(): @@ -331,11 +372,30 @@ def simulate_uinput(what, code, arg): def simulate_key(code, event): # X11 keycode but Solaar event code + if not wayland and simulate_xtest(code, event): + return True if evdev and simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event): return True logger.warning("no way to simulate key input") +def click_xtest(button, count): + if isinstance(count, int): + for _ in range(count): + if not simulate_xtest(button[0], _BUTTON_PRESS): + return False + if not simulate_xtest(button[0], _BUTTON_RELEASE): + return False + else: + if count != RELEASE: + if not simulate_xtest(button[0], _BUTTON_PRESS): + return False + if count != DEPRESS: + if not simulate_xtest(button[0], _BUTTON_RELEASE): + return False + return True + + def click_uinput(button, count): if isinstance(count, int): for _ in range(count): @@ -354,6 +414,8 @@ def click_uinput(button, count): def click(button, count): + if not wayland and click_xtest(button, count): + return True if click_uinput(button, count): return True logger.warning("no way to simulate mouse click") @@ -361,6 +423,14 @@ def click(button, count): def simulate_scroll(dx, dy): + if not wayland and xtest_available: + success = True + if dx: + success = click_xtest(buttons["scroll_right" if dx > 0 else "scroll_left"], count=abs(dx)) + if dy and success: + success = click_xtest(buttons["scroll_up" if dy > 0 else "scroll_down"], count=abs(dy)) + if success: + return True if setup_uinput(): success = True if dx: @@ -878,7 +948,18 @@ class KeyIsDown(Condition): def evaluate(self, feature, notification: HIDPPNotification, device, last_result): if logger.isEnabledFor(logging.DEBUG): logger.debug("evaluate condition: %s", self) - return key_is_down(self.key) + result = key_is_down(self.key) + # Mark the button as "used while held" so its release Noop gesture can be suppressed. + # Skip key-down notifications themselves (those features trigger on address 0x00 and would + # record every press rather than only presses that co-occur with another action). + if result and feature not in ( + SupportedFeature.REPROG_CONTROLS_V4, + SupportedFeature.GKEY, + SupportedFeature.MKEYS, + SupportedFeature.MR, + ): + keys_used_while_held.add(int(self.key)) + return result def data(self): return {"KeyIsDown": str(self.key)} @@ -996,6 +1077,8 @@ class MouseGesture(Condition): if feature == SupportedFeature.MOUSE_GESTURE: d = notification.data data = struct.unpack("!" + (int(len(d) / 2) * "h"), d) + if len(data) == 1 and _suppress_current_noop: # swallow Noop from a button that had a KeyIsDown action + return False data_offset = 1 movement_offset = 0 if self.movements and self.movements[0] not in self.MOVEMENTS: # matching against initiating key @@ -1436,8 +1519,21 @@ def key_is_down(key: NamedInt) -> bool: def evaluate_rules(feature, notification: HIDPPNotification, device): + global _suppress_current_noop if logger.isEnabledFor(logging.DEBUG): logger.debug("evaluating rules on %s %s", feature, notification) + _suppress_current_noop = False + # If this is a single-element Noop gesture from a button that was used as a KeyIsDown modifier, + # set the flag so MouseGesture.evaluate ignores it. + if feature == SupportedFeature.MOUSE_GESTURE: + d = notification.data + try: + data = struct.unpack("!" + (int(len(d) / 2) * "h"), d) + if len(data) == 1 and int(data[0]) in suppress_noop_for_buttons: + _suppress_current_noop = True + suppress_noop_for_buttons.discard(int(data[0])) + except struct.error: + pass rules.evaluate(feature, notification, device, True) @@ -1452,9 +1548,13 @@ def process_notification(device, notification: HIDPPNotification, feature) -> No for key in new_keys_down: if key and key not in keys_down: key_down = key + suppress_noop_for_buttons.discard(int(key)) # re-press clears any pending noop suppression for key in keys_down: if key and key not in new_keys_down: key_up = key + if int(key) in keys_used_while_held: # was used as a KeyIsDown modifier; its release will Noop + keys_used_while_held.discard(int(key)) + suppress_noop_for_buttons.add(int(key)) keys_down = new_keys_down # and also G keys down elif feature == SupportedFeature.GKEY: From c7f137c1e74c138b6b872cc92a2ae146e96ec869 Mon Sep 17 00:00:00 2001 From: Sudheer Date: Mon, 8 Jun 2026 02:22:07 +0200 Subject: [PATCH 2/4] Fix spurious Noop MouseGesture firing after KeyIsDown + wheel rule When a button is set to Mouse Gestures mode and used in a KeyIsDown + thumb wheel combo rule, releasing the button would spuriously fire any Noop MouseGesture rule for that button. Add gesture_suppressed flag that persists across all rule evaluations of the same MOUSE_GESTURE notification, reset at the start of each new notification in process_notification. --- lib/logitech_receiver/diversion.py | 75 ++---------------------------- 1 file changed, 5 insertions(+), 70 deletions(-) diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index bd33013b..6b1a3ad6 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -73,9 +73,10 @@ logger = logging.getLogger(__name__) # KeyPress action gets the current keyboard group using XkbGetState from libX11.so using ctypes definitions # under Wayland the keyboard group is None resulting in using the first keyboard group # KeyPress action translates keysyms to keycodes using the GDK keymap -# KeyPress, MouseScroll, and MouseClick actions use XTest (under X11) or uinput. +# KeyPress, MouseScroll, and MouseClick actions use uinput. # For uinput to work the user must have write access for /dev/uinput. -# To get this access run sudo setfacl -m u:${user}:rw /dev/uinput +# The Solaar udev rule should set this up +# Otherwise run sudo setfacl -m u:${user}:rw /dev/uinput # # Rule GUI keyname determination uses a local file generated # from http://cgit.freedesktop.org/xorg/proto/x11proto/plain/keysymdef.h @@ -85,8 +86,7 @@ logger = logging.getLogger(__name__) # Setting up is complex because there are several systems that each provide partial facilities: # GDK - always available (when running with a window system) but only provides access to keymap # X11 - provides access to active process and process with window under mouse and current modifier keys -# Xtest extension to X11 - provides input simulation, partly works under Wayland -# Wayland - provides input simulation +# uinput and evdev - provides input simulation XK_KEYS: Dict[str, int] = keysymdef.key_symbols @@ -110,16 +110,12 @@ if wayland: "accessing process only works on GNOME with Solaar Gnome extension installed" ) -# import Xlib was missing here; the try/except was a no-op and x11_setup() never attempted to load X11 try: - import Xlib - _x11 = None # X11 might be available except Exception: _x11 = False # X11 is not available # Globals -xtest_available = True # Xtest might be available xdisplay = None @@ -176,7 +172,7 @@ class XkbStateRec(ctypes.Structure): def x11_setup(): - global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS, xtest_available + global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS if _x11 is not None: return _x11 try: @@ -193,7 +189,6 @@ def x11_setup(): except Exception: logger.warning("X11 not available - some rule capabilities inoperable", exc_info=sys.exc_info()) _x11 = False - xtest_available = False return _x11 @@ -278,10 +273,6 @@ def setup_uinput(): logger.warning("cannot create uinput device: %s", e) -if wayland: # Wayland can't use xtest so may as well set up uinput now - setup_uinput() - - def kbdgroup(): if xkb_setup(): state = XkbStateRec() @@ -330,33 +321,6 @@ def xy_direction(_x, _y): return "noop" -# simulate_xtest and click_xtest were removed in a prior refactor; restored as the preferred -# input-simulation path on X11 (uinput is the fallback for Wayland and when xtest fails). -def simulate_xtest(code, event): - global xtest_available - if x11_setup() and xtest_available: - try: - event = ( - Xlib.X.KeyPress - if event == _KEY_PRESS - else Xlib.X.KeyRelease - if event == _KEY_RELEASE - else Xlib.X.ButtonPress - if event == _BUTTON_PRESS - else Xlib.X.ButtonRelease - if event == _BUTTON_RELEASE - else None - ) - Xlib.ext.xtest.fake_input(xdisplay, event, code) - xdisplay.sync() - if logger.isEnabledFor(logging.DEBUG): - logger.debug("xtest simulated input %s %s %s", xdisplay, event, code) - return True - except Exception as e: - xtest_available = False - logger.warning("xtest fake input failed: %s", e) - - def simulate_uinput(what, code, arg): global udevice if setup_uinput(): @@ -372,30 +336,11 @@ def simulate_uinput(what, code, arg): def simulate_key(code, event): # X11 keycode but Solaar event code - if not wayland and simulate_xtest(code, event): - return True if evdev and simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event): return True logger.warning("no way to simulate key input") -def click_xtest(button, count): - if isinstance(count, int): - for _ in range(count): - if not simulate_xtest(button[0], _BUTTON_PRESS): - return False - if not simulate_xtest(button[0], _BUTTON_RELEASE): - return False - else: - if count != RELEASE: - if not simulate_xtest(button[0], _BUTTON_PRESS): - return False - if count != DEPRESS: - if not simulate_xtest(button[0], _BUTTON_RELEASE): - return False - return True - - def click_uinput(button, count): if isinstance(count, int): for _ in range(count): @@ -414,8 +359,6 @@ def click_uinput(button, count): def click(button, count): - if not wayland and click_xtest(button, count): - return True if click_uinput(button, count): return True logger.warning("no way to simulate mouse click") @@ -423,14 +366,6 @@ def click(button, count): def simulate_scroll(dx, dy): - if not wayland and xtest_available: - success = True - if dx: - success = click_xtest(buttons["scroll_right" if dx > 0 else "scroll_left"], count=abs(dx)) - if dy and success: - success = click_xtest(buttons["scroll_up" if dy > 0 else "scroll_down"], count=abs(dy)) - if success: - return True if setup_uinput(): success = True if dx: From 862887c16334be783cdf526457cfbbacc71203da Mon Sep 17 00:00:00 2001 From: Sudheer Date: Mon, 8 Jun 2026 04:43:17 +0200 Subject: [PATCH 3/4] fix: Fix spurious Noop MouseGesture firing after KeyIsDown + wheel rule Releasing a button used as a KeyIsDown modifier sends a single-element MOUSE_GESTURE notification (Noop) that can incorrectly match MouseGesture rules bound to the same button. Track the state across notifications with three module-level variables: - keys_used_while_held: buttons where a KeyIsDown rule fired during hold - suppress_noop_for_buttons: buttons whose next Noop should be swallowed - _suppress_current_noop: per-notification flag checked by MouseGesture.evaluate KeyIsDown.evaluate records the button when a non-button notification co-fires (e.g. scroll, gesture). On key-up, process_notification moves it to suppress_noop_for_buttons. evaluate_rules sets _suppress_current_noop when the incoming Noop matches. MouseGesture.evaluate returns False. Re-pressing the button before release clears any pending suppression. --- lib/logitech_receiver/diversion.py | 31 ++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index 6b1a3ad6..0f959eaa 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -139,10 +139,12 @@ g_keys_down = 0 m_keys_down = 0 mr_key_down = False thumb_wheel_displacement = 0 -# When a button fires a KeyIsDown action while held, the Noop gesture generated on release is spurious. -# These three track that state so MouseGesture.evaluate can swallow the unwanted Noop. -keys_used_while_held = set() # control IDs of buttons that had a KeyIsDown action fire while held -suppress_noop_for_buttons = set() # control IDs whose next single-element MOUSE_GESTURE should be suppressed +# Spurious Noop suppression on KeyIsDown button release: +# When a button used as a KeyIsDown modifier is released, the firmware sends a single-element +# MOUSE_GESTURE (Noop) that can incorrectly match MouseGesture rules. +# keys_used_while_held / suppress_noop_for_buttons / _suppress_current_noop track and swallow it. +keys_used_while_held = set() # button IDs that had a KeyIsDown action fire while held +suppress_noop_for_buttons = set() # button IDs whose next single-element MOUSE_GESTURE should be suppressed _suppress_current_noop = False # reset each notification in evaluate_rules, checked in MouseGesture.evaluate _dbus_interface = None @@ -884,16 +886,17 @@ class KeyIsDown(Condition): if logger.isEnabledFor(logging.DEBUG): logger.debug("evaluate condition: %s", self) result = key_is_down(self.key) - # Mark the button as "used while held" so its release Noop gesture can be suppressed. - # Skip key-down notifications themselves (those features trigger on address 0x00 and would - # record every press rather than only presses that co-occur with another action). - if result and feature not in ( - SupportedFeature.REPROG_CONTROLS_V4, - SupportedFeature.GKEY, - SupportedFeature.MKEYS, - SupportedFeature.MR, - ): - keys_used_while_held.add(int(self.key)) + if result: + # Mark the button as "used while held" so its release Noop gesture can be suppressed. + # Skip button-down notifications (REPROG_CONTROLS_V4 etc.) — only record when a non-button + # action (e.g. scroll, gesture) co-fires with the held button. + if feature not in ( + SupportedFeature.REPROG_CONTROLS_V4, + SupportedFeature.GKEY, + SupportedFeature.MKEYS, + SupportedFeature.MR, + ): + keys_used_while_held.add(int(self.key)) return result def data(self): From 3a4feecd9431c83bb928594c8f643bbad275b584 Mon Sep 17 00:00:00 2001 From: Sudheer Date: Mon, 8 Jun 2026 20:56:38 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=1B[200~fix:=20prevent=20scroll=20tail=20af?= =?UTF-8?q?ter=20free-spin=20with=20HIRES=5FWHEEL=20diversion=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With hires-scroll-mode enabled, every HIRES_WHEEL notification queues a GLib.idle_add(evaluate_rules) call. Multiple Process conditions each make a separate DBus/X11 round-trip, slowing evaluate_rules enough that the idle queue backlogs during free-spin. When the wheel stops the firmware stops sending, but the backlog keeps firing MouseScroll. Fix: cache the focus lookup for the duration of each evaluate_rules call, and coalesce HIRES_WHEEL idle callbacks so at most one is ever queued. ~ --- lib/logitech_receiver/diversion.py | 78 ++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index 0f959eaa..41f818b4 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -147,6 +147,36 @@ keys_used_while_held = set() # button IDs that had a KeyIsDown action fire whi suppress_noop_for_buttons = set() # button IDs whose next single-element MOUSE_GESTURE should be suppressed _suppress_current_noop = False # reset each notification in evaluate_rules, checked in MouseGesture.evaluate +# Per-evaluation focus/pointer cache. +# +# Process and MouseProcess conditions call gnome_dbus_focus_prog() / x11_focus_prog() (and pointer +# equivalents) on every evaluate(). When multiple Process rules exist, that means multiple DBus or +# X11 round-trips per notification — typically 3+ on a normal config. At ~2 ms each those calls +# dominate the cost of evaluate_rules and cause the GLib idle queue to back up under fast scroll. +# +# Fix: _UNSET is a sentinel that evaluate_rules writes to both caches at the start of each call. +# The first Process/MouseProcess condition that fires does the IPC and caches the result; every +# subsequent condition in the same evaluation reads the cache and skips the IPC entirely. +_UNSET = object() +_focus_prog_cache = _UNSET +_pointer_prog_cache = _UNSET + +# HIRES_WHEEL idle-callback coalescing. +# +# process_notification is called from the device thread for every firmware notification. +# For each HIRES_WHEEL event it schedules GLib.idle_add(evaluate_rules, ...) — one callback per +# event. When the MX Master free-spins, the device generates events faster than the GLib main loop +# can drain them (evaluate_rules is slow because of the IPC above). The backlog causes scrolling +# to continue for 1-2 s after the wheel physically stops, proportional to spin duration. +# +# Fix: only one evaluate_rules callback is ever in flight for HIRES_WHEEL at a time. +# _hires_wheel_ref always holds the latest notification; if a flush is already pending, new events +# just update the ref and return. The single queued _flush_hires_wheel drains the latest delta. +# When the wheel stops the firmware stops sending — no more events update the ref, the pending +# flush fires once, and scrolling stops immediately. +_hires_wheel_pending = False +_hires_wheel_ref = None + _dbus_interface = None @@ -577,6 +607,9 @@ class And(Condition): def x11_focus_prog(): + global _focus_prog_cache + if _focus_prog_cache is not _UNSET: # return cached result for this evaluate_rules call + return _focus_prog_cache if not x11_setup(): return None pid = wm_class = None @@ -591,10 +624,14 @@ def x11_focus_prog(): name = psutil.Process(pid.value[0]).name() if pid else "" except Exception: name = "" - return (wm_class[0], wm_class[1], name) if wm_class else (name,) + _focus_prog_cache = (wm_class[0], wm_class[1], name) if wm_class else (name,) + return _focus_prog_cache def x11_pointer_prog(): + global _pointer_prog_cache + if _pointer_prog_cache is not _UNSET: # return cached result for this evaluate_rules call + return _pointer_prog_cache if not x11_setup(): return None pid = wm_class = None @@ -605,21 +642,30 @@ def x11_pointer_prog(): if wm_class: break name = psutil.Process(pid.value[0]).name() if pid else "" - return (wm_class[0], wm_class[1], name) if wm_class else (name,) + _pointer_prog_cache = (wm_class[0], wm_class[1], name) if wm_class else (name,) + return _pointer_prog_cache def gnome_dbus_focus_prog(): + global _focus_prog_cache + if _focus_prog_cache is not _UNSET: # return cached result for this evaluate_rules call + return _focus_prog_cache if not gnome_dbus_interface_setup(): return None wm_class = _dbus_interface.ActiveWindow() - return (wm_class,) if wm_class else None + _focus_prog_cache = (wm_class,) if wm_class else None + return _focus_prog_cache def gnome_dbus_pointer_prog(): + global _pointer_prog_cache + if _pointer_prog_cache is not _UNSET: # return cached result for this evaluate_rules call + return _pointer_prog_cache if not gnome_dbus_interface_setup(): return None wm_class = _dbus_interface.PointerOverWindow() - return (wm_class,) if wm_class else None + _pointer_prog_cache = (wm_class,) if wm_class else None + return _pointer_prog_cache class Process(Condition): @@ -1457,10 +1503,12 @@ def key_is_down(key: NamedInt) -> bool: def evaluate_rules(feature, notification: HIDPPNotification, device): - global _suppress_current_noop + global _suppress_current_noop, _focus_prog_cache, _pointer_prog_cache if logger.isEnabledFor(logging.DEBUG): logger.debug("evaluating rules on %s %s", feature, notification) _suppress_current_noop = False + _focus_prog_cache = _UNSET # invalidate per-evaluation cache (see globals block above) + _pointer_prog_cache = _UNSET # If this is a single-element Noop gesture from a button that was used as a KeyIsDown modifier, # set the flag so MouseGesture.evaluate ignores it. if feature == SupportedFeature.MOUSE_GESTURE: @@ -1475,6 +1523,15 @@ def evaluate_rules(feature, notification: HIDPPNotification, device): rules.evaluate(feature, notification, device, True) +def _flush_hires_wheel(device): + # GLib idle callback: drains the single coalesced HIRES_WHEEL event. + # Clears the pending flag first so that any new notification arriving while evaluate_rules + # runs will queue a fresh flush rather than being silently dropped. + global _hires_wheel_pending + _hires_wheel_pending = False + evaluate_rules(SupportedFeature.HIRES_WHEEL, _hires_wheel_ref, device) + + def process_notification(device, notification: HIDPPNotification, feature) -> None: """Processes HID++ notifications.""" global keys_down, g_keys_down, m_keys_down, mr_key_down, key_down, key_up, thumb_wheel_displacement @@ -1525,6 +1582,17 @@ def process_notification(device, notification: HIDPPNotification, feature) -> No if notification.data[4] <= 0x01: # when wheel starts, zero out last movement thumb_wheel_displacement = 0 thumb_wheel_displacement += signed(notification.data[0:2]) + # Coalesce rapid HIRES_WHEEL notifications into a single evaluate_rules call. + # Always update _hires_wheel_ref to the latest notification so the queued flush uses + # the most recent delta; if a flush is already pending just return — the existing + # callback will fire and consume _hires_wheel_ref before a new one is queued. + elif feature == SupportedFeature.HIRES_WHEEL: + global _hires_wheel_pending, _hires_wheel_ref + _hires_wheel_ref = notification + if not _hires_wheel_pending: + _hires_wheel_pending = True + GLib.idle_add(_flush_hires_wheel, device) + return GLib.idle_add(evaluate_rules, feature, notification, device)