Repository: spyder-ide/spyder
Pull request: PR: Add functionality run to and from current line to editor
<!--- Make sure to read the Contributing Guidelines:                   --->
<!--- https://github.com/spyder-ide/spyder/blob/master/CONTRIBUTING.md --->
<!--- and follow PEP 8, PEP 257 and Spyder's code style:               --->
<!--- https://github.com/spyder-ide/spyder/wiki/Dev:-Coding-Style      --->

## Description of Changes
This PR adds functionality to run all lines either above or below the current cursor position.
If there is interest from the Spyder team to have this functionality I'd be happy to continue to work on this PR (see suggested todo list below).

This feature is available in other IDEs, for example VS Code with interactive python and R-Studio.
Currently the implementation in this WIP PR is identical to VS Code and R-Studio as it sends all the lines above or below the cursor verbose to the editor. An alternative is also to silently execute the lines not to fill the console.

Todo list
* [x] Wrote at least one-line docstrings (for any new functions)
* [x] Added unit test(s) covering the changes
* [x] Included a screenshot or animation 
* [ ] Icons for the menu items (perhaps something along the lines of mdi.ArrowCollapseDown  with a play icons)
* [ ] Keyboard short cuts for the menu items?
   * Currently Shift+F9 for "run to line" and CTRL+F9 for "run from line". Set for testing purposes 
* [ ] Naming of functionality in UI (e.g. "Run to line/Run from beginning to line/Run lines above?)

<!--- Explain what you've done and why --->

![spyder_run_lines_from_cursor](https://user-images.githubusercontent.com/2330659/135283567-d876839c-443d-4a34-82c5-55c98300c4da.gif)



### Issue(s) Resolved

<!--- List the issue(s) below, in the form "Fixes #1234"; one per line --->

Fixes #16431


### Affirmation

By submitting this Pull Request or typing my (user)name below,
I affirm the [Developer Certificate of Origin](https://developercertificate.org)
with respect to all commits and content included in this PR,
and understand I am releasing the same under Spyder's MIT (Expat) license.

<!--- TYPE YOUR USER/NAME AFTER THE FOLLOWING: --->
I certify the above statement is true and correct: @rhkarls 

<!--- Thanks for your help making Spyder better for everyone! --->


Problem statement:
Feature request: "Run to line" and "Run from line"
## Description
This is a suggestion to add the ability to "run to line" and "run from line" to the run menu. VS Code has this ability for example.

"Run to line" would execute all the lines in the file up until the current cursor line (excluding the selected line)
"Run from line" would execute all the lines in the file from the current cursor line to the end (including the selected line)

Run to line can sort of be done by setting a breakpoint and debug the file then exit the debugger. This is however often incredibly slow compared to normal execution (expected behavior of debugger?) and thus not practical.

Edit: I can try to have a go at this (at least adding the basic functionality) - I understand you may have more pressing issues to prioritize!

Hints from issue discussion:

Commit message: rename menu items run to current line

Patch to review:
diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py
index f35b35fbb46..8619bf0d585 100644
--- a/spyder/app/tests/test_mainwindow.py
+++ b/spyder/app/tests/test_mainwindow.py
@@ -1336,13 +1336,19 @@ def test_run_code(main_window, qtbot, tmpdir):
     main_window.editor.load(filepath)
 
     # Move to the editor's first line
-    code_editor = main_window.editor.get_focus_widget()
+    editor = main_window.editor
+    code_editor = editor.get_focus_widget()
     code_editor.setFocus()
     qtbot.keyClick(code_editor, Qt.Key_Home, modifier=Qt.ControlModifier)
 
     # Get a reference to the namespace browser widget
     nsb = main_window.variableexplorer.current_widget()
 
+    # Set modifier for CTRL or darwin Meta
+    modifier = Qt.ControlModifier
+    if sys.platform == 'darwin':
+        modifier = Qt.MetaModifier
+
     # ---- Run file ----
     qtbot.keyClick(code_editor, Qt.Key_F5)
 
@@ -1376,6 +1382,41 @@ def test_run_code(main_window, qtbot, tmpdir):
 
     reset_run_code(qtbot, shell, code_editor, nsb)
 
+    # ---- Run to line ----
+    # Run lines from beginning up until but excluding current cursor line
+    # Move to line 10 then move one characters into the line and
+    # run lines above
+    editor.go_to_line(10)
+    qtbot.keyClick(code_editor, Qt.Key_Right)
+    qtbot.keyClick(code_editor, Qt.Key_F9, modifier=Qt.ShiftModifier)
+    qtbot.wait(500)
+
+    assert shell.get_value('a') == 10
+    assert shell.get_value('li') == [1, 2, 3]
+    # Test that lines below did not run
+    assert 'arr' not in nsb.editor.source_model._data.keys()
+    assert 's' not in nsb.editor.source_model._data.keys()
+
+    reset_run_code(qtbot, shell, code_editor, nsb)
+
+    # ---- Run from line ----
+    # Move to line 6, run lines from current cursor line to end
+    # Note that last line (14) will raise a NameError if 'a' is not defined
+    # Set 'a' to a different value before hand to avoid errors in shell
+    shell.execute('a = 100')
+    editor.go_to_line(6)
+    qtbot.keyClick(code_editor, Qt.Key_Right)
+    qtbot.keyClick(code_editor, Qt.Key_F9, modifier=modifier)
+    qtbot.wait(500)
+
+    assert shell.get_value('s') == "Z:\\escape\\test\\string\n"
+    assert shell.get_value('li') == [1, 2, 3]
+    assert_array_equal(shell.get_value('arr'), np.array([1, 2, 3]))
+    # Test that lines above did not run, i.e. a is still 100
+    assert shell.get_value('a') == 100
+
+    reset_run_code(qtbot, shell, code_editor, nsb)
+
     # ---- Run cell and advance ----
     # Run the five cells present in file
     # Add an unnamed cell at the top of the file
@@ -1420,9 +1461,6 @@ def test_run_code(main_window, qtbot, tmpdir):
 
     # ---- Run cell ----
     # Run the first cell in file
-    modifier = Qt.ControlModifier
-    if sys.platform == 'darwin':
-        modifier = Qt.MetaModifier
     qtbot.keyClick(code_editor, Qt.Key_Return, modifier=modifier)
 
     # Wait until the object has appeared in the variable explorer
diff --git a/spyder/config/main.py b/spyder/config/main.py
index 61c9508a45c..d51f01a038d 100644
--- a/spyder/config/main.py
+++ b/spyder/config/main.py
@@ -456,6 +456,8 @@
               'editor/breakpoint': 'F12',
               'editor/conditional breakpoint': 'Shift+F12',
               'editor/run selection': "F9",
+              'editor/run to line': 'Shift+F9',
+              'editor/run from line': CTRL + '+F9',
               'editor/go to line': 'Ctrl+L',
               'editor/go to previous file': CTRL + '+Shift+Tab',
               'editor/go to next file': CTRL + '+Tab',
diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py
index 2c6235f8435..ca12fa2437b 100644
--- a/spyder/plugins/editor/plugin.py
+++ b/spyder/plugins/editor/plugin.py
@@ -689,6 +689,22 @@ def get_plugin_actions(self):
         self.register_shortcut(run_selected_action, context="Editor",
                                name="Run selection", add_shortcut_to_tip=True)
 
+        run_to_line_action = create_action(self, _("Run &to current line"),
+                                           icon=ima.icon('run_selection'),
+                                           tip=_("Run to current line"),
+                                           triggered=self.run_to_line,
+                                           context=Qt.WidgetShortcut)
+        self.register_shortcut(run_to_line_action, context="Editor",
+                               name="Run to line", add_shortcut_to_tip=True)
+
+        run_from_line_action = create_action(self, _("Run &from current line"),
+                                             icon=ima.icon('run_selection'),
+                                             tip=_("Run from current line"),
+                                             triggered=self.run_from_line,
+                                             context=Qt.WidgetShortcut)
+        self.register_shortcut(run_from_line_action, context="Editor",
+                               name="Run from line", add_shortcut_to_tip=True)
+
         run_cell_action = create_action(self,
                             _("Run cell"),
                             icon=ima.icon('run_cell'),
@@ -1084,7 +1100,8 @@ def get_plugin_actions(self):
         run_menu_actions = [run_action, run_cell_action,
                             run_cell_advance_action,
                             re_run_last_cell_action, MENU_SEPARATOR,
-                            run_selected_action, re_run_action,
+                            run_selected_action, run_to_line_action,
+                            run_from_line_action, re_run_action,
                             configure_action, MENU_SEPARATOR]
         self.main.run_menu_actions = (
             run_menu_actions + self.main.run_menu_actions)
@@ -2819,6 +2836,18 @@ def run_selection(self):
         editorstack = self.get_current_editorstack()
         editorstack.run_selection()
 
+    @Slot()
+    def run_to_line(self):
+        """Run all lines from beginning up to current line"""
+        editorstack = self.get_current_editorstack()
+        editorstack.run_to_line()
+
+    @Slot()
+    def run_from_line(self):
+        """Run all lines from current line to end"""
+        editorstack = self.get_current_editorstack()
+        editorstack.run_from_line()
+
     @Slot()
     def run_cell(self):
         """Run current cell"""
diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py
index 05fe95e9eb9..c8f786ba602 100644
--- a/spyder/plugins/editor/widgets/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor.py
@@ -159,6 +159,8 @@ class CodeEditor(TextEditBaseWidget):
     go_to_definition = Signal(str, int, int)
     sig_show_object_info = Signal(int)
     sig_run_selection = Signal()
+    sig_run_to_line = Signal()
+    sig_run_from_line = Signal()
     sig_run_cell_and_advance = Signal()
     sig_run_cell = Signal()
     sig_re_run_last_cell = Signal()
@@ -4344,7 +4346,14 @@ def setup_context_menu(self):
             icon=ima.icon('run_selection'),
             shortcut=CONF.get_shortcut('editor', 'run selection'),
             triggered=self.sig_run_selection.emit)
-
+        self.run_to_line_action = create_action(
+            self, _("Run to current line"), icon=ima.icon('run_cell'),
+            shortcut=CONF.get_shortcut('editor', 'run to line'),
+            triggered=self.sig_run_to_line.emit)
+        self.run_from_line_action = create_action(
+            self, _("Run from current line"), icon=ima.icon('run_cell'),
+            shortcut=CONF.get_shortcut('editor', 'run from line'),
+            triggered=self.sig_run_from_line.emit)
         self.debug_cell_action = create_action(
             self, _("Debug cell"), icon=ima.icon('debug_cell'),
             shortcut=CONF.get_shortcut('editor', 'debug cell'),
@@ -4389,6 +4398,7 @@ def setup_context_menu(self):
         self.menu = QMenu(self)
         actions_1 = [self.run_cell_action, self.run_cell_and_advance_action,
                      self.re_run_last_cell_action, self.run_selection_action,
+                     self.run_to_line_action, self.run_from_line_action,
                      self.gotodef_action, None, self.undo_action,
                      self.redo_action, None, self.cut_action,
                      self.copy_action, self.paste_action, selectall_action]
@@ -5224,6 +5234,8 @@ def contextMenuEvent(self, event):
         self.run_cell_action.setVisible(self.is_python_or_ipython())
         self.run_cell_and_advance_action.setVisible(self.is_python_or_ipython())
         self.run_selection_action.setVisible(self.is_python_or_ipython())
+        self.run_to_line_action.setVisible(self.is_python_or_ipython())
+        self.run_from_line_action.setVisible(self.is_python_or_ipython())
         self.re_run_last_cell_action.setVisible(self.is_python_or_ipython())
         self.gotodef_action.setVisible(self.go_to_definition_enabled)
 
diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py
index eefc83c3402..0207c579a51 100644
--- a/spyder/plugins/editor/widgets/editor.py
+++ b/spyder/plugins/editor/widgets/editor.py
@@ -24,7 +24,7 @@
 from qtpy.compat import getsavefilename
 from qtpy.QtCore import (QByteArray, QFileInfo, QPoint, QSize, Qt, QTimer,
                          Signal, Slot)
-from qtpy.QtGui import QFont
+from qtpy.QtGui import QFont, QTextCursor
 from qtpy.QtWidgets import (QAction, QApplication, QFileDialog, QHBoxLayout,
                             QLabel, QMainWindow, QMessageBox, QMenu,
                             QSplitter, QVBoxLayout, QWidget, QListWidget,
@@ -479,6 +479,18 @@ def create_shortcuts(self):
             name='Run selection',
             parent=self)
 
+        run_to_line = CONF.config_shortcut(
+            self.run_to_line,
+            context='Editor',
+            name='Run to line',
+            parent=self)
+
+        run_from_line = CONF.config_shortcut(
+            self.run_from_line,
+            context='Editor',
+            name='Run from line',
+            parent=self)
+
         new_file = CONF.config_shortcut(
             lambda: self.sig_new_file[()].emit(),
             context='Editor',
@@ -643,11 +655,11 @@ def create_shortcuts(self):
 
         # Return configurable ones
         return [inspect, set_breakpoint, set_cond_breakpoint, gotoline, tab,
-                tabshift, run_selection, new_file, open_file, save_file,
-                save_all, save_as, close_all, prev_edit_pos, prev_cursor,
-                next_cursor, zoom_in_1, zoom_in_2, zoom_out, zoom_reset,
-                close_file_1, close_file_2, run_cell, debug_cell,
-                run_cell_and_advance,
+                tabshift, run_selection, run_to_line, run_from_line, new_file,
+                open_file, save_file, save_all, save_as, close_all,
+                prev_edit_pos, prev_cursor, next_cursor, zoom_in_1, zoom_in_2,
+                zoom_out, zoom_reset, close_file_1, close_file_2, run_cell,
+                debug_cell, run_cell_and_advance,
                 go_to_next_cell, go_to_previous_cell, re_run_last_cell,
                 prev_warning, next_warning, split_vertically,
                 split_horizontally, close_split,
@@ -2438,6 +2450,8 @@ def create_new_editor(self, fname, enc, txt, set_current, new=False,
         finfo.sig_save_bookmarks.connect(lambda s1, s2:
                                          self.sig_save_bookmarks.emit(s1, s2))
         editor.sig_run_selection.connect(self.run_selection)
+        editor.sig_run_to_line.connect(self.run_to_line)
+        editor.sig_run_from_line.connect(self.run_from_line)
         editor.sig_run_cell.connect(self.run_cell)
         editor.sig_debug_cell.connect(self.debug_cell)
         editor.sig_run_cell_and_advance.connect(self.run_cell_and_advance)
@@ -2692,6 +2706,39 @@ def format_document_or_selection(self, index=None):
         finfo.editor.format_document_or_range()
 
     #  ------ Run
+    def _run_lines_cursor(self, direction):
+        """ Select and run all lines from cursor in given direction"""
+        editor = self.get_current_editor()
+
+        # Move cursor to start of line then move to beginning or end of
+        # document with KeepAnchor
+        cursor = editor.textCursor()
+        cursor.movePosition(QTextCursor.StartOfLine)
+
+        if direction == 'up':
+            cursor.movePosition(QTextCursor.Start, QTextCursor.KeepAnchor)
+        elif direction == 'down':
+            cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
+
+        code_text = editor.get_selection_as_executable_code(cursor)
+        if code_text:
+            self.exec_in_extconsole.emit(code_text.rstrip(),
+                                         self.focus_to_editor)
+
+    def run_to_line(self):
+        """
+        Run all lines from the beginning up to, but not including, current
+        line.
+        """
+        self._run_lines_cursor(direction='up')
+
+    def run_from_line(self):
+        """
+        Run all lines from and including the current line to the end of
+        the document.
+        """
+        self._run_lines_cursor(direction='down')
+
     def run_selection(self):
         """
         Run selected text or current line in console.


Code review comments:
diff_hunk: @@ -4344,7 +4346,14 @@ def setup_context_menu(self):
             icon=ima.icon('run_selection'),
             shortcut=CONF.get_shortcut('editor', 'run selection'),
             triggered=self.sig_run_selection.emit)
-
+        self.run_to_line_action = create_action(
+            self, _("Run to current line"), icon=ima.icon('run_cell'),
+            shortcut=CONF.get_shortcut('editor', 'run to line'),
+            triggered=self.sig_run_to_line.emit)
+        self.run_from_line_action = create_action(
+            self, _("Run from current line"), icon=ima.icon('run_cell'),
path: spyder/plugins/editor/widgets/codeeditor.py
text: @user1:
I think we should change the icon here. Maybe leave the action for now without icon

diff_hunk: @@ -689,6 +689,22 @@ def get_plugin_actions(self):
         self.register_shortcut(run_selected_action, context="Editor",
                                name="Run selection", add_shortcut_to_tip=True)
 
+        run_to_line_action = create_action(self, _("Run &to current line"),
+                                           icon=ima.icon('run_selection'),
path: spyder/plugins/editor/plugin.py
text: @user1:
I think we should change the icon here. Maybe leave the action for now without icon

diff_hunk: @@ -4344,7 +4346,14 @@ def setup_context_menu(self):
             icon=ima.icon('run_selection'),
             shortcut=CONF.get_shortcut('editor', 'run selection'),
             triggered=self.sig_run_selection.emit)
-
+        self.run_to_line_action = create_action(
+            self, _("Run to current line"), icon=ima.icon('run_cell'),
path: spyder/plugins/editor/widgets/codeeditor.py
text: @user1:
I think we should change the icon here. Maybe leave the action for now without icon

@author:
I agree regarding the icons - I had just left them there to change to a proper icon later, but as you suggest it makes more sense to remove the icon and later add one if that decided.

diff_hunk: @@ -689,6 +689,22 @@ def get_plugin_actions(self):
         self.register_shortcut(run_selected_action, context="Editor",
                                name="Run selection", add_shortcut_to_tip=True)
 
+        run_to_line_action = create_action(self, _("Run &to current line"),
+                                           icon=ima.icon('run_selection'),
+                                           tip=_("Run to current line"),
+                                           triggered=self.run_to_line,
+                                           context=Qt.WidgetShortcut)
+        self.register_shortcut(run_to_line_action, context="Editor",
+                               name="Run to line", add_shortcut_to_tip=True)
+
+        run_from_line_action = create_action(self, _("Run &from current line"),
+                                             icon=ima.icon('run_selection'),
path: spyder/plugins/editor/plugin.py
text: @user1:
I think we should change the icon here. Maybe leave the action for now without icon