Repository: spyder-ide/spyder

Issue:
Removal of keyboard shortcut causes errors to be thrown
_From [thevp...@gmail.com](https://code.google.com/u/114967603654191624717/) on 2015-01-20T23:56:05Z_

Spyder Version:  2.3.2
Python Version:  3.4.1
Qt Version    :  4.8.6, PyQt4 (API v2) 4.10.4 on Windows
pyflakes >=0.6.0:  0.8.1 (OK)
pep8 >=0.6      :  1.5.7 (OK)
IPython >=0.13  :  2.3.1 (OK)
zmq >=2.1.11    :  14.4.1 (OK)
pygments >=1.6  :  2.0.1 (OK)
pandas >=0.13.1 :  0.14.0 (OK)
sphinx >=0.6.6  :  1.2.3 (OK)
rope >=0.9.2    :  0.9.4-1 (OK)
matplotlib >=1.0:  1.4.2 (OK)
sympy >=0.7.0   :  0.7.5 (OK)
pylint >=0.25   :  1.4.0 (OK) 

What steps will reproduce the problem?
1. Go to Keyboard Shortcuts (Tools -> Preferences --> Keyboard Shortcuts)
2. Pick a shortcut of your choice and select the blank value for all shortcut modifiers
3. Double-click on "Key" column for that shortcut, and select a random character.
4. Press "OK", and verify that the shortcut has been "removed" by checking the menus (In my case, I first tested the removal of the shortcut for "Quit", and the character I chose was "0". The option under File-->Quit showed that there was no shortcut to that command.) 

What is the expected output? What do you see instead?

 An error message should pop-up, stating that this is an invalid choice. Or, the command item should be removed completely. However, Spyder accepts the options but starts throwing errors and becomes unstable, throwing errors described in the section below. Also, at this point, I cannot re-define the shortcut - any choices made will be cleared as soon as another box is pressed. Only way to make things stable again is to press "restore keyboard shortcuts". 

Please provide any additional information below

Errors produced in console after the problem is created and the Keyboard Shortcuts menu is opened in the Preferences window.

```
AttributeError: 'int' object has no attribute 'modifiers'
'int' object has no attribute 'modifiers'
Traceback (most recent call last):
  File "C:\Anaconda3\lib\site-packages\spyderlib\plugins\shortcuts.py", line 158, in data
    return to_qvariant(Key.MODIFIERNAMES[key.modifiers[0]])
AttributeError: 'int' object has no attribute 'modifiers'
Traceback (most recent call last):
  File "C:\Anaconda3\lib\site-packages\spyderlib\plugins\shortcuts.py", line 160, in data
    return to_qvariant(Key.MODIFIERNAMES[key.modifiers[1]])
AttributeError: 'int' object has no attribute 'modifiers'
Traceback (most recent call last):
  File "C:\Anaconda3\lib\site-packages\spyderlib\plugins\shortcuts.py", line 162, in data
    return to_qvariant(Key.MODIFIERNAMES[key.modifiers[2]])
AttributeError: 'int' object has no attribute 'modifiers'
Traceback (most recent call last):
  File "C:\Anaconda3\lib\site-packages\spyderlib\plugins\shortcuts.py", line 164, in data
    return to_qvariant(Key.KEYS[key.key])
AttributeError: 'int' object has no attribute 'key'
```

_Original issue: http://code.google.com/p/spyderlib/issues/detail?id=2125_



Discussion:
_From [ccordoba12](https://code.google.com/u/ccordoba12/) on 2015-01-30T14:04:00Z_

**Labels:** MS-v2.3.4 Cat-Settings  

Just for you to notice it @blink1073 :-)


Resolving patch:
diff --git a/spyderlib/plugins/shortcuts.py b/spyderlib/plugins/shortcuts.py
--- a/spyderlib/plugins/shortcuts.py
+++ b/spyderlib/plugins/shortcuts.py
@@ -7,179 +7,504 @@
 """Shortcut management"""
 
 from __future__ import print_function
+import os
+import re
+import sys
 
-from spyderlib.qt.QtGui import (QVBoxLayout, QComboBox, QItemDelegate,
-                                QTableView, QMessageBox, QPushButton)
-from spyderlib.qt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex
+from spyderlib.qt.QtGui import (QVBoxLayout, QTableView, QMessageBox,
+                                QPushButton, QKeySequence, QDialog,
+                                QDialogButtonBox, QLabel, QGridLayout,
+                                QLineEdit, QAbstractItemView,
+                                QSortFilterProxyModel, QStyledItemDelegate,
+                                QStyleOptionViewItem, QApplication,
+                                QTextDocument, QStyle, QSpacerItem,
+                                QAbstractTextDocumentLayout,
+                                QRegExpValidator, QToolTip,
+                                QHBoxLayout)
+from spyderlib.qt.QtCore import (Qt, QAbstractTableModel, QModelIndex, QSize,
+                                 QPoint, QRegExp)
 from spyderlib.qt.compat import to_qvariant, from_qvariant
 import spyderlib.utils.icon_manager as ima
 
-import sys
-
 # Local imports
-from spyderlib.baseconfig import _
+from spyderlib.baseconfig import _, debug_print
 from spyderlib.guiconfig import (get_shortcut, set_shortcut,
                                  iter_shortcuts, reset_shortcuts)
 from spyderlib.plugins.configdialog import GeneralConfigPage
-from spyderlib.py3compat import to_text_string, is_text_string
+from spyderlib.utils.qthelpers import get_std_icon
+from spyderlib.widgets.arraybuilder import HelperToolButton
 
+MODIFIERS = {Qt.Key_Shift: Qt.SHIFT,
+             Qt.Key_Control: Qt.CTRL,
+             Qt.Key_Alt: Qt.ALT,
+             Qt.Key_Meta: Qt.META}
 
-KEYSTRINGS = ["Escape", "Tab", "Backtab", "Backspace", "Return", "Enter",
-              "Delete", "Pause", "Print", "Clear", "Home", "End", "Left",
+SINGLE_KEYS = ["F{}".format(_i) for _i in range(1, 36)] + ["Delete", "Escape"]
+KEYSTRINGS = ["Tab", "Backtab", "Backspace", "Return", "Enter",
+              "Pause", "Print", "Clear", "Home", "End", "Left",
               "Up", "Right", "Down", "PageUp", "PageDown"] + \
-             ["F%d" % _i for _i in range(1, 36)] + \
-             ["Space", "Exclam", "QuoteDbl", "NumberSign", "Dollar", "Percent",
-              "Ampersand", "Apostrophe", "ParenLeft", "ParenRight", "Asterisk",
-              "Plus", "Comma", "Minus", "Period", "Slash"] + \
+             ["Space", "Exclam", "QuoteDbl", "NumberSign", "Dollar",
+              "Percent", "Ampersand", "Apostrophe", "ParenLeft",
+              "ParenRight", "Asterisk", "Plus", "Comma", "Minus",
+              "Period", "Slash"] + \
              [str(_i) for _i in range(10)] + \
-             ["Colon", "Semicolon", "Less", "Equal", "Greater", "Question",
-              "At"] + [chr(_i) for _i in range(65, 91)] + \
-             ["BracketLeft", "Backslash", "BracketRight", "Underscore"]
-
-
-class Key(object):
-    MODIFIERS = {Qt.NoModifier: "", Qt.ShiftModifier: "Shift",
-                 Qt.ControlModifier: "Ctrl", Qt.AltModifier: "Alt",
-                 Qt.MetaModifier: "Meta"}
-    if sys.platform == 'darwin':
-        MODIFIERNAMES = {Qt.NoModifier: "", Qt.ShiftModifier: "Shift",
-                         Qt.ControlModifier: "Cmd", Qt.AltModifier: "Alt",
-                         Qt.MetaModifier: "Ctrl"}
-    elif sys.platform == 'win32':
-        MODIFIERNAMES = {Qt.NoModifier: "", Qt.ShiftModifier: "Shift",
-                         Qt.ControlModifier: "Ctrl", Qt.AltModifier: "Alt",
-                         Qt.MetaModifier: "Win"}
-    else:
-        MODIFIERNAMES = {Qt.NoModifier: "", Qt.ShiftModifier: "Shift",
-                         Qt.ControlModifier: "Ctrl", Qt.AltModifier: "Alt",
-                         Qt.MetaModifier: "Meta"}
-    KEYS = {}
-    for attr in KEYSTRINGS:
-        # ISSUE
-        # This is needed to cope with a strange behaviour of Qt.
-        # Qt.Key_PageUp/Qt.Key_PageDown when string parsed use PgDown/PgUp
-        attr2 = attr
-        if attr == 'PageDown':
-            attr2 = 'PgDown'
-        elif attr == 'PageUp':
-            attr2 = 'PgUp'
-
-        KEYS[getattr(Qt, "Key_"+attr)] = attr2
-
-    def __init__(self, key, mod1=Qt.NoModifier, mod2=Qt.NoModifier,
-                 mod3=Qt.NoModifier):
-        modifiers = [mod1, mod2, mod3]
-        assert all([mod in self.MODIFIERS for mod in modifiers])
-        self.modifiers = sorted(modifiers)
-        assert key in self.KEYS
-        self.key = key
+             ["Colon", "Semicolon", "Less", "Equal", "Greater",
+              "Question", "At"] + [chr(_i) for _i in range(65, 91)] + \
+             ["BracketLeft", "Backslash", "BracketRight", "Underscore",
+              "Control", "Alt", "Shift", "Meta"]
+VALID_SINGLE_KEYS = [getattr(Qt, 'Key_{0}'.format(k)) for k in SINGLE_KEYS]
+VALID_KEYS = [getattr(Qt, 'Key_{0}'.format(k)) for k in KEYSTRINGS+SINGLE_KEYS]
+VALID_ACCENT_CHARS = "ÁÉÍOÚáéíúóàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÄËÏÖÜñÑ"
+VALID_FINDER_CHARS = "[A-Za-z\s{0}]".format(VALID_ACCENT_CHARS)
+
+
+class CustomLineEdit(QLineEdit):
+    """QLineEdit that filters its key press and release events."""
+    def __init__(self, parent):
+        super(CustomLineEdit, self).__init__(parent)
+        self.setReadOnly(True)
+        self.setFocusPolicy(Qt.NoFocus)
+
+    def keyPressEvent(self, e):
+        """Qt Override"""
+        self.parent().keyPressEvent(e)
+
+    def keyReleaseEvent(self, e):
+        """Qt Override"""
+        self.parent().keyReleaseEvent(e)
+
+
+# TODO: Move to another place for common use widgets afterwards.
+class HTMLDelegate(QStyledItemDelegate):
+    """With this delegate, a QListWidgetItem can render HTML.
+
+    Taken from http://stackoverflow.com/a/5443112/2399799
+    """
+    def __init__(self, parent, margin=0):
+        super(HTMLDelegate, self).__init__(parent)
+        self._margin = margin
+
+    def paint(self, painter, option, index):
+        options = QStyleOptionViewItem(option)
+        self.initStyleOption(options, index)
+
+        style = (QApplication.style() if options.widget is None
+                 else options.widget.style())
+
+        doc = QTextDocument()
+        doc.setDocumentMargin(self._margin)
+        doc.setHtml(options.text)
+
+        options.text = ""
+        style.drawControl(QStyle.CE_ItemViewItem, options, painter)
+
+        ctx = QAbstractTextDocumentLayout.PaintContext()
+
+        textRect = style.subElementRect(QStyle.SE_ItemViewItemText, options)
+        painter.save()
+        painter.translate(textRect.topLeft())
+        painter.setClipRect(textRect.translated(-textRect.topLeft()))
+        doc.documentLayout().draw(painter, ctx)
+
+        painter.restore()
+
+    def sizeHint(self, option, index):
+        options = QStyleOptionViewItem(option)
+        self.initStyleOption(options, index)
+
+        doc = QTextDocument()
+        doc.setHtml(options.text)
+        doc.setTextWidth(options.rect.width())
+
+        return QSize(doc.idealWidth(), doc.size().height())
+
+
+class ShortcutFinder(QLineEdit):
+    """Textbox for filtering listed shortcuts in the table."""
+    def __init__(self, parent, callback=None):
+        super(ShortcutFinder, self).__init__(parent)
+
+        self._parent = parent
+        regex = QRegExp(VALID_FINDER_CHARS + "{100}")
+        self.setValidator(QRegExpValidator(regex))
+
+        # Signals
+        if callback:
+            self.textChanged.connect(callback)
+
+    def set_text(self, text):
+        """Set the filter text"""
+        text = text.strip()
+        new_text = self.text() + text
+        self.setText(new_text)
+
+    def keyPressEvent(self, event):
+        """Qt Override"""
+        key = event.key()
+        if key in [Qt.Key_Up]:
+            self._parent.previous_row()
+        elif key in [Qt.Key_Down]:
+            self._parent.next_row()
+        elif key in [Qt.Key_Enter, Qt.Key_Return]:
+            self._parent.show_editor()
+        else:
+            super(ShortcutFinder, self).keyPressEvent(event)
+
+
+class ShortcutEditor(QDialog):
+    """A dialog for entering key sequences."""
+    def __init__(self, parent, context, name, sequence, shortcuts):
+        super(ShortcutEditor, self).__init__(parent)
+        self.context = context
+        self.npressed = 0
+        self.keys = set()
+        self.key_modifiers = set()
+        self.key_non_modifiers = list()
+        self.key_text = list()
+        self.sequence = sequence
+        self.new_sequence = None
+        self.edit_state = True
+        self.shortcuts = shortcuts
+
+        # Widgets
+        self.label_info = QLabel(_("Press the new shortcut and select 'Ok': \n"
+                                   "(Press 'Tab' once to switch focus between "
+                                   "shortcut entry and buttons)"))
+        self.label_current_sequence = QLabel(_("Current shortcut:"))
+        self.text_current_sequence = QLabel(sequence)
+        self.label_new_sequence = QLabel(_("New shortcut:"))
+        self.text_new_sequence = CustomLineEdit(self)
+        self.text_new_sequence.setPlaceholderText(sequence)
+        self.helper_button = HelperToolButton()
+
+        bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+        self.button_ok = bbox.button(QDialogButtonBox.Ok)
+        self.button_cancel = bbox.button(QDialogButtonBox.Cancel)
+
+        # Setup widgets
+        self.setWindowTitle(_('Shortcut: {0}').format(name))
+        self.button_ok.setFocusPolicy(Qt.NoFocus)
+        self.button_ok.setEnabled(False)
+        self.button_cancel.setFocusPolicy(Qt.NoFocus)
+        self.helper_button.setToolTip('')
+        self.helper_button.setFocusPolicy(Qt.NoFocus)
+        style = """
+            QToolButton {
+              margin:1px;
+              border: 0px solid grey;
+              padding:0px;
+              border-radius: 0px;
+            }"""
+        self.helper_button.setStyleSheet(style)
+
+        # Layout
+        spacing = 24
+        layout_sequence = QGridLayout()
+        layout_sequence.addWidget(self.label_info, 0, 0, 1, 3)
+        layout_sequence.addItem(QSpacerItem(spacing, spacing), 1, 0, 1, 2)
+        layout_sequence.addWidget(self.label_current_sequence, 2, 0)
+        layout_sequence.addWidget(self.text_current_sequence, 2, 2)
+        layout_sequence.addWidget(self.label_new_sequence, 3, 0)
+        layout_sequence.addWidget(self.helper_button, 3, 1)
+        layout_sequence.addWidget(self.text_new_sequence, 3, 2)
+
+        layout = QVBoxLayout()
+        layout.addLayout(layout_sequence)
+        layout.addSpacing(spacing)
+        layout.addWidget(bbox)
+        self.setLayout(layout)
+
+        # Signals
+        bbox.accepted.connect(self.accept)
+        bbox.rejected.connect(self.reject)
+
+    def keyPressEvent(self, e):
+        """Qt override"""
+        key = e.key()
+
+        self.npressed += 1
+        self.key_non_modifiers.append(key)
+        self.key_modifiers.add(key)
+        self.key_text.append(e.text())
+
+        debug_print('key %s, npressed: %s' % (key, self.npressed))
+
+        if key == Qt.Key_unknown:
+            return
+
+        # The user clicked just and only the special keys
+        # Ctrl, Shift, Alt, Meta.
+        if (key == Qt.Key_Control or
+                key == Qt.Key_Shift or
+                key == Qt.Key_Alt or
+                key == Qt.Key_Meta):
+            return
+
+        # Check if valid keys
+        if key not in VALID_KEYS:
+            return
+
+        modifiers = e.modifiers()
+        if modifiers & Qt.ShiftModifier:
+            key += Qt.SHIFT
+        if modifiers & Qt.ControlModifier:
+            key += Qt.CTRL
+            if sys.platform == 'darwin':
+                self.npressed -= 1
+            debug_print('decrementing')
+        if modifiers & Qt.AltModifier:
+            key += Qt.ALT
+        if modifiers & Qt.MetaModifier:
+            key += Qt.META
+
+        self.keys.add(key)
+
+    def toggle_state(self):
+        """Switch between shortcut entry and Accept/Cancel shortcut mode."""
+        self.edit_state = not self.edit_state
+
+        if not self.edit_state:
+            self.text_new_sequence.setEnabled(False)
+            if self.button_ok.isEnabled():
+                self.button_ok.setFocus()
+            else:
+                self.button_cancel.setFocus()
+        else:
+            self.text_new_sequence.setEnabled(True)
+            self.text_new_sequence.setFocus()
+
+    def nonedit_keyrelease(self, e):
+        """Key release event for non-edit state."""
+        key = e.key()
+
+        if key == Qt.Key_Escape:
+#            self.close()
+            pass
+
+        if key in [Qt.Key_Left, Qt.Key_Right, Qt.Key_Up,
+                   Qt.Key_Down]:
+            if self.button_ok.hasFocus():
+                self.button_cancel.setFocus()
+            else:
+                self.button_ok.setFocus()
+
+    def keyReleaseEvent(self, e):
+        """Qt override"""
+        self.npressed -= 1
+        if self.npressed <= 0:
+            if len(self.keys) == 1 and (list(self.keys)[0] == Qt.Key_Tab):
+                self.toggle_state()
+
+            if not self.edit_state:
+                self.nonedit_keyrelease(e)
+            else:
+                debug_print('keys: {}'.format(self.keys))
+                if self.keys:
+                    self.validate_sequence()
+                self.keys = set()
+                self.key_modifiers = set()
+                self.key_non_modifiers = list()
+                self.key_text = list()
+                self.npressed = 0
+
+    def check_conflicts(self):
+        """Check shortcuts for conflicts"""
+        conflicts = []
+        for index, shortcut in enumerate(self.shortcuts):
+            sequence = str(shortcut.key)
+            if sequence == self.new_sequence and \
+                (shortcut.context == self.context or shortcut.context == '_' or
+                 self.context == '_'):
+                conflicts.append(shortcut)
+        return conflicts
+
+    def update_warning(self, reset=False):
+        """ """
+        conflicts = self.check_conflicts()
+        if reset:
+            conflicts = []
+
+        widget = self.helper_button
+
+        if conflicts:
+            tip_title = _('The new entered shorcut conflicts with:') + '\n'
+            tip_body = ''
+            for s in conflicts:
+                tip_body += ' - {0}: {1}\n'.format(s.context, s.name)
+            tip = '{0}{1}'.format(tip_title, tip_body)
+            widget.setIcon(get_std_icon('MessageBoxWarning'))
+            widget.setToolTip(tip)
+            QToolTip.showText(widget.mapToGlobal(QPoint(0, 5)), tip)
+        else:
+            widget.setToolTip('')
+            QToolTip.hideText()
+            widget.setIcon(get_std_icon('DialogApplyButton'))
+
+    def set_sequence(self, sequence):
+        """Set the new shortcut and update buttons."""
+        if self.sequence != sequence:
+            self.button_ok.setEnabled(True)
+            reset = False
+        else:
+            self.button_ok.setEnabled(False)
+            reset = True
+
+        self.text_new_sequence.setText(sequence)
+        self.new_sequence = sequence
+        self.update_warning(reset=reset)
+
+    def validate_sequence(self):
+        """Provide additional checks for accepting or rejecting shortcuts."""
+        for mod in MODIFIERS:
+            non_mod = set(self.key_non_modifiers)
+            non_mod.discard(mod)
+            if mod in self.key_non_modifiers:
+                self.key_non_modifiers.remove(mod)
+
+        self.key_modifiers = self.key_modifiers - non_mod
+
+        while u'' in self.key_text:
+            self.key_text.remove(u'')
+
+        self.key_text = [k.upper() for k in self.key_text]
+
+        # Fix Backtab, Tab issue
+        if os.name == 'nt':
+            if Qt.Key_Backtab in self.key_non_modifiers:
+                idx = self.key_non_modifiers.index(Qt.Key_Backtab)
+                self.key_non_modifiers[idx] = Qt.Key_Tab
+
+        if len(self.key_modifiers) == 0:
+            # Filter single key allowed
+            if self.key_non_modifiers[0] not in VALID_SINGLE_KEYS:
+                return
+            # Filter
+            elif len(self.key_non_modifiers) > 1:
+                return
+
+        # QKeySequence accepts a maximum of 4 different sequences
+        if len(self.keys) > 4:
+            # If the user enters more than 4, take the first 4 only
+            self.keys = set([0, 1, 2, 3])
+
+        keys = []
+        for i in range(len(self.keys)):
+            key_seq = 0
+            for m in self.key_modifiers:
+                key_seq += MODIFIERS[m]
+            key_seq += self.key_non_modifiers[i]
+            keys.append(key_seq)
+
+        sequence = QKeySequence(*keys)
+        self.set_sequence(sequence.toString())
 
-    def __str__(self):
-        tlist = []
-        for mod in sorted(list(set(self.modifiers))):
-            if mod != Qt.NoModifier:
-                tlist.append(self.MODIFIERS[mod])
-        tlist.append(self.KEYS[self.key])
-        return "+".join(tlist)
-    
-    def __unicode__(self):
-        return to_text_string(self.__str__())
-    
-    @staticmethod
-    def modifier_from_str(modstr):
-        for k, v in list(Key.MODIFIERS.items()):
-            if v.lower() == modstr.lower():
-                return k
-    
-    @staticmethod
-    def key_from_str(keystr):
-        for k, v in list(Key.KEYS.items()):
-            if v.lower() == keystr.lower():
-                return k
-
-    @staticmethod
-    def modifier_from_name(modname):
-        for k, v in list(Key.MODIFIERNAMES.items()):
-            if v.lower() == modname.lower():
-                return k        
-
-def keystr2key(keystr):
-    keylist = keystr.split("+")
-    mods = []
-    if len(keylist) > 1:
-        for modstr in keylist[:-1]:
-            mods.append(Key.modifier_from_str(modstr))
-    return Key(Key.key_from_str(keylist[-1]), *mods)
 
 class Shortcut(object):
     def __init__(self, context, name, key=None):
         self.context = context
         self.name = name
-        if is_text_string(key):
-            key = keystr2key(key)
         self.key = key
-        
+
     def __str__(self):
-        return "%s/%s: %s" % (self.context, self.name, self.key)
-    
+        return "{0}/{1}: {2}".format(self.context, self.name, self.key)
+
     def load(self):
-        self.key = keystr2key(get_shortcut(self.context, self.name))
-    
+        self.key = get_shortcut(self.context, self.name)
+
     def save(self):
-        set_shortcut(self.context, self.name, str(self.key))
+        set_shortcut(self.context, self.name, self.key)
 
 
-CONTEXT, NAME, MOD1, MOD2, MOD3, KEY = list(range(6))
+CONTEXT, NAME, SEQUENCE = [0, 1, 2]
+
 
 class ShortcutsModel(QAbstractTableModel):
-    def __init__(self):
+    def __init__(self, parent):
         QAbstractTableModel.__init__(self)
         self.shortcuts = []
+        self._parent = parent
+        self.letters = ''
+        self.label = QLabel()
+        self.widths = []
+
+        # Needed to compensate for the HTMLDelegate color selection unawarness
+        palette = parent.palette()
+        self.text_color = palette.text().color().name()
+        self.text_color_highlight = palette.highlightedText().color().name()
+
+    def current_index(self):
+        """Get the currently selected index in the parent table view."""
+        i = self._parent.proxy_model.mapToSource(self._parent.currentIndex())
+        return i
+
+    def _enrich_text(self, text, color):
+        """Highlight (bold) letters from the shortcut finder."""
+        template = '<b>{0}</b>'
+        if not self.letters:
+            new_text = text
+        elif self.letters in text:
+            new_text = text.replace(self.letters, template)
+            new_text = new_text.format(self.letters)
+        else:
+            new_text = [l for l in text]
+            temp_text = [l.lower() for l in text]
+            for l in self.letters:
+                if l != u'' and l in temp_text:
+                    index = temp_text.index(l)
+                    new_text[index] = template.format(text[index])
+                    temp_text = [' ']*(index + 1) + temp_text[index+1:]
+            new_text = ''.join(new_text)
+
+        new_text = new_text
+        new_text = '<p style="color:{}">'.format(color) + new_text + '</p>'
+        return new_text
 
     def sortByName(self):
+        """Qt Override"""
         self.shortcuts = sorted(self.shortcuts,
                                 key=lambda x: x.context+'/'+x.name)
         self.reset()
 
     def flags(self, index):
+        """Qt Override"""
         if not index.isValid():
             return Qt.ItemIsEnabled
-        column = index.column()
-        if column in (MOD1, MOD2, MOD3, KEY):
-            return Qt.ItemFlags(QAbstractTableModel.flags(self, index)|
-                                Qt.ItemIsEditable)
-        else:
-            return Qt.ItemFlags(QAbstractTableModel.flags(self, index))
+        return Qt.ItemFlags(QAbstractTableModel.flags(self, index))
 
     def data(self, index, role=Qt.DisplayRole):
+        """Qt Override"""
         if not index.isValid() or \
            not (0 <= index.row() < len(self.shortcuts)):
             return to_qvariant()
+
         shortcut = self.shortcuts[index.row()]
         key = shortcut.key
         column = index.column()
+
         if role == Qt.DisplayRole:
             if column == CONTEXT:
                 return to_qvariant(shortcut.context)
             elif column == NAME:
-                return to_qvariant(shortcut.name)
-            elif column == MOD1:
-                return to_qvariant(Key.MODIFIERNAMES[key.modifiers[0]])
-            elif column == MOD2:
-                return to_qvariant(Key.MODIFIERNAMES[key.modifiers[1]])
-            elif column == MOD3:
-                return to_qvariant(Key.MODIFIERNAMES[key.modifiers[2]])
-            elif column == KEY:
-                return to_qvariant(Key.KEYS[key.key])
+                color = self.text_color
+                if self._parent == QApplication.focusWidget():
+                    if self.current_index().row() == index.row():
+                        color = self.text_color_highlight
+                    else:
+                        color = self.text_color
+                return to_qvariant(self._enrich_text(shortcut.name, color))
+            elif column == SEQUENCE:
+                text = QKeySequence(key).toString(QKeySequence.NativeText)
+                return to_qvariant(text)
         elif role == Qt.TextAlignmentRole:
-            return to_qvariant(int(Qt.AlignHCenter|Qt.AlignVCenter))
+            return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter))
         return to_qvariant()
 
     def headerData(self, section, orientation, role=Qt.DisplayRole):
+        """Qt Override"""
         if role == Qt.TextAlignmentRole:
             if orientation == Qt.Horizontal:
-                return to_qvariant(int(Qt.AlignHCenter|Qt.AlignVCenter))
-            return to_qvariant(int(Qt.AlignRight|Qt.AlignVCenter))
+                return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter))
+            return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter))
         if role != Qt.DisplayRole:
             return to_qvariant()
         if orientation == Qt.Horizontal:
@@ -187,186 +512,274 @@ def headerData(self, section, orientation, role=Qt.DisplayRole):
                 return to_qvariant(_("Context"))
             elif section == NAME:
                 return to_qvariant(_("Name"))
-            elif section == MOD1:
-                return to_qvariant(_("Mod1"))
-            elif section == MOD2:
-                return to_qvariant(_("Mod2"))
-            elif section == MOD3:
-                return to_qvariant(_("Mod3"))
-            elif section == KEY:
-                return to_qvariant(_("Key"))
+            elif section == SEQUENCE:
+                return to_qvariant(_("Shortcut"))
         return to_qvariant()
 
     def rowCount(self, index=QModelIndex()):
+        """Qt Override"""
         return len(self.shortcuts)
 
     def columnCount(self, index=QModelIndex()):
-        return 6
-    
+        """Qt Override"""
+        return 3
+
     def setData(self, index, value, role=Qt.EditRole):
+        """Qt Override"""
         if index.isValid() and 0 <= index.row() < len(self.shortcuts):
             shortcut = self.shortcuts[index.row()]
-            key = shortcut.key
             column = index.column()
             text = from_qvariant(value, str)
-            if column == MOD1:
-                key.modifiers[0] = Key.modifier_from_name(text)
-            elif column == MOD2:
-                key.modifiers[1] = Key.modifier_from_name(text)
-            elif column == MOD3:
-                key.modifiers[2] = Key.modifier_from_name(text)
-            elif column == KEY:
-                key.key = Key.key_from_str(text)
+            if column == SEQUENCE:
+                shortcut.key = text
             self.dataChanged.emit(index, index)
             return True
         return False
 
+    def update_search_letters(self, text):
+        """ """
+        self.letters = text
+        self.reset()
+
+    def update_active_row(self):
+        """ """
+        self.data(self.current_index())
+
+    def row(self, row_num):
+        """Get row based on model index. Needed for the custom proxy model."""
+        return self.shortcuts[row_num]
+
     def reset(self):
         self.beginResetModel()
         self.endResetModel()
 
 
-class ShortcutsDelegate(QItemDelegate):
+class CustomSortFilterProxy(QSortFilterProxyModel):
+    """Custom column filter based on regex."""
     def __init__(self, parent=None):
-        QItemDelegate.__init__(self, parent)
-        self.modifiers = sorted(Key.MODIFIERNAMES.values())
-        self.mod = None
-        self.keys = sorted(Key.KEYS.values())
-        self.key = None
-        
-    def sizeHint(self, option, index):
-        fm = option.fontMetrics
-        if index.column() in (MOD1, MOD2, MOD3):
-            if self.mod is None:
-                w = 0
-                for mod in self.modifiers:
-                    cw = fm.width(mod)
-                    if cw > w:
-                        w = cw
-                        self.mod = mod
-            else:
-                w = fm.width(self.mod)
-            return QSize(w+20, fm.height())
-        elif index.column() == KEY:
-            if self.key is None:
-                w = 0
-                for key in self.keys:
-                    cw = fm.width(key)
-                    if cw > w:
-                        w = cw
-                        self.key = key
-            else:
-                w = fm.width(self.key)
-            return QSize(w+20, fm.height())
-        return QItemDelegate.sizeHint(self, option, index)
-
-    def createEditor(self, parent, option, index):
-        if index.column() in (MOD1, MOD2, MOD3):
-            combobox = QComboBox(parent)
-            combobox.addItems(self.modifiers)
-            return combobox
-        elif index.column() == KEY:
-            combobox = QComboBox(parent)
-            combobox.addItems(self.keys)
-            return combobox
-        else:
-            return QItemDelegate.createEditor(self, parent, option,
-                                              index)
-
-    def setEditorData(self, editor, index):
-        text = from_qvariant(index.model().data(index, Qt.DisplayRole), str)
-        if index.column() in (MOD1, MOD2, MOD3, KEY):
-            i = editor.findText(text)
-            if i == -1:
-                i = 0
-            editor.setCurrentIndex(i)
-        else:
-            QItemDelegate.setEditorData(self, editor, index)
-
-    def setModelData(self, editor, model, index):
-        if index.column() in (MOD1, MOD2, MOD3, KEY):
-            model.setData(index, to_qvariant(editor.currentText()))
+        super(CustomSortFilterProxy, self).__init__(parent)
+        self._parent = parent
+        self.pattern = re.compile(u'')
+
+    def set_filter(self, text):
+        """Set regular expression for filter."""
+        fuzzy_text = [ch for ch in text if ch != ' ']
+        fuzzy_text = '.*'.join(fuzzy_text)
+        regex = '({0})'.format(fuzzy_text)
+        self.pattern = re.compile(regex)
+        self.invalidateFilter()
+
+    def filterAcceptsRow(self, row_num, parent):
+        """Qt override
+
+        Reimplemented from base class to allow the use of custom filtering.
+        """
+        model = self.sourceModel()
+        name = model.row(row_num).name
+        r = re.search(self.pattern, name)
+
+        if r is None:
+            return False
         else:
-            QItemDelegate.setModelData(self, editor, model, index)
+            return True
 
 
 class ShortcutsTable(QTableView):
     def __init__(self, parent=None):
         QTableView.__init__(self, parent)
-        self.model = ShortcutsModel()
-        self.setModel(self.model)
-        self.setItemDelegate(ShortcutsDelegate(self))
+        self.source_model = ShortcutsModel(self)
+        self.proxy_model = CustomSortFilterProxy(self)
+        self.last_regex = ''
+
+        self.proxy_model.setSourceModel(self.source_model)
+        self.proxy_model.setDynamicSortFilter(True)
+        self.proxy_model.setFilterKeyColumn(NAME)
+        self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
+        self.setModel(self.proxy_model)
+
+        self.setItemDelegateForColumn(NAME, HTMLDelegate(self, margin=9))
+        self.setSelectionBehavior(QAbstractItemView.SelectRows)
+        self.setSelectionMode(QAbstractItemView.SingleSelection)
+
+        self.setEditTriggers(QAbstractItemView.AllEditTriggers)
+        self.selectionModel().selectionChanged.connect(self.selection)
+
+        self.verticalHeader().hide()
         self.load_shortcuts()
-                     
+
+    def focusOutEvent(self, e):
+        """Qt Override"""
+        self.source_model.update_active_row()
+        super(ShortcutsTable, self).focusOutEvent(e)
+
+    def focusInEvent(self, e):
+        """Qt Override"""
+        super(ShortcutsTable, self).focusInEvent(e)
+        self.selectRow(self.currentIndex().row())
+
+    def selection(self, index):
+        """Update selected row."""
+        self.update()
+        self.isActiveWindow()
+
     def adjust_cells(self):
+        """Adjust column size based on contents."""
         self.resizeColumnsToContents()
-#        self.resizeRowsToContents()
+        fm = self.horizontalHeader().fontMetrics()
+        names = [fm.width(s.name + ' '*9) for s in self.source_model.shortcuts]
+        self.setColumnWidth(NAME, max(names))
         self.horizontalHeader().setStretchLastSection(True)
-        
+
     def load_shortcuts(self):
+        """Load shortcuts and assign to table model."""
         shortcuts = []
         for context, name, keystr in iter_shortcuts():
             shortcut = Shortcut(context, name, keystr)
             shortcuts.append(shortcut)
         shortcuts = sorted(shortcuts, key=lambda x: x.context+x.name)
-        self.model.shortcuts = shortcuts
-        self.model.reset()
+        self.source_model.shortcuts = shortcuts
+        self.source_model.reset()
         self.adjust_cells()
 
     def check_shortcuts(self):
-        """Check shortcuts for conflicts"""
+        """Check shortcuts for conflicts."""
         conflicts = []
-        for index, sh1 in enumerate(self.model.shortcuts):
-            if index == len(self.model.shortcuts)-1:
+        for index, sh1 in enumerate(self.source_model.shortcuts):
+            if index == len(self.source_model.shortcuts)-1:
                 break
-            for sh2 in self.model.shortcuts[index+1:]:
+            for sh2 in self.source_model.shortcuts[index+1:]:
                 if sh2 is sh1:
                     continue
                 if str(sh2.key) == str(sh1.key) \
-                   and (sh1.context == sh2.context or sh1.context == '_'
-                        or sh2.context == '_'):
+                   and (sh1.context == sh2.context or sh1.context == '_' or
+                        sh2.context == '_'):
                     conflicts.append((sh1, sh2))
         if conflicts:
             self.parent().show_this_page.emit()
             cstr = "\n".join(['%s <---> %s' % (sh1, sh2)
                               for sh1, sh2 in conflicts])
-            QMessageBox.warning(self, _( "Conflicts"),
+            QMessageBox.warning(self, _("Conflicts"),
                                 _("The following conflicts have been "
                                   "detected:")+"\n"+cstr, QMessageBox.Ok)
-        
+
     def save_shortcuts(self):
+        """Save shortcuts from table model."""
         self.check_shortcuts()
-        for shortcut in self.model.shortcuts:
+        for shortcut in self.source_model.shortcuts:
             shortcut.save()
-        
+
+    def show_editor(self):
+        """Create, setup and display the shortcut editor dialog."""
+        index = self.proxy_model.mapToSource(self.currentIndex())
+        row, column = index.row(), index.column()
+        shortcuts = self.source_model.shortcuts
+        context = shortcuts[row].context
+        name = shortcuts[row].name
+
+        sequence_index = self.source_model.index(row, SEQUENCE)
+        sequence = sequence_index.data()
+
+        dialog = ShortcutEditor(self, context, name, sequence, shortcuts)
+
+        if dialog.exec_():
+            new_sequence = dialog.new_sequence
+            self.source_model.setData(sequence_index, new_sequence)
+
+    def set_regex(self, regex=None, reset=False):
+        """Update the regex text for the shortcut finder."""
+        if reset:
+            text = ''
+        else:
+            text = self.finder.text().replace(' ', '').lower()
+
+        self.proxy_model.set_filter(text)
+        self.source_model.update_search_letters(text)
+
+        if self.last_regex != regex:
+            self.selectRow(0)
+        self.last_regex = regex
+
+    def next_row(self):
+        """Move to next row from currently selected row."""
+        row = self.currentIndex().row()
+        rows = self.proxy_model.rowCount()
+        if row + 1 == rows:
+            row = -1
+        self.selectRow(row + 1)
+
+    def previous_row(self):
+        """Move to previous row from currently selected row."""
+        row = self.currentIndex().row()
+        rows = self.proxy_model.rowCount()
+        if row == 0:
+            row = rows
+        self.selectRow(row - 1)
+
+    def keyPressEvent(self, event):
+        """Qt Override"""
+        key = event.key()
+        if key in [Qt.Key_Enter, Qt.Key_Return]:
+            self.show_editor()
+        elif key in [Qt.Key_Tab]:
+            self.finder.setFocus()
+        elif key in [Qt.Key_Backtab]:
+            self.parent().reset_btn.setFocus()
+        elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]:
+            super(ShortcutsTable, self).keyPressEvent(event)
+        elif key not in [Qt.Key_Escape, Qt.Key_Space]:
+            text = event.text()
+            if text:
+                if re.search(VALID_FINDER_CHARS, text) is not None:
+                    self.finder.setFocus()
+                    self.finder.set_text(text)
+
+    def mouseDoubleClickEvent(self, event):
+        """Qt Override"""
+        self.show_editor()
+
 
 class ShortcutsConfigPage(GeneralConfigPage):
     CONF_SECTION = "shortcuts"
-    
+
     NAME = _("Keyboard shortcuts")
     ICON = ima.icon('genprefs')
-    
+
     def setup_page(self):
+        # Widgets
         self.table = ShortcutsTable(self)
-        self.table.model.dataChanged.connect(
-                     lambda i1, i2, opt='': self.has_been_modified(opt))
+        self.finder = ShortcutFinder(self.table, self.table.set_regex)
+        self.table.finder = self.finder
+        self.label_finder = QLabel(_('Search: '))
+        self.reset_btn = QPushButton(_("Reset to default values"))
+
+        # Layout
+        hlayout = QHBoxLayout()
         vlayout = QVBoxLayout()
+        hlayout.addWidget(self.label_finder)
+        hlayout.addWidget(self.finder)
         vlayout.addWidget(self.table)
-        reset_btn = QPushButton(_("Reset to default values"))
-        reset_btn.clicked.connect(self.reset_to_default)
-        vlayout.addWidget(reset_btn)
+        vlayout.addLayout(hlayout)
+        vlayout.addWidget(self.reset_btn)
         self.setLayout(vlayout)
-        
+
+        self.setTabOrder(self.table, self.finder)
+        self.setTabOrder(self.finder, self.reset_btn)
+
+        # Signals and slots
+        self.table.proxy_model.dataChanged.connect(
+                     lambda i1, i2, opt='': self.has_been_modified(opt))
+        self.reset_btn.clicked.connect(self.reset_to_default)
+
     def check_settings(self):
         self.table.check_shortcuts()
-        
+
     def reset_to_default(self):
         reset_shortcuts()
         self.main.apply_shortcuts()
         self.table.load_shortcuts()
         self.load_from_conf()
         self.set_modified(False)
-            
+
     def apply_settings(self, options):
         self.table.save_shortcuts()
         self.main.apply_shortcuts()
@@ -378,7 +791,7 @@ def test():
     table = ShortcutsTable()
     table.show()
     app.exec_()
-    print([str(s) for s in table.model.shortcuts])
+    print([str(s) for s in table.source_model.shortcuts])
     table.check_shortcuts()
 
 if __name__ == '__main__':
diff --git a/spyderlib/qt/QtGui.py b/spyderlib/qt/QtGui.py
--- a/spyderlib/qt/QtGui.py
+++ b/spyderlib/qt/QtGui.py
@@ -16,5 +16,7 @@
 elif os.environ['QT_API'] == 'pyqt':
     from PyQt4.Qt import QKeySequence, QTextCursor            # analysis:ignore
     from PyQt4.QtGui import *                                 # analysis:ignore
+    QStyleOptionViewItem = QStyleOptionViewItemV4             # analysis:ignore
 else:
     from PySide.QtGui import *                                # analysis:ignore
+    QStyleOptionViewItem = QStyleOptionViewItemV4             # analysis:ignore


Test patch: