[200~fix: prevent scroll tail after free-spin with HIRES_WHEEL diversion active
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. ~
This commit is contained in:
parent
862887c163
commit
3a4feecd94
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue