diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index 74fb2f497e..d2f0da8f07 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -17,8 +17,7 @@ import string import threading -from collections import defaultdict, namedtuple -from contextlib import contextmanager as contextlib_contextmanager +from collections import defaultdict from copy import copy from dataclasses import dataclass, field from shlex import quote as shlex_quote @@ -27,17 +26,14 @@ from gi.repository import Gdk, GObject, Gtk from logitech_receiver import diversion as _DIV from logitech_receiver.common import NamedInt, NamedInts, UnsortedNamedInts -from logitech_receiver.diversion import CLICK, DEPRESS, RELEASE -from logitech_receiver.diversion import XK_KEYS as _XK_KEYS -from logitech_receiver.diversion import Key as _Key -from logitech_receiver.diversion import buttons as _buttons -from logitech_receiver.hidpp20 import FEATURE as _ALL_FEATURES from logitech_receiver.settings import KIND as _SKIND from logitech_receiver.settings import Setting as _Setting from logitech_receiver.settings_templates import SETTINGS as _SETTINGS -from logitech_receiver.special_keys import CONTROL as _CONTROL from solaar.i18n import _ +from solaar.ui import rule_actions, rule_conditions +from solaar.ui.rule_base import RuleComponentUI, norm +from solaar.ui.rule_conditions import ConditionUI, FeatureUI logger = logging.getLogger(__name__) @@ -714,10 +710,6 @@ def update_devices(self): self.view.queue_draw() -def norm(s): - return s.replace("_", "").replace(" ", "").lower() - - class CompletionEntry(Gtk.Entry): def __init__(self, values, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1000,68 +992,6 @@ def dev_in_row(_store, _treepath, row): return updated -class RuleComponentUI: - CLASS = _DIV.RuleComponent - - def __init__(self, panel, on_update=None): - self.panel = panel - self.widgets = {} # widget -> coord. in grid - self.component = None - self._ignore_changes = 0 - self._on_update_callback = (lambda: None) if on_update is None else on_update - self.create_widgets() - - def create_widgets(self): - pass - - def show(self, component, editable=True): - self._show_widgets(editable) - self.component = component - - def collect_value(self): - return None - - @contextlib_contextmanager - def ignore_changes(self): - self._ignore_changes += 1 - yield None - self._ignore_changes -= 1 - - def _on_update(self, *_args): - if not self._ignore_changes and self.component is not None: - value = self.collect_value() - self.component.__init__(value, warn=False) - self._on_update_callback() - return value - return None - - def _show_widgets(self, editable): - self._remove_panel_items() - for widget, coord in self.widgets.items(): - self.panel.attach(widget, *coord) - widget.set_sensitive(editable) - widget.show() - - @classmethod - def left_label(cls, component): - return type(component).__name__ - - @classmethod - def right_label(cls, _component): - return "" - - @classmethod - def icon_name(cls): - return "" - - def _remove_panel_items(self): - for c in self.panel.get_children(): - self.panel.remove(c) - - def update_devices(self): - pass - - class UnsupportedRuleComponentUI(RuleComponentUI): CLASS = None @@ -1093,14 +1023,6 @@ def icon_name(cls): return "format-justify-fill" -class ConditionUI(RuleComponentUI): - CLASS = _DIV.Condition - - @classmethod - def icon_name(cls): - return "dialog-question" - - class AndUI(RuleComponentUI): CLASS = _DIV.And @@ -1178,569 +1100,6 @@ def left_label(cls, component): return _("Not") -class ProcessUI(ConditionUI): - CLASS = _DIV.Process - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text(_("X11 active process. For use in X11 only.")) - self.widgets[self.label] = (0, 0, 1, 1) - self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) - self.field.set_size_request(600, 0) - self.field.connect("changed", self._on_update) - self.widgets[self.field] = (0, 1, 1, 1) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - self.field.set_text(component.process) - - def collect_value(self): - return self.field.get_text() - - @classmethod - def left_label(cls, component): - return _("Process") - - @classmethod - def right_label(cls, component): - return str(component.process) - - -class MouseProcessUI(ConditionUI): - CLASS = _DIV.MouseProcess - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text(_("X11 mouse process. For use in X11 only.")) - self.widgets[self.label] = (0, 0, 1, 1) - self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) - self.field.set_size_request(600, 0) - self.field.connect("changed", self._on_update) - self.widgets[self.field] = (0, 1, 1, 1) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - self.field.set_text(component.process) - - def collect_value(self): - return self.field.get_text() - - @classmethod - def left_label(cls, component): - return _("MouseProcess") - - @classmethod - def right_label(cls, component): - return str(component.process) - - -class FeatureUI(ConditionUI): - CLASS = _DIV.Feature - FEATURES_WITH_DIVERSION = [ - str(_ALL_FEATURES.CROWN), - str(_ALL_FEATURES.THUMB_WHEEL), - str(_ALL_FEATURES.LOWRES_WHEEL), - str(_ALL_FEATURES.HIRES_WHEEL), - str(_ALL_FEATURES.GESTURE_2), - str(_ALL_FEATURES.REPROG_CONTROLS_V4), - str(_ALL_FEATURES.GKEY), - str(_ALL_FEATURES.MKEYS), - str(_ALL_FEATURES.MR), - ] - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text(_("Feature name of notification triggering rule processing.")) - self.widgets[self.label] = (0, 0, 1, 1) - self.field = Gtk.ComboBoxText.new_with_entry() - self.field.append("", "") - for feature in self.FEATURES_WITH_DIVERSION: - self.field.append(feature, feature) - self.field.set_valign(Gtk.Align.CENTER) - # self.field.set_vexpand(True) - self.field.set_size_request(600, 0) - self.field.connect("changed", self._on_update) - all_features = [str(f) for f in _ALL_FEATURES] - CompletionEntry.add_completion_to_entry(self.field.get_child(), all_features) - self.widgets[self.field] = (0, 1, 1, 1) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - f = str(component.feature) if component.feature else "" - self.field.set_active_id(f) - if f not in self.FEATURES_WITH_DIVERSION: - self.field.get_child().set_text(f) - - def collect_value(self): - return (self.field.get_active_text() or "").strip() - - def _on_update(self, *args): - super()._on_update(*args) - icon = "dialog-warning" if not self.component.feature else "" - self.field.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - - @classmethod - def left_label(cls, component): - return _("Feature") - - @classmethod - def right_label(cls, component): - return "%s (%04X)" % (str(component.feature), int(component.feature or 0)) - - -class ReportUI(ConditionUI): - CLASS = _DIV.Report - MIN_VALUE = -1 # for invalid values - MAX_VALUE = 15 - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text(_("Report number of notification triggering rule processing.")) - self.widgets[self.label] = (0, 0, 1, 1) - self.field = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) - self.field.set_halign(Gtk.Align.CENTER) - self.field.set_valign(Gtk.Align.CENTER) - self.field.set_hexpand(True) - # self.field.set_vexpand(True) - self.field.connect("changed", self._on_update) - self.widgets[self.field] = (0, 1, 1, 1) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - self.field.set_value(component.report) - - def collect_value(self): - return int(self.field.get_value()) - - @classmethod - def left_label(cls, component): - return _("Report") - - @classmethod - def right_label(cls, component): - return str(component.report) - - -class ModifiersUI(ConditionUI): - CLASS = _DIV.Modifiers - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text(_("Active keyboard modifiers. Not always available in Wayland.")) - self.widgets[self.label] = (0, 0, 5, 1) - self.labels = {} - self.switches = {} - for i, m in enumerate(_DIV.MODIFIERS): - switch = Gtk.Switch(halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) - label = Gtk.Label(m, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) - self.widgets[label] = (i, 1, 1, 1) - self.widgets[switch] = (i, 2, 1, 1) - self.labels[m] = label - self.switches[m] = switch - switch.connect("notify::active", self._on_update) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - for m in _DIV.MODIFIERS: - self.switches[m].set_active(m in component.modifiers) - - def collect_value(self): - return [m for m, s in self.switches.items() if s.get_active()] - - @classmethod - def left_label(cls, component): - return _("Modifiers") - - @classmethod - def right_label(cls, component): - return "+".join(component.modifiers) or "None" - - -class KeyUI(ConditionUI): - CLASS = _DIV.Key - KEY_NAMES = map(str, _CONTROL) - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text( - _( - "Diverted key or button depressed or released.\n" - "Use the Key/Button Diversion and Divert G Keys settings to divert keys and buttons." - ) - ) - self.widgets[self.label] = (0, 0, 5, 1) - self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) - self.key_field.set_size_request(600, 0) - self.key_field.connect("changed", self._on_update) - self.widgets[self.key_field] = (0, 1, 2, 1) - self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Key down")) - self.action_pressed_radio.connect("toggled", self._on_update, _Key.DOWN) - self.widgets[self.action_pressed_radio] = (2, 1, 1, 1) - self.action_released_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_pressed_radio, _("Key up")) - self.action_released_radio.connect("toggled", self._on_update, _Key.UP) - self.widgets[self.action_released_radio] = (3, 1, 1, 1) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - self.key_field.set_text(str(component.key) if self.component.key else "") - if not component.action or component.action == _Key.DOWN: - self.action_pressed_radio.set_active(True) - else: - self.action_released_radio.set_active(True) - - def collect_value(self): - action = _Key.UP if self.action_released_radio.get_active() else _Key.DOWN - return [self.key_field.get_text(), action] - - def _on_update(self, *args): - super()._on_update(*args) - icon = "dialog-warning" if not self.component.key or not self.component.action else "" - self.key_field.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - - @classmethod - def left_label(cls, component): - return _("Key") - - @classmethod - def right_label(cls, component): - return "%s (%04X) (%s)" % (str(component.key), int(component.key), _(component.action)) if component.key else "None" - - -class KeyIsDownUI(ConditionUI): - CLASS = _DIV.KeyIsDown - KEY_NAMES = map(str, _CONTROL) - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text( - _( - "Diverted key or button is currently down.\n" - "Use the Key/Button Diversion and Divert G Keys settings to divert keys and buttons." - ) - ) - self.widgets[self.label] = (0, 0, 5, 1) - self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) - self.key_field.set_size_request(600, 0) - self.key_field.connect("changed", self._on_update) - self.widgets[self.key_field] = (0, 1, 1, 1) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - self.key_field.set_text(str(component.key) if self.component.key else "") - - def collect_value(self): - return self.key_field.get_text() - - def _on_update(self, *args): - super()._on_update(*args) - icon = "dialog-warning" if not self.component.key else "" - self.key_field.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - - @classmethod - def left_label(cls, component): - return _("KeyIsDown") - - @classmethod - def right_label(cls, component): - return "%s (%04X)" % (str(component.key), int(component.key)) if component.key else "None" - - -class TestUI(ConditionUI): - CLASS = _DIV.Test - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text(_("Test condition on notification triggering rule processing.")) - self.widgets[self.label] = (0, 0, 4, 1) - lbl = Gtk.Label(_("Test"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=False, vexpand=False) - self.widgets[lbl] = (0, 1, 1, 1) - lbl = Gtk.Label(_("Parameter"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=False, vexpand=False) - self.widgets[lbl] = (2, 1, 1, 1) - - self.test = Gtk.ComboBoxText.new_with_entry() - self.test.append("", "") - for t in _DIV.TESTS: - self.test.append(t, t) - self.test.set_halign(Gtk.Align.END) - self.test.set_valign(Gtk.Align.CENTER) - self.test.set_hexpand(False) - self.test.set_size_request(300, 0) - CompletionEntry.add_completion_to_entry(self.test.get_child(), _DIV.TESTS) - self.test.connect("changed", self._on_update) - self.widgets[self.test] = (1, 1, 1, 1) - - self.parameter = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) - self.parameter.set_size_request(150, 0) - self.parameter.connect("changed", self._on_update) - self.widgets[self.parameter] = (3, 1, 1, 1) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - self.test.set_active_id(component.test) - self.parameter.set_text(str(component.parameter) if component.parameter is not None else "") - if component.test not in _DIV.TESTS: - self.test.get_child().set_text(component.test) - self._change_status_icon() - - def collect_value(self): - try: - param = int(self.parameter.get_text()) if self.parameter.get_text() else None - except Exception: - param = self.parameter.get_text() - test = (self.test.get_active_text() or "").strip() - return [test, param] if param is not None else [test] - - def _on_update(self, *args): - super()._on_update(*args) - self._change_status_icon() - - def _change_status_icon(self): - icon = "dialog-warning" if (self.test.get_active_text() or "").strip() not in _DIV.TESTS else "" - self.test.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - - @classmethod - def left_label(cls, component): - return _("Test") - - @classmethod - def right_label(cls, component): - return component.test + (" " + repr(component.parameter) if component.parameter is not None else "") - - -_TestBytesElement = namedtuple("TestBytesElement", ["id", "label", "min", "max"]) -_TestBytesMode = namedtuple("TestBytesMode", ["label", "elements", "label_fn"]) - - -class TestBytesUI(ConditionUI): - CLASS = _DIV.TestBytes - - _common_elements = [ - _TestBytesElement("begin", _("begin (inclusive)"), 0, 16), - _TestBytesElement("end", _("end (exclusive)"), 0, 16), - ] - - _global_min = -(2**31) - _global_max = 2**31 - 1 - - _modes = { - "range": _TestBytesMode( - _("range"), - _common_elements - + [ - _TestBytesElement("minimum", _("minimum"), _global_min, _global_max), # uint32 - _TestBytesElement("maximum", _("maximum"), _global_min, _global_max), - ], - lambda e: _("bytes %(0)d to %(1)d, ranging from %(2)d to %(3)d" % {str(i): v for i, v in enumerate(e)}), - ), - "mask": _TestBytesMode( - _("mask"), - _common_elements + [_TestBytesElement("mask", _("mask"), _global_min, _global_max)], - lambda e: _("bytes %(0)d to %(1)d, mask %(2)d" % {str(i): v for i, v in enumerate(e)}), - ), - } - - def create_widgets(self): - self.fields = {} - self.field_labels = {} - self.widgets = {} - self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) - self.label.set_text(_("Bit or range test on bytes in notification message triggering rule processing.")) - self.widgets[self.label] = (0, 0, 5, 1) - col = 0 - mode_col = 2 - self.mode_field = Gtk.ComboBox.new_with_model(Gtk.ListStore(str, str)) - mode_renderer = Gtk.CellRendererText() - self.mode_field.set_id_column(0) - self.mode_field.pack_start(mode_renderer, True) - self.mode_field.add_attribute(mode_renderer, "text", 1) - self.widgets[self.mode_field] = (mode_col, 2, 1, 1) - mode_label = Gtk.Label(_("type"), margin_top=20) - self.widgets[mode_label] = (mode_col, 1, 1, 1) - for mode_id, mode in TestBytesUI._modes.items(): - self.mode_field.get_model().append([mode_id, mode.label]) - for element in mode.elements: - if element.id not in self.fields: - field = Gtk.SpinButton.new_with_range(element.min, element.max, 1) - field.set_value(0) - field.set_size_request(150, 0) - field.connect("value-changed", self._on_update) - label = Gtk.Label(element.label, margin_top=20) - self.fields[element.id] = field - self.field_labels[element.id] = label - self.widgets[label] = (col, 1, 1, 1) - self.widgets[field] = (col, 2, 1, 1) - col += 1 if col != mode_col - 1 else 2 - self.mode_field.connect("changed", lambda cb: (self._on_update(), self._only_mode(cb.get_active_id()))) - self.mode_field.set_active_id("range") - - def show(self, component, editable): - super().show(component, editable) - - with self.ignore_changes(): - mode_id = {3: "mask", 4: "range"}.get(len(component.test), None) - self._only_mode(mode_id) - if not mode_id: - return - self.mode_field.set_active_id(mode_id) - if mode_id: - mode = TestBytesUI._modes[mode_id] - for i, element in enumerate(mode.elements): - self.fields[element.id].set_value(component.test[i]) - - def collect_value(self): - mode_id = self.mode_field.get_active_id() - return [self.fields[element.id].get_value_as_int() for element in TestBytesUI._modes[mode_id].elements] - - def _only_mode(self, mode_id): - if not mode_id: - return - keep = {element.id for element in TestBytesUI._modes[mode_id].elements} - for element_id, f in self.fields.items(): - visible = element_id in keep - f.set_visible(visible) - self.field_labels[element_id].set_visible(visible) - - def _on_update(self, *args): - super()._on_update(*args) - if not self.component: - return - begin, end, *etc = self.component.test - icon = "dialog-warning" if end <= begin else "" - self.fields["end"].set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - if len(self.component.test) == 4: - *etc, minimum, maximum = self.component.test - icon = "dialog-warning" if maximum < minimum else "" - self.fields["maximum"].set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - - @classmethod - def left_label(cls, component): - return _("Test bytes") - - @classmethod - def right_label(cls, component): - mode_id = {3: "mask", 4: "range"}.get(len(component.test), None) - if not mode_id: - return str(component.test) - return TestBytesUI._modes[mode_id].label_fn(component.test) - - -class MouseGestureUI(ConditionUI): - CLASS = _DIV.MouseGesture - MOUSE_GESTURE_NAMES = [ - "Mouse Up", - "Mouse Down", - "Mouse Left", - "Mouse Right", - "Mouse Up-left", - "Mouse Up-right", - "Mouse Down-left", - "Mouse Down-right", - ] - MOVE_NAMES = list(map(str, _CONTROL)) + MOUSE_GESTURE_NAMES - - def create_widgets(self): - self.widgets = {} - self.fields = [] - self.label = Gtk.Label( - _("Mouse gesture with optional initiating button followed by zero or more mouse movements."), - halign=Gtk.Align.CENTER, - ) - self.widgets[self.label] = (0, 0, 5, 1) - self.del_btns = [] - self.add_btn = Gtk.Button(_("Add movement"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) - self.add_btn.connect("clicked", self._clicked_add) - self.widgets[self.add_btn] = (1, 1, 1, 1) - - def _create_field(self): - field = Gtk.ComboBoxText.new_with_entry() - for g in self.MOUSE_GESTURE_NAMES: - field.append(g, g) - CompletionEntry.add_completion_to_entry(field.get_child(), self.MOVE_NAMES) - field.connect("changed", self._on_update) - self.fields.append(field) - self.widgets[field] = (len(self.fields) - 1, 1, 1, 1) - return field - - def _create_del_btn(self): - btn = Gtk.Button(_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) - self.del_btns.append(btn) - self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1) - btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1) - return btn - - def _clicked_add(self, _btn): - self.component.__init__(self.collect_value() + [""], warn=False) - self.show(self.component, editable=True) - self.fields[len(self.component.movements) - 1].grab_focus() - - def _clicked_del(self, _btn, pos): - v = self.collect_value() - v.pop(pos) - self.component.__init__(v, warn=False) - self.show(self.component, editable=True) - self._on_update_callback() - - def _on_update(self, *args): - super()._on_update(*args) - for i, f in enumerate(self.fields): - if f.get_visible(): - icon = ( - "dialog-warning" - if i < len(self.component.movements) and self.component.movements[i] not in self.MOVE_NAMES - else "" - ) - f.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - - def show(self, component, editable): - n = len(component.movements) - while len(self.fields) < n: - self._create_field() - self._create_del_btn() - self.widgets[self.add_btn] = (n + 1, 1, 1, 1) - super().show(component, editable) - for i in range(n): - field = self.fields[i] - with self.ignore_changes(): - field.get_child().set_text(component.movements[i]) - field.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0) - field.show_all() - self.del_btns[i].show() - for i in range(n, len(self.fields)): - self.fields[i].hide() - self.del_btns[i].hide() - self.add_btn.set_valign(Gtk.Align.END if n >= 1 else Gtk.Align.CENTER) - - def collect_value(self): - return [f.get_active_text().strip() for f in self.fields if f.get_visible()] - - @classmethod - def left_label(cls, component): - return _("Mouse Gesture") - - @classmethod - def right_label(cls, component): - if len(component.movements) == 0: - return "No-op" - else: - return " -> ".join(component.movements) - - class ActionUI(RuleComponentUI): CLASS = _DIV.Action @@ -1749,287 +1108,6 @@ def icon_name(cls): return "go-next" -class KeyPressUI(ActionUI): - CLASS = _DIV.KeyPress - KEY_NAMES = [k[3:] if k.startswith("XK_") else k for k, v in _XK_KEYS.items() if isinstance(v, int)] - - def create_widgets(self): - self.widgets = {} - self.fields = [] - self.label = Gtk.Label( - _("Simulate a chorded key click or depress or release.\nOn Wayland requires write access to /dev/uinput."), - halign=Gtk.Align.CENTER, - ) - self.widgets[self.label] = (0, 0, 5, 1) - self.del_btns = [] - self.add_btn = Gtk.Button(_("Add key"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) - self.add_btn.connect("clicked", self._clicked_add) - self.widgets[self.add_btn] = (1, 1, 1, 1) - self.action_clicked_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Click")) - self.action_clicked_radio.connect("toggled", self._on_update, CLICK) - self.widgets[self.action_clicked_radio] = (0, 3, 1, 1) - self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_clicked_radio, _("Depress")) - self.action_pressed_radio.connect("toggled", self._on_update, DEPRESS) - self.widgets[self.action_pressed_radio] = (1, 3, 1, 1) - self.action_released_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_pressed_radio, _("Release")) - self.action_released_radio.connect("toggled", self._on_update, RELEASE) - self.widgets[self.action_released_radio] = (2, 3, 1, 1) - - def _create_field(self): - field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) - field.connect("changed", self._on_update) - self.fields.append(field) - self.widgets[field] = (len(self.fields) - 1, 1, 1, 1) - return field - - def _create_del_btn(self): - btn = Gtk.Button(_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) - self.del_btns.append(btn) - self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1) - btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1) - return btn - - def _clicked_add(self, _btn): - keys, action = self.component.regularize_args(self.collect_value()) - self.component.__init__([keys + [""], action], warn=False) - self.show(self.component, editable=True) - self.fields[len(self.component.key_names) - 1].grab_focus() - - def _clicked_del(self, _btn, pos): - keys, action = self.component.regularize_args(self.collect_value()) - keys.pop(pos) - self.component.__init__([keys, action], warn=False) - self.show(self.component, editable=True) - self._on_update_callback() - - def _on_update(self, *args): - super()._on_update(*args) - for i, f in enumerate(self.fields): - if f.get_visible(): - icon = ( - "dialog-warning" - if i < len(self.component.key_names) and self.component.key_names[i] not in self.KEY_NAMES - else "" - ) - f.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - - def show(self, component, editable=True): - n = len(component.key_names) - while len(self.fields) < n: - self._create_field() - self._create_del_btn() - - # self.widgets[self.add_btn] = (n + 1, 0, 1, 1) - self.widgets[self.add_btn] = (n, 1, 1, 1) - super().show(component, editable) - for i in range(n): - field = self.fields[i] - with self.ignore_changes(): - field.set_text(component.key_names[i]) - field.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0) - field.show_all() - self.del_btns[i].show() - for i in range(n, len(self.fields)): - self.fields[i].hide() - self.del_btns[i].hide() - - def collect_value(self): - action = ( - CLICK if self.action_clicked_radio.get_active() else DEPRESS if self.action_pressed_radio.get_active() else RELEASE - ) - return [[f.get_text().strip() for f in self.fields if f.get_visible()], action] - - @classmethod - def left_label(cls, component): - return _("Key press") - - @classmethod - def right_label(cls, component): - return " + ".join(component.key_names) + (" (" + component.action + ")" if component.action != CLICK else "") - - -class MouseScrollUI(ActionUI): - CLASS = _DIV.MouseScroll - MIN_VALUE = -2000 - MAX_VALUE = 2000 - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label( - _("Simulate a mouse scroll.\nOn Wayland requires write access to /dev/uinput."), halign=Gtk.Align.CENTER - ) - self.widgets[self.label] = (0, 0, 4, 1) - self.label_x = Gtk.Label(label="x", halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True) - self.label_y = Gtk.Label(label="y", halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True) - self.field_x = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) - self.field_y = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) - for f in [self.field_x, self.field_y]: - f.set_halign(Gtk.Align.CENTER) - f.set_valign(Gtk.Align.START) - self.field_x.connect("changed", self._on_update) - self.field_y.connect("changed", self._on_update) - self.widgets[self.label_x] = (0, 1, 1, 1) - self.widgets[self.field_x] = (1, 1, 1, 1) - self.widgets[self.label_y] = (2, 1, 1, 1) - self.widgets[self.field_y] = (3, 1, 1, 1) - - @classmethod - def __parse(cls, v): - try: - # allow floats, but round them down - return int(float(v)) - except (TypeError, ValueError): - return 0 - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - self.field_x.set_value(self.__parse(component.amounts[0] if len(component.amounts) >= 1 else 0)) - self.field_y.set_value(self.__parse(component.amounts[1] if len(component.amounts) >= 2 else 0)) - - def collect_value(self): - return [int(self.field_x.get_value()), int(self.field_y.get_value())] - - @classmethod - def left_label(cls, component): - return _("Mouse scroll") - - @classmethod - def right_label(cls, component): - x = y = 0 - x = cls.__parse(component.amounts[0] if len(component.amounts) >= 1 else 0) - y = cls.__parse(component.amounts[1] if len(component.amounts) >= 2 else 0) - return f"{x}, {y}" - - -class MouseClickUI(ActionUI): - CLASS = _DIV.MouseClick - MIN_VALUE = 1 - MAX_VALUE = 9 - BUTTONS = list(_buttons.keys()) - ACTIONS = [CLICK, DEPRESS, RELEASE] - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label( - _("Simulate a mouse click.\nOn Wayland requires write access to /dev/uinput."), halign=Gtk.Align.CENTER - ) - self.widgets[self.label] = (0, 0, 4, 1) - self.label_b = Gtk.Label(label=_("Button"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True) - self.label_c = Gtk.Label(label=_("Count and Action"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True) - self.field_b = CompletionEntry(self.BUTTONS) - self.field_c = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) - self.field_d = CompletionEntry(self.ACTIONS) - for f in [self.field_b, self.field_c]: - f.set_halign(Gtk.Align.CENTER) - f.set_valign(Gtk.Align.START) - self.field_b.connect("changed", self._on_update) - self.field_c.connect("changed", self._on_update) - self.field_d.connect("changed", self._on_update) - self.widgets[self.label_b] = (0, 1, 1, 1) - self.widgets[self.field_b] = (1, 1, 1, 1) - self.widgets[self.label_c] = (2, 1, 1, 1) - self.widgets[self.field_c] = (3, 1, 1, 1) - self.widgets[self.field_d] = (4, 1, 1, 1) - - def show(self, component, editable): - super().show(component, editable) - with self.ignore_changes(): - self.field_b.set_text(component.button) - if isinstance(component.count, int): - self.field_c.set_value(component.count) - self.field_d.set_text(CLICK) - else: - self.field_c.set_value(1) - self.field_d.set_text(component.count) - - def collect_value(self): - b, c, d = self.field_b.get_text(), int(self.field_c.get_value()), self.field_d.get_text() - if b not in self.BUTTONS: - b = "unknown" - if d != CLICK: - c = d - return [b, c] - - @classmethod - def left_label(cls, component): - return _("Mouse click") - - @classmethod - def right_label(cls, component): - return f'{component.button} ({"x" if isinstance(component.count, int) else ""}{component.count})' - - -class ExecuteUI(ActionUI): - CLASS = _DIV.Execute - - def create_widgets(self): - self.widgets = {} - self.label = Gtk.Label(_("Execute a command with arguments."), halign=Gtk.Align.CENTER) - self.widgets[self.label] = (0, 0, 5, 1) - self.fields = [] - self.add_btn = Gtk.Button(_("Add argument"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) - self.del_btns = [] - self.add_btn.connect("clicked", self._clicked_add) - self.widgets[self.add_btn] = (1, 1, 1, 1) - - def _create_field(self): - field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) - field.set_size_request(150, 0) - field.connect("changed", self._on_update) - self.fields.append(field) - self.widgets[field] = (len(self.fields) - 1, 1, 1, 1) - return field - - def _create_del_btn(self): - btn = Gtk.Button(_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) - btn.set_size_request(150, 0) - self.del_btns.append(btn) - self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1) - btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1) - return btn - - def _clicked_add(self, *_args): - self.component.__init__(self.collect_value() + [""], warn=False) - self.show(self.component, editable=True) - self.fields[len(self.component.args) - 1].grab_focus() - - def _clicked_del(self, _btn, pos): - v = self.collect_value() - v.pop(pos) - self.component.__init__(v, warn=False) - self.show(self.component, editable=True) - self._on_update_callback() - - def show(self, component, editable): - n = len(component.args) - while len(self.fields) < n: - self._create_field() - self._create_del_btn() - for i in range(n): - field = self.fields[i] - with self.ignore_changes(): - field.set_text(component.args[i]) - self.del_btns[i].show() - self.widgets[self.add_btn] = (n + 1, 1, 1, 1) - super().show(component, editable) - for i in range(n, len(self.fields)): - self.fields[i].hide() - self.del_btns[i].hide() - self.add_btn.set_valign(Gtk.Align.END if n >= 1 else Gtk.Align.CENTER) - - def collect_value(self): - return [f.get_text() for f in self.fields if f.get_visible()] - - @classmethod - def left_label(cls, component): - return _("Execute") - - @classmethod - def right_label(cls, component): - return " ".join([shlex_quote(a) for a in component.args]) - - def _from_named_ints(v, all_values): """Obtain a NamedInt from NamedInts given its numeric value (as int) or name.""" if all_values and (v in all_values): @@ -2658,24 +1736,24 @@ def _on_update(self, *_args): _DIV.Or: OrUI, _DIV.And: AndUI, _DIV.Later: LaterUI, - _DIV.Process: ProcessUI, - _DIV.MouseProcess: MouseProcessUI, + _DIV.Process: rule_conditions.ProcessUI, + _DIV.MouseProcess: rule_conditions.MouseProcessUI, _DIV.Active: ActiveUI, _DIV.Device: DeviceUI, _DIV.Host: HostUI, - _DIV.Feature: FeatureUI, - _DIV.Report: ReportUI, - _DIV.Modifiers: ModifiersUI, - _DIV.Key: KeyUI, - _DIV.KeyIsDown: KeyIsDownUI, - _DIV.Test: TestUI, - _DIV.TestBytes: TestBytesUI, + _DIV.Feature: rule_conditions.FeatureUI, + _DIV.Report: rule_conditions.ReportUI, + _DIV.Modifiers: rule_conditions.ModifiersUI, + _DIV.Key: rule_conditions.KeyUI, + _DIV.KeyIsDown: rule_conditions.KeyIsDownUI, + _DIV.Test: rule_conditions.TestUI, + _DIV.TestBytes: rule_conditions.TestBytesUI, _DIV.Setting: SettingUI, - _DIV.MouseGesture: MouseGestureUI, - _DIV.KeyPress: KeyPressUI, - _DIV.MouseScroll: MouseScrollUI, - _DIV.MouseClick: MouseClickUI, - _DIV.Execute: ExecuteUI, + _DIV.MouseGesture: rule_conditions.MouseGestureUI, + _DIV.KeyPress: rule_actions.KeyPressUI, + _DIV.MouseScroll: rule_actions.MouseScrollUI, + _DIV.MouseClick: rule_actions.MouseClickUI, + _DIV.Execute: rule_actions.ExecuteUI, _DIV.Set: SetUI, type(None): RuleComponentUI, # placeholders for empty rule/And/Or } diff --git a/lib/solaar/ui/rule_actions.py b/lib/solaar/ui/rule_actions.py new file mode 100644 index 0000000000..263556c906 --- /dev/null +++ b/lib/solaar/ui/rule_actions.py @@ -0,0 +1,315 @@ +## Copyright (C) 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. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from shlex import quote as shlex_quote + +from gi.repository import Gtk +from logitech_receiver import diversion as _DIV +from logitech_receiver.diversion import CLICK, DEPRESS, RELEASE +from logitech_receiver.diversion import XK_KEYS as _XK_KEYS +from logitech_receiver.diversion import buttons as _buttons + +from solaar.i18n import _ +from solaar.ui.rule_base import CompletionEntry, RuleComponentUI + + +class ActionUI(RuleComponentUI): + CLASS = _DIV.Action + + @classmethod + def icon_name(cls): + return "go-next" + + +class KeyPressUI(ActionUI): + CLASS = _DIV.KeyPress + KEY_NAMES = [k[3:] if k.startswith("XK_") else k for k, v in _XK_KEYS.items() if isinstance(v, int)] + + def create_widgets(self): + self.widgets = {} + self.fields = [] + self.label = Gtk.Label( + _("Simulate a chorded key click or depress or release.\nOn Wayland requires write access to /dev/uinput."), + halign=Gtk.Align.CENTER, + ) + self.widgets[self.label] = (0, 0, 5, 1) + self.del_btns = [] + self.add_btn = Gtk.Button(_("Add key"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) + self.add_btn.connect("clicked", self._clicked_add) + self.widgets[self.add_btn] = (1, 1, 1, 1) + self.action_clicked_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Click")) + self.action_clicked_radio.connect("toggled", self._on_update, CLICK) + self.widgets[self.action_clicked_radio] = (0, 3, 1, 1) + self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_clicked_radio, _("Depress")) + self.action_pressed_radio.connect("toggled", self._on_update, DEPRESS) + self.widgets[self.action_pressed_radio] = (1, 3, 1, 1) + self.action_released_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_pressed_radio, _("Release")) + self.action_released_radio.connect("toggled", self._on_update, RELEASE) + self.widgets[self.action_released_radio] = (2, 3, 1, 1) + + def _create_field(self): + field_entry = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) + field_entry.connect("changed", self._on_update) + self.fields.append(field_entry) + self.widgets[field_entry] = (len(self.fields) - 1, 1, 1, 1) + return field_entry + + def _create_del_btn(self): + btn = Gtk.Button(_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) + self.del_btns.append(btn) + self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1) + btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1) + return btn + + def _clicked_add(self, _btn): + keys, action = self.component.regularize_args(self.collect_value()) + self.component.__init__([keys + [""], action], warn=False) + self.show(self.component, editable=True) + self.fields[len(self.component.key_names) - 1].grab_focus() + + def _clicked_del(self, _btn, pos): + keys, action = self.component.regularize_args(self.collect_value()) + keys.pop(pos) + self.component.__init__([keys, action], warn=False) + self.show(self.component, editable=True) + self._on_update_callback() + + def _on_update(self, *args): + super()._on_update(*args) + for i, f in enumerate(self.fields): + if f.get_visible(): + icon = ( + "dialog-warning" + if i < len(self.component.key_names) and self.component.key_names[i] not in self.KEY_NAMES + else "" + ) + f.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + + def show(self, component, editable=True): + n = len(component.key_names) + while len(self.fields) < n: + self._create_field() + self._create_del_btn() + + # self.widgets[self.add_btn] = (n + 1, 0, 1, 1) + self.widgets[self.add_btn] = (n, 1, 1, 1) + super().show(component, editable) + for i in range(n): + field_entry = self.fields[i] + with self.ignore_changes(): + field_entry.set_text(component.key_names[i]) + field_entry.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0) + field_entry.show_all() + self.del_btns[i].show() + for i in range(n, len(self.fields)): + self.fields[i].hide() + self.del_btns[i].hide() + + def collect_value(self): + action = ( + CLICK if self.action_clicked_radio.get_active() else DEPRESS if self.action_pressed_radio.get_active() else RELEASE + ) + return [[f.get_text().strip() for f in self.fields if f.get_visible()], action] + + @classmethod + def left_label(cls, component): + return _("Key press") + + @classmethod + def right_label(cls, component): + return " + ".join(component.key_names) + (" (" + component.action + ")" if component.action != CLICK else "") + + +class MouseScrollUI(ActionUI): + CLASS = _DIV.MouseScroll + MIN_VALUE = -2000 + MAX_VALUE = 2000 + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label( + _("Simulate a mouse scroll.\nOn Wayland requires write access to /dev/uinput."), halign=Gtk.Align.CENTER + ) + self.widgets[self.label] = (0, 0, 4, 1) + self.label_x = Gtk.Label(label="x", halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True) + self.label_y = Gtk.Label(label="y", halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True) + self.field_x = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) + self.field_y = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) + for f in [self.field_x, self.field_y]: + f.set_halign(Gtk.Align.CENTER) + f.set_valign(Gtk.Align.START) + self.field_x.connect("changed", self._on_update) + self.field_y.connect("changed", self._on_update) + self.widgets[self.label_x] = (0, 1, 1, 1) + self.widgets[self.field_x] = (1, 1, 1, 1) + self.widgets[self.label_y] = (2, 1, 1, 1) + self.widgets[self.field_y] = (3, 1, 1, 1) + + @classmethod + def __parse(cls, v): + try: + # allow floats, but round them down + return int(float(v)) + except (TypeError, ValueError): + return 0 + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + self.field_x.set_value(self.__parse(component.amounts[0] if len(component.amounts) >= 1 else 0)) + self.field_y.set_value(self.__parse(component.amounts[1] if len(component.amounts) >= 2 else 0)) + + def collect_value(self): + return [int(self.field_x.get_value()), int(self.field_y.get_value())] + + @classmethod + def left_label(cls, component): + return _("Mouse scroll") + + @classmethod + def right_label(cls, component): + x = y = 0 + x = cls.__parse(component.amounts[0] if len(component.amounts) >= 1 else 0) + y = cls.__parse(component.amounts[1] if len(component.amounts) >= 2 else 0) + return f"{x}, {y}" + + +class MouseClickUI(ActionUI): + CLASS = _DIV.MouseClick + MIN_VALUE = 1 + MAX_VALUE = 9 + BUTTONS = list(_buttons.keys()) + ACTIONS = [CLICK, DEPRESS, RELEASE] + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label( + _("Simulate a mouse click.\nOn Wayland requires write access to /dev/uinput."), halign=Gtk.Align.CENTER + ) + self.widgets[self.label] = (0, 0, 4, 1) + self.label_b = Gtk.Label(label=_("Button"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True) + self.label_c = Gtk.Label(label=_("Count and Action"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True) + self.field_b = CompletionEntry(self.BUTTONS) + self.field_c = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) + self.field_d = CompletionEntry(self.ACTIONS) + for f in [self.field_b, self.field_c]: + f.set_halign(Gtk.Align.CENTER) + f.set_valign(Gtk.Align.START) + self.field_b.connect("changed", self._on_update) + self.field_c.connect("changed", self._on_update) + self.field_d.connect("changed", self._on_update) + self.widgets[self.label_b] = (0, 1, 1, 1) + self.widgets[self.field_b] = (1, 1, 1, 1) + self.widgets[self.label_c] = (2, 1, 1, 1) + self.widgets[self.field_c] = (3, 1, 1, 1) + self.widgets[self.field_d] = (4, 1, 1, 1) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + self.field_b.set_text(component.button) + if isinstance(component.count, int): + self.field_c.set_value(component.count) + self.field_d.set_text(CLICK) + else: + self.field_c.set_value(1) + self.field_d.set_text(component.count) + + def collect_value(self): + b, c, d = self.field_b.get_text(), int(self.field_c.get_value()), self.field_d.get_text() + if b not in self.BUTTONS: + b = "unknown" + if d != CLICK: + c = d + return [b, c] + + @classmethod + def left_label(cls, component): + return _("Mouse click") + + @classmethod + def right_label(cls, component): + return f'{component.button} ({"x" if isinstance(component.count, int) else ""}{component.count})' + + +class ExecuteUI(ActionUI): + CLASS = _DIV.Execute + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(_("Execute a command with arguments."), halign=Gtk.Align.CENTER) + self.widgets[self.label] = (0, 0, 5, 1) + self.fields = [] + self.add_btn = Gtk.Button(_("Add argument"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) + self.del_btns = [] + self.add_btn.connect("clicked", self._clicked_add) + self.widgets[self.add_btn] = (1, 1, 1, 1) + + def _create_field(self): + field_entry = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) + field_entry.set_size_request(150, 0) + field_entry.connect("changed", self._on_update) + self.fields.append(field_entry) + self.widgets[field_entry] = (len(self.fields) - 1, 1, 1, 1) + return field_entry + + def _create_del_btn(self): + btn = Gtk.Button(_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) + btn.set_size_request(150, 0) + self.del_btns.append(btn) + self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1) + btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1) + return btn + + def _clicked_add(self, *_args): + self.component.__init__(self.collect_value() + [""], warn=False) + self.show(self.component, editable=True) + self.fields[len(self.component.args) - 1].grab_focus() + + def _clicked_del(self, _btn, pos): + v = self.collect_value() + v.pop(pos) + self.component.__init__(v, warn=False) + self.show(self.component, editable=True) + self._on_update_callback() + + def show(self, component, editable): + n = len(component.args) + while len(self.fields) < n: + self._create_field() + self._create_del_btn() + for i in range(n): + field_entry = self.fields[i] + with self.ignore_changes(): + field_entry.set_text(component.args[i]) + self.del_btns[i].show() + self.widgets[self.add_btn] = (n + 1, 1, 1, 1) + super().show(component, editable) + for i in range(n, len(self.fields)): + self.fields[i].hide() + self.del_btns[i].hide() + self.add_btn.set_valign(Gtk.Align.END if n >= 1 else Gtk.Align.CENTER) + + def collect_value(self): + return [f.get_text() for f in self.fields if f.get_visible()] + + @classmethod + def left_label(cls, component): + return _("Execute") + + @classmethod + def right_label(cls, component): + return " ".join([shlex_quote(a) for a in component.args]) diff --git a/lib/solaar/ui/rule_base.py b/lib/solaar/ui/rule_base.py new file mode 100644 index 0000000000..66ea020752 --- /dev/null +++ b/lib/solaar/ui/rule_base.py @@ -0,0 +1,108 @@ +## Copyright (C) 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. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from contextlib import contextmanager as contextlib_contextmanager + +from gi.repository import Gtk +from logitech_receiver import diversion as _DIV + + +def norm(s): + return s.replace("_", "").replace(" ", "").lower() + + +class CompletionEntry(Gtk.Entry): + def __init__(self, values, *args, **kwargs): + super().__init__(*args, **kwargs) + CompletionEntry.add_completion_to_entry(self, values) + + @classmethod + def add_completion_to_entry(cls, entry, values): + completion = entry.get_completion() + if not completion: + liststore = Gtk.ListStore(str) + completion = Gtk.EntryCompletion() + completion.set_model(liststore) + completion.set_match_func(lambda completion, key, it: norm(key) in norm(completion.get_model()[it][0])) + completion.set_text_column(0) + entry.set_completion(completion) + else: + liststore = completion.get_model() + liststore.clear() + for v in sorted(set(values), key=str.casefold): + liststore.append((v,)) + + +class RuleComponentUI: + CLASS = _DIV.RuleComponent + + def __init__(self, panel, on_update=None): + self.panel = panel + self.widgets = {} # widget -> coord. in grid + self.component = None + self._ignore_changes = 0 + self._on_update_callback = (lambda: None) if on_update is None else on_update + self.create_widgets() + + def create_widgets(self): + pass + + def show(self, component, editable=True): + self._show_widgets(editable) + self.component = component + + def collect_value(self): + return None + + @contextlib_contextmanager + def ignore_changes(self): + self._ignore_changes += 1 + yield None + self._ignore_changes -= 1 + + def _on_update(self, *_args): + if not self._ignore_changes and self.component is not None: + value = self.collect_value() + self.component.__init__(value, warn=False) + self._on_update_callback() + return value + return None + + def _show_widgets(self, editable): + self._remove_panel_items() + for widget, coord in self.widgets.items(): + self.panel.attach(widget, *coord) + widget.set_sensitive(editable) + widget.show() + + @classmethod + def left_label(cls, component): + return type(component).__name__ + + @classmethod + def right_label(cls, _component): + return "" + + @classmethod + def icon_name(cls): + return "" + + def _remove_panel_items(self): + for c in self.panel.get_children(): + self.panel.remove(c) + + def update_devices(self): + pass diff --git a/lib/solaar/ui/rule_conditions.py b/lib/solaar/ui/rule_conditions.py new file mode 100644 index 0000000000..e5f3eec4a2 --- /dev/null +++ b/lib/solaar/ui/rule_conditions.py @@ -0,0 +1,596 @@ +## Copyright (C) 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. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +from collections import namedtuple + +from gi.repository import Gtk +from logitech_receiver import diversion as _DIV +from logitech_receiver.diversion import Key as _Key +from logitech_receiver.hidpp20 import FEATURE as _ALL_FEATURES +from logitech_receiver.special_keys import CONTROL as _CONTROL + +from solaar.i18n import _ +from solaar.ui.rule_base import CompletionEntry, RuleComponentUI + + +class ConditionUI(RuleComponentUI): + CLASS = _DIV.Condition + + @classmethod + def icon_name(cls): + return "dialog-question" + + +class ProcessUI(ConditionUI): + CLASS = _DIV.Process + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text(_("X11 active process. For use in X11 only.")) + self.widgets[self.label] = (0, 0, 1, 1) + self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) + self.field.set_size_request(600, 0) + self.field.connect("changed", self._on_update) + self.widgets[self.field] = (0, 1, 1, 1) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + self.field.set_text(component.process) + + def collect_value(self): + return self.field.get_text() + + @classmethod + def left_label(cls, component): + return _("Process") + + @classmethod + def right_label(cls, component): + return str(component.process) + + +class MouseProcessUI(ConditionUI): + CLASS = _DIV.MouseProcess + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text(_("X11 mouse process. For use in X11 only.")) + self.widgets[self.label] = (0, 0, 1, 1) + self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) + self.field.set_size_request(600, 0) + self.field.connect("changed", self._on_update) + self.widgets[self.field] = (0, 1, 1, 1) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + self.field.set_text(component.process) + + def collect_value(self): + return self.field.get_text() + + @classmethod + def left_label(cls, component): + return _("MouseProcess") + + @classmethod + def right_label(cls, component): + return str(component.process) + + +class FeatureUI(ConditionUI): + CLASS = _DIV.Feature + FEATURES_WITH_DIVERSION = [ + str(_ALL_FEATURES.CROWN), + str(_ALL_FEATURES.THUMB_WHEEL), + str(_ALL_FEATURES.LOWRES_WHEEL), + str(_ALL_FEATURES.HIRES_WHEEL), + str(_ALL_FEATURES.GESTURE_2), + str(_ALL_FEATURES.REPROG_CONTROLS_V4), + str(_ALL_FEATURES.GKEY), + str(_ALL_FEATURES.MKEYS), + str(_ALL_FEATURES.MR), + ] + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text(_("Feature name of notification triggering rule processing.")) + self.widgets[self.label] = (0, 0, 1, 1) + self.field = Gtk.ComboBoxText.new_with_entry() + self.field.append("", "") + for feature in self.FEATURES_WITH_DIVERSION: + self.field.append(feature, feature) + self.field.set_valign(Gtk.Align.CENTER) + # self.field.set_vexpand(True) + self.field.set_size_request(600, 0) + self.field.connect("changed", self._on_update) + all_features = [str(f) for f in _ALL_FEATURES] + CompletionEntry.add_completion_to_entry(self.field.get_child(), all_features) + self.widgets[self.field] = (0, 1, 1, 1) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + f = str(component.feature) if component.feature else "" + self.field.set_active_id(f) + if f not in self.FEATURES_WITH_DIVERSION: + self.field.get_child().set_text(f) + + def collect_value(self): + return (self.field.get_active_text() or "").strip() + + def _on_update(self, *args): + super()._on_update(*args) + icon = "dialog-warning" if not self.component.feature else "" + self.field.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + + @classmethod + def left_label(cls, component): + return _("Feature") + + @classmethod + def right_label(cls, component): + return "%s (%04X)" % (str(component.feature), int(component.feature or 0)) + + +class ReportUI(ConditionUI): + CLASS = _DIV.Report + MIN_VALUE = -1 # for invalid values + MAX_VALUE = 15 + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text(_("Report number of notification triggering rule processing.")) + self.widgets[self.label] = (0, 0, 1, 1) + self.field = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) + self.field.set_halign(Gtk.Align.CENTER) + self.field.set_valign(Gtk.Align.CENTER) + self.field.set_hexpand(True) + # self.field.set_vexpand(True) + self.field.connect("changed", self._on_update) + self.widgets[self.field] = (0, 1, 1, 1) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + self.field.set_value(component.report) + + def collect_value(self): + return int(self.field.get_value()) + + @classmethod + def left_label(cls, component): + return _("Report") + + @classmethod + def right_label(cls, component): + return str(component.report) + + +class ModifiersUI(ConditionUI): + CLASS = _DIV.Modifiers + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text(_("Active keyboard modifiers. Not always available in Wayland.")) + self.widgets[self.label] = (0, 0, 5, 1) + self.labels = {} + self.switches = {} + for i, m in enumerate(_DIV.MODIFIERS): + switch = Gtk.Switch(halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) + label = Gtk.Label(m, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) + self.widgets[label] = (i, 1, 1, 1) + self.widgets[switch] = (i, 2, 1, 1) + self.labels[m] = label + self.switches[m] = switch + switch.connect("notify::active", self._on_update) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + for m in _DIV.MODIFIERS: + self.switches[m].set_active(m in component.modifiers) + + def collect_value(self): + return [m for m, s in self.switches.items() if s.get_active()] + + @classmethod + def left_label(cls, component): + return _("Modifiers") + + @classmethod + def right_label(cls, component): + return "+".join(component.modifiers) or "None" + + +class KeyUI(ConditionUI): + CLASS = _DIV.Key + KEY_NAMES = map(str, _CONTROL) + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text( + _( + "Diverted key or button depressed or released.\n" + "Use the Key/Button Diversion and Divert G Keys settings to divert keys and buttons." + ) + ) + self.widgets[self.label] = (0, 0, 5, 1) + self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) + self.key_field.set_size_request(600, 0) + self.key_field.connect("changed", self._on_update) + self.widgets[self.key_field] = (0, 1, 2, 1) + self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Key down")) + self.action_pressed_radio.connect("toggled", self._on_update, _Key.DOWN) + self.widgets[self.action_pressed_radio] = (2, 1, 1, 1) + self.action_released_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_pressed_radio, _("Key up")) + self.action_released_radio.connect("toggled", self._on_update, _Key.UP) + self.widgets[self.action_released_radio] = (3, 1, 1, 1) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + self.key_field.set_text(str(component.key) if self.component.key else "") + if not component.action or component.action == _Key.DOWN: + self.action_pressed_radio.set_active(True) + else: + self.action_released_radio.set_active(True) + + def collect_value(self): + action = _Key.UP if self.action_released_radio.get_active() else _Key.DOWN + return [self.key_field.get_text(), action] + + def _on_update(self, *args): + super()._on_update(*args) + icon = "dialog-warning" if not self.component.key or not self.component.action else "" + self.key_field.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + + @classmethod + def left_label(cls, component): + return _("Key") + + @classmethod + def right_label(cls, component): + return "%s (%04X) (%s)" % (str(component.key), int(component.key), _(component.action)) if component.key else "None" + + +class KeyIsDownUI(ConditionUI): + CLASS = _DIV.KeyIsDown + KEY_NAMES = map(str, _CONTROL) + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text( + _( + "Diverted key or button is currently down.\n" + "Use the Key/Button Diversion and Divert G Keys settings to divert keys and buttons." + ) + ) + self.widgets[self.label] = (0, 0, 5, 1) + self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) + self.key_field.set_size_request(600, 0) + self.key_field.connect("changed", self._on_update) + self.widgets[self.key_field] = (0, 1, 1, 1) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + self.key_field.set_text(str(component.key) if self.component.key else "") + + def collect_value(self): + return self.key_field.get_text() + + def _on_update(self, *args): + super()._on_update(*args) + icon = "dialog-warning" if not self.component.key else "" + self.key_field.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + + @classmethod + def left_label(cls, component): + return _("KeyIsDown") + + @classmethod + def right_label(cls, component): + return "%s (%04X)" % (str(component.key), int(component.key)) if component.key else "None" + + +class TestUI(ConditionUI): + CLASS = _DIV.Test + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text(_("Test condition on notification triggering rule processing.")) + self.widgets[self.label] = (0, 0, 4, 1) + lbl = Gtk.Label(_("Test"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=False, vexpand=False) + self.widgets[lbl] = (0, 1, 1, 1) + lbl = Gtk.Label(_("Parameter"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=False, vexpand=False) + self.widgets[lbl] = (2, 1, 1, 1) + + self.test = Gtk.ComboBoxText.new_with_entry() + self.test.append("", "") + for t in _DIV.TESTS: + self.test.append(t, t) + self.test.set_halign(Gtk.Align.END) + self.test.set_valign(Gtk.Align.CENTER) + self.test.set_hexpand(False) + self.test.set_size_request(300, 0) + CompletionEntry.add_completion_to_entry(self.test.get_child(), _DIV.TESTS) + self.test.connect("changed", self._on_update) + self.widgets[self.test] = (1, 1, 1, 1) + + self.parameter = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) + self.parameter.set_size_request(150, 0) + self.parameter.connect("changed", self._on_update) + self.widgets[self.parameter] = (3, 1, 1, 1) + + def show(self, component, editable): + super().show(component, editable) + with self.ignore_changes(): + self.test.set_active_id(component.test) + self.parameter.set_text(str(component.parameter) if component.parameter is not None else "") + if component.test not in _DIV.TESTS: + self.test.get_child().set_text(component.test) + self._change_status_icon() + + def collect_value(self): + try: + param = int(self.parameter.get_text()) if self.parameter.get_text() else None + except Exception: + param = self.parameter.get_text() + test = (self.test.get_active_text() or "").strip() + return [test, param] if param is not None else [test] + + def _on_update(self, *args): + super()._on_update(*args) + self._change_status_icon() + + def _change_status_icon(self): + icon = "dialog-warning" if (self.test.get_active_text() or "").strip() not in _DIV.TESTS else "" + self.test.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + + @classmethod + def left_label(cls, component): + return _("Test") + + @classmethod + def right_label(cls, component): + return component.test + (" " + repr(component.parameter) if component.parameter is not None else "") + + +_TestBytesElement = namedtuple("TestBytesElement", ["id", "label", "min", "max"]) +_TestBytesMode = namedtuple("TestBytesMode", ["label", "elements", "label_fn"]) + + +class TestBytesUI(ConditionUI): + CLASS = _DIV.TestBytes + + _common_elements = [ + _TestBytesElement("begin", _("begin (inclusive)"), 0, 16), + _TestBytesElement("end", _("end (exclusive)"), 0, 16), + ] + + _global_min = -(2**31) + _global_max = 2**31 - 1 + + _modes = { + "range": _TestBytesMode( + _("range"), + _common_elements + + [ + _TestBytesElement("minimum", _("minimum"), _global_min, _global_max), # uint32 + _TestBytesElement("maximum", _("maximum"), _global_min, _global_max), + ], + lambda e: _("bytes %(0)d to %(1)d, ranging from %(2)d to %(3)d" % {str(i): v for i, v in enumerate(e)}), + ), + "mask": _TestBytesMode( + _("mask"), + _common_elements + [_TestBytesElement("mask", _("mask"), _global_min, _global_max)], + lambda e: _("bytes %(0)d to %(1)d, mask %(2)d" % {str(i): v for i, v in enumerate(e)}), + ), + } + + def create_widgets(self): + self.fields = {} + self.field_labels = {} + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True) + self.label.set_text(_("Bit or range test on bytes in notification message triggering rule processing.")) + self.widgets[self.label] = (0, 0, 5, 1) + col = 0 + mode_col = 2 + self.mode_field = Gtk.ComboBox.new_with_model(Gtk.ListStore(str, str)) + mode_renderer = Gtk.CellRendererText() + self.mode_field.set_id_column(0) + self.mode_field.pack_start(mode_renderer, True) + self.mode_field.add_attribute(mode_renderer, "text", 1) + self.widgets[self.mode_field] = (mode_col, 2, 1, 1) + mode_label = Gtk.Label(_("type"), margin_top=20) + self.widgets[mode_label] = (mode_col, 1, 1, 1) + for mode_id, mode in TestBytesUI._modes.items(): + self.mode_field.get_model().append([mode_id, mode.label]) + for element in mode.elements: + if element.id not in self.fields: + field = Gtk.SpinButton.new_with_range(element.min, element.max, 1) + field.set_value(0) + field.set_size_request(150, 0) + field.connect("value-changed", self._on_update) + label = Gtk.Label(element.label, margin_top=20) + self.fields[element.id] = field + self.field_labels[element.id] = label + self.widgets[label] = (col, 1, 1, 1) + self.widgets[field] = (col, 2, 1, 1) + col += 1 if col != mode_col - 1 else 2 + self.mode_field.connect("changed", lambda cb: (self._on_update(), self._only_mode(cb.get_active_id()))) + self.mode_field.set_active_id("range") + + def show(self, component, editable): + super().show(component, editable) + + with self.ignore_changes(): + mode_id = {3: "mask", 4: "range"}.get(len(component.test), None) + self._only_mode(mode_id) + if not mode_id: + return + self.mode_field.set_active_id(mode_id) + if mode_id: + mode = TestBytesUI._modes[mode_id] + for i, element in enumerate(mode.elements): + self.fields[element.id].set_value(component.test[i]) + + def collect_value(self): + mode_id = self.mode_field.get_active_id() + return [self.fields[element.id].get_value_as_int() for element in TestBytesUI._modes[mode_id].elements] + + def _only_mode(self, mode_id): + if not mode_id: + return + keep = {element.id for element in TestBytesUI._modes[mode_id].elements} + for element_id, f in self.fields.items(): + visible = element_id in keep + f.set_visible(visible) + self.field_labels[element_id].set_visible(visible) + + def _on_update(self, *args): + super()._on_update(*args) + if not self.component: + return + begin, end, *etc = self.component.test + icon = "dialog-warning" if end <= begin else "" + self.fields["end"].set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + if len(self.component.test) == 4: + *etc, minimum, maximum = self.component.test + icon = "dialog-warning" if maximum < minimum else "" + self.fields["maximum"].set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + + @classmethod + def left_label(cls, component): + return _("Test bytes") + + @classmethod + def right_label(cls, component): + mode_id = {3: "mask", 4: "range"}.get(len(component.test), None) + if not mode_id: + return str(component.test) + return TestBytesUI._modes[mode_id].label_fn(component.test) + + +class MouseGestureUI(ConditionUI): + CLASS = _DIV.MouseGesture + MOUSE_GESTURE_NAMES = [ + "Mouse Up", + "Mouse Down", + "Mouse Left", + "Mouse Right", + "Mouse Up-left", + "Mouse Up-right", + "Mouse Down-left", + "Mouse Down-right", + ] + MOVE_NAMES = list(map(str, _CONTROL)) + MOUSE_GESTURE_NAMES + + def create_widgets(self): + self.widgets = {} + self.fields = [] + self.label = Gtk.Label( + _("Mouse gesture with optional initiating button followed by zero or more mouse movements."), + halign=Gtk.Align.CENTER, + ) + self.widgets[self.label] = (0, 0, 5, 1) + self.del_btns = [] + self.add_btn = Gtk.Button(_("Add movement"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) + self.add_btn.connect("clicked", self._clicked_add) + self.widgets[self.add_btn] = (1, 1, 1, 1) + + def _create_field(self): + field = Gtk.ComboBoxText.new_with_entry() + for g in self.MOUSE_GESTURE_NAMES: + field.append(g, g) + CompletionEntry.add_completion_to_entry(field.get_child(), self.MOVE_NAMES) + field.connect("changed", self._on_update) + self.fields.append(field) + self.widgets[field] = (len(self.fields) - 1, 1, 1, 1) + return field + + def _create_del_btn(self): + btn = Gtk.Button(_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) + self.del_btns.append(btn) + self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1) + btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1) + return btn + + def _clicked_add(self, _btn): + self.component.__init__(self.collect_value() + [""], warn=False) + self.show(self.component, editable=True) + self.fields[len(self.component.movements) - 1].grab_focus() + + def _clicked_del(self, _btn, pos): + v = self.collect_value() + v.pop(pos) + self.component.__init__(v, warn=False) + self.show(self.component, editable=True) + self._on_update_callback() + + def _on_update(self, *args): + super()._on_update(*args) + for i, f in enumerate(self.fields): + if f.get_visible(): + icon = ( + "dialog-warning" + if i < len(self.component.movements) and self.component.movements[i] not in self.MOVE_NAMES + else "" + ) + f.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + + def show(self, component, editable): + n = len(component.movements) + while len(self.fields) < n: + self._create_field() + self._create_del_btn() + self.widgets[self.add_btn] = (n + 1, 1, 1, 1) + super().show(component, editable) + for i in range(n): + field = self.fields[i] + with self.ignore_changes(): + field.get_child().set_text(component.movements[i]) + field.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0) + field.show_all() + self.del_btns[i].show() + for i in range(n, len(self.fields)): + self.fields[i].hide() + self.del_btns[i].hide() + self.add_btn.set_valign(Gtk.Align.END if n >= 1 else Gtk.Align.CENTER) + + def collect_value(self): + return [f.get_active_text().strip() for f in self.fields if f.get_visible()] + + @classmethod + def left_label(cls, component): + return _("Mouse Gesture") + + @classmethod + def right_label(cls, component): + if len(component.movements) == 0: + return "No-op" + else: + return " -> ".join(component.movements)