Repository: spyder-ide/spyder

Issue:
Shortcuts for splitting and closing panels
Another kind of spoiled feature request from a huge Spyder fan:  it would be nice to have shortcuts to split the panel in the editor instead of having to hit the `Options` button, then `Split horizontally` each time.

And also a shortcut for closing the panel.



Discussion:
Be more than a fan: become a contributor ! :wink: 

I rarely use splitted editor, but why not.

Haha, good point!  Well, I contribute to Pandas which I believe is upstream of Spyder...

@Garrett-R you should give it a try 

+1 from me, both to add shortcuts for those actions and to receive a PR from @Garrett-R ;-)

Uh oh, sorry to let you guys down, but I don't think I'm going to, even though I know it's lame to ask for all this stuff, then not help out. :worried:  I just think I can contribute to the free software movement more efficiently by working on Pandas which is more similar to what I do at work.  They also have a whopping 1200+ issues!

This is an easy fix so you should really give it atry

I totally understand that you can't contribute to every software you use! The fix itself may not be complicated but understanding how and where it should be done, following the code style, updating the PR after each comment, all this takes time.
Go go pandas Go !

:+1: 

I understand it as well :-), no pressure there @Garrett-R 

I will put this in my bucket list, this means I will probably take care of it anywhere between a week or a couple of months....

Cool, thanks!


Resolving patch:
diff --git a/spyder/config/main.py b/spyder/config/main.py
--- a/spyder/config/main.py
+++ b/spyder/config/main.py
@@ -413,6 +413,9 @@
               'editor/go to next cell': 'Ctrl+Down',
               'editor/go to previous cell': 'Ctrl+Up',
               'editor/re-run last cell': RE_RUN_LAST_CELL_SHORTCUT,
+              'editor/split vertically': "Ctrl+{",
+              'editor/split horizontally': "Ctrl+_",
+              'editor/close split panel': "Alt+Shift+W",
               # -- In Breakpoints
               '_/switch to breakpoints': "Ctrl+Shift+B",
               # ---- Consoles (in widgets/shell) ----
diff --git a/spyder/widgets/editor.py b/spyder/widgets/editor.py
--- a/spyder/widgets/editor.py
+++ b/spyder/widgets/editor.py
@@ -461,8 +461,9 @@ class EditorStack(QWidget):
     current_file_changed = Signal(str ,int)
     plugin_load = Signal((str,), ())
     edit_goto = Signal(str, int, str)
-    split_vertically = Signal()
-    split_horizontally = Signal()
+    sig_split_vertically = Signal()
+    sig_split_horizontally = Signal()
+    sig_close_split = Signal()
     sig_new_file = Signal((str,), ())
     sig_save_as = Signal()
     sig_prev_edit_pos = Signal()
@@ -718,6 +719,18 @@ def create_shortcuts(self):
                                        context="Editor",
                                        name="Next warning",
                                        parent=self)
+        split_vertically = config_shortcut(lambda: self.sig_split_vertically.emit(),
+                                           context="Editor",
+                                           name="split vertically",
+                                           parent=self)
+        split_horizontally = config_shortcut(lambda: self.sig_split_horizontally.emit(),
+                                             context="Editor",
+                                             name="split horizontally",
+                                             parent=self)
+        close_split = config_shortcut(lambda: self.sig_close_split.emit(),
+                                      context="Editor",
+                                      name="close split panel",
+                                      parent=self)
 
         # Return configurable ones
         return [inspect, set_breakpoint, set_cond_breakpoint, gotoline, tab,
@@ -726,7 +739,8 @@ def create_shortcuts(self):
                 next_cursor, zoom_in_1, zoom_in_2, zoom_out, zoom_reset,
                 close_file_1, close_file_2, run_cell, run_cell_and_advance,
                 go_to_next_cell, go_to_previous_cell, re_run_last_cell,
-                prev_warning, next_warning]
+                prev_warning, next_warning, split_vertically,
+                split_horizontally, close_split]
 
     def get_shortcut_data(self):
         """
@@ -1343,13 +1357,20 @@ def __get_split_actions(self):
         self.versplit_action = create_action(self, _("Split vertically"),
                 icon=ima.icon('versplit'),
                 tip=_("Split vertically this editor window"),
-                triggered=lambda: self.split_vertically.emit())
+                triggered=lambda: self.sig_split_vertically.emit(),
+                shortcut=get_shortcut(context='Editor', name='split vertically'),
+                context=Qt.WidgetShortcut)
         self.horsplit_action = create_action(self, _("Split horizontally"),
                 icon=ima.icon('horsplit'),
                 tip=_("Split horizontally this editor window"),
-                triggered=lambda: self.split_horizontally.emit())
+                triggered=lambda: self.sig_split_horizontally.emit(),
+                shortcut=get_shortcut(context='Editor', name='split horizontally'),
+                context=Qt.WidgetShortcut)
         self.close_action = create_action(self, _("Close this panel"),
-                icon=ima.icon('close_panel'), triggered=self.close)
+                icon=ima.icon('close_panel'),
+                triggered=lambda: self.sig_close_split.emit(),
+                shortcut=get_shortcut(context='Editor', name='close split panel'),
+                context=Qt.WidgetShortcut)
         actions = [MENU_SEPARATOR, self.undock_action,
                    MENU_SEPARATOR, self.versplit_action,
                    self.horsplit_action, self.close_action]
@@ -2368,8 +2389,27 @@ def dropEvent(self, event):
 
 
 class EditorSplitter(QSplitter):
+    """QSplitter for editor windows."""
+
     def __init__(self, parent, plugin, menu_actions, first=False,
                  register_editorstack_cb=None, unregister_editorstack_cb=None):
+        """Create a splitter for dividing an editor window into panels.
+
+        Adds a new EditorStack instance to this splitter.  If it's not
+        the first splitter, clones the current EditorStack from the plugin.
+
+        Args:
+            parent: Parent widget.
+            plugin: Plugin this widget belongs to.
+            menu_actions: QActions to include from the parent.
+            first: Boolean if this is the first splitter in the editor.
+            register_editorstack_cb: Callback to register the EditorStack.
+                        Defaults to plugin.register_editorstack() to
+                        register the EditorStack with the Editor plugin.
+            unregister_editorstack_cb: Callback to unregister the EditorStack.
+                        Defaults to plugin.unregister_editorstack() to
+                        unregister the EditorStack with the Editor plugin.
+        """
         QSplitter.__init__(self, parent)
         self.setAttribute(Qt.WA_DeleteOnClose)
         self.setChildrenCollapsible(False)
@@ -2392,13 +2432,19 @@ def __init__(self, parent, plugin, menu_actions, first=False,
         if not first:
             self.plugin.clone_editorstack(editorstack=self.editorstack)
         self.editorstack.destroyed.connect(lambda: self.editorstack_closed())
-        self.editorstack.split_vertically.connect(
+        self.editorstack.sig_split_vertically.connect(
                      lambda: self.split(orientation=Qt.Vertical))
-        self.editorstack.split_horizontally.connect(
+        self.editorstack.sig_split_horizontally.connect(
                      lambda: self.split(orientation=Qt.Horizontal))
+        self.editorstack.sig_close_split.connect(lambda: self.close())
         self.addWidget(self.editorstack)
 
     def closeEvent(self, event):
+        """Override QWidget closeEvent().
+
+        This event handler is called with the given event when Qt
+        receives a window close request from a top-level widget.
+        """
         QSplitter.closeEvent(self, event)
         if is_pyqt46:
             self.destroyed.emit()
@@ -2449,6 +2495,16 @@ def editorsplitter_closed(self):
         self.__give_focus_to_remaining_editor()
 
     def split(self, orientation=Qt.Vertical):
+        """Create and attach a new EditorSplitter to the current EditorSplitter.
+
+        The new EditorSplitter widget will contain an EditorStack that
+        is a clone of the current EditorStack.
+
+        A single EditorSplitter instance can be split multiple times, but the
+        orientation will be the same for all the direct splits.  If one of
+        the child splits is split, then that split can have a different
+        orientation.
+        """
         self.setOrientation(orientation)
         self.editorstack.set_orientation(orientation)
         editorsplitter = EditorSplitter(self.parent(), self.plugin,
@@ -2462,6 +2518,14 @@ def split(self, orientation=Qt.Vertical):
             current_editor.setFocus()
 
     def iter_editorstacks(self):
+        """Return the editor stacks for this splitter and every first child.
+
+        Note: If a splitter contains more than one splitter as a direct
+              child, only the first child's editor stack is included.
+
+        Returns:
+            List of tuples containing (EditorStack instance, orientation).
+        """
         editorstacks = [(self.widget(0), self.orientation())]
         if self.count() > 1:
             editorsplitter = self.widget(1)
@@ -2469,11 +2533,29 @@ def iter_editorstacks(self):
         return editorstacks
 
     def get_layout_settings(self):
-        """Return layout state"""
+        """Return the layout state for this splitter and its children.
+
+        Record the current state, including file names and current line
+        numbers, of the splitter panels.
+
+        Returns:
+            A dictionary containing keys {hexstate, sizes, splitsettings}.
+                hexstate: String of saveState() for self.
+                sizes: List for size() for self.
+                splitsettings: List of tuples of the form
+                       (orientation, cfname, clines) for each EditorSplitter
+                       and its EditorStack.
+                           orientation: orientation() for the editor
+                                 splitter (which may be a child of self).
+                           cfname: EditorStack current file name.
+                           clines: Current line number for each file in the
+                               EditorStack.
+        """
         splitsettings = []
         for editorstack, orientation in self.iter_editorstacks():
             clines = []
             cfname = ''
+            # XXX - this overrides value from the loop to always be False?
             orientation = False
             if hasattr(editorstack, 'data'):
                 clines = [finfo.editor.get_cursor_line_number()
@@ -2484,7 +2566,27 @@ def get_layout_settings(self):
                     sizes=self.sizes(), splitsettings=splitsettings)
 
     def set_layout_settings(self, settings, dont_goto=None):
-        """Restore layout state."""
+        """Restore layout state for the splitter panels.
+
+        Apply the settings to restore a saved layout within the editor.  If
+        the splitsettings key doesn't exist, then return without restoring
+        any settings.
+
+        The current EditorSplitter (self) calls split() for each element
+        in split_settings, thus recreating the splitter panels from the saved
+        state.  split() also clones the editorstack, which is then
+        iterated over to restore the saved line numbers on each file.
+
+        The size and positioning of each splitter panel is restored from
+        hexstate.
+
+        Args:
+            settings: A dictionary with keys {hexstate, sizes, orientation}
+                    that define the layout for the EditorSplitter panels.
+            dont_goto: Defaults to None, which positions the cursor to the
+                    end of the editor.  If there's a value, positions the
+                    cursor on the saved line number for each editor.
+        """
         splitsettings = settings.get('splitsettings')
         if splitsettings is None:
             return


Test patch:
diff --git a/spyder/widgets/tests/test_editor.py b/spyder/widgets/tests/test_editor.py
--- a/spyder/widgets/tests/test_editor.py
+++ b/spyder/widgets/tests/test_editor.py
@@ -103,16 +103,6 @@ def editor_folding_bot(base_editor_bot):
     return editor_stack, finfo.editor, find_replace, qtbot
 
 
-@pytest.fixture
-def editor_splitter_bot(qtbot):
-    """Create editor splitter."""
-    editor_splitter = EditorSplitter(None, Mock(), [], first=True)
-    qtbot.addWidget(editor_splitter)
-    editor_splitter.show()
-    yield editor_splitter
-    editor_splitter.destroy()
-
-
 # Tests
 #-------------------------------
 def test_find_number_matches(qtbot):
@@ -496,30 +486,5 @@ def test_get_current_word(base_editor_bot):
     assert editor.get_current_word() == 'valid_python_word'
 
 
-def test_editor_splitter_init(editor_splitter_bot):
-    """"Test EditorSplitter.__init__."""
-    es = editor_splitter_bot
-    assert es.orientation() == Qt.Horizontal
-    assert es.testAttribute(Qt.WA_DeleteOnClose)
-    assert not es.childrenCollapsible()
-    assert not es.toolbar_list
-    assert not es.menu_list
-    assert es.register_editorstack_cb == es.plugin.register_editorstack
-    assert es.unregister_editorstack_cb == es.plugin.unregister_editorstack
-
-    # No menu actions in parameter call.
-    assert not es.menu_actions
-    # EditorStack adds its own menu actions to the existing actions.
-    assert es.editorstack.menu_actions
-
-    assert isinstance(es.editorstack, EditorStack)
-    es.plugin.register_editorstack.assert_called_with(es.editorstack)
-    es.plugin.unregister_editorstack.assert_not_called()
-    es.plugin.clone_editorstack.assert_not_called()
-
-    assert es.count() == 1
-    assert es.widget(0) == es.editorstack
-
-
 if __name__ == "__main__":
     pytest.main()
diff --git a/spyder/widgets/tests/test_editorsplitter.py b/spyder/widgets/tests/test_editorsplitter.py
new file mode 100644
--- /dev/null
+++ b/spyder/widgets/tests/test_editorsplitter.py
@@ -0,0 +1,291 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+#
+
+"""
+Tests for EditorSplitter class in editor.py
+"""
+
+# Standard library imports
+try:
+    from unittest.mock import Mock
+except ImportError:
+    from mock import Mock  # Python 2
+
+# Third party imports
+import pytest
+from qtpy import PYQT4
+from qtpy.QtCore import Qt
+
+# Local imports
+from spyder.widgets.editor import EditorStack, EditorSplitter
+
+# Qt Test Fixtures
+#--------------------------------
+@pytest.fixture
+def base_editor_bot(qtbot):
+    editor_stack = EditorStack(None, [])
+    editor_stack.set_introspector(Mock())
+    editor_stack.set_find_widget(Mock())
+    editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock())
+    return editor_stack, qtbot
+
+
+@pytest.fixture
+def editor_splitter_bot(qtbot):
+    """Create editor splitter."""
+    es = editor_splitter = EditorSplitter(None, Mock(), [], first=True)
+    qtbot.addWidget(es)
+    es.show()
+    yield es
+    es.destroy()
+
+
+@pytest.fixture
+def editor_splitter_layout_bot(editor_splitter_bot):
+    """Create editor splitter for testing layouts."""
+    es = editor_splitter_bot
+
+    # Allow the split() to duplicate editor stacks.
+    def clone(editorstack):
+        editorstack.close_action.setEnabled(False)
+        editorstack.set_introspector(Mock())
+        editorstack.set_find_widget(Mock())
+        editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock())
+        editorstack.new('foo.py', 'utf-8', 'a = 1\nprint(a)\n\nx = 2')
+        editorstack.new('layout_test.py', 'utf-8', 'print(spam)')
+        with open(__file__) as f:
+            text = f.read()
+        editorstack.new(__file__, 'utf-8', text)
+
+    es.plugin.clone_editorstack.side_effect = clone
+
+    # Setup editor info for this EditorStack.
+    clone(es.editorstack)
+    return es
+
+
+# Tests
+#-------------------------------
+def test_init(editor_splitter_bot):
+    """"Test __init__."""
+    es = editor_splitter_bot
+    assert es.orientation() == Qt.Horizontal
+    assert es.testAttribute(Qt.WA_DeleteOnClose)
+    assert not es.childrenCollapsible()
+    assert not es.toolbar_list
+    assert not es.menu_list
+    assert es.register_editorstack_cb == es.plugin.register_editorstack
+    assert es.unregister_editorstack_cb == es.plugin.unregister_editorstack
+
+    # No menu actions in parameter call.
+    assert not es.menu_actions
+    # EditorStack adds its own menu actions to the existing actions.
+    assert es.editorstack.menu_actions != []
+
+    assert isinstance(es.editorstack, EditorStack)
+    es.plugin.register_editorstack.assert_called_with(es.editorstack)
+    es.plugin.unregister_editorstack.assert_not_called()
+    es.plugin.clone_editorstack.assert_not_called()
+
+    assert es.count() == 1
+    assert es.widget(0) == es.editorstack
+
+
+def test_close(editor_splitter_bot):
+    """Test close().
+
+    This is a widget close function that only hides the split
+    panel.  It does not close the files on the tabs or close any plugin.
+    """
+    es = editor_splitter_bot
+    es.split()
+    es.widget(1).split()
+    es.widget(1).split()
+    esw1w2 = es.widget(1).widget(2)
+    esw1w2.split(Qt.Horizontal)
+    assert not es.isHidden()
+    assert not es.widget(1).isHidden()
+    assert not es.widget(1).widget(1).isHidden()
+    assert not esw1w2.isHidden()
+
+    # Close last split only.
+    esw1w2.editorstack.sig_close_split.emit()  # Signal call.
+    assert not es.isHidden()
+    assert not es.widget(1).isHidden()
+    assert not es.widget(1).widget(1).isHidden()
+    assert esw1w2.isHidden()
+
+    # Close all splits.
+    assert es.widget(1).close()  # Call directly.
+    assert es.widget(1).isHidden()
+
+
+def test_split(editor_splitter_layout_bot):
+    """Test split() that adds new splitters to this instance."""
+    es = editor_splitter_layout_bot
+
+    # Split main panel with default split.
+    es.split()  # Call directly.
+    assert es.orientation() == Qt.Vertical
+    assert not es.editorstack.horsplit_action.isEnabled()
+    assert es.editorstack.versplit_action.isEnabled()
+    assert es.count() == 2
+    assert isinstance(es.widget(1), EditorSplitter)
+    # Each splitter gets its own editor stack as the first widget.
+    assert es.widget(1).count() == 1
+    assert es.widget(1).editorstack == es.widget(1).widget(0)
+    es.widget(1).plugin.clone_editorstack.assert_called_with(
+                                    editorstack=es.widget(1).editorstack)
+
+    # Create a horizontal split on original widget.
+    es.editorstack.sig_split_horizontally.emit()  # Call from signal.
+    assert es.orientation() == Qt.Horizontal
+    assert es.editorstack.horsplit_action.isEnabled()
+    assert not es.editorstack.versplit_action.isEnabled()
+    assert es.count() == 3
+    assert isinstance(es.widget(2), EditorSplitter)
+    # Two splits have been created and each contains one EditorStack.
+    assert es.widget(1).count() == 1
+    assert es.widget(2).count() == 1
+
+    # Test splitting one of the children.
+    es1 = es.widget(1)
+    es1.editorstack.sig_split_vertically.emit()
+    assert es.orientation() == Qt.Horizontal  # Main split didn't change.
+    assert es1.orientation() == Qt.Vertical  # Child splitter.
+    assert not es1.editorstack.horsplit_action.isEnabled()
+    assert es1.editorstack.versplit_action.isEnabled()
+    assert es1.count() == 2
+    assert isinstance(es1.widget(0), EditorStack)
+    assert isinstance(es1.widget(1), EditorSplitter)
+    assert not es1.widget(1).isHidden()
+
+
+def test_iter_editorstacks(editor_splitter_bot):
+    """Test iter_editorstacks."""
+    es = editor_splitter_bot
+    es_iter = es.iter_editorstacks
+
+    # Check base splitter.
+    assert es_iter() == [(es.editorstack, es.orientation())]
+
+    # Split once.
+    es.split(Qt.Vertical)
+    esw1 = es.widget(1)
+    assert es_iter() == [(es.editorstack, es.orientation()),
+                         (esw1.editorstack, esw1.orientation())]
+
+    # Second splitter on base isn't iterated.
+    es.split(Qt.Horizontal)
+    assert es_iter() == [(es.editorstack, es.orientation()),
+                         (esw1.editorstack, esw1.orientation())]
+
+    # Split a child.
+    esw1.split(Qt.Vertical)
+    esw1w1 = es.widget(1).widget(1)
+    assert es_iter() == [(es.editorstack, es.orientation()),
+                         (esw1.editorstack, esw1.orientation()),
+                         (esw1w1.editorstack, esw1w1.orientation())]
+
+
+def test_get_layout_settings(editor_splitter_bot, qtbot, mocker):
+    """Test get_layout_settings()."""
+    es = editor_splitter_bot
+
+    # Initial settings from setup.
+    setting = es.get_layout_settings()
+    assert setting['splitsettings'] == [(False, None, [])]
+
+    # Add some editors to patch output of iter_editorstacks.
+    stack1 = base_editor_bot(qtbot)[0]
+    stack1.new('foo.py', 'utf-8', 'a = 1\nprint(a)\n\nx = 2')
+    stack1.new('layout_test.py', 'utf-8', 'spam egg\n')
+
+    stack2 = base_editor_bot(qtbot)[0]
+    stack2.new('test.py', 'utf-8', 'test text')
+
+    mocker.patch.object(EditorSplitter, "iter_editorstacks")
+    EditorSplitter.iter_editorstacks.return_value = (
+        [(stack1, Qt.Vertical), (stack2, Qt.Horizontal)])
+
+    setting = es.get_layout_settings()
+    assert setting['hexstate']
+    assert setting['sizes'] == es.sizes()
+    assert setting['splitsettings'] == [(False, 'foo.py', [5, 3]),
+                                        (False, 'test.py', [2])]
+
+
+def test_set_layout_settings_dont_goto(editor_splitter_layout_bot):
+    """Test set_layout_settings()."""
+    es = editor_splitter_layout_bot
+    linecount = es.editorstack.data[2].editor.get_cursor_line_number()
+
+    # New layout to restore.
+    state = '000000ff000000010000000200000231000001ff00ffffffff010000000200'
+    sizes = [561, 511]
+    splitsettings = [(False, 'layout_test.py', [2, 1, 52]),
+                     (False, 'foo.py', [3, 2, 125]),
+                     (False, __file__, [1, 1, 1])]
+
+    new_settings = {'hexstate': state,
+                    'sizes': sizes,
+                    'splitsettings': splitsettings}
+
+    # Current widget doesn't have saved settings applied.
+    get_settings = es.get_layout_settings()
+    assert es.count() == 1
+    assert get_settings['hexstate'] != state
+    assert get_settings['splitsettings'] != splitsettings
+
+    # Invalid settings value.
+    assert es.set_layout_settings({'spam': 'test'}) is None
+
+    # Restore layout with dont_goto set.
+    es.set_layout_settings(new_settings, dont_goto=True)
+    get_settings = es.get_layout_settings()
+
+    # Check that the panels were restored.
+    assert es.count() == 2  # One EditorStack and one EditorSplitter.
+    assert es.widget(1).count() == 2  # One EditorStack and one EditorSplitter.
+    assert es.widget(1).widget(1).count() == 1  # One EditorStack.
+    if not PYQT4:
+        assert get_settings['hexstate'] == state
+
+    # All the lines for each tab and split are at the last line number.
+    assert get_settings['splitsettings'] == [(False, 'foo.py', [5, 2, linecount]),
+                                             (False, 'foo.py', [5, 2, linecount]),
+                                             (False, 'foo.py', [5, 2, linecount])]
+
+
+def test_set_layout_settings_goto(editor_splitter_layout_bot):
+    """Test set_layout_settings()."""
+    es = editor_splitter_layout_bot
+
+    # New layout to restore.
+    state = '000000ff000000010000000200000231000001ff00ffffffff010000000200'
+    sizes = [561, 511]
+    splitsettings = [(False, 'layout_test.py', [2, 1, 52]),
+                     (False, 'foo.py', [3, 2, 125]),
+                     (False, __file__, [1, 1, 1])]
+
+    new_settings = {'hexstate': state,
+                    'sizes': sizes,
+                    'splitsettings': splitsettings}
+
+    # Restore layout without dont_goto, meaning it should position to the lines.
+    es.set_layout_settings(new_settings, dont_goto=None)
+    get_settings = es.get_layout_settings()
+    # Even though the original splitsettings had different file names
+    # selected, the current tab isn't restored in set_layout_settings().
+    # However, this shows that the current line was positioned for each tab
+    # and each split.
+    assert get_settings['splitsettings'] == [(False, 'foo.py', [2, 1, 52]),
+                                             (False, 'foo.py', [3, 2, 125]),
+                                             (False, 'foo.py', [1, 1, 1])]
+
+
+if __name__ == "__main__":
+    pytest.main()