From 1379da70a8e13ff82e929a1064ecba3621c0c5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius?= Date: Sat, 14 Nov 2020 00:34:01 -0300 Subject: [PATCH] ui: add GUI for diversion rules (draft) --- lib/logitech_receiver/diversion.py | 61 +- lib/solaar/ui/diversion_rules.py | 1309 ++++++++++++++++++++++++++++ lib/solaar/ui/window.py | 3 + 3 files changed, 1368 insertions(+), 5 deletions(-) create mode 100644 lib/solaar/ui/diversion_rules.py diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index ea7c8ed8..b0636bc0 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -60,7 +60,7 @@ def active_program(): window = disp_prog.create_resource_object('window', window_id) window_pid = window.get_full_property(NET_WM_PID, 0).value[0] return psutil.Process(window_pid).name() - except Xlib.error.XError: # simplify dealing with BadWindow + except (Xlib.error.XError, AttributeError): # simplify dealing with BadWindow return None @@ -187,6 +187,9 @@ class Rule(RuleComponent): return result return result + def data(self): + return {'Rule': [c.data() for c in self.components]} + class Condition(RuleComponent): def __init__(self, *args): @@ -201,6 +204,8 @@ class Condition(RuleComponent): class Not(Condition): def __init__(self, op): + if isinstance(op, list) and len(op) == 1: + op = op[0] self.op = op self.component = self.compile(op) @@ -211,6 +216,9 @@ class Not(Condition): result = self.component.evaluate(feature, notification, device, status, last_result) return None if result is None else not result + def data(self): + return {'Not': self.component.data()} + class Or(Condition): def __init__(self, args): @@ -229,6 +237,9 @@ class Or(Condition): return result return result + def data(self): + return {'Or': [c.data() for c in self.components]} + class And(Condition): def __init__(self, args): @@ -247,12 +258,16 @@ class And(Condition): return result return result + def data(self): + return {'And': [c.data() for c in self.components]} + class Process(Condition): def __init__(self, process): self.process = process if not isinstance(process, str): _log.warn('rule Process argument not a string: %s', process) + self.process = str(process) def __str__(self): return 'Process: ' + str(self.process) @@ -260,6 +275,9 @@ class Process(Condition): def evaluate(self, feature, notification, device, status, last_result): return active_process_name.startswith(self.process) if isinstance(self.process, str) else False + def data(self): + return {'Process': str(self.process)} + class Feature(Condition): def __init__(self, feature): @@ -274,6 +292,9 @@ class Feature(Condition): def evaluate(self, feature, notification, device, status, last_result): return feature == self.feature + def data(self): + return {'Feature': str(self.feature)} + class Report(Condition): def __init__(self, report): @@ -288,6 +309,9 @@ class Report(Condition): def evaluate(self, report, notification, device, status, last_result): return (notification.address >> 4) == self.report + def data(self): + return {'Report': self.report} + MODIFIERS = {'Shift': 0x01, 'Control': 0x04, 'Alt': 0x08, 'Super': 0x40} MODIFIER_MASK = MODIFIERS['Shift'] + MODIFIERS['Control'] + MODIFIERS['Alt'] + MODIFIERS['Super'] @@ -297,9 +321,11 @@ class Modifiers(Condition): def __init__(self, modifiers): modifiers = [modifiers] if isinstance(modifiers, str) else modifiers self.desired = 0 + self.modifiers = [] for k in modifiers: if k in MODIFIERS: self.desired += MODIFIERS.get(k, 0) + self.modifiers.append(k) else: _log.warn('unknown rule Modifier value: %s', k) @@ -309,6 +335,9 @@ class Modifiers(Condition): def evaluate(self, feature, notification, device, status, last_result): return self.desired == (current_key_modifiers & MODIFIER_MASK) + def data(self): + return {'Modifiers': [str(m) for m in self.modifiers]} + class Key(Condition): def __init__(self, key): @@ -324,6 +353,9 @@ class Key(Condition): def evaluate(self, feature, notification, device, status, last_result): return self.key and self.key == key_down + def data(self): + return {'Key': str(self.key)} + def bit_test(start, end, bits): return lambda f, r, d: int.from_bytes(d[start:end], byteorder='big', signed=True) & bits @@ -360,6 +392,9 @@ class Test(Condition): def evaluate(self, feature, notification, device, status, last_result): return self.function(feature, notification.address, notification.data) + def data(self): + return {'Test': str(self.test)} + class Action(RuleComponent): def __init__(self, *args): @@ -410,6 +445,9 @@ class KeyPress(Action): self.keyUp(reversed(self.keys), current) return None + def data(self): + return {'KeyPress': [str(k) for k in self.key_symbols]} + # KeyDown is dangerous as the key can auto-repeat and make your system unusable # class KeyDown(KeyPress): @@ -427,6 +465,7 @@ class MouseScroll(Action): amounts = amounts[0] if not (len(amounts) == 2 and all([isinstance(a, numbers.Number) for a in amounts])): _log.warn('rule MouseScroll argument not two numbers %s', amounts) + amounts = [0, 0] self.amounts = amounts def __str__(self): @@ -443,6 +482,9 @@ class MouseScroll(Action): mouse.scroll(*amounts) return None + def data(self): + return {'MouseScroll': self.amounts[:]} + class MouseClick(Action): def __init__(self, args): @@ -470,6 +512,9 @@ class MouseClick(Action): mouse.click(getattr(_mouse.Button, self.button), self.count) return None + def data(self): + return {'MouseClick': [self.button, self.count]} + class Execute(Action): def __init__(self, args): @@ -477,7 +522,7 @@ class Execute(Action): args = [args] if not (isinstance(args, list) and all(isinstance(arg), str) for arg in args): _log.warn('rule Execute argument not list of strings: %s', args) - self.args = None + self.args = [] else: self.args = args @@ -491,6 +536,9 @@ class Execute(Action): subprocess.Popen(self.args) return None + def data(self): + return {'Execute': self.args[:]} + COMPONENTS = { 'Rule': Rule, @@ -510,7 +558,7 @@ COMPONENTS = { } -rules = Rule([ +built_in_rules = Rule([ ## Some malformed Rules for testing ## Rule([Process(0), Feature(0), Modifiers(['XX', 0]), Modifiers('XXX'), Modifiers([0]), ## KeyPress(['XXXXX', 0]), KeyPress(['XXXXXX']), KeyPress(0), @@ -567,9 +615,12 @@ def process_notification(device, status, notification, feature): _XDG_CONFIG_HOME = _os.environ.get('XDG_CONFIG_HOME') or _path.expanduser(_path.join('~', '.config')) _file_path = _path.join(_XDG_CONFIG_HOME, 'solaar', 'rules.yaml') +rules = built_in_rules + def _load_config_rule_file(): global rules + loaded_rules = [] if _path.isfile(_file_path): try: with open(_file_path, 'r') as config_file: @@ -581,10 +632,10 @@ def _load_config_rule_file(): loaded_rules.append(rule) if _log.isEnabledFor(_INFO): _log.info('loaded %d rules from %s', len(loaded_rules), config_file.name) - loaded_rules.extend(rules.components) - rules = Rule(loaded_rules) except Exception as e: _log.error('failed to load from %s\n%s', _file_path, e) + loaded_rules.append(built_in_rules) + rules = Rule(loaded_rules) _load_config_rule_file() diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py new file mode 100644 index 00000000..ad4c014f --- /dev/null +++ b/lib/solaar/ui/diversion_rules.py @@ -0,0 +1,1309 @@ +# -*- python-mode -*- +# -*- coding: UTF-8 -*- + +## Copyright (C) 2020 Solaar +## +## 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 __future__ import absolute_import, division, print_function, unicode_literals + +from collections import defaultdict +from contextlib import contextmanager as contextlib_contextmanager +from logging import INFO as _INFO +from logging import getLogger +from shlex import quote as shlex_quote + +import Xlib.XK + +from gi.repository import Gdk, GObject, Gtk +from logitech_receiver import diversion as _DIV +from logitech_receiver.special_keys import CONTROL as _CONTROL +from pynput import mouse as _mouse +from solaar.i18n import _ +from yaml import dump as _yaml_dump + +_log = getLogger(__name__) +del getLogger + +# +# +# + +_diversion_dialog = None +_rule_component_clipboard = None + + +class RuleComponentWrapper(GObject.GObject): + def __init__(self, component, level=0, editable=False): + self.component = component + self.level = level + self.editable = editable + GObject.GObject.__init__(self) + + def display_left(self): + if isinstance(self.component, _DIV.Rule): + if self.level == -1: + return _('Diverson rules') + if self.level == 0: + return _('Built-in rules') if not self.editable else _('User-defined rules') + if self.level == 1: + return ' ' + _('Rule') + return ' ' + _('Sub-rule') + if self.component is None: + return _('[empty]') + return ' ' + self.__component_ui().left_label(self.component) + + def display_right(self): + if self.component is None: + return '' + return self.__component_ui().right_label(self.component) + + def display_icon(self): + if self.component is None: + return '' + if isinstance(self.component, _DIV.Rule) and self.level == 0: + return 'emblem-system' if not self.editable else 'avatar-default' + return self.__component_ui().icon_name() + + def __component_ui(self): + return COMPONENT_UI.get(type(self.component), UnsupportedRuleComponentUI) + + +class DiversionDialog: + def __init__(self): + + window = Gtk.Window() + window.set_title(_('Diversion settings')) + window.connect('delete-event', self._closing) + vbox = Gtk.VBox() + + self.top_panel, self.view = self._create_top_panel() + for col in self._create_view_columns(): + self.view.append_column(col) + vbox.pack_start(self.top_panel, True, True, 0) + + self.dirty = False # if dirty, there are pending changes to be saved + + self.type_ui = {} + self.update_ui = {} + self.bottom_panel = self._create_bottom_panel() + self.ui = defaultdict(lambda: UnsupportedRuleComponentUI(self.bottom_panel)) + self.ui.update({ # one instance per type + rc_class: rc_ui_class(self.bottom_panel, on_update=self.on_update) + for rc_class, rc_ui_class in COMPONENT_UI.items() + }) + bottom_box = Gtk.ScrolledWindow() + bottom_box.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) + bottom_box.add(self.bottom_panel) + vbox.pack_start(bottom_box, True, True, 0) + + self.model = self._create_model() + self.view.set_model(self.model) + self.view.expand_all() + + window.add(vbox) + + geometry = Gdk.Geometry() + geometry.min_width = 800 + geometry.min_height = 800 + window.set_geometry_hints(None, geometry, Gdk.WindowHints.MIN_SIZE) + window.set_position(Gtk.WindowPosition.CENTER) + + window.show_all() + + window.connect('delete-event', lambda w, e: w.hide_on_delete() or True) + + style = window.get_style_context() + style.add_class('solaar') + self.window = window + self._editing_component = None + + def _closing(self, w, e): + if self.dirty: + dialog = Gtk.MessageDialog( + self.window, + type=Gtk.MessageType.QUESTION, + title=_('Make changes permanent?'), + flags=Gtk.DialogFlags.MODAL, + ) + dialog.set_default_size(400, 100) + dialog.add_buttons( + Gtk.STOCK_YES, + Gtk.ResponseType.YES, + Gtk.STOCK_NO, + Gtk.ResponseType.NO, + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + ) + dialog.set_markup(_('If you choose No, changes will be lost when Solaar is closed.')) + dialog.show_all() + response = dialog.run() + dialog.destroy() + if response == Gtk.ResponseType.NO: + w.hide() + elif response == Gtk.ResponseType.YES: + self._save_yaml_file(_DIV._file_path) + w.hide() + else: + # don't close + return True + else: + w.hide() + + def _reload_yaml_file(self): + self.discard_btn.set_sensitive(False) + self.save_btn.set_sensitive(False) + for c in self.bottom_panel.get_children(): + self.bottom_panel.remove(c) + _DIV._load_config_rule_file() + self.model = self._create_model() + self.view.set_model(self.model) + self.view.expand_all() + + def _save_yaml_file(self, file_name): + rules = [r.data()['Rule'] for r in _DIV.rules.components if r.source == file_name] + if len(rules) == 1: + rules = rules[0] + if rules: + if _log.isEnabledFor(_INFO): + _log.info('saving %d rule(s) to %s', len(rules), file_name) + try: + with open(file_name, 'w') as f: + _yaml_dump(rules, f, default_flow_style=False) + self.dirty = False + self.save_btn.set_sensitive(False) + self.discard_btn.set_sensitive(False) + except Exception as e: + _log.error('failed to save to %s\n%s', file_name, e) + + def _create_top_panel(self): + sw = Gtk.ScrolledWindow() + sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.ALWAYS) + view = Gtk.TreeView() + view.set_headers_visible(False) + view.set_enable_tree_lines(True) + view.set_reorderable(False) + + view.connect('key-press-event', self._event_key_pressed) + view.connect('button-release-event', self._event_button_released) + view.get_selection().connect('changed', self._selection_changed) + sw.add(view) + sw.set_size_request(0, 600) + + vbox = Gtk.VBox(spacing=20) + self.save_btn = Gtk.Button(_('Save changes'), halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, sensitive=False) + self.discard_btn = Gtk.Button(_('Discard changes'), halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, sensitive=False) + self.save_btn.connect('clicked', lambda *_args: self._save_yaml_file(_DIV._file_path)) + self.discard_btn.connect('clicked', lambda *_args: self._reload_yaml_file()) + vbox.pack_start(self.save_btn, False, False, 0) + vbox.pack_start(self.discard_btn, False, False, 0) + vbox.set_halign(Gtk.Align.CENTER) + vbox.set_valign(Gtk.Align.CENTER) + vbox.set_size_request(200, 0) + + hbox = Gtk.HBox() + hbox.pack_start(sw, True, True, 0) + hbox.pack_start(vbox, False, False, 0) + + return hbox, view + + def _create_model(self): + model = Gtk.TreeStore(RuleComponentWrapper) + if len(_DIV.rules.components) == 1: + # only built-in rules - add empty user rule list + _DIV.rules.components.insert(0, _DIV.Rule([], source=_DIV._file_path)) + self._populate_model(model, None, _DIV.rules.components) + return model + + def _create_view_columns(self): + cell_icon = Gtk.CellRendererPixbuf() + cell1 = Gtk.CellRendererText() + col1 = Gtk.TreeViewColumn('Type') + col1.pack_start(cell_icon, False) + col1.pack_start(cell1, True) + col1.set_cell_data_func(cell1, lambda _c, c, m, it, _d: c.set_property('text', m.get_value(it, 0).display_left())) + cell2 = Gtk.CellRendererText() + col2 = Gtk.TreeViewColumn('Summary') + col2.pack_start(cell2, True) + col2.set_cell_data_func(cell2, lambda _c, c, m, it, _d: c.set_property('text', m.get_value(it, 0).display_right())) + col2.set_cell_data_func( + cell_icon, lambda _c, c, m, it, _d: c.set_property('icon-name', + m.get_value(it, 0).display_icon()) + ) + return col1, col2 + + def _populate_model(self, model, it, rule_component, level=0, pos=-1, editable=None): + if isinstance(rule_component, list): + for c in rule_component: + self._populate_model(model, it, c, level=level, pos=pos, editable=editable) + if pos >= 0: + pos += 1 + return + if editable is None: + editable = model[it][0].editable if it is not None else False + if isinstance(rule_component, _DIV.Rule): + editable = editable or (rule_component.source is not None) + wrapped = RuleComponentWrapper(rule_component, level, editable=editable) + piter = model.insert(it, pos, (wrapped, )) + if isinstance(rule_component, (_DIV.Rule, _DIV.And, _DIV.Or)): + for c in rule_component.components: + ed = editable or (isinstance(c, _DIV.Rule) and c.source is not None) + self._populate_model(model, piter, c, level + 1, editable=ed) + if len(rule_component.components) == 0: + self._populate_model(model, piter, None, level + 1, editable=editable) + elif isinstance(rule_component, _DIV.Not): + self._populate_model(model, piter, rule_component.component, level + 1, editable=editable) + + def _create_bottom_panel(self): + grid = Gtk.Grid() + grid.set_row_spacing(10) + grid.set_column_spacing(10) + grid.set_halign(Gtk.Align.CENTER) + grid.set_valign(Gtk.Align.CENTER) + grid.set_size_request(0, 200) + return grid + + def on_update(self): + self.view.queue_draw() + self.dirty = True + self.save_btn.set_sensitive(True) + self.discard_btn.set_sensitive(True) + + def _selection_changed(self, selection): + self.bottom_panel.set_sensitive(False) + (model, it) = selection.get_selected() + if it is None: + return + wrapped = model[it][0] + component = wrapped.component + self._editing_component = component + self.ui[type(component)].show(component) + for c in self.bottom_panel.get_children(): + c.set_sensitive(wrapped.editable) + self.bottom_panel.set_sensitive(wrapped.editable) + + def _event_key_pressed(self, v, e): + ''' + Shortcuts: + Ctrl + I insert component + Ctrl + Delete delete row + & wrap with And + | wrap with Or + Shift + R wrap with Rule + ! negate + Ctrl + X cut + Ctrl + C copy + Ctrl + V paste below (or here if empty) + Ctrl + Shift + V paste above + * flatten + ''' + state = e.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK) + m, it = v.get_selection().get_selected() + wrapped = m[it][0] + c = wrapped.component + parent_it = m.iter_parent(it) + parent_c = m[parent_it][0].component if wrapped.level > 0 else None + can_wrap = wrapped.editable and wrapped.component is not None and wrapped.level >= 2 + can_delete = wrapped.editable and not isinstance(parent_c, _DIV.Not) and wrapped.component is not None + can_insert = wrapped.editable and not isinstance(parent_c, _DIV.Not) and wrapped.level >= 2 + can_insert_only_rule = wrapped.editable and wrapped.level == 1 + can_flatten = wrapped.editable and not isinstance(parent_c, _DIV.Not) and isinstance( + c, (_DIV.Rule, _DIV.And, _DIV.Or) + ) and wrapped.level >= 2 and len(c.components) + can_copy = wrapped.level >= 1 + can_insert_root = wrapped.editable and wrapped.level == 0 + if state & Gdk.ModifierType.CONTROL_MASK: + if can_delete and e.keyval in [Gdk.KEY_x, Gdk.KEY_X]: + self._menu_do_cut(None, m, it) + elif can_copy and e.keyval in [Gdk.KEY_c, Gdk.KEY_C] and _rule_component_clipboard is not None: + self._menu_do_copy(None, m, it) + elif can_insert and _rule_component_clipboard is not None and e.keyval in [Gdk.KEY_v, Gdk.KEY_V]: + self._menu_do_paste(None, m, it, below=c is not None and not (state & Gdk.ModifierType.SHIFT_MASK)) + elif can_insert_only_rule and isinstance(_rule_component_clipboard, + _DIV.Rule) and e.keyval in [Gdk.KEY_v, Gdk.KEY_V]: + self._menu_do_paste(None, m, it, below=c is not None and not (state & Gdk.ModifierType.SHIFT_MASK)) + elif can_insert_root and isinstance(_rule_component_clipboard, _DIV.Rule) and e.keyval in [Gdk.KEY_v, Gdk.KEY_V]: + self._menu_do_paste(None, m, m.iter_nth_child(it, 0)) + elif can_delete and e.keyval in [Gdk.KEY_KP_Delete, Gdk.KEY_Delete]: + self._menu_do_delete(None, m, it) + elif (can_insert or can_insert_only_rule or can_insert_root) and e.keyval in [Gdk.KEY_i, Gdk.KEY_I]: + menu = Gtk.Menu() + for item in self.__get_insert_menus(m, it, c, can_insert, can_insert_only_rule, can_insert_root): + menu.append(item) + menu.show_all() + rect = self.view.get_cell_area(m.get_path(it), self.view.get_column(1)) + menu.popup_at_rect(self.window.get_window(), rect, Gdk.Gravity.WEST, Gdk.Gravity.CENTER, e) + elif state & Gdk.ModifierType.CONTROL_MASK == 0: + if can_wrap: + if e.keyval == Gdk.KEY_exclam: + self._menu_do_negate(None, m, it) + elif e.keyval == Gdk.KEY_ampersand: + self._menu_do_wrap(None, m, it, _DIV.And) + elif e.keyval == Gdk.KEY_bar: + self._menu_do_wrap(None, m, it, _DIV.Or) + elif e.keyval in [Gdk.KEY_r, Gdk.KEY_R] and (state & Gdk.ModifierType.SHIFT_MASK): + self._menu_do_wrap(None, m, it, _DIV.Rule) + if can_flatten and e.keyval in [Gdk.KEY_asterisk, Gdk.KEY_KP_Multiply]: + self._menu_do_flatten(None, m, it) + + def __get_insert_menus(self, m, it, c, can_insert, can_insert_only_rule, can_insert_root): + items = [] + if can_insert: + ins = self._menu_insert(m, it) + items.append(ins) + if c is None: # just a placeholder + ins.set_label(_('Insert here')) + else: + ins.set_label(_('Insert above')) + ins2 = self._menu_insert(m, it, below=True) + ins2.set_label(_('Insert below')) + items.append(ins2) + elif can_insert_only_rule: + ins = self._menu_create_rule(m, it) + items.append(ins) + if c is None: + ins.set_label(_('Insert new rule here')) + else: + ins.set_label(_('Insert new rule above')) + ins2 = self._menu_create_rule(m, it, below=True) + ins2.set_label(_('Insert new rule below')) + items.append(ins2) + elif can_insert_root: + ins = self._menu_create_rule(m, m.iter_nth_child(it, 0)) + items.append(ins) + return items + + def _event_button_released(self, v, e): + if e.button == 3: # right click + m, it = v.get_selection().get_selected() + wrapped = m[it][0] + c = wrapped.component + parent_it = m.iter_parent(it) + parent_c = m[parent_it][0].component if wrapped.level > 0 else None + menu = Gtk.Menu() + can_wrap = wrapped.editable and wrapped.component is not None and wrapped.level >= 2 + can_delete = wrapped.editable and not isinstance(parent_c, _DIV.Not) and wrapped.component is not None + can_insert = wrapped.editable and not isinstance(parent_c, _DIV.Not) and wrapped.level >= 2 + can_insert_only_rule = wrapped.editable and wrapped.level == 1 + can_flatten = wrapped.editable and not isinstance(parent_c, _DIV.Not) and isinstance( + c, (_DIV.Rule, _DIV.And, _DIV.Or) + ) and wrapped.level >= 2 and len(c.components) + can_copy = wrapped.level >= 1 + can_insert_root = wrapped.editable and wrapped.level == 0 + for item in self.__get_insert_menus(m, it, c, can_insert, can_insert_only_rule, can_insert_root): + menu.append(item) + if can_flatten: + menu.append(self._menu_flatten(m, it)) + if can_wrap: + menu.append(self._menu_wrap(m, it)) + menu.append(self._menu_negate(m, it)) + if menu.get_children(): + menu.append(Gtk.SeparatorMenuItem(visible=True)) + if can_delete: + menu.append(self._menu_cut(m, it)) + if can_copy and _rule_component_clipboard is not None: + menu.append(self._menu_copy(m, it)) + if can_insert and _rule_component_clipboard is not None: + p = self._menu_paste(m, it) + menu.append(p) + if c is None: # just a placeholder + p.set_label(_('Paste here')) + else: + p.set_label(_('Paste above')) + p2 = self._menu_paste(m, it, below=True) + p2.set_label(_('Paste below')) + menu.append(p2) + elif can_insert_only_rule and isinstance(_rule_component_clipboard, _DIV.Rule): + p = self._menu_paste(m, it) + menu.append(p) + if c is None: + p.set_label(_('Paste rule here')) + else: + p.set_label(_('Paste rule above')) + p2 = self._menu_paste(m, it, below=True) + p2.set_label(_('Paste rule below')) + menu.append(p2) + elif can_insert_root: + p = self._menu_paste(m, m.iter_nth_child(it, 0)) + p.set_label(_('Paste rule')) + menu.append(p) + if menu.get_children() and can_delete: + menu.append(Gtk.SeparatorMenuItem(visible=True)) + if can_delete: + menu.append(self._menu_delete(m, it)) + if menu.get_children(): + menu.popup_at_pointer(e) + + def _menu_do_flatten(self, _mitem, m, it): + wrapped = m[it][0] + c = wrapped.component + parent_it = m.iter_parent(it) + parent_c = m[parent_it][0].component + idx = parent_c.components.index(c) + if isinstance(c, _DIV.Not): + parent_c.components = [*parent_c.components[:idx], c.component, *parent_c.components[idx + 1:]] + children = [next(m[it].iterchildren())[0].component] + else: + parent_c.components = [*parent_c.components[:idx], *c.components, *parent_c.components[idx + 1:]] + children = [child[0].component for child in m[it].iterchildren()] + m.remove(it) + self._populate_model(m, parent_it, children, level=wrapped.level, pos=idx) + new_iter = m.iter_nth_child(parent_it, idx) + self.view.expand_row(m.get_path(parent_it), True) + self.view.get_selection().select_iter(new_iter) + self.on_update() + + def _menu_flatten(self, m, it): + menu_flatten = Gtk.MenuItem(_('Flatten')) + menu_flatten.connect('activate', self._menu_do_flatten, m, it) + menu_flatten.show() + return menu_flatten + + def _menu_do_insert(self, _mitem, m, it, new_c, below=False): + wrapped = m[it][0] + c = wrapped.component + parent_it = m.iter_parent(it) + parent_c = m[parent_it][0].component + if len(parent_c.components) == 0: # we had only a placeholder + idx = 0 + else: + idx = parent_c.components.index(c) + if isinstance(new_c, _DIV.Rule) and wrapped.level == 1: + new_c.source = _DIV._file_path # new rules will be saved to the YAML file + idx += int(below) + parent_c.components.insert(idx, new_c) + self._populate_model(m, parent_it, new_c, level=wrapped.level, pos=idx) + self.on_update() + if len(parent_c.components) == 1: + m.remove(it) # remove placeholder in the end + new_iter = m.iter_nth_child(parent_it, idx) + self.view.get_selection().select_iter(new_iter) + if isinstance(new_c, (_DIV.Rule, _DIV.And, _DIV.Or, _DIV.Not)): + self.view.expand_row(m.get_path(new_iter), True) + + def _menu_do_insert_new(self, _mitem, m, it, cls, initial_value, below=False): + new_c = cls(initial_value) + return self._menu_do_insert(_mitem, m, it, new_c, below=below) + + def _menu_insert(self, m, it, below=False): + elements = [ + _('Insert'), + [ + (_('Sub-rule'), _DIV.Rule, []), + (_('Or'), _DIV.Or, []), + (_('And'), _DIV.And, []), + [ + _('Condition'), + [ + (_('Feature'), _DIV.Feature, FeatureUI.FEATURES_WITH_DIVERSION[0]), + (_('Process'), _DIV.Process, ''), + (_('Report'), _DIV.Report, 0), + (_('Modifiers'), _DIV.Modifiers, []), + (_('Key'), _DIV.Key, ''), + (_('Test'), _DIV.Test, next(iter(_DIV.TESTS))), + ] + ], + [ + _('Action'), + [ + (_('Key press'), _DIV.KeyPress, 'space'), + (_('Mouse scroll'), _DIV.MouseScroll, [0, 0]), + (_('Mouse click'), _DIV.MouseClick, ['left', 1]), + (_('Execute'), _DIV.Execute, ['']), + ] + ], + ] + ] + + def build(spec): + if isinstance(spec, list): # has sub-menu + label, children = spec + item = Gtk.MenuItem(label) + submenu = Gtk.Menu() + item.set_submenu(submenu) + for child in children: + submenu.append(build(child)) + return item + elif isinstance(spec, tuple): # has click action + label, feature, *args = spec + item = Gtk.MenuItem(label) + args = [a.copy() if isinstance(a, list) else a for a in args] + item.connect('activate', self._menu_do_insert_new, m, it, feature, *args, below) + return item + else: + return None + + menu_insert = build(elements) + menu_insert.show_all() + return menu_insert + + def _menu_create_rule(self, m, it, below=False): + menu_create_rule = Gtk.MenuItem(_('Insert new rule')) + menu_create_rule.connect('activate', self._menu_do_insert_new, m, it, _DIV.Rule, [], below) + menu_create_rule.show() + return menu_create_rule + + def _menu_do_delete(self, _mitem, m, it): + wrapped = m[it][0] + c = wrapped.component + parent_it = m.iter_parent(it) + parent_c = m[parent_it][0].component + idx = parent_c.components.index(c) + parent_c.components.pop(idx) + if len(parent_c.components) == 0: # placeholder + self._populate_model(m, parent_it, None, level=wrapped.level) + m.remove(it) + self.view.get_selection().select_iter(m.iter_nth_child(parent_it, max(0, min(idx, len(parent_c.components) - 1)))) + self.on_update() + return c + + def _menu_delete(self, m, it): + menu_delete = Gtk.MenuItem(_('Delete')) + menu_delete.connect('activate', self._menu_do_delete, m, it) + menu_delete.show() + return menu_delete + + def _menu_do_negate(self, _mitem, m, it): + wrapped = m[it][0] + c = wrapped.component + parent_it = m.iter_parent(it) + parent_c = m[parent_it][0].component + if isinstance(c, _DIV.Not): # avoid double negation + self._menu_do_flatten(_mitem, m, it) + self.view.expand_row(m.get_path(parent_it), True) + elif isinstance(parent_c, _DIV.Not): # avoid double negation + self._menu_do_flatten(_mitem, m, parent_it) + else: + idx = parent_c.components.index(c) + self._menu_do_insert_new(_mitem, m, it, _DIV.Not, c, below=True) + self._menu_do_delete(_mitem, m, m.iter_nth_child(parent_it, idx)) + self.on_update() + + def _menu_negate(self, m, it): + menu_negate = Gtk.MenuItem(_('Negate')) + menu_negate.connect('activate', self._menu_do_negate, m, it) + menu_negate.show() + return menu_negate + + def _menu_do_wrap(self, _mitem, m, it, cls): + wrapped = m[it][0] + c = wrapped.component + parent_it = m.iter_parent(it) + parent_c = m[parent_it][0].component + if isinstance(parent_c, _DIV.Not): + new_c = cls([c]) + parent_c.component = new_c + m.remove(it) + self._populate_model(m, parent_it, new_c, level=wrapped.level, pos=0) + self.view.expand_row(m.get_path(parent_it), True) + self.view.get_selection().select_iter(m.iter_nth_child(parent_it, 0)) + else: + idx = parent_c.components.index(c) + self._menu_do_insert_new(_mitem, m, it, cls, [c], below=True) + self._menu_do_delete(_mitem, m, m.iter_nth_child(parent_it, idx)) + self.on_update() + + def _menu_wrap(self, m, it): + menu_wrap = Gtk.MenuItem(_('Wrap with')) + submenu_wrap = Gtk.Menu() + menu_sub_rule = Gtk.MenuItem(_('Sub-rule')) + menu_and = Gtk.MenuItem(_('And')) + menu_or = Gtk.MenuItem(_('Or')) + menu_sub_rule.connect('activate', self._menu_do_wrap, m, it, _DIV.Rule) + menu_and.connect('activate', self._menu_do_wrap, m, it, _DIV.And) + menu_or.connect('activate', self._menu_do_wrap, m, it, _DIV.Or) + submenu_wrap.append(menu_sub_rule) + submenu_wrap.append(menu_and) + submenu_wrap.append(menu_or) + menu_wrap.set_submenu(submenu_wrap) + menu_wrap.show_all() + return menu_wrap + + def _menu_do_cut(self, _mitem, m, it): + c = self._menu_do_delete(_mitem, m, it) + self.on_update() + global _rule_component_clipboard + _rule_component_clipboard = c + + def _menu_cut(self, m, it): + menu_cut = Gtk.MenuItem(_('Cut')) + menu_cut.connect('activate', self._menu_do_cut, m, it) + menu_cut.show() + return menu_cut + + def _menu_do_paste(self, _mitem, m, it, below=False): + global _rule_component_clipboard + c = _rule_component_clipboard + _rule_component_clipboard = None + if c: + _rule_component_clipboard = _DIV.RuleComponent().compile(c.data()) + self._menu_do_insert(_mitem, m, it, new_c=c, below=below) + self.on_update() + + def _menu_paste(self, m, it, below=False): + menu_paste = Gtk.MenuItem(_('Paste')) + menu_paste.connect('activate', self._menu_do_paste, m, it, below) + menu_paste.show() + return menu_paste + + def _menu_do_copy(self, _mitem, m, it): + global _rule_component_clipboard + wrapped = m[it][0] + c = wrapped.component + _rule_component_clipboard = _DIV.RuleComponent().compile(c.data()) + + def _menu_copy(self, m, it): + menu_copy = Gtk.MenuItem(_('Copy')) + menu_copy.connect('activate', self._menu_do_copy, m, it) + menu_copy.show() + return menu_copy + + +## Not currently used +# +# class HexEntry(Gtk.Entry, Gtk.Editable): +# +# def do_insert_text(self, new_text, length, pos): +# new_text = new_text.upper() +# from string import hexdigits +# if any(c for c in new_text if c not in hexdigits): +# return pos +# else: +# self.get_buffer().insert_text(pos, new_text, length) +# return pos + length + + +class CompletionEntry(Gtk.Entry): + def __init__(self, values, *args, **kwargs): + super().__init__(*args, **kwargs) + self.liststore = Gtk.ListStore(str) + for v in sorted(values, key=str.casefold): + self.liststore.append((v, )) + self.completion = Gtk.EntryCompletion() + self.completion.set_model(self.liststore) + norm = lambda s: s.replace('_', '').replace(' ', '').lower() + self.completion.set_match_func(lambda completion, key, it: norm(key) in norm(completion.get_model()[it][0])) + self.completion.set_text_column(0) + self.set_completion(self.completion) + + +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 = False + 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): + self._show_widgets() + self.component = component + + def collect_value(self): + return None + + @contextlib_contextmanager + def ignore_changes(self): + self._ignore_changes = True + yield None + self._ignore_changes = False + + def _on_update(self, *_args): + if not self._ignore_changes and self.component is not None: + value = self.collect_value() + self.component.__init__(value) + self._on_update_callback() + return value + return None + + def _show_widgets(self): + self._remove_panel_items() + for widget, coord in self.widgets.items(): + self.panel.attach(widget, *coord) + 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) + + @classmethod + def _named_int_with_underscores(cls, s): + return str(s).replace('/', '__').replace(' ', '_') + + +class UnsupportedRuleComponentUI(RuleComponentUI): + + CLASS = None + + def create_widgets(self): + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, vexpand=True) + self.label.set_text(_('This editor does not support the selected rule component yet.')) + self.widgets[self.label] = (0, 0, 1, 1) + + @classmethod + def right_label(cls, component): + return str(component) + + +class RuleUI(RuleComponentUI): + + CLASS = _DIV.Rule + + def create_widgets(self): + self.widgets = {} + + def collect_value(self): + return self.component.components[:] # not editable on the bottom panel + + @classmethod + def left_label(cls, component): + return _('Rule') + + @classmethod + 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 + + def create_widgets(self): + self.widgets = {} + + def collect_value(self): + return self.component.components[:] # not editable on the bottom panel + + @classmethod + def left_label(cls, component): + return _('And') + + +class OrUI(RuleComponentUI): + + CLASS = _DIV.Or + + def create_widgets(self): + self.widgets = {} + + def collect_value(self): + return self.component.components[:] # not editable on the bottom panel + + @classmethod + def left_label(cls, component): + return _('Or') + + +class NotUI(RuleComponentUI): + + CLASS = _DIV.Not + + def create_widgets(self): + self.widgets = {} + + def collect_value(self): + return self.component.component # not editable on the bottom panel + + @classmethod + def left_label(cls, component): + return _('Not') + + +class ProcessUI(ConditionUI): + + CLASS = _DIV.Process + + def create_widgets(self): + self.widgets = {} + self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True, vexpand=True) + self.field.set_size_request(300, 0) + self.field.connect('changed', self._on_update) + self.widgets[self.field] = (0, 0, 1, 1) + + def show(self, component): + super().show(component) + 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 FeatureUI(ConditionUI): + + CLASS = _DIV.Feature + FEATURES_WITH_DIVERSION = [ + 'CROWN', + 'GESTURE 2', + 'REPROG CONTROLS V4', + 'THUMB WHEEL', + ] + + def create_widgets(self): + self.widgets = {} + self.field = CompletionEntry( + self.FEATURES_WITH_DIVERSION, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True + ) + self.field.set_size_request(200, 0) + self.field.connect('changed', self._on_update) + self.label = Gtk.Label(halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True) + self.widgets[self.field] = (0, 0, 1, 1) + self.widgets[self.label] = (0, 1, 1, 1) + + def show(self, component): + super().show(component) + with self.ignore_changes(): + self.field.set_text(str(component.feature)) + + def collect_value(self): + return self.field.get_text().strip() + + def _on_update(self, *args): + super()._on_update(*args) + self.label.set_text('%04X' % int(self.component.feature) if self.component.feature is not None else 'None') + + @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.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, 0, 1, 1) + + def show(self, component): + super().show(component) + 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.labels = {} + self.switches = {} + for i, m in enumerate(_DIV.MODIFIERS): + switch = Gtk.Switch(halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True) + label = Gtk.Label(m, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) + self.widgets[label] = (i, 0, 1, 1) + self.widgets[switch] = (i, 1, 1, 1) + self.labels[m] = label + self.switches[m] = switch + switch.connect('notify::active', self._on_update) + + def show(self, component): + super().show(component) + 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.field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) + self.label = Gtk.Label(halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True) + self.field.connect('changed', self._on_update) + self.widgets[self.label] = (0, 1, 1, 1) + self.widgets[self.field] = (0, 0, 1, 1) + + def show(self, component): + super().show(component) + with self.ignore_changes(): + self.field.set_text(str(component.key) if self.component.key else '') + + def collect_value(self): + return self.field.get_text() + + def _on_update(self, *args): + super()._on_update(*args) + self.label.set_text('%04X' % int(self.component.key)) + + @classmethod + def left_label(cls, component): + return _('Key') + + @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.field = field = CompletionEntry( + _DIV.TESTS, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True, vexpand=True + ) + self.field.connect('changed', self._on_update) + self.widgets[field] = (0, 0, 1, 1) + + def show(self, component): + super().show(component) + with self.ignore_changes(): + self.field.set_text(component.test) + + def collect_value(self): + return self.field.get_text() + + @classmethod + def left_label(cls, component): + return _('Test') + + @classmethod + def right_label(cls, component): + return str(component.test) + + +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 vars(Xlib.XK).items() if isinstance(v, int)] + + def create_widgets(self): + self.widgets = {} + self.fields = [] + self.add_btn = Gtk.Button(_('Add key'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) + self.del_btn = Gtk.Button( + _('Delete last key'), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True + ) + self.add_btn.connect('clicked', self._clicked_add) + self.del_btn.connect('clicked', self._clicked_del) + self.widgets[self.add_btn] = (1, 0, 1, 1) + self.widgets[self.del_btn] = (0, 0, 1, 1) + + def _create_field(self): + field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) + field.set_size_request(200, 0) + field.connect('changed', self._on_update) + self.fields.append(field) + self.widgets[field] = (len(self.fields) - 1, 0, 1, 1) + return field + + def _clicked_add(self, *_args): + self.component.__init__(self.collect_value() + ['']) + self.show(self.component) + self.fields[len(self.component.key_symbols) - 1].grab_focus() + + def _clicked_del(self, *_args): + self.component.__init__(self.collect_value()[:-1]) + self.show(self.component) + self._on_update_callback() + + def show(self, component): + n = len(component.key_symbols) + while len(self.fields) < n: + self._create_field() + self.widgets[self.add_btn] = (n + 1, 0, 1, 1) + self.widgets[self.del_btn] = (n + 1, 1, 1, 1) + super().show(component) + for i in range(n): + field = self.fields[i] + with self.ignore_changes(): + field.set_text(component.key_symbols[i]) + field.show_all() + for i in range(n, len(self.fields)): + self.fields[i].set_visible(False) + self.del_btn.set_visible(n >= 1) + 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 _('KeyPress') + + @classmethod + def right_label(cls, component): + return ' + '.join(component.key_symbols) + + +class MouseScrollUI(ActionUI): + + CLASS = _DIV.MouseScroll + MIN_VALUE = -2000 + MAX_VALUE = 2000 + + def create_widgets(self): + self.widgets = {} + self.label_x = Gtk.Label(label='x', halign=Gtk.Align.START, valign=Gtk.Align.END, hexpand=True, vexpand=True) + self.label_y = Gtk.Label(label='y', halign=Gtk.Align.START, valign=Gtk.Align.END, hexpand=True, vexpand=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 field in [self.field_x, self.field_y]: + field.set_halign(Gtk.Align.CENTER) + field.set_valign(Gtk.Align.START) + field.set_vexpand(True) + self.field_x.connect('changed', self._on_update) + self.field_y.connect('changed', self._on_update) + self.widgets[self.label_x] = (0, 0, 1, 1) + self.widgets[self.label_y] = (1, 0, 1, 1) + self.widgets[self.field_x] = (0, 1, 1, 1) + self.widgets[self.field_y] = (1, 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): + super().show(component) + 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 _('MouseScroll') + + @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 = [b for b in dir(_mouse.Button) if not b.startswith('__')] + + def create_widgets(self): + self.widgets = {} + self.label_b = Gtk.Label(label=_('Button'), halign=Gtk.Align.START, valign=Gtk.Align.END, hexpand=True, vexpand=True) + self.label_c = Gtk.Label(label=_('Count'), halign=Gtk.Align.START, valign=Gtk.Align.END, hexpand=True, vexpand=True) + self.field_b = CompletionEntry(self.BUTTONS) + self.field_c = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1) + for field in [self.field_b, self.field_c]: + field.set_halign(Gtk.Align.CENTER) + field.set_valign(Gtk.Align.START) + field.set_vexpand(True) + self.field_b.connect('changed', self._on_update) + self.field_c.connect('changed', self._on_update) + self.widgets[self.label_b] = (0, 0, 1, 1) + self.widgets[self.label_c] = (1, 0, 1, 1) + self.widgets[self.field_b] = (0, 1, 1, 1) + self.widgets[self.field_c] = (1, 1, 1, 1) + + def show(self, component): + super().show(component) + with self.ignore_changes(): + self.field_b.set_text(component.button) + self.field_c.set_value(component.count) + + def collect_value(self): + b, c = self.field_b.get_text(), int(self.field_c.get_value()) + if b not in self.BUTTONS: + b = 'unknown' + return [b, c] + + @classmethod + def left_label(cls, component): + return _('MouseClick') + + @classmethod + def right_label(cls, component): + return f'{component.button} (x{component.count})' + + +class ExecuteUI(ActionUI): + + CLASS = _DIV.Execute + + def create_widgets(self): + self.widgets = {} + self.fields = [] + self.add_btn = Gtk.Button(_('Add argument'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) + self.del_btn = Gtk.Button( + _('Delete last argument'), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True + ) + self.add_btn.connect('clicked', self._clicked_add) + self.del_btn.connect('clicked', self._clicked_del) + self.widgets[self.add_btn] = (1, 0, 1, 1) + self.widgets[self.del_btn] = (0, 0, 1, 1) + + def _create_field(self): + field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) + field.set_size_request(150, 0) + field.connect('changed', self._on_update) + self.fields.append(field) + self.widgets[field] = (len(self.fields) - 1, 0, 1, 1) + return field + + def _clicked_add(self, *_args): + self.component.__init__(self.collect_value() + ['']) + self.show(self.component) + self.fields[len(self.component.args) - 1].grab_focus() + + def _clicked_del(self, *_args): + self.component.__init__(self.collect_value()[:-1]) + self.show(self.component) + self._on_update_callback() + + def show(self, component): + n = len(component.args) + while len(self.fields) < n: + self._create_field() + for i in range(n): + field = self.fields[i] + with self.ignore_changes(): + field.set_text(component.args[i]) + self.widgets[self.add_btn] = (n + 1, 0, 1, 1) + self.widgets[self.del_btn] = (n + 1, 1, 1, 1) + super().show(component) + for i in range(n, len(self.fields)): + self.fields[i].set_visible(False) + self.del_btn.set_visible(n >= 1) + 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]) + + +COMPONENT_UI = { + _DIV.Rule: RuleUI, + _DIV.Not: NotUI, + _DIV.Or: OrUI, + _DIV.And: AndUI, + _DIV.Process: ProcessUI, + _DIV.Feature: FeatureUI, + _DIV.Report: ReportUI, + _DIV.Modifiers: ModifiersUI, + _DIV.Key: KeyUI, + _DIV.Test: TestUI, + _DIV.KeyPress: KeyPressUI, + _DIV.MouseScroll: MouseScrollUI, + _DIV.MouseClick: MouseClickUI, + _DIV.Execute: ExecuteUI, + type(None): RuleComponentUI, # placeholders for empty rule/And/Or +} + + +def show_window(trigger=None): + GObject.type_register(RuleComponentWrapper) + global _diversion_dialog + if _diversion_dialog is None: + _diversion_dialog = DiversionDialog() + _diversion_dialog.window.present() diff --git a/lib/solaar/ui/window.py b/lib/solaar/ui/window.py index 2fc1a57c..7cdfd760 100644 --- a/lib/solaar/ui/window.py +++ b/lib/solaar/ui/window.py @@ -37,6 +37,7 @@ from . import action as _action from . import config_panel as _config_panel from . import icons as _icons from .about import show_window as _show_about_window +from .diversion_rules import show_window as _show_diversion_window _log = getLogger(__name__) del getLogger @@ -327,6 +328,8 @@ def _create_window_layout(): _('About') + ' ' + NAME, 'help-about', icon_size=_SMALL_BUTTON_ICON_SIZE, clicked=_show_about_window ) bottom_buttons_box.add(about_button) + diversion_button = _new_button(_('Diversion rules'), '', icon_size=_SMALL_BUTTON_ICON_SIZE, clicked=_show_diversion_window) + bottom_buttons_box.add(diversion_button) # solaar_version = Gtk.Label() # solaar_version.set_markup('' + NAME + ' v' + VERSION + '')