# -*- python-mode -*- ## 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 collections import defaultdict from contextlib import contextmanager as contextlib_contextmanager from logging import getLogger from shlex import quote as shlex_quote from gi.repository import Gdk, GObject, Gtk from logitech_receiver import diversion as _DIV from logitech_receiver.common import NamedInt, UnsortedNamedInts 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 _ _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 == 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(_('Solaar Rule Editor')) 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( _('Yes'), Gtk.ResponseType.YES, _('No'), Gtk.ResponseType.NO, _('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() 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) self.dirty = 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): if _DIV._save_config_rule_file(): self.dirty = False self.save_btn.set_sensitive(False) self.discard_btn.set_sensitive(False) 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) button_box = Gtk.HBox(spacing=20) self.save_btn = Gtk.Button.new_from_icon_name('document-save', Gtk.IconSize.BUTTON) self.save_btn.set_label(_('Save changes')) self.save_btn.set_always_show_image(True) self.save_btn.set_sensitive(False) self.save_btn.set_valign(Gtk.Align.CENTER) self.discard_btn = Gtk.Button.new_from_icon_name('document-revert', Gtk.IconSize.BUTTON) self.discard_btn.set_label(_('Discard changes')) self.discard_btn.set_always_show_image(True) self.discard_btn.set_sensitive(False) self.discard_btn.set_valign(Gtk.Align.CENTER) self.save_btn.connect('clicked', lambda *_args: self._save_yaml_file()) self.discard_btn.connect('clicked', lambda *_args: self._reload_yaml_file()) button_box.pack_start(self.save_btn, False, False, 0) button_box.pack_start(self.discard_btn, False, False, 0) button_box.set_halign(Gtk.Align.CENTER) button_box.set_valign(Gtk.Align.CENTER) button_box.set_size_request(0, 50) vbox = Gtk.VBox() vbox.pack_start(button_box, False, False, 0) vbox.pack_start(sw, True, True, 0) return vbox, 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, 120) 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, 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 Ctrl + S save changes ''' 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 c is not None and wrapped.level >= 1 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 c 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 self.dirty and e.keyval in [Gdk.KEY_s, Gdk.KEY_S]: self._save_yaml_file() else: 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 c is not None and wrapped.level >= 1 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 c 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 and isinstance(_rule_component_clipboard, _DIV.Rule): 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, ''), (_('MouseProcess'), _DIV.MouseProcess, ''), (_('Report'), _DIV.Report, 0), (_('Modifiers'), _DIV.Modifiers, []), (_('Key'), _DIV.Key, ''), (_('Test'), _DIV.Test, next(iter(_DIV.TESTS))), (_('Mouse Gesture'), _DIV.MouseGesture, ''), ] ], [ _('Action'), [ (_('Key press'), _DIV.KeyPress, 'space'), (_('Mouse scroll'), _DIV.MouseScroll, [0, 0]), (_('Mouse click'), _DIV.MouseClick, ['left', 1]), (_('Execute'), _DIV.Execute, ['']), (_('Set'), _DIV.Set, [None, '', None]), ] ], ] ] 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 def update_devices(self): for rc in self.ui.values(): rc.update_devices() ## 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) CompletionEntry.add_completion_to_entry(self, values) @classmethod def add_completion_to_entry(cls, entry, values): entry.liststore = Gtk.ListStore(str) for v in sorted(values, key=str.casefold): entry.liststore.append((v, )) entry.completion = Gtk.EntryCompletion() entry.completion.set_model(entry.liststore) norm = lambda s: s.replace('_', '').replace(' ', '').lower() entry.completion.set_match_func(lambda completion, key, it: norm(key) in norm(completion.get_model()[it][0])) entry.completion.set_text_column(0) entry.set_completion(entry.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, editable): self._show_widgets(editable) 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, 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 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(600, 0) self.field.connect('changed', self._on_update) self.widgets[self.field] = (0, 0, 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.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True, vexpand=True) self.field.set_size_request(600, 0) self.field.connect('changed', self._on_update) self.widgets[self.field] = (0, 0, 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 = [ 'CROWN', 'GESTURE 2', 'REPROG CONTROLS V4', 'THUMB WHEEL', ] def create_widgets(self): self.widgets = {} 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, 0, 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.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, 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.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, 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.key_field = CompletionEntry( self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True, vexpand=True ) self.key_field.set_size_request(600, 0) self.key_field.connect('changed', self._on_update) self.widgets[self.key_field] = (0, 0, 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, 0, 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, 0, 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 TestUI(ConditionUI): CLASS = _DIV.Test def create_widgets(self): self.widgets = {} self.field = Gtk.ComboBoxText.new_with_entry() self.field.append('', '') for t in _DIV.TESTS: self.field.append(t, t) self.field.set_valign(Gtk.Align.CENTER) self.field.set_vexpand(True) self.field.set_size_request(600, 0) CompletionEntry.add_completion_to_entry(self.field.get_child(), _DIV.TESTS) self.field.connect('changed', self._on_update) self.widgets[self.field] = (0, 0, 1, 1) def show(self, component, editable): super().show(component, editable) with self.ignore_changes(): self.field.set_active_id(component.test or '') if component.test not in _DIV.TESTS: self.field.get_child().set_text(component.test) self._change_status_icon() def collect_value(self): return (self.field.get_active_text() or '').strip() def _on_update(self, *args): super()._on_update(*args) self._change_status_icon() def _change_status_icon(self): icon = 'dialog-warning' if self.component.test not in _DIV.TESTS else '' self.field.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 str(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.del_btns = [] self.add_btn = Gtk.Button(_('Add action'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) self.add_btn.connect('clicked', self._clicked_add) self.widgets[self.add_btn] = (1, 0, 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, 0, 1, 1) return field def _create_del_btn(self): btn = Gtk.Button(_('Delete'), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True) self.del_btns.append(btn) self.widgets[btn] = (len(self.del_btns) - 1, 1, 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() + ['']) self.show(self.component) 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) self.show(self.component) 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, 0, 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 @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.del_btns = [] self.add_btn = Gtk.Button(_('Add key'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) self.add_btn.connect('clicked', self._clicked_add) self.widgets[self.add_btn] = (1, 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.connect('changed', self._on_update) self.fields.append(field) self.widgets[field] = (len(self.fields) - 1, 0, 1, 1) return field def _create_del_btn(self): btn = Gtk.Button(_('Delete'), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True) self.del_btns.append(btn) self.widgets[btn] = (len(self.del_btns) - 1, 1, 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() + ['']) self.show(self.component) self.fields[len(self.component.key_symbols) - 1].grab_focus() def _clicked_del(self, _btn, pos): v = self.collect_value() v.pop(pos) self.component.__init__(v) self.show(self.component) 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_symbols ) and self.component.key_symbols[i] not in self.KEY_NAMES else '' f.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) def show(self, component, editable): n = len(component.key_symbols) while len(self.fields) < n: self._create_field() self._create_del_btn() self.widgets[self.add_btn] = (n + 1, 0, 1, 1) super().show(component, editable) for i in range(n): field = self.fields[i] with self.ignore_changes(): field.set_text(component.key_symbols[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_text().strip() for f in self.fields if f.get_visible()] @classmethod def left_label(cls, component): return _('Key press') @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, 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()) 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, editable): super().show(component, editable) 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 _('Mouse click') @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_btns = [] self.add_btn.connect('clicked', self._clicked_add) self.widgets[self.add_btn] = (1, 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 _create_del_btn(self): btn = Gtk.Button(_('Delete'), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True) btn.set_size_request(150, 0) self.del_btns.append(btn) self.widgets[btn] = (len(self.del_btns) - 1, 1, 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() + ['']) self.show(self.component) 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) self.show(self.component) 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, 0, 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 str) or name.""" if isinstance(v, str) and v.isdigit(): v = int(v) if all_values and (v in all_values): return all_values[v] return v class SetValueControl(Gtk.HBox): TOGGLE_VALUES = [('~', _('Toggle')), ('t', _('True')), ('f', _('False'))] TYPES = ('toggle', 'choice', 'range') def __init__(self, on_change, *args, **kwargs): super().__init__(*args, **kwargs) self.on_change = on_change self.toggle_widget = Gtk.ComboBoxText() for v in self.TOGGLE_VALUES: self.toggle_widget.append(*v) self.toggle_widget.connect('changed', self._changed) self.range_widget = Gtk.SpinButton.new_with_range(0, 0xFFFF, 1) self.range_widget.connect('changed', self._changed) self.choice_widget = Gtk.ComboBoxText.new_with_entry() self.choice_widget.connect('changed', self._changed) self.unsupported_label = Gtk.Label(_('Unsupported setting')) for w in [self.toggle_widget, self.range_widget, self.choice_widget, self.unsupported_label]: self.pack_end(w, True, True, 0) w.hide() self.unsupp_value = None self.get_value = lambda: None self.set_value = lambda value: None def _changed(self, widget, *args): if widget.get_visible(): value = self.get_value() if widget == self.choice_widget: value = _from_named_ints(value, widget._allowed_values) icon = 'dialog-warning' if widget._allowed_values and (value not in widget._allowed_values) else '' widget.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) self.on_change(value) def _hide_all(self): for w in self.get_children(): w.hide() def make_toggle(self): self._hide_all() def g(): value = self.toggle_widget.get_active_id() return True if value == 't' else False if value == 'f' else value self.get_value = g def s(value): if value in ('true', 'yes', 'on', 't', 'y'): self.toggle_widget.set_active_id('t') elif value in ('false', 'no', 'off', 'f', 'n'): self.toggle_widget.set_active_id('f') elif value in ('~', 'toggle'): self.toggle_widget.set_active_id('~') else: self.toggle_widget.set_active_id(None) self.set_value = lambda value: s(str(value).lower()) self.toggle_widget.show() def make_range(self, minimum, maximum): self._hide_all() self.range_widget.set_range(minimum, maximum) self.get_value = lambda: int(self.range_widget.get_value()) def s(value): try: v = round(float(value)) except (ValueError, TypeError): v = minimum self.range_widget.set_value(max(minimum, min(maximum, v))) self.set_value = s self.range_widget.show() def make_choice(self, values): self._hide_all() self.choice_widget.remove_all() sort_key = int if all(str(v).isdigit() for v in values) else str for v in sorted(values, key=sort_key): self.choice_widget.append(str(int(v)), str(v)) CompletionEntry.add_completion_to_entry(self.choice_widget.get_child(), map(str, values)) self.choice_widget._allowed_values = values def g(): value = self.choice_widget.get_active_id() or self.choice_widget.get_active_text().strip() or '' return _from_named_ints(value, self.choice_widget._allowed_values) def s(value): value = _from_named_ints(value, self.choice_widget._allowed_values) if value in self.choice_widget._allowed_values: self.choice_widget.set_active_id(str(int(value))) else: self.choice_widget.get_child().set_text(str(value)) self.get_value = g self.set_value = s self.choice_widget.show() def make_unsupported(self): self._hide_all() self.get_value = lambda: self.unsupp_value def s(value): # preserve unsupported values self.unsupp_value = value self.set_value = s self.unsupported_label.show() def _all_settings(): settings = {} for s in sorted(_SETTINGS, key=lambda setting: setting.label): if s.validator_class.kind == _SKIND.multiple_range: # not supported yet continue if s.name not in settings: settings[s.name] = [s] else: prev_setting = settings[s.name][0] prev_kind = prev_setting.validator_class.kind if prev_kind != s.validator_class.kind: _log.warning( 'ignoring setting {} - same name of {}, but different kind ({} != {})'.format( s.__name__, prev_setting.__name__, prev_kind, s.validator_class.kind ) ) continue settings[s.name].append(s) return settings def _all_devices(): devices = [] def dev_in_row(_store, _treepath, row): device = _dev_model.get_value(row, 7) if device and device.kind: devices.append(device) _dev_model.foreach(dev_in_row) return devices def _device_display_name(device): id = device.serial or device.unitId name = device.codename return name if not id else name + ' (' + id + ')' def _find_device(devices, search): if not search: return None return next((d for d in devices if search in [d.unitId, d.serial, _device_display_name(d)]), None) class SetUI(ActionUI): CLASS = _DIV.Set ALL_SETTINGS = _all_settings() MULTIPLE = [_SKIND.multiple_toggle, _SKIND.map_choice, _SKIND.multiple_range] def create_widgets(self): self.devices = [] self.widgets = {} lbl = Gtk.Label(_('Device'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) self.widgets[lbl] = (1, 0, 1, 1) self.device_field = Gtk.ComboBoxText.new_with_entry() self.device_field.get_child().set_text('') self.device_field.set_valign(Gtk.Align.START) self.device_field.set_size_request(300, 0) self.device_field.connect('changed', self._on_update) self.widgets[self.device_field] = (1, 1, 1, 1) lbl = Gtk.Label(_('Setting'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) self.widgets[lbl] = (2, 0, 1, 1) self.setting_field = Gtk.ComboBoxText() self.setting_field.append('', '') for setting in self.ALL_SETTINGS.values(): self.setting_field.append(setting[0].name, setting[0].label) self.setting_field.set_valign(Gtk.Align.START) self.setting_field.connect('changed', self._on_update) self.setting_field.connect('changed', self._changed_setting) self.widgets[self.setting_field] = (2, 1, 1, 1) self.value_lbl = Gtk.Label(_('Value'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) self.widgets[self.value_lbl] = (4, 0, 1, 1) self.value_field = SetValueControl(self._on_update) self.value_field.set_size_request(250, 35) self.widgets[self.value_field] = (4, 1, 1, 1) self.key_field = Gtk.ComboBoxText.new_with_entry() self.key_field.hide() self.key_field.set_valign(Gtk.Align.END) self.key_field.connect('changed', self._on_update) self.widgets[self.key_field] = (4, 0, 1, 1) @classmethod def _all_choices(cls, setting): # choice and map-choice """Return a NamedInts instance with the choices for a setting. If the argument `setting` is a Setting instance, then the choices are taken only from it. If instead it is a name, then the function returns the union of the choices for each setting with that name. Only one label per number is kept. """ if isinstance(setting, type) and issubclass(setting, _Setting): choices = UnsortedNamedInts() universe = getattr(setting, 'choices_universe', None) if universe: choices |= universe extra = getattr(setting, 'choices_extra', None) if extra: choices |= extra return choices settings = cls.ALL_SETTINGS.get(setting, []) choices = UnsortedNamedInts() for s in settings: choices |= cls._all_choices(s) return choices @classmethod def _setting_attributes(cls, setting_name): settings = cls.ALL_SETTINGS.get(setting_name, [None]) setting = settings[0] # if settings have the same name, use the first one to get the basic data val_class = setting.validator_class if setting else None kind = val_class.kind if val_class else None if kind in cls.MULTIPLE: keys = UnsortedNamedInts() for s in settings: universe = getattr(s, 'choices_universe' if kind == _SKIND.multiple_toggle else 'keys_universe', None) if universe: keys |= universe # only one key per number is used else: keys = None return setting, val_class, kind, keys def _changed_setting(self, *args): setting_name = self.setting_field.get_active_id() or None setting, val_class, kind, keys = self._setting_attributes(setting_name) if kind in (_SKIND.toggle, _SKIND.multiple_toggle): self.value_field.make_toggle() elif kind in (_SKIND.choice, _SKIND.map_choice): all_values = self._all_choices(setting_name) self.value_field.make_choice(all_values) elif kind in (_SKIND.range, ): # _SKIND.multiple_range not supported self.value_field.make_range(val_class.min_value, val_class.max_value) else: self.value_field.make_unsupported() value = self.component.args[-1] self.value_field.set_value(value if value is not None else '') multiple = kind in self.MULTIPLE if multiple: self.key_field.remove_all() self.key_field.append('', '') CompletionEntry.add_completion_to_entry(self.key_field.get_child(), map(str, keys)) for k in sorted(keys, key=str): self.key_field.append(str(int(k)), str(k)) def update_devices(self): with self.ignore_changes(): device_value = self.collect_value()[0] self.devices = _all_devices() if not self.component: return self.device_field.remove_all() self.device_field.append('', _('Originating device')) acceptable_values = [] for device in self.devices: display_name = _device_display_name(device) acceptable_values += [display_name, device.unitId, device.serial] self.device_field.append(device.serial or device.unitId, display_name) CompletionEntry.add_completion_to_entry(self.device_field.get_child(), filter(lambda v: v, acceptable_values)) device = _find_device(self.devices, device_value) if device or not device_value: self.device_field.set_active_id((device.serial or device.unitId) if device else '') else: self.device_field.get_child().set_text(device_value or '') def _update_visibility(self): if not self.component: return a = iter(self.component.args) device_str = next(a, None) icon = 'dialog-warning' if device_str and not _find_device(self.devices, device_str) else '' self.device_field.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) setting_name = next(a, '') setting, val_class, kind, keys = self._setting_attributes(setting_name) multiple = kind in self.MULTIPLE self.value_lbl.set_visible(not multiple) self.key_field.set_visible(multiple) if multiple: key = _from_named_ints(next(a, ''), keys) icon = 'dialog-warning' if keys and (key not in keys) else '' self.key_field.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) def _on_update(self, *_args): if self._ignore_changes: return super()._on_update(*_args) self._update_visibility() def show(self, component, editable): super().show(component, editable) self.update_devices() a = iter(component.args) with self.ignore_changes(): device_str = next(a, None) same = not device_str device = _find_device(self.devices, device_str) if device or same: self.device_field.set_active_id((device.serial or device.unitId) if device else '') else: self.device_field.get_child().set_text(device_str or '') setting_name = next(a, '') setting, _v, kind, keys = self._setting_attributes(setting_name) self.setting_field.set_active_id(setting.name if setting else '') self._changed_setting() if kind in self.MULTIPLE or kind is None and len(self.component.args) > 3: key = _from_named_ints(next(a, ''), keys) if isinstance(key, NamedInt): self.key_field.set_active_id(str(int(key))) else: self.key_field.get_child().set_text(key) self.value_field.set_value(next(a, '')) self._update_visibility() def collect_value(self): device_str = self.device_field.get_active_id() if device_str is None: device_str = self.device_field.get_active_text().strip() same = device_str in ['', _('Originating device')] device = None if same else _find_device(self.devices, device_str) device_value = (device.serial or device.unitId) if device else None if same else device_str setting_name = self.setting_field.get_active_id() or None setting, val_class, kind, keys = self._setting_attributes(setting_name) key_value = [] if kind in self.MULTIPLE or kind is None and len(self.component.args) > 3: key = self.key_field.get_active_id() or self.key_field.get_active_text().strip() or '' key = _from_named_ints(key, keys) key_value.append(keys[key] if keys else key) key_value.append(self.value_field.get_value()) return [device_value, setting_name, *key_value] @classmethod def right_label(cls, component): a = iter(component.args) device_str = next(a, None) device = None if not device_str else _find_device(_all_devices(), device_str) device_disp = _('Originating device' ) if not device_str else _device_display_name(device) if device else shlex_quote(device_str) setting_name = next(a, None) setting, val_class, kind, keys = cls._setting_attributes(setting_name) disp = [setting.label if setting else setting_name] if kind in cls.MULTIPLE: key = next(a, None) disp.append(_from_named_ints(key, keys) if keys else key) value = next(a, None) if setting and (kind in (_SKIND.choice, _SKIND.map_choice)): all_values = cls._all_choices(setting_name) if all_values: value = all_values[value] disp.append(value) return device_disp + ': ' + ' '.join(map(lambda s: shlex_quote(str(s)), [*disp, *a])) COMPONENT_UI = { _DIV.Rule: RuleUI, _DIV.Not: NotUI, _DIV.Or: OrUI, _DIV.And: AndUI, _DIV.Process: ProcessUI, _DIV.MouseProcess: MouseProcessUI, _DIV.Feature: FeatureUI, _DIV.Report: ReportUI, _DIV.Modifiers: ModifiersUI, _DIV.Key: KeyUI, _DIV.Test: TestUI, _DIV.MouseGesture: MouseGestureUI, _DIV.KeyPress: KeyPressUI, _DIV.MouseScroll: MouseScrollUI, _DIV.MouseClick: MouseClickUI, _DIV.Execute: ExecuteUI, _DIV.Set: SetUI, type(None): RuleComponentUI, # placeholders for empty rule/And/Or } def update_devices(): global _diversion_dialog if _diversion_dialog: _diversion_dialog.update_devices() def show_window(model): GObject.type_register(RuleComponentWrapper) global _diversion_dialog global _dev_model _dev_model = model if _diversion_dialog is None: _diversion_dialog = DiversionDialog() _diversion_dialog.window.present()