Repository: spyder-ide/spyder
Pull request: PR: Improvements to the Object Explorer (font, resize, and actions over row)
<!--- 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

* [ ] Wrote at least one-line docstrings (for any new functions)
* [ ] Added unit test(s) covering the changes (if testable)
<!--- Remember that an image/animation is worth a thousand words! --->
* [x] Included a screenshot or animation (if affecting the UI, see [Licecap](https://www.cockos.com/licecap/))


<!--- Explain what you've done and why --->

Improvements for the Object Explorer:
![oe](https://user-images.githubusercontent.com/16781833/67886268-3431a880-fb17-11e9-85bc-f3a9dd63460c.gif)


### Issue(s) Resolved

<!--- List the issue(s) below, in the form "Fixes #1234"; one per line --->

Fixes #10537 


### 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: dalthviz

<!--- Thanks for your help making Spyder better for everyone! --->


Problem statement:
Object Explorer improvements
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

## TODO:

- [x] Make columns resizable
- [x] Set same font used in the Variable Explorer
- [x] Don't expand rows of collections (list, set, dict, numpy arrays, etc.).
- [x] Remove `repr` preview
- [x] Open editor by clicking in any cell of the row (not only value)

As discussed with @ccordoba12

Hints from issue discussion:

Commit message: Object Explorer: Prevent font size changes

Patch to review:
diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py
index cc9e393a704..29cbea1f542 100644
--- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py
+++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py
@@ -409,8 +409,6 @@ def show_warning(self, index):
 
     def createEditor(self, parent, option, index):
         """Overriding method createEditor"""
-        if index.column() < 3:
-            return None
         if self.show_warning(index):
             answer = QMessageBox.warning(
                 self.parent(), _("Warning"),
@@ -425,6 +423,8 @@ def createEditor(self, parent, option, index):
                 self.old_obj = value.copy()
             except AttributeError:
                 self.old_obj = copy.deepcopy(value)
+            except Exception:
+                value = None
             if value is None:
                 return None
         except Exception as msg:
diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/attribute_model.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/attribute_model.py
index b3ae7dd39fa..77c3e0fe20e 100644
--- a/spyder/plugins/variableexplorer/widgets/objectexplorer/attribute_model.py
+++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/attribute_model.py
@@ -269,7 +269,7 @@ def settings_name(self):
 
 
 ATTR_MODEL_REPR = AttributeModel(
-    'repr (*)',
+    'repr',
     doc=_("The string representation of the "
           "object using the repr() function."),
     data_fn=lambda tree_item: repr(tree_item.obj),
@@ -470,7 +470,7 @@ def settings_name(self):
     ATTR_MODEL_GET_DOC,
     ATTR_MODEL_GET_SOURCE,
     ATTR_MODEL_GET_FILE,
-    ATTR_MODEL_REPR,
+    # ATTR_MODEL_REPR, # not used, too expensive for large objects/collections
     # ATTR_MODEL_DOC_STRING, # not used, too similar to ATTR_MODEL_GET_DOC
     # ATTR_MODEL_GET_MODULE, # not used, already in table
     # ATTR_MODEL_GET_SOURCE_FILE,  # not used, already in table
diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py
index f83a845ea34..e1dbaab9ff4 100644
--- a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py
+++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py
@@ -20,11 +20,12 @@
 from qtpy.QtGui import QKeySequence, QTextOption
 from qtpy.QtWidgets import (QAbstractItemView, QAction, QButtonGroup,
                             QDialog, QGroupBox, QHBoxLayout, QHeaderView,
-                            QLabel, QMenu, QPushButton, QRadioButton,
-                            QSplitter, QToolButton, QVBoxLayout, QWidget)
+                            QMenu, QPushButton, QRadioButton, QSplitter,
+                            QToolButton, QVBoxLayout, QWidget)
 
 # Local imports
 from spyder.config.base import _
+from spyder.config.fonts import DEFAULT_SMALL_DELTA
 from spyder.config.gui import get_font, is_dark_interface
 from spyder.config.manager import CONF
 from spyder.utils.qthelpers import (add_actions, create_plugin_layout,
@@ -108,6 +109,16 @@ def __init__(self,
         self._proxy_tree_model.setDynamicSortFilter(True)
         # self._proxy_tree_model.setSortCaseSensitivity(Qt.CaseInsensitive)
 
+        # Tree widget
+        self.obj_tree = ToggleColumnTreeView(
+                dataframe_format=self._dataframe_format)
+        self.obj_tree.setAlternatingRowColors(True)
+        self.obj_tree.setModel(self._proxy_tree_model)
+        self.obj_tree.setSelectionBehavior(QAbstractItemView.SelectRows)
+        self.obj_tree.setUniformRowHeights(True)
+        self.obj_tree.add_header_context_menu()
+        self.obj_tree.sig_option_changed.connect(self.sig_option_changed.emit)
+
         # Views
         self._setup_actions()
         self._setup_menu(show_callable_attributes=show_callable_attributes,
@@ -152,6 +163,8 @@ def _setup_actions(self):
                                 "that are callable (functions, methods, etc)"))
         self.toggle_show_callable_action.toggled.connect(
             self._proxy_tree_model.setShowCallables)
+        self.toggle_show_callable_action.toggled.connect(
+            self.obj_tree.resize_columns_to_contents)
 
         # Show/hide special attributes
         self.toggle_show_special_attribute_action = \
@@ -160,6 +173,8 @@ def _setup_actions(self):
                     statusTip=_("Shows or hides __special__ attributes"))
         self.toggle_show_special_attribute_action.toggled.connect(
             self._proxy_tree_model.setShowSpecialAttributes)
+        self.toggle_show_special_attribute_action.toggled.connect(
+            self.obj_tree.resize_columns_to_contents)
 
     def _setup_menu(self, show_callable_attributes=False,
                     show_special_attributes=False):
@@ -234,17 +249,6 @@ def _setup_views(self):
                                       self.central_splitter)
         self.setLayout(layout)
 
-        # Tree widget
-        self.obj_tree = ToggleColumnTreeView(
-                dataframe_format=self._dataframe_format)
-        self.obj_tree.setAlternatingRowColors(True)
-        self.obj_tree.setModel(self._proxy_tree_model)
-        self.obj_tree.setSelectionBehavior(QAbstractItemView.SelectRows)
-        self.obj_tree.setUniformRowHeights(True)
-        self.obj_tree.setAnimated(True)
-        self.obj_tree.add_header_context_menu()
-        self.obj_tree.sig_option_changed.connect(self.sig_option_changed.emit)
-
         # Stretch last column?
         # It doesn't play nice when columns are hidden and then shown again.
         obj_tree_header = self.obj_tree.header()
@@ -296,12 +300,6 @@ def _setup_views(self):
         self.editor.setReadOnly(True)
         h_group_layout.addWidget(self.editor)
 
-        # Warining label about repr
-        repr_label = QLabel(_("(*) Some objects have very large repr's, "
-                              "which can freeze Spyder. Please use this "
-                              "with care."))
-        v_group_layout.addWidget(repr_label)
-
         # Save and close buttons
         btn_layout = QHBoxLayout()
         btn_layout.addStretch()
@@ -377,7 +375,8 @@ def _readViewSettings(self, reset=False):
                 if not self._resize_to_contents and size > 0:  # Just in case
                     header.resizeSection(idx, size)
                 else:
-                    header.setSectionResizeMode(QHeaderView.ResizeToContents)
+                    header.resizeSections(QHeaderView.ResizeToContents)
+                    break
 
             for idx, visible in enumerate(column_visible):
                 elem = self.obj_tree.toggle_column_actions_group.actions()[idx]
@@ -425,11 +424,12 @@ def _update_details_for_item(self, tree_item):
             show_blanks = CONF.get('editor', 'blank_spaces')
             update_scrollbar = CONF.get('editor', 'scroll_past_end')
             scheme_name = CONF.get('appearance', 'selected')
-            self.editor.setup_editor(tab_mode=False,
-                                     font=get_font(),
-                                     show_blanks=show_blanks,
-                                     color_scheme=scheme_name,
-                                     scroll_past_end=update_scrollbar)
+            self.editor.setup_editor(
+                tab_mode=False,
+                font=get_font(font_size_delta=DEFAULT_SMALL_DELTA),
+                show_blanks=show_blanks,
+                color_scheme=scheme_name,
+                scroll_past_end=update_scrollbar)
             self.editor.set_text(data)
             if attr_details.name == 'Source code':
                 self.editor.set_language('Python')
diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py
index 072e302c4ae..0e7dac9d98d 100644
--- a/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py
+++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/tests/test_objectexplorer.py
@@ -106,27 +106,67 @@ def error_attribute(self):
     assert model.columnCount() == 11
 
 
-@pytest.mark.skipif(PY2, reason="Number of rows is different in PY2")
-def test_objectexplorer_types(objectexplorer):
-    """Test to validate proper handling of multiple data types."""
-    test = {'str': 'kjkj kj k j j kj k jkj',
-            'list': [1, 3, 4, 'kjkj', None],
-            'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False},
-            'dict': {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]},
-            'float': 1.2233,
-            'array': np.random.rand(10, 10),
-            'date': datetime.date(1945, 5, 8),
-            'datetime': datetime.datetime(1945, 5, 8)}
+@pytest.mark.parametrize('params', [
+            'kjkj kj k j j kj k jkj',
+            [1, 3, 4, 'kjkj', None],
+            {1, 2, 1, 3, None, 'A', 'B', 'C', True, False},
+            1.2233,
+            np.random.rand(10, 10),
+            datetime.date(1945, 5, 8),
+            datetime.datetime(1945, 5, 8)
+        ])
+def test_objectexplorer_collection_types(objectexplorer, params):
+    """Test to validate proper handling of collection data types."""
+    test = params
     editor = objectexplorer(test,
-                            expanded=True,
+                            name='variable',
                             show_callable_attributes=True,
                             show_special_attributes=True)
     # Editor was created
     assert editor
 
-    # Check number of rows
+    # Check number of rows and row content
     model = editor.obj_tree.model()
-    assert model.rowCount() == 48
+    # The row for the variable
+    assert model.rowCount() == 1
+    # Root row without children
+    assert model.rowCount(model.index(0, 0)) == 0
+    assert model.columnCount() == 11
+
+
+@pytest.mark.parametrize('params', [
+            # show_callable_, show_special_, rowCount for python 3 and 2
+            (True, True, [34, 26], ),
+            (False, False, [8, 8], )
+        ])
+def test_objectexplorer_types(objectexplorer, params):
+    """Test to validate proper handling of data types inside an object."""
+    class Foobar(object):
+        def __init__(self):
+            self.text = "toto"
+            self.list = [1, 3, 4, 'kjkj', None]
+            self.set = {1, 2, 1, 3, None, 'A', 'B', 'C', True, False},
+            self.dict = {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]},
+            self.float = 1.2233,
+            self.array = np.random.rand(10, 10),
+            self.date = datetime.date(1945, 5, 8),
+            self.datetime = datetime.datetime(1945, 5, 8)
+    foo = Foobar()
+
+    show_callable, show_special, row_count = params
+    editor = objectexplorer(foo,
+                            name='foo',
+                            show_callable_attributes=show_callable,
+                            show_special_attributes=show_special)
+    # Editor was created
+    assert editor
+
+    # Check number of rows and row content
+    model = editor.obj_tree.model()
+    # The row for the object
+    assert model.rowCount() == 1
+    # Rows from the object attributes
+    assert model.rowCount(model.index(0, 0)) in row_count
     assert model.columnCount() == 11
 
 
diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py
index 20922e1d147..d3af63e1f00 100644
--- a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py
+++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py
@@ -14,7 +14,7 @@
 # Third-party imports
 from qtpy.QtCore import Qt, Signal, Slot
 from qtpy.QtWidgets import (QAbstractItemView, QAction, QActionGroup,
-                            QTableWidget, QTreeView, QTreeWidget)
+                            QHeaderView, QTableWidget, QTreeView, QTreeWidget)
 
 # Local imports
 from spyder.config.base import _
@@ -164,6 +164,13 @@ def __init__(self, dataframe_format=None, readonly=False):
             import ToggleColumnDelegate
         self.setItemDelegate(ToggleColumnDelegate(self))
         self.setEditTriggers(QAbstractItemView.DoubleClicked)
+        self.expanded.connect(self.resize_columns_to_contents)
+        self.collapsed.connect(self.resize_columns_to_contents)
+
+    @Slot()
+    def resize_columns_to_contents(self):
+        """Resize all the columns to its contents."""
+        self._horizontal_header().resizeSections(QHeaderView.ResizeToContents)
 
     @Slot(str)
     def set_dataframe_format(self, new_format):
diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/tree_model.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/tree_model.py
index 120ad3946d3..054f25eddbc 100644
--- a/spyder/plugins/variableexplorer/widgets/objectexplorer/tree_model.py
+++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/tree_model.py
@@ -15,15 +15,16 @@
 # Standard library imports
 import logging
 from difflib import SequenceMatcher
-from collections import OrderedDict
 
 # Third-party imports
 from qtpy.QtCore import (QAbstractItemModel, QModelIndex, Qt,
                          QSortFilterProxyModel, Signal)
-from qtpy.QtGui import QFont, QBrush, QColor
+from qtpy.QtGui import QBrush, QColor
 
 # Local imports
 from spyder.config.base import _
+from spyder.config.gui import get_font
+from spyder_kernels.utils.nsview import is_editable_type
 from spyder.plugins.variableexplorer.widgets.objectexplorer.utils import (
     cut_off_str)
 from spyder.plugins.variableexplorer.widgets.objectexplorer.tree_item import (
@@ -54,7 +55,9 @@ def __init__(self,
                  obj,
                  obj_name='',
                  attr_cols=None,
-                 parent=None):
+                 parent=None,
+                 regular_font=None,
+                 special_attribute_font=None):
         """
         Constructor
 
@@ -67,9 +70,12 @@ def __init__(self,
         super(TreeModel, self).__init__(parent)
         self._attr_cols = attr_cols
 
-        self.regular_font = QFont()  # Font for members (non-functions)
+        # Font for members (non-functions)
+        self.regular_font = regular_font if regular_font else get_font()
         # Font for __special_attributes__
-        self.special_attribute_font = QFont()
+        self.special_attribute_font = (special_attribute_font
+                                       if special_attribute_font
+                                       else get_font())
         self.special_attribute_font.setItalic(False)
 
         self.regular_color = QBrush(QColor(ima.MAIN_FG_COLOR))
@@ -279,62 +285,31 @@ def _fetchObjectChildren(self, obj, obj_path):
         """
         obj_children = []
         path_strings = []
+        tree_items = []
 
-        if isinstance(obj, (list, tuple)):
-            obj_children = sorted(enumerate(obj))
-            path_strings = ['{}[{}]'.format(obj_path,
-                            item[0]) if obj_path else item[0]
-                            for item in obj_children]
-        elif isinstance(obj, (set, frozenset)):
-            obj_children = [('pop()', elem) for elem in obj]
-            path_strings = ['{0}.pop()'.format(obj_path,
-                            item[0]) if obj_path else item[0]
-                            for item in obj_children]
-        elif hasattr(obj, 'items'):  # dictionaries and the likes.
-            try:
-                obj_children = list(obj.items())
-            except Exception as ex:
-                # Can happen if the items method expects an argument,
-                # for instance the types.DictType.items
-                # method expects a dictionary.
-                logger.warn("No items expanded. "
-                            "Objects items() call failed: {}".format(ex))
-                obj_children = []
-
-            # Sort keys, except when the object is an OrderedDict.
-            if not isinstance(obj, OrderedDict):
+        # Only populate children for objects without their own editor
+        if not is_editable_type(obj):
+            is_attr_list = [False] * len(obj_children)
+
+            # Object attributes
+            # Needed to handle errors while getting object's attributes
+            # Related with spyder-ide/spyder#6728 and spyder-ide/spyder#9959
+            for attr_name in dir(obj):
                 try:
-                    obj_children = sorted(obj.items())
-                except Exception as ex:
-                    logger.debug("Unable to sort "
-                                 "dictionary keys: {}".format(ex))
-
-            path_strings = ['{}[{!r}]'.format(obj_path,
-                            item[0]) if obj_path else item[0]
-                            for item in obj_children]
-
-        assert len(obj_children) == len(path_strings), "sanity check"
-        is_attr_list = [False] * len(obj_children)
-
-        # Object attributes
-        # Needed to handle errors while getting object's attributes
-        # Related with spyder-ide/spyder#6728 and spyder-ide/spyder#9959
-        for attr_name in dir(obj):
-            try:
-                attr_value = getattr(obj, attr_name)
-                obj_children.append((attr_name, attr_value))
-                path_strings.append('{}.{}'.format(obj_path, attr_name)
-                                    if obj_path else attr_name)
-                is_attr_list.append(True)
-            except Exception:
-                # Attribute could not be get
-                pass
-        assert len(obj_children) == len(path_strings), "sanity check"
-        tree_items = []
-        for item, path_str, is_attr in zip(obj_children, path_strings,
-                                           is_attr_list):
-            name, child_obj = item
-            tree_items.append(TreeItem(child_obj, name, path_str, is_attr))
+                    attr_value = getattr(obj, attr_name)
+                    obj_children.append((attr_name, attr_value))
+                    path_strings.append('{}.{}'.format(obj_path, attr_name)
+                                        if obj_path else attr_name)
+                    is_attr_list.append(True)
+                except Exception:
+                    # Attribute could not be get
+                    pass
+            assert len(obj_children) == len(path_strings), "sanity check"
+
+            for item, path_str, is_attr in zip(obj_children, path_strings,
+                                               is_attr_list):
+                name, child_obj = item
+                tree_items.append(TreeItem(child_obj, name, path_str, is_attr))
 
         return tree_items
 


Code review comments:
diff_hunk: @@ -425,6 +423,8 @@ def createEditor(self, parent, option, index):
                 self.old_obj = value.copy()
             except AttributeError:
                 self.old_obj = copy.deepcopy(value)
+            except Exception:
+                value = None
path: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py
text: @user1:
Please remove these two lines or we won't be able to display an error message when trying to create an editor for a variable.

diff_hunk: @@ -15,15 +15,16 @@
 # Standard library imports
 import logging
 from difflib import SequenceMatcher
-from collections import OrderedDict
 
 # Third-party imports
 from qtpy.QtCore import (QAbstractItemModel, QModelIndex, Qt,
                          QSortFilterProxyModel, Signal)
-from qtpy.QtGui import QFont, QBrush, QColor
+from qtpy.QtGui import QBrush, QColor
 
 # Local imports
 from spyder.config.base import _
+from spyder.config.gui import get_font
+from spyder_kernels.utils.nsview import is_editable_type
path: spyder/plugins/variableexplorer/widgets/objectexplorer/tree_model.py
text: @user1:
This is not a local import, so please move it to be below the qtpy imports