Repository: spyder-ide/spyder

Issue:
Single click to interact with items in Project Explorer, rather than double-click
I've recently switched from VSCode to Spyder and a minor annoyance for me is that in Spyder I have to double-click to interact with items in the Project Explorer. By "interact", I mean open and closing folders and opening documents.

In VSCode, you only have to single-click and, even though it might seem like hardly any difference, I think single-clicking is a better user experience. 


Discussion:
Thanks for the suggestion. We'll try to address this for Spyder 4.

Resolving patch:
diff --git a/spyder/config/main.py b/spyder/config/main.py
--- a/spyder/config/main.py
+++ b/spyder/config/main.py
@@ -282,6 +282,7 @@
               'show_hidden': True,
               'show_all': True,
               'show_icontext': False,
+              'single_click_to_open': False,
               }),
             ('find_in_files',
              {
diff --git a/spyder/plugins/explorer/plugin.py b/spyder/plugins/explorer/plugin.py
--- a/spyder/plugins/explorer/plugin.py
+++ b/spyder/plugins/explorer/plugin.py
@@ -33,11 +33,13 @@ def __init__(self, parent=None):
         SpyderPluginWidget.__init__(self, parent)
 
         self.fileexplorer = ExplorerWidget(
-                                self,
-                                name_filters=self.get_option('name_filters'),
-                                show_all=self.get_option('show_all'),
-                                show_icontext=self.get_option('show_icontext'),
-                                options_button=self.options_button)
+            self,
+            name_filters=self.get_option('name_filters'),
+            show_all=self.get_option('show_all'),
+            show_icontext=self.get_option('show_icontext'),
+            options_button=self.options_button,
+            single_click_to_open=self.get_option('single_click_to_open'),
+        )
 
         # Initialize plugin
         self.initialize_plugin()
@@ -46,6 +48,9 @@ def __init__(self, parent=None):
         layout.addWidget(self.fileexplorer)
         self.setLayout(layout)
 
+        self.fileexplorer.sig_option_changed.connect(
+            self._update_config_options)
+
     #------ SpyderPluginWidget API ---------------------------------------------
     def get_plugin_title(self):
         """Return widget title"""
@@ -118,3 +123,7 @@ def on_first_registration(self):
     def chdir(self, directory):
         """Set working directory"""
         self.fileexplorer.treewidget.chdir(directory)
+
+    def _update_config_options(self, option, value):
+        """Update the config options of the explorer to make them permanent."""
+        self.set_option(option, value)
diff --git a/spyder/plugins/explorer/widgets.py b/spyder/plugins/explorer/widgets.py
--- a/spyder/plugins/explorer/widgets.py
+++ b/spyder/plugins/explorer/widgets.py
@@ -146,9 +146,13 @@ class DirView(QTreeView):
 
     def __init__(self, parent=None):
         super(DirView, self).__init__(parent)
-        self.name_filters = ['*.py']
         self.parent_widget = parent
+
+        # Options
+        self.name_filters = ['*.py']
         self.show_all = None
+        self.single_click_to_open = False
+
         self.menu = None
         self.common_actions = None
         self.__expanded_state = None
@@ -183,7 +187,13 @@ def setup_view(self):
         self.reset_icon_provider()
         # Disable the view of .spyproject. 
         self.filter_directories()
-        
+
+    def set_single_click_to_open(self, value):
+        """Set single click to open items."""
+        self.single_click_to_open = value
+        self.parent_widget.sig_option_changed.emit('single_click_to_open',
+                                                   value)
+
     def set_name_filters(self, name_filters):
         """Set name filters"""
         self.name_filters = name_filters
@@ -225,12 +235,14 @@ def get_dirname(self, index):
                 return osp.dirname(fname)
         
     #---- Tree view widget
-    def setup(self, name_filters=['*.py', '*.pyw'], show_all=False):
+    def setup(self, name_filters=['*.py', '*.pyw'], show_all=False,
+              single_click_to_open=False):
         """Setup tree widget"""
         self.setup_view()
 
         self.set_name_filters(name_filters)
         self.show_all = show_all
+        self.single_click_to_open = single_click_to_open
         
         # Setup context menu
         self.menu = QMenu(self)
@@ -253,8 +265,15 @@ def setup_common_actions(self):
                                    toggled=self.toggle_all)
         all_action.setChecked(self.show_all)
         self.toggle_all(self.show_all)
-        
-        return [filters_action, all_action]
+
+        # Show all files
+        single_click_to_open = create_action(
+            self,
+            _("Single click to open"),
+            toggled=self.set_single_click_to_open,
+        )
+        single_click_to_open.setChecked(self.single_click_to_open)
+        return [filters_action, all_action, single_click_to_open]
 
     @Slot()
     def edit_filter(self):
@@ -486,6 +505,12 @@ def mouseDoubleClickEvent(self, event):
         QTreeView.mouseDoubleClickEvent(self, event)
         self.clicked()
 
+    def mouseReleaseEvent(self, event):
+        """Reimplement Qt method."""
+        QTreeView.mouseReleaseEvent(self, event)
+        if self.single_click_to_open:
+            self.clicked()
+
     @Slot()
     def clicked(self):
         """Selected item was double-clicked or enter/return was pressed"""
@@ -1214,7 +1239,7 @@ class ExplorerTreeWidget(DirView):
     
     def __init__(self, parent=None, show_cd_only=None):
         DirView.__init__(self, parent)
-                
+
         self.history = []
         self.histindex = None
 
@@ -1227,7 +1252,7 @@ def __init__(self, parent=None, show_cd_only=None):
         
         # Enable drag events
         self.setDragEnabled(True)
-        
+
     #---- Context menu
     def setup_common_actions(self):
         """Setup context menu common actions"""
@@ -1368,6 +1393,7 @@ class ExplorerWidget(QWidget):
 
     def __init__(self, parent=None, name_filters=['*.py', '*.pyw'],
                  show_all=False, show_cd_only=None, show_icontext=True,
+                 single_click_to_open=False,
                  options_button=None):
         QWidget.__init__(self, parent)
 
@@ -1394,7 +1420,11 @@ def __init__(self, parent=None, name_filters=['*.py', '*.pyw'],
                             triggered=self.treewidget.go_to_parent_directory)
 
         # Setup widgets
-        self.treewidget.setup(name_filters=name_filters, show_all=show_all)
+        self.treewidget.setup(
+            name_filters=name_filters,
+            show_all=show_all,
+            single_click_to_open=single_click_to_open,
+        )
         self.treewidget.chdir(getcwd_or_home())
         self.treewidget.common_actions += [None, icontext_action]
 


Test patch:
diff --git a/spyder/plugins/explorer/tests/test_explorer.py b/spyder/plugins/explorer/tests/test_explorer.py
--- a/spyder/plugins/explorer/tests/test_explorer.py
+++ b/spyder/plugins/explorer/tests/test_explorer.py
@@ -14,6 +14,8 @@
 
 # Third party imports
 import pytest
+from qtpy.QtCore import Qt, QPoint, QEvent
+from qtpy.QtGui import QMouseEvent
 from qtpy.QtWidgets import QApplication
 from qtpy.QtWidgets import QMessageBox
 
@@ -127,5 +129,49 @@ def test_delete_file(explorer_with_files, mocker):
     assert not osp.exists(top_folder)
 
 
+def test_single_click_to_open(qtbot, file_explorer):
+    """Test single and double click open option for the file explorer."""
+    file_explorer.show()
+
+    treewidget = file_explorer.explorer.treewidget
+    model = treewidget.model()
+    cwd = os.getcwd()
+    qtbot.keyClick(treewidget, Qt.Key_Up)  # To focus and select the 1st item
+    initial_index = treewidget.currentIndex()  # To keep a reference
+
+    def run_test_helper(single_click, initial_index):
+        # Reset the widget
+        treewidget.setCurrentIndex(initial_index)
+        file_explorer.label3.setText('')
+        file_explorer.label1.setText('')
+
+        for i in range(4):  # 4 items inside `/spyder/plugins/explorer/`
+            qtbot.keyClick(treewidget, Qt.Key_Down)        
+            index = treewidget.currentIndex()
+            path = model.data(index)
+            if path:
+                full_path = os.path.join(cwd, path)
+                # Skip folder to avoid changing the view for single click case
+                if os.path.isfile(full_path):
+                    rect = treewidget.visualRect(index)
+                    pos = rect.center()
+                    qtbot.mouseClick(treewidget.viewport(), Qt.LeftButton, pos=pos)
+
+                    if single_click:
+                        assert full_path == file_explorer.label1.text()
+                    else:
+                        assert full_path != file_explorer.label1.text()
+
+    # Test single click to open
+    treewidget.set_single_click_to_open(True)
+    assert 'True' in file_explorer.label3.text()
+    run_test_helper(single_click=True, initial_index=initial_index)
+
+    # Test double click to open
+    treewidget.set_single_click_to_open(False)
+    assert 'False' in file_explorer.label3.text()
+    run_test_helper(single_click=False, initial_index=initial_index)
+
+
 if __name__ == "__main__":
     pytest.main()