Add shortcut binding CLI for diverted keys
This commit is contained in:
parent
ff9324d346
commit
7f3403a160
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue