Repository: spyder-ide/spyder

Issue:
Feature request: scroll line up/down using keyboard shortcut
Hi!

I've found that vscode and pycharm have a quite neat feature, which is to scroll down/up one line per key press, using ctrl + up/down.

Here is vscode's keyboard shortcut: https://code.visualstudio.com/docs/getstarted/keybindings (seacrh for "scrollLineDown"), and PyCharm has the same keyboard shortcut.

Currently, Spyder's ctrl + up/down is reserved for navigating between different code cells, so I wonder if we can have a line-by-line scroll assigned to another keyboard shortcut?


Discussion:
Again, a nice suggestion of yours but unfortunately we don't have time to implement it. You're welcome to submit a PR for it though.
I think this would be a nice feature to have for the editor.

The hard part is to find a proper key sequence that everyone will agree with...
Maybe we could use `Ctrl+Shift+Up` and `Ctrl+Shift+Down`?

@goanpeca any thoughts?
Psychologically, `ctrl + up/down` feels more incremental, and `ctrl + shift + up/down` feels more like a bigger step. If we are OK with breaking past user behaviors, I'd say assign `ctrl + up/down` to "scroll by line", and `ctrl + shift + up/down` to "navigate by cell". 
@jsh9, you can easily remap shortcuts to your convenience, so I don't think that's necessary.
Ok I can take care of that one, I've tested it and it is simple to implement.
Sure, thanks a lot for your help @jnsebgosselin!
I think new, non-critical features should go for Spyder 5. So please create your branch from master.

Resolving patch:
diff --git a/spyder/config/main.py b/spyder/config/main.py
--- a/spyder/config/main.py
+++ b/spyder/config/main.py
@@ -477,6 +477,8 @@
               'editor/go to next cell': 'Ctrl+Down',
               'editor/go to previous cell': 'Ctrl+Up',
               'editor/re-run cell': 'Alt+Return',
+              'editor/scroll line down': '',
+              'editor/scroll line up': '',
               'editor/split vertically': "Ctrl+{",
               'editor/split horizontally': "Ctrl+_",
               'editor/close split panel': "Alt+Shift+W",
diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py
--- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py
@@ -579,7 +579,9 @@ def create_shortcuts(self):
              self.writer_docstring.write_docstring_for_shortcut),
             ('editor', 'autoformatting', self.format_document_or_range),
             ('array_builder', 'enter array inline', self.enter_array_inline),
-            ('array_builder', 'enter array table', self.enter_array_table)
+            ('array_builder', 'enter array table', self.enter_array_table),
+            ('editor', 'scroll line down', self.scroll_line_down),
+            ('editor', 'scroll line up', self.scroll_line_up)
             )
 
         shortcuts = []
@@ -1269,6 +1271,18 @@ def delete(self):
             self.setTextCursor(cursor)
         self.remove_selected_text()
 
+    # ---- Scrolling
+    # -------------------------------------------------------------------------
+    def scroll_line_down(self):
+        """Scroll the editor down by one step."""
+        vsb = self.verticalScrollBar()
+        vsb.setValue(vsb.value() + vsb.singleStep())
+
+    def scroll_line_up(self):
+        """Scroll the editor up by one step."""
+        vsb = self.verticalScrollBar()
+        vsb.setValue(vsb.value() - vsb.singleStep())
+
     # ---- Find occurrences
     # -------------------------------------------------------------------------
     def __find_first(self, text):


Test patch:
diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py
--- a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py
+++ b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py
@@ -62,6 +62,24 @@ def editor_bot(base_editor_bot, mocker, qtbot):
     return editor_stack, finfo.editor
 
 
+@pytest.fixture
+def visible_editor_bot(editor_bot, mocker):
+    """
+    Set up an EditorStack to test functionalities that require a
+    visible editor.
+    """
+    editorstack, editor = editor_bot
+
+    # We need to patch osp.isfile to avoid the 'this file does not exist'
+    # message box.
+    mocker.patch('spyder.plugins.editor.widgets.editor.osp.isfile',
+                 returned_value=True)
+
+    editorstack.show()
+
+    return editorstack, editor
+
+
 @pytest.fixture
 def editor_find_replace_bot(base_editor_bot, qtbot):
     # Widget to show together the editor stack and findreplace ones together
@@ -116,6 +134,36 @@ def editor_cells_bot(base_editor_bot, qtbot):
 # =============================================================================
 # ---- Tests
 # =============================================================================
+def test_scroll_line_up_and_down(visible_editor_bot, mocker, qtbot):
+    """
+    Test that the scroll line up and down functionalities are working
+    as expected.
+    """
+    editorstack, editor = visible_editor_bot
+
+    # Define text in the editor long enough to require a vertical scrollbar.
+    editor.setPlainText('new_line\n' * 1000)
+    editorstack.go_to_line(1)
+    vsb = editor.verticalScrollBar()
+
+    # Assert initial state.
+    assert vsb.value() == 0
+    assert vsb.maximum() > 0
+
+    # Scroll line down five times.
+    expected_vsb_value = 0
+    for _ in range(5):
+        expected_vsb_value += vsb.singleStep()
+        editor.scroll_line_down()
+        assert vsb.value() == expected_vsb_value
+
+    # Scroll line up three times.
+    for _ in range(3):
+        expected_vsb_value += -vsb.singleStep()
+        editor.scroll_line_up()
+        assert vsb.value() == expected_vsb_value
+
+
 def test_find_number_matches(editor_find_replace_bot):
     """Test for number matches in find/replace."""
     editor = editor_find_replace_bot.editor
@@ -219,22 +267,17 @@ def test_move_multiple_lines_up(editor_bot):
     assert editor.toPlainText() == expected_new_text
 
 
-@pytest.mark.skipif(not sys.platform.startswith('linux'),
-                    reason="Works only on Linux")
-def test_copy_lines_down_up(editor_bot, mocker, qtbot):
+@pytest.mark.skipif(running_in_ci() and os.name == 'nt',
+                    reason="It fails on Windows CI")
+def test_copy_lines_down_up(visible_editor_bot, qtbot):
     """
     Test that copy lines down and copy lines up are working as expected.
     """
-    editorstack, editor = editor_bot
-
-    # We need to show the editor because the copy lines down and copy lines up
-    # functionalities both rely on a paint event override to work as expected.
-    editorstack.show()
+    # Note that we need a visible editor because the copy lines down and copy
+    # lines up functionalities both rely on a paint event override to work
+    # as expected.
 
-    # We need to patch osp.isfile to avoid the 'this file does not exist'
-    # message box.
-    mocker.patch('spyder.plugins.editor.widgets.editorstack.editorstack.osp.isfile',
-                 returned_value=True)
+    editorstack, editor = visible_editor_bot
 
     # Assert initial state.
     editorstack.go_to_line(1)
@@ -944,4 +987,4 @@ def test_ipython_files(base_editor_bot, qtbot):
 
 
 if __name__ == "__main__":
-    pytest.main(['test_editor.py'])
+    pytest.main(['test_editor.py', '-vv', '-rw'])
diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py b/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py
--- a/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py
+++ b/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py
@@ -27,7 +27,7 @@
 
 # ---- Qt Test Fixtures
 @pytest.fixture
-def editor_bot(qtbot):
+def editorstack(qtbot):
     """
     Set up EditorStack with CodeEditors containing some Python code.
     The cursor is at the empty line below the code.
@@ -42,7 +42,7 @@ def editor_bot(qtbot):
     editorstack.show()
     editorstack.go_to_line(1)
 
-    return editorstack, qtbot
+    return editorstack
 
 
 # ---- Tests
@@ -76,12 +76,11 @@ def test_default_keybinding_values():
 @pytest.mark.skipif(
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
-def test_start_and_end_of_document_shortcuts(editor_bot):
+def test_start_and_end_of_document_shortcuts(editorstack, qtbot):
     """
     Test that the start of document and end of document shortcut are working
     as expected.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
 
     # Assert initial state.
@@ -99,14 +98,13 @@ def test_start_and_end_of_document_shortcuts(editor_bot):
 @pytest.mark.skipif(
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
-def test_del_undo_redo_shortcuts(editor_bot):
+def test_del_undo_redo_shortcuts(editorstack, qtbot):
     """
     Test that the undo and redo keyboard shortcuts are working as expected
     with the default Spyder keybindings.
 
     Regression test for spyder-ide/spyder#7743.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
 
     # Delete the first character of the first line.
@@ -130,12 +128,11 @@ def test_del_undo_redo_shortcuts(editor_bot):
 @pytest.mark.skipif(
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
-def test_copy_cut_paste_shortcuts(editor_bot):
+def test_copy_cut_paste_shortcuts(editorstack, qtbot):
     """
     Test that the copy, cut, and paste keyboard shortcuts are working as
     expected with the default Spyder keybindings.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
     QApplication.clipboard().clear()
 
@@ -164,12 +161,11 @@ def test_copy_cut_paste_shortcuts(editor_bot):
 @pytest.mark.skipif(
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
-def test_select_all_shortcut(editor_bot):
+def test_select_all_shortcut(editorstack, qtbot):
     """
     Test that the select all keyboard shortcut is working as
     expected with the default Spyder keybindings.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
 
     # Select all the text in the editor.
@@ -181,12 +177,11 @@ def test_select_all_shortcut(editor_bot):
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
 @pytest.mark.no_xvfb
-def test_delete_line_shortcut(editor_bot):
+def test_delete_line_shortcut(editorstack, qtbot):
     """
     Test that the delete line keyboard shortcut is working as
     expected with the default Spyder keybindings.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
 
     # Delete the second line in the editor.
@@ -199,12 +194,11 @@ def test_delete_line_shortcut(editor_bot):
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
 @pytest.mark.no_xvfb
-def test_go_to_line_shortcut(editor_bot, mocker):
+def test_go_to_line_shortcut(editorstack, qtbot, mocker):
     """
     Test that the go to line keyboard shortcut is working
     as expected with the default Spyder keybindings.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
     qtbot.keyClick(editor, Qt.Key_Home, modifier=Qt.ControlModifier)
 
@@ -219,12 +213,11 @@ def test_go_to_line_shortcut(editor_bot, mocker):
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
 @pytest.mark.no_xvfb
-def test_transform_to_lowercase_shortcut(editor_bot):
+def test_transform_to_lowercase_shortcut(editorstack, qtbot):
     """
     Test that the transform to lowercase shorcut is working as expected with
     the default Spyder keybindings.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
 
     # Transform all the text to lowercase.
@@ -237,12 +230,11 @@ def test_transform_to_lowercase_shortcut(editor_bot):
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
 @pytest.mark.no_xvfb
-def test_transform_to_uppercase_shortcut(editor_bot):
+def test_transform_to_uppercase_shortcut(editorstack, qtbot):
     """
     Test that the transform to uppercase shortcuts is working as expected with
     the default Spyder keybindings.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
 
     # Transform all the text to uppercase.
@@ -256,12 +248,11 @@ def test_transform_to_uppercase_shortcut(editor_bot):
     sys.platform.startswith('linux') and running_in_ci(),
     reason="It fails on Linux due to the lack of a proper X server.")
 @pytest.mark.no_xvfb
-def test_next_and_previous_word_shortcuts(editor_bot):
+def test_next_and_previous_word_shortcuts(editorstack, qtbot):
     """
     Test that the next word and previous word shortcuts are working as
     expected with the default Spyder keybindings.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
 
     # Go to the next word 3 times.
@@ -283,7 +274,7 @@ def test_next_and_previous_word_shortcuts(editor_bot):
 
 
 @pytest.mark.skipif(sys.platform == 'darwin', reason="Not valid in macOS")
-def test_builtin_shift_del_and_ins(editor_bot):
+def test_builtin_shift_del_and_ins(editorstack, qtbot):
     """
     Test that the builtin key sequences Ctrl+Ins, Shit+Del and Shift+Ins result
     in copy, cut and paste actions in Windows and Linux.
@@ -291,7 +282,6 @@ def test_builtin_shift_del_and_ins(editor_bot):
     Regression test for spyder-ide/spyder#5035, spyder-ide/spyder#4947, and
     spyder-ide/spyder#5973.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
     QApplication.clipboard().clear()
 
@@ -319,13 +309,12 @@ def test_builtin_shift_del_and_ins(editor_bot):
 
 
 @pytest.mark.skipif(os.name != 'nt', reason='Only valid in Windows system')
-def test_builtin_undo_redo(editor_bot):
+def test_builtin_undo_redo(editorstack, qtbot):
     """
     Test that the builtin key sequence Alt+Backspace, Ctrl+Y and
     Alt+Shift+Backspace result in, respectively, an undo, redo and redo action
     in Windows.
     """
-    editorstack, qtbot = editor_bot
     editor = editorstack.get_current_editor()
 
     # Write something on a new line.