# -*- coding: utf-8 -*-

# AwesomeTTS text-to-speech add-on for Anki
# Copyright (C) 2010-Present  Anki AwesomeTTS Development Team
#
# 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 3 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, see <http://www.gnu.org/licenses/>.

"""Configuration dialog"""

from locale import format as locale
import os
import os.path
from sys import platform

from PyQt5 import QtCore, QtWidgets, QtGui

from ..paths import ICONS
from .base import Dialog
from .common import Checkbox, Label, Note, Slate
from .common import key_event_combo, key_combo_desc
from .listviews import SubListView
from .presets import Presets
from .groups import Groups

__all__ = ['Configurator']

# all methods might need 'self' in the future, pylint:disable=R0201


class Configurator(Dialog):
    """Provides a dialog for configuring the add-on."""

    _PROPERTY_KEYS = [
        'automatic_answers', 'automatic_answers_errors', 'automatic_questions',
        'automatic_questions_errors', 'cache_days', 'delay_answers_onthefly',
        'delay_answers_stored_ours', 'delay_answers_stored_theirs',
        'delay_questions_onthefly', 'delay_questions_stored_ours',
        'delay_questions_stored_theirs', 'ellip_note_newlines',
        'ellip_template_newlines', 'filenames', 'filenames_human',
        'lame_flags', 'launch_browser_generator', 'launch_browser_stripper',
        'launch_configurator', 'launch_editor_generator', 'launch_templater',
        'otf_only_revealed_cloze', 'otf_remove_hints', 'spec_note_strip',
        'spec_note_ellipsize', 'spec_template_ellipsize', 'spec_note_count',
        'spec_note_count_wrap', 'spec_template_count',
        'spec_template_count_wrap', 'spec_template_strip', 'strip_note_braces',
        'strip_note_brackets', 'strip_note_parens', 'strip_template_braces',
        'strip_template_brackets', 'strip_template_parens', 'sub_note_cloze',
        'sub_template_cloze', 'sul_note', 'sul_template', 'throttle_sleep',
        'throttle_threshold', 'tts_key_a', 'tts_key_q',
    ]

    _PROPERTY_WIDGETS = (Checkbox, QtWidgets.QComboBox, QtWidgets.QLineEdit,
                         QtWidgets.QPushButton, QtWidgets.QSpinBox, QtWidgets.QListView)

    __slots__ = ['_alerts', '_ask', '_preset_editor', '_group_editor',
                 '_sul_compiler']

    def __init__(self, alerts, ask, sul_compiler, *args, **kwargs):
        self._alerts = alerts
        self._ask = ask
        self._preset_editor = None
        self._group_editor = None
        self._sul_compiler = sul_compiler

        super(Configurator, self).__init__(title="Configuration",
                                           *args, **kwargs)

    # UI Construction ########################################################

    def _ui(self):
        """Returns vertical layout w/ banner, our tabs, cancel/OK."""

        layout = super(Configurator, self)._ui()
        layout.addWidget(self._ui_tabs())
        layout.addWidget(self._ui_buttons())
        return layout

    def _ui_tabs(self):
        """Returns tab widget w/ Playback, Text, MP3s, Advanced."""

        use_icons = not platform.startswith('darwin')
        tabs = QtWidgets.QTabWidget()

        for content, icon, label in [
                (self._ui_tabs_playback, 'player-time', "Playback"),
                (self._ui_tabs_text, 'editclear', "Text"),
                (self._ui_tabs_mp3gen, 'document-new', "MP3s"),
                (self._ui_tabs_windows, 'kpersonalizer', "Windows"),
                (self._ui_tabs_advanced, 'configure', "Advanced"),
        ]:
            if use_icons:
                tabs.addTab(content(), QtGui.QIcon(f'{ICONS}/{icon}.png'),
                            label)
            else:  # active tabs do not display correctly on Mac OS X w/ icons
                tabs.addTab(content(), label)

        tabs.currentChanged.connect(lambda: (tabs.adjustSize(),
                                             self.adjustSize()))
        return tabs

    def _ui_tabs_playback(self):
        """Returns the "Playback" tab."""

        vert = QtWidgets.QVBoxLayout()
        vert.addWidget(self._ui_tabs_playback_group(
            'automatic_questions', 'tts_key_q',
            'delay_questions_', "Questions / Fronts of Cards",
        ))
        vert.addWidget(self._ui_tabs_playback_group(
            'automatic_answers', 'tts_key_a',
            'delay_answers_', "Answers / Backs of Cards",
        ))
        vert.addSpacing(self._SPACING)
        vert.addWidget(Label('Anki controls if and how to play [sound] '
                             'tags. See "Help" for more information.'))
        vert.addStretch()

        tab = QtWidgets.QWidget()
        tab.setLayout(vert)
        return tab

    def _ui_tabs_playback_group(self, automatic_key, shortcut_key,
                                delay_key_prefix, label):
        """
        Returns the "Questions / Fronts of Cards" and "Answers / Backs
        of Cards" input groups.
        """

        hor = QtWidgets.QHBoxLayout()
        automatic = Checkbox("Automatically play on-the-fly <tts> tags",
                             automatic_key)
        errors = Checkbox("Show errors", automatic_key + '_errors')
        hor.addWidget(automatic)
        hor.addWidget(errors)
        hor.addStretch()

        layout = QtWidgets.QVBoxLayout()
        layout.addLayout(hor)

        wait_widgets = {}
        for subkey, desc in [('onthefly', "on-the-fly <tts> tags"),
                             ('stored_ours', "AwesomeTTS [sound] tags"),
                             ('stored_theirs', "other [sound] tags")]:
            spinner = QtWidgets.QSpinBox()
            spinner.setObjectName(delay_key_prefix + subkey)
            spinner.setRange(0, 30)
            spinner.setSingleStep(1)
            spinner.setSuffix(" seconds")
            wait_widgets[subkey] = spinner

            hor = QtWidgets.QHBoxLayout()
            hor.addWidget(Label("Wait"))
            hor.addWidget(spinner)
            hor.addWidget(Label("before automatically playing " + desc))
            hor.addStretch()
            layout.addLayout(hor)

        automatic.stateChanged.connect(lambda enabled: (
            errors.setEnabled(enabled),
            wait_widgets['onthefly'].setEnabled(enabled),
        ))

        hor = QtWidgets.QHBoxLayout()
        hor.addWidget(Label("To manually play on-the-fly <tts> tags, strike"))
        hor.addWidget(self._factory_shortcut(shortcut_key))
        hor.addStretch()
        layout.addLayout(hor)

        group = QtWidgets.QGroupBox(label)
        group.setLayout(layout)
        return group

    def _ui_tabs_text(self):
        """Returns the "Text" tab."""

        layout = QtWidgets.QVBoxLayout()
        layout.setContentsMargins(10, 0, 10, 0)
        layout.addWidget(self._ui_tabs_text_mode(
            '_template_',
            "Handling Template Text (e.g. On-the-Fly, Context Menus)",
            "For a front-side rendered cloze,",
            [('anki', "read however Anki displayed it"),
             ('wrap', "read w/ hint wrapped in ellipses"),
             ('ellipsize', "read as an ellipsis, ignoring hint"),
             ('remove', "remove entirely")],
            template_options=True,
        ), 50)
        layout.addWidget(self._ui_tabs_text_mode(
            '_note_',
            "Handling Text from a Note Field (e.g. Browser Generator)",
            "For a braced cloze marker,",
            [('anki', "read as Anki would display on a card front"),
             ('wrap', "replace w/ hint wrapped in ellipses"),
             ('deleted', "replace w/ deleted text"),
             ('ellipsize', "replace w/ ellipsis, ignoring both"),
             ('remove', "remove entirely")],
        ), 50)

        tab = QtWidgets.QWidget()
        tab.setLayout(layout)
        return tab

    def _ui_tabs_text_mode(self, infix, label, *args, **kwargs):
        """Returns group box for the given text manipulation context."""

        subtabs = QtWidgets.QTabWidget()
        subtabs.setTabPosition(QtWidgets.QTabWidget.West)

        for sublabel, sublayout in [
                ("Simple", self._ui_tabs_text_mode_simple(infix, *args,
                                                          **kwargs)),
                ("Advanced", self._ui_tabs_text_mode_adv(infix)),
        ]:
            subwidget = QtWidgets.QWidget()
            subwidget.setLayout(sublayout)
            subtabs.addTab(subwidget, sublabel)

        layout = QtWidgets.QVBoxLayout()
        # TODO
        # layout.setCanvasMargin(0)
        layout.addWidget(subtabs)

        group = QtWidgets.QGroupBox(label)
        group.setFlat(True)
        group.setLayout(layout)

        _, top, right, bottom = layout.getContentsMargins()
        layout.setContentsMargins(0, top, right, bottom)
        _, top, right, bottom = group.getContentsMargins()
        group.setContentsMargins(0, top, right, bottom)
        return group

    def _ui_tabs_text_mode_simple(self, infix, cloze_description,
                                  cloze_options, template_options=False):
        """
        Returns a layout with the "simple" configuration options
        available for manipulating text from the given context.
        """

        select = QtWidgets.QComboBox()
        for option_value, option_text in cloze_options:
            select.addItem(option_text, option_value)
        select.setObjectName(infix.join(['sub', 'cloze']))

        hor = QtWidgets.QHBoxLayout()
        hor.addWidget(Label(cloze_description))
        hor.addWidget(select)
        hor.addStretch()

        layout = QtWidgets.QVBoxLayout()
        layout.setContentsMargins(10, 0, 10, 0)
        layout.addLayout(hor)

        if template_options:
            hor = QtWidgets.QHBoxLayout()
            hor.addWidget(Checkbox("For cloze answers, read revealed text "
                                   "only", 'otf_only_revealed_cloze'))
            hor.addWidget(Checkbox("Ignore {{hint}} fields",
                                   'otf_remove_hints'))
            layout.addLayout(hor)

        layout.addWidget(Checkbox(
            "Convert any newline(s) in input into an ellipsis",
            infix.join(['ellip', 'newlines'])
        ))

        hor = QtWidgets.QHBoxLayout()
        hor.addWidget(Label("Strip off text within:"))
        for option_subkey, option_label in [('parens', "parentheses"),
                                            ('brackets', "brackets"),
                                            ('braces', "braces")]:
            hor.addWidget(Checkbox(option_label,
                                   infix.join(['strip', option_subkey])))
        hor.addStretch()

        layout.addLayout(hor)
        layout.addLayout(self._ui_tabs_text_mode_simple_spec(
            infix, 'strip', ("Remove all", "characters from the input")))
        layout.addLayout(self._ui_tabs_text_mode_simple_spec(
            infix, 'count', ("Count adjacent", "characters"), True))
        layout.addLayout(self._ui_tabs_text_mode_simple_spec(
            infix, 'ellipsize', ("Replace", "characters with an ellipsis")))
        layout.addStretch()
        return layout

    def _ui_tabs_text_mode_simple_spec(self, infix, suffix, labels,
                                       wrap=False):
        """Returns a layout for specific character handling."""

        line_edit = QtWidgets.QLineEdit()
        line_edit.setObjectName(infix.join(['spec', suffix]))
        line_edit.setValidator(self._ui_tabs_text_mode_simple_spec.ucsv)
        line_edit.setFixedWidth(50)

        hor = QtWidgets.QHBoxLayout()
        hor.addWidget(Label(labels[0]))
        hor.addWidget(line_edit)
        hor.addWidget(Label(labels[1]))
        if wrap:
            hor.addWidget(Checkbox("wrap in ellipses",
                                   ''.join(['spec', infix, suffix, '_wrap'])))
        hor.addStretch()
        return hor

    class _UniqueCharacterStringValidator(QtGui.QValidator):
        """QValidator returning unique, sorted characters."""

        def fixup(self, original):
            """Returns unique characters from original, sorted."""

            return ''.join(sorted({c for c in original if not c.isspace()}))

        def validate(self, original, offset):  # pylint:disable=W0613
            """Fixes original text and resets cursor to end of line."""

            filtered = self.fixup(original)
            return QtGui.QValidator.Acceptable, filtered, len(filtered)

    _ui_tabs_text_mode_simple_spec.ucsv = _UniqueCharacterStringValidator()

    def _ui_tabs_text_mode_adv(self, infix):
        """
        Returns a layout with the "advanced" pattern replacement
        panel for manipulating text from the given context.
        """

        return Slate("Rule", SubListView, [self._sul_compiler],
                     'sul' + infix.rstrip('_'))

    def _ui_tabs_mp3gen(self):
        """Returns the "MP3s" tab."""

        vert = QtWidgets.QVBoxLayout()
        vert.addWidget(self._ui_tabs_mp3gen_filenames())
        vert.addWidget(self._ui_tabs_mp3gen_lame())
        vert.addWidget(self._ui_tabs_mp3gen_throttle())
        vert.addStretch()

        tab = QtWidgets.QWidget()
        tab.setLayout(vert)
        return tab

    def _ui_tabs_mp3gen_filenames(self):
        """Returns the "Filenames of MP3s" group."""

        dropdown = QtWidgets.QComboBox()
        dropdown.setObjectName('filenames')
        dropdown.addItem("hashed (safe and portable)", 'hash')
        dropdown.addItem("human-readable (may not work everywhere)", 'human')

        dropdown_line = QtWidgets.QHBoxLayout()
        dropdown_line.addWidget(Label("Filenames should be "))
        dropdown_line.addWidget(dropdown)
        dropdown_line.addStretch()

        human = QtWidgets.QLineEdit()
        human.setObjectName('filenames_human')
        human.setPlaceholderText("e.g. {{service}} {{voice}} - {{text}}")
        human.setEnabled(False)

        human_line = QtWidgets.QHBoxLayout()
        human_line.addWidget(Label("Format human-readable filenames as "))
        human_line.addWidget(human)
        human_line.addWidget(Label(".mp3"))

        dropdown.currentIndexChanged. \
            connect(lambda index: human.setEnabled(index > 0))

        vertical = QtWidgets.QVBoxLayout()
        vertical.addLayout(dropdown_line)
        vertical.addLayout(human_line)
        vertical.addWidget(Note("Changes are not retroactive to old files."))

        group = QtWidgets.QGroupBox("Filenames of MP3s Stored in Your Collection")
        group.setLayout(vertical)

        return group

    def _ui_tabs_mp3gen_lame(self):
        """Returns the "LAME Transcoder" input group."""

        flags = QtWidgets.QLineEdit()
        flags.setObjectName('lame_flags')
        flags.setPlaceholderText("e.g. '-q 5' for medium quality")

        rtr = self._addon.router
        vert = QtWidgets.QVBoxLayout()
        vert.addWidget(Note("Specify flags passed to lame when making MP3s."))
        vert.addWidget(flags)
        vert.addWidget(Note("Affects %s. Changes are not retroactive to old "
                            "files." %
                            ', '.join(rtr.by_trait(rtr.Trait.TRANSCODING))))

        group = QtWidgets.QGroupBox("LAME Transcoder")
        group.setLayout(vert)
        return group

    def _ui_tabs_mp3gen_throttle(self):
        """Returns the "Download Throttling" input group."""

        threshold = QtWidgets.QSpinBox()
        threshold.setObjectName('throttle_threshold')
        threshold.setRange(5, 1000)
        threshold.setSingleStep(5)
        threshold.setSuffix(" operations")

        sleep = QtWidgets.QSpinBox()
        sleep.setObjectName('throttle_sleep')
        sleep.setRange(15, 10800)
        sleep.setSingleStep(15)
        sleep.setSuffix(" seconds")

        hor = QtWidgets.QHBoxLayout()
        hor.addWidget(Label("After "))
        hor.addWidget(threshold)
        hor.addWidget(Label(" sleep for "))
        hor.addWidget(sleep)
        hor.addStretch()

        rtr = self._addon.router
        vert = QtWidgets.QVBoxLayout()
        vert.addWidget(Note("Tweak how often AwesomeTTS takes a break when "
                            "mass downloading files from online services."))
        vert.addLayout(hor)
        vert.addWidget(Note("Affects %s." %
                            ', '.join(rtr.by_trait(rtr.Trait.INTERNET))))

        group = QtWidgets.QGroupBox("Download Throttling during Batch Processing")
        group.setLayout(vert)
        return group

    def _ui_tabs_windows(self):
        """Returns the "Window" tab."""

        grid = QtWidgets.QGridLayout()
        for i, (desc, sub) in enumerate([
                ("open configuration in main window", 'configurator'),
                ("insert <tts> tag in template editor", 'templater'),
                ("mass generate MP3s in card browser", 'browser_generator'),
                ("mass remove audio in card browser", 'browser_stripper'),
                ("generate single MP3 in note editor*", 'editor_generator'),
        ]):
            grid.addWidget(Label("To " + desc + ", strike"), i, 0)
            grid.addWidget(self._factory_shortcut('launch_' + sub), i, 1)
        grid.setColumnStretch(1, 1)

        group = QtWidgets.QGroupBox("Window Shortcuts")
        group.setLayout(grid)

        vert = QtWidgets.QVBoxLayout()
        vert.addWidget(group)
        vert.addWidget(Note(
            "* By default, AwesomeTTS binds %(native)s for most actions. If "
            "you use math equations and LaTeX with Anki using the %(native)s "
            "E/M/T keystrokes, you may want to reassign or unbind the "
            "shortcut for generating in the note editor." %
            dict(native=key_combo_desc(QtCore.Qt.ControlModifier |
                                       QtCore.Qt.Key_T))
        ))
        vert.addWidget(Note("Editor and browser shortcuts will take effect "
                            "the next time you open those windows."))
        vert.addWidget(Note("Some keys cannot be used as shortcuts and some "
                            "keystrokes might not work in some windows, "
                            "depending on your operating system and other "
                            "add-ons you are running. You may have to "
                            "experiment to find what works best."))
        vert.addStretch()

        tab = QtWidgets.QWidget()
        tab.setLayout(vert)
        return tab

    def _ui_tabs_advanced(self):
        """Returns the "Advanced" tab."""

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self._ui_tabs_advanced_presets())
        layout.addWidget(self._ui_tabs_advanced_cache())
        layout.addStretch()

        tab = QtWidgets.QWidget()
        tab.setLayout(layout)
        return tab

    def _ui_tabs_advanced_presets(self):
        """Returns the "Presets" input group."""

        presets_button = QtWidgets.QPushButton("Manage Presets...")
        presets_button.clicked.connect(self._on_presets)

        groups_button = QtWidgets.QPushButton("Manage Groups...")
        groups_button.clicked.connect(self._on_groups)

        hor = QtWidgets.QHBoxLayout()
        hor.addWidget(presets_button)
        hor.addWidget(groups_button)
        hor.addStretch()

        vert = QtWidgets.QVBoxLayout()
        vert.addWidget(Note("Setup services for easy access, menu playback, "
                            "randomization, or fallbacks."))
        vert.addLayout(hor)

        group = QtWidgets.QGroupBox("Service Presets and Groups")
        group.setLayout(vert)
        return group

    def _ui_tabs_advanced_cache(self):
        """Returns the "Caching" input group."""

        days = QtWidgets.QSpinBox()
        days.setObjectName('cache_days')
        days.setRange(0, 9999)
        days.setSuffix(" days")

        hor = QtWidgets.QHBoxLayout()
        hor.addWidget(Label("Delete files older than"))
        hor.addWidget(days)
        hor.addWidget(Label("at exit (zero clears everything)"))
        hor.addStretch()

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(Note("AwesomeTTS caches generated audio files and "
                              "remembers failures during each session to "
                              "speed up repeated playback."))
        layout.addLayout(hor)

        abutton = QtWidgets.QPushButton("Delete Files")
        abutton.setObjectName('on_cache')
        abutton.clicked.connect(lambda: self._on_cache_clear(abutton))

        fbutton = QtWidgets.QPushButton("Forget Failures")
        fbutton.setObjectName('on_forget')
        fbutton.clicked.connect(lambda: self._on_forget_failures(fbutton))

        hor = QtWidgets.QHBoxLayout()
        hor.addWidget(abutton)
        hor.addWidget(fbutton)
        layout.addLayout(hor)

        group = QtWidgets.QGroupBox("Caching")
        group.setLayout(layout)
        return group

    # Factories ##############################################################

    def _factory_shortcut(self, object_name):
        """Returns a push button capable of being assigned a shortcut."""

        shortcut = QtWidgets.QPushButton()
        shortcut.atts_pending = False
        shortcut.setObjectName(object_name)
        shortcut.setCheckable(True)
        shortcut.toggled.connect(
            lambda is_down: (
                shortcut.setText("press keystroke"),
                shortcut.setFocus(),  # needed for OS X if text inputs present
            ) if is_down
            else shortcut.setText(key_combo_desc(shortcut.atts_value))
        )
        return shortcut

    # Events #################################################################

    def show(self, *args, **kwargs):
        """Restores state on inputs; rough opposite of the accept()."""

        for widget, value in [
                (widget, self._addon.config[widget.objectName()])
                for widget in self.findChildren(self._PROPERTY_WIDGETS)
                if widget.objectName() in self._PROPERTY_KEYS
        ]:
            if isinstance(widget, Checkbox):
                widget.setChecked(value)
                widget.stateChanged.emit(value)
            elif isinstance(widget, QtWidgets.QLineEdit):
                widget.setText(value)
            elif isinstance(widget, QtWidgets.QPushButton):
                widget.atts_value = value
                widget.setText(key_combo_desc(widget.atts_value))
            elif isinstance(widget, QtWidgets.QComboBox):
                widget.setCurrentIndex(max(widget.findData(value), 0))
            elif isinstance(widget, QtWidgets.QSpinBox):
                widget.setValue(value)
            elif isinstance(widget, QtWidgets.QListView):
                widget.setModel(value)

        widget = self.findChild(QtWidgets.QPushButton, 'on_cache')
        widget.atts_list = (
            [filename for filename in os.listdir(self._addon.paths.cache)]
            if os.path.isdir(self._addon.paths.cache) else []
        )
        if widget.atts_list:
            widget.setEnabled(True)
            widget.setText("Delete Files (%s)" %
                           locale("%d", len(widget.atts_list), grouping=True))
        else:
            widget.setEnabled(False)
            widget.setText("Delete Files")

        widget = self.findChild(QtWidgets.QPushButton, 'on_forget')
        fail_count = self._addon.router.get_failure_count()
        if fail_count:
            widget.setEnabled(True)
            widget.setText("Forget Failures (%s)" %
                           locale("%d", fail_count, grouping=True))
        else:
            widget.setEnabled(False)
            widget.setText("Forget Failures")

        super(Configurator, self).show(*args, **kwargs)

    def accept(self):
        """Saves state on inputs; rough opposite of show()."""

        for list_view in self.findChildren(QtWidgets.QListView):
            for editor in list_view.findChildren(QtWidgets.QWidget, 'editor'):
                list_view.commitData(editor)  # if an editor is open, save it

        self._addon.config.update({
            widget.objectName(): (
                widget.isChecked() if isinstance(widget, Checkbox)
                else widget.atts_value if isinstance(widget, QtWidgets.QPushButton)
                else widget.value() if isinstance(widget, QtWidgets.QSpinBox)
                else widget.itemData(widget.currentIndex()) if isinstance(
                    widget, QtWidgets.QComboBox)
                else [
                    i for i in widget.model().raw_data
                    if i['compiled'] and 'bad_replace' not in i
                ] if isinstance(widget, QtWidgets.QListView)
                else widget.text()
            )
            for widget in self.findChildren(self._PROPERTY_WIDGETS)
            if widget.objectName() in self._PROPERTY_KEYS
        })

        super(Configurator, self).accept()

    def help_request(self):
        """Launch browser to the URL for the user's current tab."""

        tabs = self.findChild(QtWidgets.QTabWidget)
        self._launch_link('config/' +
                          tabs.tabText(tabs.currentIndex()).lower())

    def keyPressEvent(self, key_event):  # from PyQt5, pylint:disable=C0103
        """Assign new combo for shortcut buttons undergoing changes."""

        buttons = self._get_pressed_shortcut_buttons()
        if not buttons:
            return super(Configurator, self).keyPressEvent(key_event)

        key = key_event.key()

        if key == QtCore.Qt.Key_Escape:
            for button in buttons:
                button.atts_pending = False
                button.setText(key_combo_desc(button.atts_value))
            return

        if key in [QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete]:
            combo = None
        else:
            combo = key_event_combo(key_event)
            if not combo:
                return

        for button in buttons:
            button.atts_pending = combo
            button.setText(key_combo_desc(combo))

    def keyReleaseEvent(self, key_event):  # from PyQt5, pylint:disable=C0103
        """Disengage all shortcut buttons undergoing changes."""

        buttons = self._get_pressed_shortcut_buttons()
        if not buttons:
            return super(Configurator, self).keyReleaseEvent(key_event)

        elif key_event.key() in [QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return]:
            # need to ignore and eat key release on enter/return so that user
            # can activate the button without immediately deactivating it
            return

        for button in buttons:
            if button.atts_pending is not False:
                button.atts_value = button.atts_pending
            button.setChecked(False)

    def _get_pressed_shortcut_buttons(self):
        """Returns all shortcut buttons that are pressed."""

        return [button
                for button in self.findChildren(QtWidgets.QPushButton)
                if (button.isChecked() and
                    (button.objectName().startswith('launch_') or
                     button.objectName().startswith('tts_key_')))]

    def _on_presets(self):
        """Opens the presets editor."""

        if not self._preset_editor:
            self._preset_editor = Presets(addon=self._addon,
                                          alerts=self._alerts,
                                          ask=self._ask,
                                          parent=self)
        self._preset_editor.show()

    def _on_groups(self):
        """
        Check to make sure the user as at least two presets, and if so,
        launch the Groups management window.
        """

        if len(self._addon.config['presets']) < 2:
            self._alerts("You must have at least two presets before you can "
                         "create a group.", parent=self)
            return

        if not self._group_editor:
            self._group_editor = Groups(ask=self._ask, addon=self._addon,
                                        parent=self)

        self._group_editor.show()

    def _on_cache_clear(self, button):
        """Attempts clear known files from cache."""

        button.setEnabled(False)
        count_error = count_success = 0

        for filename in button.atts_list:
            try:
                os.unlink(os.path.join(self._addon.paths.cache, filename))
                count_success += 1
            except:  # capture all exceptions, pylint:disable=W0702
                count_error += 1

        if count_error:
            if count_success:
                button.setText("partially emptied (%s left)" %
                               locale("%d", count_error, grouping=True))
            else:
                button.setText("unable to empty")
        else:
            button.setText("emptied cache")

    def _on_forget_failures(self, button):
        """Tells the router to forget all cached failures."""

        button.setEnabled(False)
        self._addon.router.forget_failures()
        button.setText("forgot failures")