# -*- 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.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.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)
        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
            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, ['']),
                    ]
                ],
            ]
        ]

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


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):
        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 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):
        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 _('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):
        super().show(component)
        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):
        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.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):
        super().show(component)
        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):
        super().show(component)
        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):
        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)
        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):
        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)
        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):
        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 _('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):
        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 _('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):
        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)
        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])


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