From 3359ee374c6e88bcc68f1b5a9356e3c85d2dfc79 Mon Sep 17 00:00:00 2001 From: xieve <2876434-xieve@users.noreply.gitlab.com> Date: Tue, 27 Jan 2026 20:03:21 +0100 Subject: [PATCH] feat: hi-res scroll action --- lib/logitech_receiver/diversion.py | 64 ++++++++++++++++++++++-------- lib/solaar/ui/diversion_rules.py | 2 + lib/solaar/ui/rule_actions.py | 18 +++++++++ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index e933f5ad..2315b0fe 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -246,7 +246,12 @@ if evdev: key_events.append(evcode) devicecap = { evdev.ecodes.EV_KEY: key_events, - evdev.ecodes.EV_REL: [evdev.ecodes.REL_WHEEL, evdev.ecodes.REL_HWHEEL], + evdev.ecodes.EV_REL: [ + evdev.ecodes.REL_WHEEL, + evdev.ecodes.REL_HWHEEL, + evdev.ecodes.REL_WHEEL_HI_RES, + evdev.ecodes.REL_HWHEEL_HI_RES, + ], } else: # Just mock these since they won't be useful without evdev anyway @@ -360,15 +365,23 @@ def click(button, count): return False -def simulate_scroll(dx, dy): +def simulate_scroll(dx, dy, hi_res_dx=None, hi_res_dy=None): + hi_res_dx = hi_res_dx or dx * 120 + hi_res_dy = hi_res_dy or dy * 120 if setup_uinput(): - success = True - if dx: - success = simulate_uinput(evdev.ecodes.EV_REL, evdev.ecodes.REL_HWHEEL, dx) - if dy and success: - success = simulate_uinput(evdev.ecodes.EV_REL, evdev.ecodes.REL_WHEEL, dy) - if success: - return True + for code, delta in ( + (evdev.ecodes.REL_HWHEEL_HI_RES, hi_res_dx), + (evdev.ecodes.REL_WHEEL_HI_RES, hi_res_dy), + (evdev.ecodes.REL_HWHEEL, dx), + (evdev.ecodes.REL_WHEEL, dy), + ): + if delta: + success = simulate_uinput(evdev.ecodes.EV_REL, code, delta) + if not success: + return False + + return True + logger.warning("no way to simulate scrolling") @@ -621,8 +634,7 @@ class Process(Condition): if (not wayland and not x11_setup()) or (wayland and not gnome_dbus_interface_setup()): if warn: logger.warning( - "rules can only access active process in X11 or in Wayland under GNOME with Solaar Gnome " - "extension - %s", + "rules can only access active process in X11 or in Wayland under GNOME with Solaar Gnome extension - %s", self, ) if not isinstance(process, str): @@ -1218,26 +1230,45 @@ class MouseScroll(Action): amounts = amounts[0] if not (len(amounts) == 2 and all([isinstance(a, numbers.Number) for a in amounts])): if warn: - logger.warning("rule MouseScroll argument not two numbers %s", amounts) + logger.warning(f"rule {self.__class__.__name__} argument not two numbers {amounts}") amounts = [0, 0] self.amounts = amounts def __str__(self): - return "MouseScroll: " + " ".join([str(a) for a in self.amounts]) + return f"{self.__class__.__name__}: " + " ".join([str(a) for a in self.amounts]) def evaluate(self, feature, notification: HIDPPNotification, device, last_result): amounts = self.amounts if isinstance(last_result, numbers.Number): amounts = [math.floor(last_result * a) for a in self.amounts] if logger.isEnabledFor(logging.INFO): - logger.info("MouseScroll action: %s %s %s", self.amounts, last_result, amounts) + logger.info(f"{self.__class__.__name__} action: {self.amounts} {last_result} {amounts}") dx, dy = amounts - simulate_scroll(dx, dy) + self._scroll(dx, dy) time.sleep(0.01) return None + @classmethod + def _scroll(cls, dx, dy): + simulate_scroll(dx, dy) + def data(self): - return {"MouseScroll": self.amounts[:]} + return {f"{self.__class__.__name__}": self.amounts[:]} + + +class MouseScrollHiRes(MouseScroll): + accumulated_x = 0 + accumulated_y = 0 + + @classmethod + def _scroll(cls, hi_res_dx, hi_res_dy): + cls.accumulated_x += hi_res_dx + cls.accumulated_y += hi_res_dy + dx = cls.accumulated_x // 120 + dy = cls.accumulated_y // 120 + cls.accumulated_x %= 120 + cls.accumulated_y %= 120 + simulate_scroll(dx, dy, hi_res_dx, hi_res_dy) class MouseClick(Action): @@ -1405,6 +1436,7 @@ COMPONENTS = { "Host": Host, "KeyPress": KeyPress, "MouseScroll": MouseScroll, + "MouseScrollHiRes": MouseScrollHiRes, "MouseClick": MouseClick, "Set": Set, "Execute": Execute, diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index 6e2556fc..1b4de2ea 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -407,6 +407,7 @@ class ActionMenu: [ (_("Key press"), diversion.KeyPress, "space"), (_("Mouse scroll"), diversion.MouseScroll, [0, 0]), + (_("Mouse scroll (hi-res)"), diversion.MouseScrollHiRes, [0, 0]), (_("Mouse click"), diversion.MouseClick, ["left", 1]), (_("Set"), diversion.Set, [None, "", None]), (_("Execute"), diversion.Execute, [""]), @@ -1865,6 +1866,7 @@ COMPONENT_UI: dict[Any, RuleComponentUI] = { diversion.MouseGesture: rule_conditions.MouseGestureUI, diversion.KeyPress: rule_actions.KeyPressUI, diversion.MouseScroll: rule_actions.MouseScrollUI, + diversion.MouseScrollHiRes: rule_actions.MouseScrollHiResUI, diversion.MouseClick: rule_actions.MouseClickUI, diversion.Execute: rule_actions.ExecuteUI, diversion.Set: SetUI, diff --git a/lib/solaar/ui/rule_actions.py b/lib/solaar/ui/rule_actions.py index 706479f9..ec3d1ab9 100644 --- a/lib/solaar/ui/rule_actions.py +++ b/lib/solaar/ui/rule_actions.py @@ -198,6 +198,24 @@ class MouseScrollUI(ActionUI): return f"{x}, {y}" +class MouseScrollHiResUI(MouseScrollUI): + CLASS = diversion.MouseScrollHiRes + + def create_widgets(self): + super().create_widgets() + self.label.set_text( + _( + "Simulate a high-resolution scroll.\n" + 'One detent click, or "regular" scroll unit, is represented by 120 high resolution scroll units.\n' + "Always requires write access to /dev/uinput." + ) + ) + + @classmethod + def left_label(cls, component): + return _("Mouse scroll (hi-res)") + + class MouseClickUI(ActionUI): CLASS = diversion.MouseClick MIN_VALUE = 1