diff --git a/docs/rules.md b/docs/rules.md index da19895d..968544bb 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -90,6 +90,8 @@ matched, respectively. Logitech key and button names are shown in the `Key/Butto setting. These names are also shown in the output of `solaar show` in the 'Reprogrammable keys' section. Only keys or buttons that have 'Divertable' in their report can be diverted. Some keyboards have 'Gn', 'Mn', or 'MR' keys, which are diverted using the 'Divert G Keys' setting. +For a quick command-line binding, use `solaar shortcut --key `. +The key defaults to `smart-shift`, so `solaar shortcut "MX Master" Control_L+Alt_L+T` diverts the Smart Shift button and adds a rule that sends that shortcut when the button is pressed. ### Key is down `KeyIsDown` conditions are true if the **diverted** key or button that is their string argument is currently down. diff --git a/lib/logitech_receiver/diversion_shortcuts.py b/lib/logitech_receiver/diversion_shortcuts.py new file mode 100644 index 00000000..313bdd1a --- /dev/null +++ b/lib/logitech_receiver/diversion_shortcuts.py @@ -0,0 +1,76 @@ +## Copyright (C) 2026 Solaar Contributors +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. + +import os + +from logitech_receiver import diversion +from logitech_receiver.common import NamedInt +from logitech_receiver.special_keys import CONTROL + + +def normalize_key_name(name): + cleaned = name.replace("-", " ").replace("_", " ").strip().lower() + for key in CONTROL: + if str(key).lower() == cleaned: + return key + raise Exception(f"unknown Logitech key/button '{name}'") + + +def parse_shortcut(shortcut): + parts = [p.strip() for p in shortcut.replace("+", " ").split() if p.strip()] + if not parts: + raise Exception("shortcut must contain at least one X11 keysym") + return parts[0] if len(parts) == 1 else parts + + +def key_value(key): + return int(key) if isinstance(key, NamedInt) else int(CONTROL[key]) + + +def is_shortcut_rule(rule, key): + if not isinstance(rule, diversion.Rule) or len(rule.components) != 2: + return False + condition, action = rule.components + return ( + isinstance(condition, diversion.Key) + and condition.key == key + and condition.action == diversion.Key.DOWN + and isinstance(action, diversion.KeyPress) + ) + + +def ensure_user_rule_container(): + diversion.load_config_rule_file() + if not isinstance(diversion.rules, diversion.Rule): + diversion.rules = diversion.Rule([]) + for component in diversion.rules.components: + if isinstance(component, diversion.Rule) and component.source == diversion._file_path: + return component + user_rules = diversion.Rule([], source=diversion._file_path) + diversion.rules.components.insert(0, user_rules) + return user_rules + + +def set_shortcut_rule(key, shortcut): + user_rules = ensure_user_rule_container() + rule = diversion.Rule( + [ + {"Key": [str(key), diversion.Key.DOWN]}, + {"KeyPress": [parse_shortcut(shortcut), "click"]}, + ], + source=diversion._file_path, + ) + for index, component in enumerate(user_rules.components): + if is_shortcut_rule(component, key): + user_rules.components[index] = rule + break + else: + user_rules.components.append(rule) + os.makedirs(os.path.dirname(diversion._file_path), exist_ok=True) + if not diversion._save_config_rule_file(diversion._file_path): + raise Exception(f"failed to save shortcut rule to {diversion._file_path}") + return rule diff --git a/lib/solaar/cli/__init__.py b/lib/solaar/cli/__init__.py index 3e4928c9..b24a6bf9 100644 --- a/lib/solaar/cli/__init__.py +++ b/lib/solaar/cli/__init__.py @@ -94,6 +94,23 @@ def _create_parser(): ) sp.set_defaults(action="pair") + sp = subparsers.add_parser( + "shortcut", + description="Bind a Logitech key/button to an arbitrary keyboard shortcut using diversion rules.", + epilog="Example: solaar shortcut 'MX Master' Control_L+Alt_L+T --key smart-shift", + ) + sp.add_argument( + "device", + help="device to configure; may be a device number (1..6), a serial number, or a substring of a device's name", + ) + sp.add_argument("shortcut", help="X11 keysym or '+'-separated shortcut, e.g. Control_L+Alt_L+T") + sp.add_argument( + "--key", + default="smart-shift", + help="Logitech key/button to bind; defaults to smart-shift for the Smart Shift wheel-mode button", + ) + sp.set_defaults(action="shortcut") + sp = subparsers.add_parser("unpair", description="Unpair a device from its receiver. Not all receivers allow unpairing.") sp.add_argument( "device", @@ -232,7 +249,7 @@ def run(cli_args=None, hidraw_path=None): assert action in actions try: - if action == "show" or action == "probe" or action == "config" or action == "profiles": + if action in ("show", "probe", "config", "profiles", "shortcut"): c = list(_receivers_and_devices(hidraw_path)) else: c = list(_receivers(hidraw_path)) diff --git a/lib/solaar/cli/shortcut.py b/lib/solaar/cli/shortcut.py new file mode 100644 index 00000000..6e8b896e --- /dev/null +++ b/lib/solaar/cli/shortcut.py @@ -0,0 +1,52 @@ +## Copyright (C) 2026 Solaar Contributors +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. + +from argparse import Namespace + +from logitech_receiver import diversion +from logitech_receiver import diversion_shortcuts +from logitech_receiver import settings_templates + +from solaar.cli import config + + +def _divert_key(dev, key): + setting = settings_templates.check_feature_setting(dev, "divert-keys") + if setting is None: + raise Exception(f"no key/button diversion setting for {dev.name}") + result, _message, _value = config.set( + dev, + setting, + # Choices are translated, so select the second choice by 1-based index instead of by label. + Namespace(value_key=str(diversion_shortcuts.key_value(key)), extra_subkey="2", extra2=None), + save=False, + ) + if result is None: + raise Exception(f"failed to divert {key} on {dev.name}") + if dev.persister and setting.persist: + dev.persister[setting.name] = setting._value + + +def run(receivers, args, _find_receiver, find_device): + assert receivers + assert args.device + + key = diversion_shortcuts.normalize_key_name(args.key) + device_name = args.device.lower() + + dev = None + for dev in find_device(receivers, device_name): + if dev.ping(): + break + dev = None + if not dev: + raise Exception(f"no online device found matching '{device_name}'") + + _divert_key(dev, key) + rule = diversion_shortcuts.set_shortcut_rule(key, args.shortcut) + print(f"Diverted {key} on {dev.name} and bound it to {args.shortcut!r} via {diversion._file_path}") + print(rule) diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index 7622dcba..0661eefe 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -22,8 +22,10 @@ from threading import Timer import gi +from logitech_receiver import diversion_shortcuts from logitech_receiver import hidpp20 from logitech_receiver import settings +from logitech_receiver.special_keys import CONTROL from solaar.i18n import _ from solaar.i18n import ngettext @@ -270,7 +272,24 @@ class MapChoiceControl(Gtk.HBox, Control): self.valueBox = _create_choice_control(sbox.setting, choices=self.value_choices, delegate=self) self.pack_start(self.keyBox, False, False, 0) self.pack_end(self.valueBox, False, False, 0) + self.shortcutEntry = None + self.shortcutRecordButton = None + self.shortcutButton = None + if sbox.setting.name == "divert-keys" and int(CONTROL["Smart Shift"]) in sbox.setting.choices: + self.shortcutEntry = Gtk.Entry() + self.shortcutEntry.set_placeholder_text(_("Shortcut, e.g. Control_L+Alt_L+T")) + self.shortcutEntry.set_tooltip_text(_("Bind selected diverted key/button to a keyboard shortcut")) + self.shortcutRecordButton = Gtk.Button(label=_("Record")) + self.shortcutRecordButton.set_tooltip_text(_("Press a shortcut interactively")) + self.shortcutRecordButton.connect(GtkSignal.CLICKED.value, self.record_shortcut) + self.shortcutButton = Gtk.Button(label=_("Bind Shortcut")) + self.shortcutButton.set_tooltip_text(_("Set diversion and add a rule that sends this shortcut on press")) + self.shortcutButton.connect(GtkSignal.CLICKED.value, self.bind_shortcut) + self.pack_start(self.shortcutEntry, False, False, 0) + self.pack_start(self.shortcutRecordButton, False, False, 0) + self.pack_start(self.shortcutButton, False, False, 0) self.keyBox.connect(GtkSignal.CHANGED.value, self.map_value_notify_key) + self.update_shortcut_visibility() def get_value(self): key_choice = int(self.keyBox.get_active_id()) @@ -299,6 +318,71 @@ class MapChoiceControl(Gtk.HBox, Control): key_choice = int(self.keyBox.get_active_id()) if self.keyBox.get_sensitive(): self.map_populate_value_box(key_choice) + self.update_shortcut_visibility() + + def update_shortcut_visibility(self): + if not self.shortcutEntry or not self.shortcutRecordButton or not self.shortcutButton: + return + key_choice = int(self.keyBox.get_active_id()) + visible = key_choice == int(CONTROL["Smart Shift"]) + self.shortcutEntry.set_visible(visible) + self.shortcutRecordButton.set_visible(visible) + self.shortcutButton.set_visible(visible) + + @staticmethod + def _event_to_shortcut(event): + key_name = Gdk.keyval_name(event.keyval) + modifier_keys = ("Shift_L", "Shift_R", "Control_L", "Control_R", "Alt_L", "Alt_R", "Super_L", "Super_R") + if not key_name or key_name in modifier_keys: + return None + modifiers = [] + state = event.state + if state & Gdk.ModifierType.CONTROL_MASK: + modifiers.append("Control_L") + if state & Gdk.ModifierType.MOD1_MASK: + modifiers.append("Alt_L") + if state & Gdk.ModifierType.MOD4_MASK: + modifiers.append("Super_L") + if state & Gdk.ModifierType.SHIFT_MASK: + modifiers.append("Shift_L") + return "+".join(modifiers + [key_name]) + + def record_shortcut(self, _button): + dialog = Gtk.MessageDialog( + transient_for=self.get_toplevel() if isinstance(self.get_toplevel(), Gtk.Window) else None, + modal=True, + message_type=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.CANCEL, + text=_("Press the shortcut to bind"), + ) + dialog.format_secondary_text(_("Press Escape to cancel.")) + + def on_key_press(widget, event): + if event.keyval == Gdk.KEY_Escape: + widget.response(Gtk.ResponseType.CANCEL) + return True + shortcut = self._event_to_shortcut(event) + if shortcut: + self.shortcutEntry.set_text(shortcut) + widget.response(Gtk.ResponseType.OK) + return True + + dialog.connect("key-press-event", on_key_press) + dialog.run() + dialog.destroy() + + def bind_shortcut(self, _button): + key_choice = int(self.keyBox.get_active_id()) + shortcut = self.shortcutEntry.get_text().strip() + if not shortcut: + self.shortcutEntry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "dialog-warning") + return + self.shortcutEntry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "") + diverted = self.value_choices[1] + self.valueBox.set_value(diverted) + self.sbox.setting._value[key_choice] = diverted + _write_async(self.sbox.setting, diverted, self.sbox, key=key_choice) + diversion_shortcuts.set_shortcut_rule(CONTROL[key_choice], shortcut) def update(self): key_choice = int(self.keyBox.get_active_id()) diff --git a/tests/solaar/test_cli_shortcut.py b/tests/solaar/test_cli_shortcut.py new file mode 100644 index 00000000..281d28e7 --- /dev/null +++ b/tests/solaar/test_cli_shortcut.py @@ -0,0 +1,66 @@ +from unittest import mock + +from logitech_receiver import diversion +from logitech_receiver import diversion_shortcuts +from logitech_receiver.special_keys import CONTROL + + +def test_normalize_smart_shift_alias(): + assert diversion_shortcuts.normalize_key_name("smart-shift") == CONTROL["Smart Shift"] + assert diversion_shortcuts.normalize_key_name("Smart Shift") == CONTROL["Smart Shift"] + + +def test_parse_shortcut(): + assert diversion_shortcuts.parse_shortcut("XF86AudioPlay") == "XF86AudioPlay" + assert diversion_shortcuts.parse_shortcut("Control_L+Alt_L+T") == ["Control_L", "Alt_L", "T"] + + +def test_set_shortcut_rule_creates_user_rule(tmp_path): + rules_file = tmp_path / "rules.yaml" + original_rules = diversion.rules + original_file_path = diversion._file_path + try: + diversion.rules = diversion.built_in_rules + diversion._file_path = str(rules_file) + with mock.patch("logitech_receiver.diversion.load_config_rule_file"): + rule = diversion_shortcuts.set_shortcut_rule(CONTROL["Smart Shift"], "Control_L+Alt_L+T") + + assert rules_file.exists() + assert rule.components[0].data() == {"Key": ["Smart Shift", "pressed"]} + assert rule.components[1].data() == {"KeyPress": [["Control_L", "Alt_L", "T"], "click"]} + assert diversion.rules.components[0].source == str(rules_file) + finally: + diversion.rules = original_rules + diversion._file_path = original_file_path + + +def test_set_shortcut_rule_replaces_existing_rule(tmp_path): + rules_file = tmp_path / "rules.yaml" + original_rules = diversion.rules + original_file_path = diversion._file_path + try: + diversion._file_path = str(rules_file) + diversion.rules = diversion.Rule( + [ + diversion.Rule( + [ + diversion.Rule( + [ + {"Key": ["Smart Shift", "pressed"]}, + {"KeyPress": [["Control_L", "Alt_L", "T"], "click"]}, + ] + ) + ], + source=str(rules_file), + ) + ] + ) + with mock.patch("logitech_receiver.diversion.load_config_rule_file"): + diversion_shortcuts.set_shortcut_rule(CONTROL["Smart Shift"], "Super_L+space") + + user_rules = diversion.rules.components[0] + assert len(user_rules.components) == 1 + assert user_rules.components[0].components[1].data() == {"KeyPress": [["Super_L", "space"], "click"]} + finally: + diversion.rules = original_rules + diversion._file_path = original_file_path