Add shortcut binding CLI for diverted keys

This commit is contained in:
blockedby 2026-04-26 23:56:49 +03:00
parent ff9324d346
commit 7f3403a160
6 changed files with 298 additions and 1 deletions

View File

@ -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 <device> <shortcut> --key <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.

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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())

View File

@ -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