1# -*- coding: utf-8 -*-
2#
3# Copyright © Spyder Project Contributors
4# Licensed under the terms of the MIT License
5# (see spyder/__init__.py for details)
6
7"""Shortcut management"""
8
9# Standard library imports
10from __future__ import print_function
11import os
12import re
13import sys
14
15# Third party imports
16from qtpy import PYQT5
17from qtpy.compat import from_qvariant, to_qvariant
18from qtpy.QtCore import (QAbstractTableModel, QModelIndex, QRegExp,
19                         QSortFilterProxyModel, Qt, Slot)
20from qtpy.QtGui import (QKeySequence, QRegExpValidator)
21from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog,
22                            QDialogButtonBox, QGridLayout, QHBoxLayout, QLabel,
23                            QLineEdit, QMessageBox, QPushButton, QSpacerItem,
24                            QTableView, QVBoxLayout)
25
26# Local imports
27from spyder.config.base import _, debug_print
28from spyder.config.gui import (get_shortcut, iter_shortcuts,
29                               reset_shortcuts, set_shortcut)
30from spyder.plugins.configdialog import GeneralConfigPage
31from spyder.utils import icon_manager as ima
32from spyder.utils.qthelpers import get_std_icon
33from spyder.utils.stringmatching import get_search_scores, get_search_regex
34from spyder.widgets.helperwidgets import HTMLDelegate
35from spyder.widgets.helperwidgets import HelperToolButton
36
37
38MODIFIERS = {Qt.Key_Shift: Qt.SHIFT,
39             Qt.Key_Control: Qt.CTRL,
40             Qt.Key_Alt: Qt.ALT,
41             Qt.Key_Meta: Qt.META}
42
43# Valid shortcut keys
44SINGLE_KEYS = ["F{}".format(_i) for _i in range(1, 36)] + ["Delete", "Escape"]
45KEYSTRINGS = ["Tab", "Backtab", "Backspace", "Return", "Enter",
46              "Pause", "Print", "Clear", "Home", "End", "Left",
47              "Up", "Right", "Down", "PageUp", "PageDown"] + \
48             ["Space", "Exclam", "QuoteDbl", "NumberSign", "Dollar",
49              "Percent", "Ampersand", "Apostrophe", "ParenLeft",
50              "ParenRight", "Asterisk", "Plus", "Comma", "Minus",
51              "Period", "Slash"] + \
52             [str(_i) for _i in range(10)] + \
53             ["Colon", "Semicolon", "Less", "Equal", "Greater",
54              "Question", "At"] + [chr(_i) for _i in range(65, 91)] + \
55             ["BracketLeft", "Backslash", "BracketRight", "Underscore",
56              "Control", "Alt", "Shift", "Meta"]
57VALID_SINGLE_KEYS = [getattr(Qt, 'Key_{0}'.format(k)) for k in SINGLE_KEYS]
58VALID_KEYS = [getattr(Qt, 'Key_{0}'.format(k)) for k in KEYSTRINGS+SINGLE_KEYS]
59
60# Valid finder chars. To be improved
61VALID_ACCENT_CHARS = "ÁÉÍOÚáéíúóàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÄËÏÖÜñÑ"
62VALID_FINDER_CHARS = r"[A-Za-z\s{0}]".format(VALID_ACCENT_CHARS)
63
64BLACKLIST = {
65    'Shift+Del': _('Currently used to delete lines on editor/Cut a word'),
66    'Shift+Ins': _('Currently used to paste a word')
67}
68
69if os.name == 'nt':
70    BLACKLIST['Alt+Backspace'] = _('We cannot support this '
71                                   'shortcut on Windows')
72
73BLACKLIST['Shift'] = _('Shortcuts that use Shift and another key'
74                       ' are unsupported')
75
76
77class CustomLineEdit(QLineEdit):
78    """QLineEdit that filters its key press and release events."""
79    def __init__(self, parent):
80        super(CustomLineEdit, self).__init__(parent)
81        self.setReadOnly(True)
82        self.setFocusPolicy(Qt.NoFocus)
83
84    def keyPressEvent(self, e):
85        """Qt Override"""
86        self.parent().keyPressEvent(e)
87
88    def keyReleaseEvent(self, e):
89        """Qt Override"""
90        self.parent().keyReleaseEvent(e)
91
92
93class ShortcutFinder(QLineEdit):
94    """Textbox for filtering listed shortcuts in the table."""
95    def __init__(self, parent, callback=None):
96        super(ShortcutFinder, self).__init__(parent)
97        self._parent = parent
98
99        # Widget setup
100        regex = QRegExp(VALID_FINDER_CHARS + "{100}")
101        self.setValidator(QRegExpValidator(regex))
102
103        # Signals
104        if callback:
105            self.textChanged.connect(callback)
106
107    def set_text(self, text):
108        """Set the filter text."""
109        text = text.strip()
110        new_text = self.text() + text
111        self.setText(new_text)
112
113    def keyPressEvent(self, event):
114        """Qt Override."""
115        key = event.key()
116        if key in [Qt.Key_Up]:
117            self._parent.previous_row()
118        elif key in [Qt.Key_Down]:
119            self._parent.next_row()
120        elif key in [Qt.Key_Enter, Qt.Key_Return]:
121            self._parent.show_editor()
122        else:
123            super(ShortcutFinder, self).keyPressEvent(event)
124
125
126# Error codes for the shortcut editor dialog
127(NO_WARNING, SEQUENCE_LENGTH, SEQUENCE_CONFLICT,
128 INVALID_KEY, IN_BLACKLIST, SHIFT_BLACKLIST) = [0, 1, 2, 3, 4, 5]
129
130
131class ShortcutEditor(QDialog):
132    """A dialog for entering key sequences."""
133    def __init__(self, parent, context, name, sequence, shortcuts):
134        super(ShortcutEditor, self).__init__(parent)
135        self._parent = parent
136
137        self.context = context
138        self.npressed = 0
139        self.keys = set()
140        self.key_modifiers = set()
141        self.key_non_modifiers = list()
142        self.key_text = list()
143        self.sequence = sequence
144        self.new_sequence = None
145        self.edit_state = True
146        self.shortcuts = shortcuts
147
148        # Widgets
149        self.label_info = QLabel()
150        self.label_info.setText(_("Press the new shortcut and select 'Ok': \n"
151             "(Press 'Tab' once to switch focus between the shortcut entry \n"
152             "and the buttons below it)"))
153        self.label_current_sequence = QLabel(_("Current shortcut:"))
154        self.text_current_sequence = QLabel(sequence)
155        self.label_new_sequence = QLabel(_("New shortcut:"))
156        self.text_new_sequence = CustomLineEdit(self)
157        self.text_new_sequence.setPlaceholderText(sequence)
158        self.helper_button = HelperToolButton()
159        self.helper_button.hide()
160        self.label_warning = QLabel()
161        self.label_warning.hide()
162
163        bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
164        self.button_ok = bbox.button(QDialogButtonBox.Ok)
165        self.button_cancel = bbox.button(QDialogButtonBox.Cancel)
166
167        # Setup widgets
168        self.setWindowTitle(_('Shortcut: {0}').format(name))
169        self.button_ok.setFocusPolicy(Qt.NoFocus)
170        self.button_ok.setEnabled(False)
171        self.button_cancel.setFocusPolicy(Qt.NoFocus)
172        self.helper_button.setToolTip('')
173        self.helper_button.setFocusPolicy(Qt.NoFocus)
174        style = """
175            QToolButton {
176              margin:1px;
177              border: 0px solid grey;
178              padding:0px;
179              border-radius: 0px;
180            }"""
181        self.helper_button.setStyleSheet(style)
182        self.text_new_sequence.setFocusPolicy(Qt.NoFocus)
183        self.label_warning.setFocusPolicy(Qt.NoFocus)
184
185        # Layout
186        spacing = 5
187        layout_sequence = QGridLayout()
188        layout_sequence.addWidget(self.label_info, 0, 0, 1, 3)
189        layout_sequence.addItem(QSpacerItem(spacing, spacing), 1, 0, 1, 2)
190        layout_sequence.addWidget(self.label_current_sequence, 2, 0)
191        layout_sequence.addWidget(self.text_current_sequence, 2, 2)
192        layout_sequence.addWidget(self.label_new_sequence, 3, 0)
193        layout_sequence.addWidget(self.helper_button, 3, 1)
194        layout_sequence.addWidget(self.text_new_sequence, 3, 2)
195        layout_sequence.addWidget(self.label_warning, 4, 2, 1, 2)
196
197        layout = QVBoxLayout()
198        layout.addLayout(layout_sequence)
199        layout.addSpacing(spacing)
200        layout.addWidget(bbox)
201        self.setLayout(layout)
202
203        # Signals
204        bbox.accepted.connect(self.accept)
205        bbox.rejected.connect(self.reject)
206
207    @Slot()
208    def reject(self):
209        """Slot for rejected signal."""
210        # Added for issue #5426.  Due to the focusPolicy of Qt.NoFocus for the
211        # buttons, if the cancel button was clicked without first setting focus
212        # to the button, it would cause a seg fault crash.
213        self.button_cancel.setFocus()
214        super(ShortcutEditor, self).reject()
215
216    @Slot()
217    def accept(self):
218        """Slot for accepted signal."""
219        # Added for issue #5426.  Due to the focusPolicy of Qt.NoFocus for the
220        # buttons, if the ok button was clicked without first setting focus to
221        # the button, it would cause a seg fault crash.
222        self.button_ok.setFocus()
223        super(ShortcutEditor, self).accept()
224
225    def keyPressEvent(self, e):
226        """Qt override."""
227        key = e.key()
228        # Check if valid keys
229        if key not in VALID_KEYS:
230            self.invalid_key_flag = True
231            return
232
233        self.npressed += 1
234        self.key_non_modifiers.append(key)
235        self.key_modifiers.add(key)
236        self.key_text.append(e.text())
237        self.invalid_key_flag = False
238
239        debug_print('key {0}, npressed: {1}'.format(key, self.npressed))
240
241        if key == Qt.Key_unknown:
242            return
243
244        # The user clicked just and only the special keys
245        # Ctrl, Shift, Alt, Meta.
246        if (key == Qt.Key_Control or
247                key == Qt.Key_Shift or
248                key == Qt.Key_Alt or
249                key == Qt.Key_Meta):
250            return
251
252        modifiers = e.modifiers()
253        if modifiers & Qt.ShiftModifier:
254            key += Qt.SHIFT
255        if modifiers & Qt.ControlModifier:
256            key += Qt.CTRL
257            if sys.platform == 'darwin':
258                self.npressed -= 1
259            debug_print('decrementing')
260        if modifiers & Qt.AltModifier:
261            key += Qt.ALT
262        if modifiers & Qt.MetaModifier:
263            key += Qt.META
264
265        self.keys.add(key)
266
267    def toggle_state(self):
268        """Switch between shortcut entry and Accept/Cancel shortcut mode."""
269        self.edit_state = not self.edit_state
270
271        if not self.edit_state:
272            self.text_new_sequence.setEnabled(False)
273            if self.button_ok.isEnabled():
274                self.button_ok.setFocus()
275            else:
276                self.button_cancel.setFocus()
277        else:
278            self.text_new_sequence.setEnabled(True)
279            self.text_new_sequence.setFocus()
280
281    def nonedit_keyrelease(self, e):
282        """Key release event for non-edit state."""
283        key = e.key()
284        if key in [Qt.Key_Escape]:
285            self.close()
286            return
287
288        if key in [Qt.Key_Left, Qt.Key_Right, Qt.Key_Up,
289                   Qt.Key_Down]:
290            if self.button_ok.hasFocus():
291                self.button_cancel.setFocus()
292            else:
293                self.button_ok.setFocus()
294
295    def keyReleaseEvent(self, e):
296        """Qt override."""
297        self.npressed -= 1
298        if self.npressed <= 0:
299            key = e.key()
300
301            if len(self.keys) == 1 and key == Qt.Key_Tab:
302                self.toggle_state()
303                return
304
305            if len(self.keys) == 1 and key == Qt.Key_Escape:
306                self.set_sequence('')
307                self.label_warning.setText(_("Please introduce a different "
308                                             "shortcut"))
309
310            if len(self.keys) == 1 and key in [Qt.Key_Return, Qt.Key_Enter]:
311                self.toggle_state()
312                return
313
314            if not self.edit_state:
315                self.nonedit_keyrelease(e)
316            else:
317                debug_print('keys: {}'.format(self.keys))
318                if self.keys and key != Qt.Key_Escape:
319                    self.validate_sequence()
320                self.keys = set()
321                self.key_modifiers = set()
322                self.key_non_modifiers = list()
323                self.key_text = list()
324                self.npressed = 0
325
326    def check_conflicts(self):
327        """Check shortcuts for conflicts."""
328        conflicts = []
329        for index, shortcut in enumerate(self.shortcuts):
330            sequence = str(shortcut.key)
331            if sequence == self.new_sequence and \
332                (shortcut.context == self.context or shortcut.context == '_' or
333                 self.context == '_'):
334                conflicts.append(shortcut)
335        return conflicts
336
337    def update_warning(self, warning_type=NO_WARNING, conflicts=[]):
338        """Update warning label to reflect conflict status of new shortcut"""
339        if warning_type == NO_WARNING:
340            warn = False
341            tip = 'This shortcut is correct!'
342        elif warning_type == SEQUENCE_CONFLICT:
343            template = '<i>{0}<b>{1}</b></i>'
344            tip_title = _('The new shortcut conflicts with:') + '<br>'
345            tip_body = ''
346            for s in conflicts:
347                tip_body += ' - {0}: {1}<br>'.format(s.context, s.name)
348            tip_body = tip_body[:-4]  # Removing last <br>
349            tip = template.format(tip_title, tip_body)
350            warn = True
351        elif warning_type == IN_BLACKLIST:
352            template = '<i>{0}<b>{1}</b></i>'
353            tip_title = _('Forbidden key sequence!') + '<br>'
354            tip_body = ''
355            use = BLACKLIST[self.new_sequence]
356            if use is not None:
357                tip_body = use
358            tip = template.format(tip_title, tip_body)
359            warn = True
360        elif warning_type == SHIFT_BLACKLIST:
361            template = '<i>{0}<b>{1}</b></i>'
362            tip_title = _('Forbidden key sequence!') + '<br>'
363            tip_body = ''
364            use = BLACKLIST['Shift']
365            if use is not None:
366                tip_body = use
367            tip = template.format(tip_title, tip_body)
368            warn = True
369        elif warning_type == SEQUENCE_LENGTH:
370            # Sequences with 5 keysequences (i.e. Ctrl+1, Ctrl+2, Ctrl+3,
371            # Ctrl+4, Ctrl+5) are invalid
372            template = '<i>{0}</i>'
373            tip = _('A compound sequence can have {break} a maximum of '
374                    '4 subsequences.{break}').format(**{'break': '<br>'})
375            warn = True
376        elif warning_type == INVALID_KEY:
377            template = '<i>{0}</i>'
378            tip = _('Invalid key entered') + '<br>'
379            warn = True
380
381        self.helper_button.show()
382        if warn:
383            self.label_warning.show()
384            self.helper_button.setIcon(get_std_icon('MessageBoxWarning'))
385            self.button_ok.setEnabled(False)
386        else:
387            self.helper_button.setIcon(get_std_icon('DialogApplyButton'))
388
389        self.label_warning.setText(tip)
390
391    def set_sequence(self, sequence):
392        """Set the new shortcut and update buttons."""
393        if not sequence or self.sequence == sequence:
394            self.button_ok.setEnabled(False)
395            different_sequence = False
396        else:
397            self.button_ok.setEnabled(True)
398            different_sequence = True
399
400        if sys.platform == 'darwin':
401            if 'Meta+Ctrl' in sequence:
402                shown_sequence = sequence.replace('Meta+Ctrl', 'Ctrl+Cmd')
403            elif 'Ctrl+Meta' in sequence:
404                shown_sequence = sequence.replace('Ctrl+Meta', 'Cmd+Ctrl')
405            elif 'Ctrl' in sequence:
406                shown_sequence = sequence.replace('Ctrl', 'Cmd')
407            elif 'Meta' in sequence:
408                shown_sequence = sequence.replace('Meta', 'Ctrl')
409            else:
410                shown_sequence = sequence
411        else:
412            shown_sequence = sequence
413        self.text_new_sequence.setText(shown_sequence)
414        self.new_sequence = sequence
415
416        conflicts = self.check_conflicts()
417        blacklist = self.new_sequence in BLACKLIST
418        individual_keys = self.new_sequence.split('+')
419        if conflicts and different_sequence:
420            warning_type = SEQUENCE_CONFLICT
421        elif blacklist:
422            warning_type = IN_BLACKLIST
423        elif len(individual_keys) == 2 and individual_keys[0] == 'Shift':
424            warning_type = SHIFT_BLACKLIST
425        else:
426            warning_type = NO_WARNING
427
428        self.update_warning(warning_type=warning_type, conflicts=conflicts)
429
430    def validate_sequence(self):
431        """Provide additional checks for accepting or rejecting shortcuts."""
432        if self.invalid_key_flag:
433            self.update_warning(warning_type=INVALID_KEY)
434            return
435
436        for mod in MODIFIERS:
437            non_mod = set(self.key_non_modifiers)
438            non_mod.discard(mod)
439            if mod in self.key_non_modifiers:
440                self.key_non_modifiers.remove(mod)
441
442        self.key_modifiers = self.key_modifiers - non_mod
443
444        while u'' in self.key_text:
445            self.key_text.remove(u'')
446
447        self.key_text = [k.upper() for k in self.key_text]
448
449        # Fix Backtab, Tab issue
450        if Qt.Key_Backtab in self.key_non_modifiers:
451            idx = self.key_non_modifiers.index(Qt.Key_Backtab)
452            self.key_non_modifiers[idx] = Qt.Key_Tab
453
454        if len(self.key_modifiers) == 0:
455            # Filter single key allowed
456            if self.key_non_modifiers[0] not in VALID_SINGLE_KEYS:
457                return
458            # Filter
459            elif len(self.key_non_modifiers) > 1:
460                return
461
462        # QKeySequence accepts a maximum of 4 different sequences
463        if len(self.keys) > 4:
464            # Update warning
465            self.update_warning(warning_type=SEQUENCE_LENGTH)
466            return
467
468        keys = []
469        for i in range(len(self.keys)):
470            key_seq = 0
471            for m in self.key_modifiers:
472                key_seq += MODIFIERS[m]
473            key_seq += self.key_non_modifiers[i]
474            keys.append(key_seq)
475
476        sequence = QKeySequence(*keys)
477
478        self.set_sequence(sequence.toString())
479
480
481class Shortcut(object):
482    """Shortcut convenience class for holding shortcut context, name,
483    original ordering index, key sequence for the shortcut and localized text.
484    """
485    def __init__(self, context, name, key=None):
486        self.index = 0  # Sorted index. Populated when loading shortcuts
487        self.context = context
488        self.name = name
489        self.key = key
490
491    def __str__(self):
492        return "{0}/{1}: {2}".format(self.context, self.name, self.key)
493
494    def load(self):
495        self.key = get_shortcut(self.context, self.name)
496
497    def save(self):
498        set_shortcut(self.context, self.name, self.key)
499
500
501CONTEXT, NAME, SEQUENCE, SEARCH_SCORE = [0, 1, 2, 3]
502
503
504class ShortcutsModel(QAbstractTableModel):
505    def __init__(self, parent):
506        QAbstractTableModel.__init__(self)
507        self._parent = parent
508
509        self.shortcuts = []
510        self.scores = []
511        self.rich_text = []
512        self.normal_text = []
513        self.letters = ''
514        self.label = QLabel()
515        self.widths = []
516
517        # Needed to compensate for the HTMLDelegate color selection unawarness
518        palette = parent.palette()
519        self.text_color = palette.text().color().name()
520        self.text_color_highlight = palette.highlightedText().color().name()
521
522    def current_index(self):
523        """Get the currently selected index in the parent table view."""
524        i = self._parent.proxy_model.mapToSource(self._parent.currentIndex())
525        return i
526
527    def sortByName(self):
528        """Qt Override."""
529        self.shortcuts = sorted(self.shortcuts,
530                                key=lambda x: x.context+'/'+x.name)
531        self.reset()
532
533    def flags(self, index):
534        """Qt Override."""
535        if not index.isValid():
536            return Qt.ItemIsEnabled
537        return Qt.ItemFlags(QAbstractTableModel.flags(self, index))
538
539    def data(self, index, role=Qt.DisplayRole):
540        """Qt Override."""
541        row = index.row()
542        if not index.isValid() or not (0 <= row < len(self.shortcuts)):
543            return to_qvariant()
544
545        shortcut = self.shortcuts[row]
546        key = shortcut.key
547        column = index.column()
548
549        if role == Qt.DisplayRole:
550            if column == CONTEXT:
551                return to_qvariant(shortcut.context)
552            elif column == NAME:
553                color = self.text_color
554                if self._parent == QApplication.focusWidget():
555                    if self.current_index().row() == row:
556                        color = self.text_color_highlight
557                    else:
558                        color = self.text_color
559                text = self.rich_text[row]
560                text = '<p style="color:{0}">{1}</p>'.format(color, text)
561                return to_qvariant(text)
562            elif column == SEQUENCE:
563                text = QKeySequence(key).toString(QKeySequence.NativeText)
564                return to_qvariant(text)
565            elif column == SEARCH_SCORE:
566                # Treating search scores as a table column simplifies the
567                # sorting once a score for a specific string in the finder
568                # has been defined. This column however should always remain
569                # hidden.
570                return to_qvariant(self.scores[row])
571        elif role == Qt.TextAlignmentRole:
572            return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter))
573        return to_qvariant()
574
575    def headerData(self, section, orientation, role=Qt.DisplayRole):
576        """Qt Override."""
577        if role == Qt.TextAlignmentRole:
578            if orientation == Qt.Horizontal:
579                return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter))
580            return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter))
581        if role != Qt.DisplayRole:
582            return to_qvariant()
583        if orientation == Qt.Horizontal:
584            if section == CONTEXT:
585                return to_qvariant(_("Context"))
586            elif section == NAME:
587                return to_qvariant(_("Name"))
588            elif section == SEQUENCE:
589                return to_qvariant(_("Shortcut"))
590            elif section == SEARCH_SCORE:
591                return to_qvariant(_("Score"))
592        return to_qvariant()
593
594    def rowCount(self, index=QModelIndex()):
595        """Qt Override."""
596        return len(self.shortcuts)
597
598    def columnCount(self, index=QModelIndex()):
599        """Qt Override."""
600        return 4
601
602    def setData(self, index, value, role=Qt.EditRole):
603        """Qt Override."""
604        if index.isValid() and 0 <= index.row() < len(self.shortcuts):
605            shortcut = self.shortcuts[index.row()]
606            column = index.column()
607            text = from_qvariant(value, str)
608            if column == SEQUENCE:
609                shortcut.key = text
610            self.dataChanged.emit(index, index)
611            return True
612        return False
613
614    def update_search_letters(self, text):
615        """Update search letters with text input in search box."""
616        self.letters = text
617        names = [shortcut.name for shortcut in self.shortcuts]
618        results = get_search_scores(text, names, template='<b>{0}</b>')
619        self.normal_text, self.rich_text, self.scores = zip(*results)
620        self.reset()
621
622    def update_active_row(self):
623        """Update active row to update color in selected text."""
624        self.data(self.current_index())
625
626    def row(self, row_num):
627        """Get row based on model index. Needed for the custom proxy model."""
628        return self.shortcuts[row_num]
629
630    def reset(self):
631        """"Reset model to take into account new search letters."""
632        self.beginResetModel()
633        self.endResetModel()
634
635
636class CustomSortFilterProxy(QSortFilterProxyModel):
637    """Custom column filter based on regex."""
638    def __init__(self, parent=None):
639        super(CustomSortFilterProxy, self).__init__(parent)
640        self._parent = parent
641        self.pattern = re.compile(r'')
642
643    def set_filter(self, text):
644        """Set regular expression for filter."""
645        self.pattern = get_search_regex(text)
646        if self.pattern:
647            self._parent.setSortingEnabled(False)
648        else:
649            self._parent.setSortingEnabled(True)
650        self.invalidateFilter()
651
652    def filterAcceptsRow(self, row_num, parent):
653        """Qt override.
654
655        Reimplemented from base class to allow the use of custom filtering.
656        """
657        model = self.sourceModel()
658        name = model.row(row_num).name
659        r = re.search(self.pattern, name)
660
661        if r is None:
662            return False
663        else:
664            return True
665
666
667class ShortcutsTable(QTableView):
668    def __init__(self, parent=None):
669        QTableView.__init__(self, parent)
670        self._parent = parent
671        self.finder = None
672
673        self.source_model = ShortcutsModel(self)
674        self.proxy_model = CustomSortFilterProxy(self)
675        self.last_regex = ''
676
677        self.proxy_model.setSourceModel(self.source_model)
678        self.proxy_model.setDynamicSortFilter(True)
679        self.proxy_model.setFilterKeyColumn(NAME)
680        self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
681        self.setModel(self.proxy_model)
682
683        self.hideColumn(SEARCH_SCORE)
684        self.setItemDelegateForColumn(NAME, HTMLDelegate(self, margin=9))
685        self.setSelectionBehavior(QAbstractItemView.SelectRows)
686        self.setSelectionMode(QAbstractItemView.SingleSelection)
687        self.setSortingEnabled(True)
688        self.setEditTriggers(QAbstractItemView.AllEditTriggers)
689        self.selectionModel().selectionChanged.connect(self.selection)
690
691        self.verticalHeader().hide()
692        self.load_shortcuts()
693
694    def focusOutEvent(self, e):
695        """Qt Override."""
696        self.source_model.update_active_row()
697        super(ShortcutsTable, self).focusOutEvent(e)
698
699    def focusInEvent(self, e):
700        """Qt Override."""
701        super(ShortcutsTable, self).focusInEvent(e)
702        self.selectRow(self.currentIndex().row())
703
704    def selection(self, index):
705        """Update selected row."""
706        self.update()
707        self.isActiveWindow()
708
709    def adjust_cells(self):
710        """Adjust column size based on contents."""
711        self.resizeRowsToContents()
712        self.resizeColumnsToContents()
713        fm = self.horizontalHeader().fontMetrics()
714        names = [fm.width(s.name + ' '*9) for s in self.source_model.shortcuts]
715        self.setColumnWidth(NAME, max(names))
716        self.horizontalHeader().setStretchLastSection(True)
717
718    def load_shortcuts(self):
719        """Load shortcuts and assign to table model."""
720        shortcuts = []
721        for context, name, keystr in iter_shortcuts():
722            shortcut = Shortcut(context, name, keystr)
723            shortcuts.append(shortcut)
724        shortcuts = sorted(shortcuts, key=lambda x: x.context+x.name)
725        # Store the original order of shortcuts
726        for i, shortcut in enumerate(shortcuts):
727            shortcut.index = i
728        self.source_model.shortcuts = shortcuts
729        self.source_model.scores = [0]*len(shortcuts)
730        self.source_model.rich_text = [s.name for s in shortcuts]
731        self.source_model.reset()
732        self.adjust_cells()
733        self.sortByColumn(CONTEXT, Qt.AscendingOrder)
734
735    def check_shortcuts(self):
736        """Check shortcuts for conflicts."""
737        conflicts = []
738        for index, sh1 in enumerate(self.source_model.shortcuts):
739            if index == len(self.source_model.shortcuts)-1:
740                break
741            for sh2 in self.source_model.shortcuts[index+1:]:
742                if sh2 is sh1:
743                    continue
744                if str(sh2.key) == str(sh1.key) \
745                   and (sh1.context == sh2.context or sh1.context == '_' or
746                        sh2.context == '_'):
747                    conflicts.append((sh1, sh2))
748        if conflicts:
749            self.parent().show_this_page.emit()
750            cstr = "\n".join(['%s <---> %s' % (sh1, sh2)
751                              for sh1, sh2 in conflicts])
752            QMessageBox.warning(self, _("Conflicts"),
753                                _("The following conflicts have been "
754                                  "detected:")+"\n"+cstr, QMessageBox.Ok)
755
756    def save_shortcuts(self):
757        """Save shortcuts from table model."""
758        self.check_shortcuts()
759        for shortcut in self.source_model.shortcuts:
760            shortcut.save()
761
762    def show_editor(self):
763        """Create, setup and display the shortcut editor dialog."""
764        index = self.proxy_model.mapToSource(self.currentIndex())
765        row, column = index.row(), index.column()
766        shortcuts = self.source_model.shortcuts
767        context = shortcuts[row].context
768        name = shortcuts[row].name
769
770        sequence_index = self.source_model.index(row, SEQUENCE)
771        sequence = sequence_index.data()
772
773        dialog = ShortcutEditor(self, context, name, sequence, shortcuts)
774
775        if dialog.exec_():
776            new_sequence = dialog.new_sequence
777            self.source_model.setData(sequence_index, new_sequence)
778
779    def set_regex(self, regex=None, reset=False):
780        """Update the regex text for the shortcut finder."""
781        if reset:
782            text = ''
783        else:
784            text = self.finder.text().replace(' ', '').lower()
785
786        self.proxy_model.set_filter(text)
787        self.source_model.update_search_letters(text)
788        self.sortByColumn(SEARCH_SCORE, Qt.AscendingOrder)
789
790        if self.last_regex != regex:
791            self.selectRow(0)
792        self.last_regex = regex
793
794    def next_row(self):
795        """Move to next row from currently selected row."""
796        row = self.currentIndex().row()
797        rows = self.proxy_model.rowCount()
798        if row + 1 == rows:
799            row = -1
800        self.selectRow(row + 1)
801
802    def previous_row(self):
803        """Move to previous row from currently selected row."""
804        row = self.currentIndex().row()
805        rows = self.proxy_model.rowCount()
806        if row == 0:
807            row = rows
808        self.selectRow(row - 1)
809
810    def keyPressEvent(self, event):
811        """Qt Override."""
812        key = event.key()
813        if key in [Qt.Key_Enter, Qt.Key_Return]:
814            self.show_editor()
815        elif key in [Qt.Key_Tab]:
816            self.finder.setFocus()
817        elif key in [Qt.Key_Backtab]:
818            self.parent().reset_btn.setFocus()
819        elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]:
820            super(ShortcutsTable, self).keyPressEvent(event)
821        elif key not in [Qt.Key_Escape, Qt.Key_Space]:
822            text = event.text()
823            if text:
824                if re.search(VALID_FINDER_CHARS, text) is not None:
825                    self.finder.setFocus()
826                    self.finder.set_text(text)
827        elif key in [Qt.Key_Escape]:
828            self.finder.keyPressEvent(event)
829
830    def mouseDoubleClickEvent(self, event):
831        """Qt Override."""
832        self.show_editor()
833
834
835class ShortcutsConfigPage(GeneralConfigPage):
836    CONF_SECTION = "shortcuts"
837
838    NAME = _("Keyboard shortcuts")
839    ICON = ima.icon('keyboard')
840
841    def setup_page(self):
842        # Widgets
843        self.table = ShortcutsTable(self)
844        self.finder = ShortcutFinder(self.table, self.table.set_regex)
845        self.table.finder = self.finder
846        self.label_finder = QLabel(_('Search: '))
847        self.reset_btn = QPushButton(_("Reset to default values"))
848
849        # Layout
850        hlayout = QHBoxLayout()
851        vlayout = QVBoxLayout()
852        hlayout.addWidget(self.label_finder)
853        hlayout.addWidget(self.finder)
854        vlayout.addWidget(self.table)
855        vlayout.addLayout(hlayout)
856        vlayout.addWidget(self.reset_btn)
857        self.setLayout(vlayout)
858
859        self.setTabOrder(self.table, self.finder)
860        self.setTabOrder(self.finder, self.reset_btn)
861
862        # Signals and slots
863        if PYQT5:
864            # Qt5 'dataChanged' has 3 parameters
865            self.table.proxy_model.dataChanged.connect(
866                lambda i1, i2, roles, opt='': self.has_been_modified(opt))
867        else:
868            self.table.proxy_model.dataChanged.connect(
869                lambda i1, i2, opt='': self.has_been_modified(opt))
870        self.reset_btn.clicked.connect(self.reset_to_default)
871
872    def check_settings(self):
873        self.table.check_shortcuts()
874
875    def reset_to_default(self):
876        """Reset to default values of the shortcuts making a confirmation."""
877        reset = QMessageBox.warning(self, _("Shortcuts reset"),
878                                    _("Do you want to reset "
879                                      "to default values?"),
880                                    QMessageBox.Yes | QMessageBox.No)
881        if reset == QMessageBox.No:
882            return
883        reset_shortcuts()
884        self.main.apply_shortcuts()
885        self.table.load_shortcuts()
886        self.load_from_conf()
887        self.set_modified(False)
888
889    def apply_settings(self, options):
890        self.table.save_shortcuts()
891        self.main.apply_shortcuts()
892
893
894def test():
895    from spyder.utils.qthelpers import qapplication
896    app = qapplication()
897    table = ShortcutsTable()
898    table.show()
899    app.exec_()
900    print([str(s) for s in table.source_model.shortcuts])  # spyder: test-skip
901    table.check_shortcuts()
902
903if __name__ == '__main__':
904    test()
905