Repository: spyder-ide/spyder

Issue:
GNU Emacs Style Key Sequences do not work in master AND shortcut conflicts
## Problem Description

Key sequences similar to those used in GNU Emacs , allowing up to four key codes, do not work in the Editor after the changes introduced in PR #7768. I haven't thought about this case when working on this PR, so this needs to be fix.

@ccordoba12 what do you think  is the severity of this?

Also, I'm not sure, but I think there is a problem with how we check for conflicts for these kind of shortcuts in our keyboard preference editor. For example, I do not understand how a shortcut with a keyseq defined as `Ctrl+C, then Ctrl+X` could work if `Ctrl+C` is already used for `copy` and `Ctrl+X` is already used for `cut`.

![image](https://user-images.githubusercontent.com/10170372/45426797-3747ac00-b66b-11e8-8100-9885e7794499.png)

Ressources:
https://srinikom.github.io/pyside-docs/PySide/QtGui/QKeySequence.html#gnu-emacs-style-key-sequences
https://en.wikipedia.org/wiki/Table_of_keyboard_shortcuts
https://www.gnu.org/software/emacs/refcards/pdf/refcard.pdf




Discussion:
> what do you think is the severity of this?

Not that big, but we definitely need to fix this for beta2.
> what do you think is the severity of this?

>> Not that big, but we definitely need to fix this for beta2.

Do we have a release date for beta 2?
End of this month, if we manage to fix all pending LSP stuff.
> End of this month, if we manage to fix all pending LSP stuff.

Ok good, it is going to be fixed by then. But I'm not sure I understand correctly how these kind of Emacs style shortcuts are supposed to work. See my example in the OP.

To activate the `Ctrl+C, Ctrl+X` shortcuts, do I have to keep the `Ctrl` modifier down while I press the `C` and `X`?

How can such a shortcut work if `Ctrl+C` and `Ctrl+X` keyseq are already assigned to other shortcuts? Sorry, I don't get it. I'll try do to some research about this tonight.
> do I have to keep the Ctrl modifier down while I press the C and X?

Yes, that's how Emacs shortcuts work.
> do I have to keep the Ctrl modifier down while I press the C and X?

>> Yes, that's how Emacs shortcuts work.

OK I see, thanks. 

But then, lets say `Ctrl+B, Ctrl+D` is assigned to the `delete line` shortcut and `Ctrl+B` is assigned to the `copy` shortcut. I took these keyseq so not to have the builtin shortcuts in the way while trying to understand this.

How can I know, when the `Ctrl+B` combination is pressed, that the user want to do the `delete line` shorctut and not the `copy` shortcut?

I can tell you that this situation is currently not working in Spyder 3, i.e. `Ctrl+B, Ctrl+D` do not trigger the `delete line` shortcut, but the `copy` shortcut instead. It is working only if I unbind `Ctrl+B` from the `copy` shortcut.

![image](https://user-images.githubusercontent.com/10170372/45433953-459dc400-b67b-11e8-98d6-d8cea9d4c230.png)

In Emacs, you do not have to keep the `Ctrl` key pressed down when typing `Ctrl+B, Ctrl+D`. However, it is not possible to have both `Ctrl+B` and `Ctrl+B, Ctrl-D` shortcuts defined. If you do, the `Ctrl-B` shortcut takes precedence.
> In Emacs, you do not have to keep the Ctrl key pressed down when typing Ctrl+B, Ctrl+D. However, it is not possible to have both Ctrl+B and Ctrl+B, Ctrl-D shortcuts defined. If you do, the Ctrl-B shortcut takes precedence.

Ok thank you very much.
> However, it is not possible to have both `Ctrl+B` and `Ctrl+B, Ctrl-D` shortcuts defined. If you do, the `Ctrl-B` shortcut takes precedence.

@ccordoba12 is it ok if I do not allow this in our keyboard shortcut editor? I would display a `The new shortcut conflicts with` warning in these cases.

![image](https://user-images.githubusercontent.com/10170372/45447036-6f1b1780-b69c-11e8-9eb5-7b1f63c8d07e.png)

Sure, that's fine.
@jitseniesen What about something like `Ctrl+x, then k`? This is an example I've taken from here: https://en.wikipedia.org/wiki/Table_of_keyboard_shortcuts

Does it mean that you need to do a `Ctrl+x` combo, then you can release the `Ctrl` if you want and press `k`? Alone? Without the `Ctrl`?

I do not think we currently support that kind of shortcut in Spyder. I mean, it's not possible to define such a shortcut currently in our shortcut editor.


> @jitseniesen What about something like `Ctrl+x, then k`? This is an example I've taken from here: https://en.wikipedia.org/wiki/Table_of_keyboard_shortcuts
> 
> Does it mean that you need to do a `Ctrl+x` combo, then you can release the `Ctrl` if you want and press `k`? Alone? Without the `Ctrl`?

Yes. `Ctrl+x, Ctrl+k` and `Ctrl+x, k` are two different shortcuts. In the first case, you type `x` and `k` while holding `Ctrl`; in the second case, you type `x` while holding `Ctrl`, then release `Ctrl` and then type `k`. You can also have `Ctrl+x, Shift+k` (and many other possibilities).
> Yes. Ctrl+x, Ctrl+k and Ctrl+x, k are two different shortcuts. In the first case, you type x and k while holding Ctrl; in the second case, you type x while holding Ctrl, then release Ctrl and then type k. You can also have Ctrl+x, Shift+k (and many other possibilities).

Thank you very much @jitseniesen, this is very useful information.

So @ccordoba12... do I try to cover the full range of Emacs keyseq or do I aim only at covering what we were supporting before?
The idea is (one day) to add Emacs support to the Editor, please try to cover the full range of possibilities.

Resolving patch:
diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py
--- a/spyder/plugins/editor/widgets/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor.py
@@ -28,7 +28,7 @@
 
 # Third party imports
 from qtpy.compat import to_qvariant
-from qtpy.QtCore import QRegExp, Qt, QTimer, Signal, Slot
+from qtpy.QtCore import QRegExp, Qt, QTimer, Signal, Slot, QEvent
 from qtpy.QtGui import (QColor, QCursor, QFont, QIntValidator,
                         QKeySequence, QPaintEvent, QPainter, QMouseEvent,
                         QTextBlockUserData, QTextCharFormat, QTextCursor,
@@ -44,7 +44,7 @@
 
 # Local imports
 from spyder.config.base import get_conf_path, _, DEBUG
-from spyder.config.gui import get_shortcut, iter_shortcuts
+from spyder.config.gui import get_shortcut, config_shortcut
 from spyder.config.main import (CONF, RUN_CELL_SHORTCUT,
                                 RUN_CELL_AND_ADVANCE_SHORTCUT)
 from spyder.py3compat import to_text_string
@@ -253,7 +253,6 @@ class CodeEditor(TextEditBaseWidget):
     sig_run_cell_and_advance = Signal()
     sig_run_cell = Signal()
     sig_re_run_last_cell = Signal()
-    sig_go_to_line = Signal()
     go_to_definition_regex = Signal(str, int, int)
     sig_cursor_position_changed = Signal(int, int)
     sig_new_file = Signal(str)
@@ -465,7 +464,6 @@ def __init__(self, parent=None):
 
         # Keyboard shortcuts
         self.shortcuts = self.create_shortcuts()
-        self.shortcut_callbacks = self.create_shortcut_callbacks()
 
         # Code editor
         self.__visible_blocks = []  # Visible blocks, update with repaint
@@ -515,84 +513,62 @@ def cursor_move_event():
             self.setTextCursor(cursor)
         return cursor_move_event
 
-    def create_shortcut_callbacks(self):
-        """
-        Make callbacks for the local shortcuts of the editor. The
-        shortcut_callbacks dict needs to be updated when a new shortcut
-        is added to the Editor.
-        """
-        shortcut_callbacks = {
-            'editor/code completion': self.do_completion,
-            'editor/duplicate line': self.duplicate_line,
-            'editor/copy line': self.copy_line,
-            'editor/delete line': self.delete_line,
-            'editor/move line up': self.move_line_up,
-            'editor/move line down': self.move_line_down,
-            'editor/go to new line': self.go_to_new_line,
-            'editor/go to definition': self.do_go_to_definition,
-            'editor/toggle comment': self.toggle_comment,
-            'editor/blockcomment': self.blockcomment,
-            'editor/unblockcomment': self.unblockcomment,
-            'editor/transform to uppercase': self.transform_to_uppercase,
-            'editor/transform to lowercase': self.transform_to_lowercase,
-            'editor/indent': lambda: self.indent(force=True),
-            'editor/unindent': lambda: self.unindent(force=True),
-            'editor/start of line': self.create_cursor_callback('StartOfLine'),
-            'editor/end of line': self.create_cursor_callback('EndOfLine'),
-            'editor/previous line': self.create_cursor_callback('Up'),
-            'editor/next line': self.create_cursor_callback('Down'),
-            'editor/previous char': self.create_cursor_callback('Left'),
-            'editor/next char': self.create_cursor_callback('Right'),
-            'editor/previous word':
-                self.create_cursor_callback('PreviousWord'),
-            'editor/next word': self.create_cursor_callback('NextWord'),
-            'editor/kill to line end': self.kill_line_end,
-            'editor/kill to line start': self.kill_line_start,
-            'editor/yank': self._kill_ring.yank,
-            'editor/rotate kill ring': self._kill_ring.rotate,
-            'editor/kill previous word': self.kill_prev_word,
-            'editor/kill next word': self.kill_next_word,
-            'editor/start of document': self.create_cursor_callback('Start'),
-            'editor/end of document': self.create_cursor_callback('End'),
-            'editor/undo': self.undo,
-            'editor/redo': self.redo,
-            'editor/cut': self.cut,
-            'editor/copy': self.copy,
-            'editor/paste': self.paste,
-            'editor/delete': self.delete,
-            'editor/select all': self.selectAll,
-            'array_builder/enter array inline': self.enter_array_inline,
-            'array_builder/enter array table': self.enter_array_table
-            }
-
-        # The following actions should normally be handled by shortcuts
-        # configured in the EditorStack. However, if a reserved qt builtin
-        # key sequence is assigned to one of these actions (ex.: Ctrl+C),
-        # the action of the shortcut will never be called and the event
-        # will be handled instead directly in the keyPressEvent of the
-        # CodedEditor. In this case, we need to emit a signal to trigger the
-        # corresponding action in the EditorStack.
-        # See Issue #7743 and PR #7768.
-        shortcut_callbacks.update({
-            'editor/run cell': self.sig_run_cell.emit,
-            'editor/run cell and advance': self.sig_run_cell_and_advance.emit,
-            'editor/run selection': self.sig_run_selection.emit,
-            'editor/go to line': self.sig_go_to_line.emit,
-            'editor/re-run last cell': self.sig_re_run_last_cell.emit
-            })
-        return shortcut_callbacks
-
     def create_shortcuts(self):
-        """Create the local shortcuts for the CodeEditor.
-
-        This method is used by many other widgets in Spyder to configure
-        shortcuts with the QShortcut class. However, this approach is not used
-        in the Editor because of many conflicts with builtin shortcuts that are
-        specific to each OS and prevent the use of certain key sequence
-        correctly. This method is kept here for consistency reasons.
-        See PR #7768.
-        """
-        return []
+        """Create the local shortcuts for the CodeEditor."""
+        shortcut_context_name_callbacks = (
+            ('editor', 'code completion', self.do_completion),
+            ('editor', 'duplicate line', self.duplicate_line),
+            ('editor', 'copy line', self.copy_line),
+            ('editor', 'delete line', self.delete_line),
+            ('editor', 'move line up', self.move_line_up),
+            ('editor', 'move line down', self.move_line_down),
+            ('editor', 'go to new line', self.go_to_new_line),
+            ('editor', 'go to definition', self.do_go_to_definition),
+            ('editor', 'toggle comment', self.toggle_comment),
+            ('editor', 'blockcomment', self.blockcomment),
+            ('editor', 'unblockcomment', self.unblockcomment),
+            ('editor', 'transform to uppercase', self.transform_to_uppercase),
+            ('editor', 'transform to lowercase', self.transform_to_lowercase),
+            ('editor', 'indent', lambda: self.indent(force=True)),
+            ('editor', 'unindent', lambda: self.unindent(force=True)),
+            ('editor', 'start of line',
+             self.create_cursor_callback('StartOfLine')),
+            ('editor', 'end of line',
+             self.create_cursor_callback('EndOfLine')),
+            ('editor', 'previous line', self.create_cursor_callback('Up')),
+            ('editor', 'next line', self.create_cursor_callback('Down')),
+            ('editor', 'previous char', self.create_cursor_callback('Left')),
+            ('editor', 'next char', self.create_cursor_callback('Right')),
+            ('editor', 'previous word',
+             self.create_cursor_callback('PreviousWord')),
+            ('editor', 'next word', self.create_cursor_callback('NextWord')),
+            ('editor', 'kill to line end', self.kill_line_end),
+            ('editor', 'kill to line start', self.kill_line_start),
+            ('editor', 'yank', self._kill_ring.yank),
+            ('editor', 'rotate kill ring', self._kill_ring.rotate),
+            ('editor', 'kill previous word', self.kill_prev_word),
+            ('editor', 'kill next word', self.kill_next_word),
+            ('editor', 'start of document',
+             self.create_cursor_callback('Start')),
+            ('editor', 'end of document',
+             self.create_cursor_callback('End')),
+            ('editor', 'undo', self.undo),
+            ('editor', 'redo', self.redo),
+            ('editor', 'cut', self.cut),
+            ('editor', 'copy', self.copy),
+            ('editor', 'paste', self.paste),
+            ('editor', 'delete', self.delete),
+            ('editor', 'select all', self.selectAll),
+            ('array_builder', 'enter array inline', self.enter_array_inline),
+            ('array_builder', 'enter array table', self.enter_array_table)
+            )
+
+        shortcuts = []
+        for context, name, callback in shortcut_context_name_callbacks:
+            shortcuts.append(
+                config_shortcut(
+                    callback, context=context, name=name, parent=self))
+        return shortcuts
 
     def get_shortcut_data(self):
         """
@@ -2810,6 +2786,14 @@ def keyReleaseEvent(self, event):
         super(CodeEditor, self).keyReleaseEvent(event)
         event.ignore()
 
+    def event(self, event):
+        """Qt method override."""
+        if event.type() == QEvent.ShortcutOverride:
+            event.ignore()
+            return False
+        else:
+            return super(CodeEditor, self).event(event)
+
     def keyPressEvent(self, event):
         """Reimplement Qt method"""
         # Send the signal to the editor's extension.
@@ -2827,38 +2811,10 @@ def keyPressEvent(self, event):
             # The event was handled by one of the editor extension.
             return
 
-        # ---- Handle local shortcuts
-        # See Issue #7743 and PR #7768.
-        event_modifiers = event.modifiers() & ~Qt.KeypadModifier
-        event_keyseq = QKeySequence(event_modifiers | event.key())
-        event_keystr = event_keyseq.toString()
-
-        # Iterate over all shortcuts to search for a global or a local
-        # shortcut that matches the key sequence of the event.
-        for sc_context, sc_name, sc_keystr in iter_shortcuts():
-            if sc_context == '_' and event_keystr == sc_keystr:
-                # The key sequence of the event corresponds to a
-                # global shortcut, so we let the parent handle it.
-                event.ignore()
-                return
-            elif (sc_context in ['editor', 'array_builder']
-                  and event_keystr == sc_keystr):
-                sc_callback = \
-                    self.shortcut_callbacks.get(sc_context + '/' + sc_name)
-                if sc_callback is None:
-                    # The key sequence of the event corresponds to a
-                    # local shortcut that is handled in the editorstack.
-                    event.ignore()
-                else:
-                    # The key sequence of the event corresponds to a
-                    # local shortcut, so we call our action.
-                    event.accept()
-                    sc_callback()
-                return
-        else:
-            # There is no local or global shortcut matching the key sequence
-            # of the event, so we let the codeeditor handle it.
-            event.ignore()
+        if key in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt,
+                   Qt.Key_Meta, Qt.KeypadModifier]:
+            # The user pressed only a modifier key.
+            return
 
         # ---- Handle hard coded and builtin actions
         has_selection = self.has_selected_text()
diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py
--- a/spyder/plugins/editor/widgets/editor.py
+++ b/spyder/plugins/editor/widgets/editor.py
@@ -2296,7 +2296,6 @@ def create_new_editor(self, fname, enc, txt, set_current, new=False,
         editor.sig_run_cell_and_advance.connect(self.run_cell_and_advance)
         editor.sig_re_run_last_cell.connect(self.re_run_last_cell)
         editor.sig_new_file.connect(self.sig_new_file.emit)
-        editor.sig_go_to_line.connect(self.go_to_line)
         language = get_file_language(fname, txt)
         editor.setup_editor(
                 linenumbers=self.linenumbers_enabled,


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_shortcuts.py b/spyder/plugins/editor/widgets/tests/test_shortcuts.py
--- a/spyder/plugins/editor/widgets/tests/test_shortcuts.py
+++ b/spyder/plugins/editor/widgets/tests/test_shortcuts.py
@@ -75,6 +75,9 @@ def test_default_keybinding_values():
     assert get_shortcut('editor', 'previous word') == 'Ctrl+Left'
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_start_and_end_of_document_shortcuts(editor_bot):
     """
     Test that the start of document and end of document shortcut are working
@@ -95,6 +98,9 @@ def test_start_and_end_of_document_shortcuts(editor_bot):
     assert editor.get_cursor_line_column() == (0, 0)
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_del_undo_redo_shortcuts(editor_bot):
     """
     Test that the undo and redo keyboard shortcuts are working as expected
@@ -123,6 +129,9 @@ def test_del_undo_redo_shortcuts(editor_bot):
     assert editor.toPlainText() == 'Line1\nLine2\nLine3\nLine4\n'
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_copy_cut_paste_shortcuts(editor_bot):
     """
     Test that the copy, cut, and paste keyboard shortcuts are working as
@@ -154,6 +163,9 @@ def test_copy_cut_paste_shortcuts(editor_bot):
     assert editor.toPlainText() == '\nLine2\nLine3\nLine4\n'
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_select_all_shortcut(editor_bot):
     """
     Test that the select all keyboard shortcut is working as
@@ -167,6 +179,9 @@ def test_select_all_shortcut(editor_bot):
     assert editor.get_selected_text() == 'Line1\nLine2\nLine3\nLine4\n'
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_delete_line_shortcut(editor_bot):
     """
     Test that the delete line keyboard shortcut is working as
@@ -181,6 +196,9 @@ def test_delete_line_shortcut(editor_bot):
     assert editor.toPlainText() == 'Line1\nLine3\nLine4\n'
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_go_to_line_shortcut(editor_bot, mocker):
     """
     Test that the go to line keyboard shortcut is working
@@ -197,6 +215,9 @@ def test_go_to_line_shortcut(editor_bot, mocker):
     assert editor.get_cursor_line_column() == (2, 0)
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_transform_to_lowercase_shortcut(editor_bot):
     """
     Test that the transform to lowercase shorcut is working as expected with
@@ -211,6 +232,9 @@ def test_transform_to_lowercase_shortcut(editor_bot):
     assert editor.toPlainText() == 'line1\nline2\nline3\nline4\n'
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_transform_to_uppercase_shortcut(editor_bot):
     """
     Test that the transform to uppercase shorcuts is working as expected with
@@ -226,6 +250,9 @@ def test_transform_to_uppercase_shortcut(editor_bot):
     assert editor.toPlainText() == 'LINE1\nLINE2\nLINE3\nLINE4\n'
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('linux') and os.environ.get('CI') is not None,
+    reason="It fails on Linux due to the lack of a proper X server.")
 def test_next_and_previous_word_shortcuts(editor_bot):
     """
     Test that the next word and previous word shortcuts are working as