Repository: spyder-ide/spyder
Pull request: PR: Display plots from collections editor in the Plots pane
<!--- 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

* [x] Wrote at least one-line docstrings (for any new functions)
* [x] Added unit test(s) covering the changes (if testable)
* [x] Included a screenshot or animation (if affecting the UI, see [Licecap](https://www.cockos.com/licecap/))

<!--- Explain what you've done and why --->

The collection editor is opened when the user edits a list, set, dict or tuple in the Variable Explorer. At the moment, plotting in the collection editor opens a new window with the plot. This PR changes this so that the plot is displayed in the Plots pane, for consistency with the namespace browser. The Plots pane is brought to the front so that users notice the new plot. However, if the user unticks the setting "Mute inline plots" in the Plots pane, then the old behaviour is restored and plots are shown in a separate window.

The collection editor is also used in several places outside the Variable Explorer:
* The IPython Console uses it to show environment variables and sys.path contents. Neither of these can be plotted as far as I can see.
* The `oedit()` function can create a collection editor. I am not sure about this function. I don't think it is used anywhere in Spyder but perhaps it is meant to be run from the Internal Console. Plotting from a collection editor created by `oedit()` will not work, because the signal is not connected to anything.

One niggle is that plots from the collection editor will be shown in the currently active figure browser in the Plots pane, which is associated to the currently active console. However, the variable shown in the collection editor may originate from a different console.

![collection-plot](https://github.com/spyder-ide/spyder/assets/7941918/0f3cd8a6-9071-48a0-bd5d-d0b33471cd1c)


### Issue(s) Resolved

<!--- List the issue(s) below, in the form "Fixes #1234"; one per line --->

Fixes #21145


### 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: Jitse Niesen

<!--- Thanks for your help making Spyder better for everyone! --->


Problem statement:
Plots from collection editor don't appear in Plots pane
## Problem Description

Plots from the namespace editor open in the Plots pane, but plots from the collection editor don't.

### What steps reproduce the problem?

1. Run the code `var = [1,2,3,4]` in the Console 
2. In the variable editor, right click on the variable `var` and click on `Plot`
3. See that the plot is shown in the `Plots` pane
4. Run the code `var = {'a': [1,2,3,4]}` in the Console
5. In the variable editor, double click on the variable `var` to open a window with the contents of the dictionary.
6. In the new window, right click to open the context menu and click on `Plot`
7. See that Spyder opens a new window with the plot in it.

### What is the expected output? What do you see instead?

The UI should be consistent. 

The reason is that in the first case, the plots are run in the console using the magic `%varexp --plot var`. In the second case, the matplotlib command that generates the plot is run in Spyder.

We think it is best to always show plots in the Plot pane. The easiest is to generate a static image (with a non-interactive backend) and pass it to the Plots plugin.

Hints from issue discussion:
Here is a proof of concept:

![collection-plot](https://github.com/spyder-ide/spyder/assets/7941918/e4516be6-1fba-49c7-8ffa-5bca2b8de4cd)

One problem is that the user does not get any feedback after clicking on "Plot". They have to go to the Plots pane to find the result. Compare this with when you plot from the Variable Explorer, in which case the `%varexp` magic command is shown in the console ... also not the best feedback in my opinion, the console may even be hidden. Maybe we should bring the Plots pane to the fore after the user plots something? It does make sense that if you make a plot, you want to see it.

A smaller issue is that if you plot from the Variable Explorer, it respect the setting from the console (if the console uses interactive plots then the Variable Explorer will also use interactive plot). Do we want to do the same from the Collection Editor? 
> Maybe we should bring the Plots pane to the fore after the user plots something? It does make sense that if you make a plot, you want to see it.

Yep, I totally agree with that, so proceed in that direction.

My only comment about this is that it has to take into account if users have the `Mute inline plotting` option enabled/disabled in Plots. If it's disabled, plots that come from our collections editor should be displayed in separate windows, I'd say.

Commit message: Set plots_plugin_enabled in namespace browser from plugin

Calling a function from the Variable Explorer plugin in the namespace browser
to determine whether the Plots plugin is enabled, causes tight coupling.
Instead, this commit makes the Variable Explorer plugin push this information
to all the namespace browsers.

Patch to review:
diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py
index c3bc44e982f..62036d9ef6f 100644
--- a/spyder/app/tests/test_mainwindow.py
+++ b/spyder/app/tests/test_mainwindow.py
@@ -2388,6 +2388,50 @@ def test_tight_layout_option_for_inline_plot(main_window, qtbot, tmpdir):
     assert compare_images(savefig_figname, inline_figname, 0.1) is None
 
 
+def test_plot_from_collectioneditor(main_window, qtbot):
+    """
+    Create a variable with value `[[1, 2, 3], [4, 5, 6]]`, use the variable
+    explorer to open a collection editor and plot the first sublist. Check
+    that a plot is displayed in the Plots pane.
+    """
+    CONF.set('plots', 'mute_inline_plotting', True)
+    shell = main_window.ipyconsole.get_current_shellwidget()
+    figbrowser = main_window.plots.current_widget()
+    nsb = main_window.variableexplorer.current_widget()
+
+    # Check that we start with no plots
+    assert len(figbrowser.thumbnails_sb._thumbnails) == 0
+
+    # Wait until the window console is fully up
+    qtbot.waitUntil(
+        lambda: shell.spyder_kernel_ready and shell._prompt_html is not None,
+        timeout=SHELL_TIMEOUT)
+
+    # Create variable
+    with qtbot.waitSignal(shell.executed):
+        shell.execute(('nested_list = [[1, 2, 3], [4, 5, 6]]'))
+
+    # Edit `nested_list` in Variable Explorer
+    main_window.variableexplorer.change_visibility(True)
+    qtbot.waitUntil(
+        lambda: nsb.editor.source_model.rowCount() > 0, timeout=EVAL_TIMEOUT)
+    nsb.editor.setFocus()
+    nsb.editor.edit_item()
+
+    # Find the collection editor
+    from spyder.widgets.collectionseditor import CollectionsEditor
+    for child in nsb.editor.children():
+        for grandchild in child.children():
+            if isinstance(grandchild, CollectionsEditor):
+                collections_editor = grandchild
+
+    # Plot item 0 in collection editor
+    collections_editor.widget.editor.plot(0, 'plot')
+
+    # Check that we now have one plot
+    assert len(figbrowser.thumbnails_sb._thumbnails) == 1
+
+
 @flaky(max_runs=3)
 @pytest.mark.use_introspection
 @pytest.mark.order(after="test_debug_unsaved_function")
diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py
index eaa9b6a68cb..fe80d412747 100644
--- a/spyder/plugins/ipythonconsole/plugin.py
+++ b/spyder/plugins/ipythonconsole/plugin.py
@@ -534,6 +534,10 @@ def get_current_shellwidget(self):
         """Return the shellwidget of the current client"""
         return self.get_widget().get_current_shellwidget()
 
+    def set_current_shellwidget(self, shellwidget):
+        """Activate client associated to given shellwidget"""
+        return self.get_widget().select_tab(shellwidget)
+
     def rename_client_tab(self, client, given_name):
         """
         Rename a client's tab.
diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py
index c14758b4e91..a73a33e8906 100644
--- a/spyder/plugins/ipythonconsole/widgets/main_widget.py
+++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py
@@ -1233,6 +1233,15 @@ def add_tab(self, client, name, filename='', give_focus=True):
         # Register client
         self.register_client(client)
 
+    def select_tab(self, shellwidget):
+        """
+        Select tab with given shellwidget.
+        """
+        for client in self.clients:
+            if client.shellwidget == shellwidget:
+                self.tabwidget.setCurrentWidget(client)
+                return
+
     def move_tab(self, index_from, index_to):
         """
         Move tab (tabs themselves have already been moved by the tabwidget).
diff --git a/spyder/plugins/plots/plugin.py b/spyder/plugins/plots/plugin.py
index 6517dfe7a87..ee52378f450 100644
--- a/spyder/plugins/plots/plugin.py
+++ b/spyder/plugins/plots/plugin.py
@@ -46,6 +46,23 @@ def on_initialize(self):
         # a plot is generated.
         self.get_widget().sig_figure_loaded.connect(self._on_first_plot)
 
+    # ---- Public API
+    # ------------------------------------------------------------------------
+    def add_plot(self, fig, fmt, shellwidget):
+        """
+        Add a plot to the specified figure browser.
+
+        Add the plot to the figure browser with the given shellwidget. Also,
+        bring the plugin to the front and raise the window that it is in so
+        that the plot is shown.
+
+        If no figure browser with the given shellwidget exists, then nothing
+        happens.
+        """
+        self.switch_to_plugin(force_focus=False)
+        self.get_widget().window().raise_()
+        self.get_widget().add_plot(fig, fmt, shellwidget)
+
     # ---- Private API
     # ------------------------------------------------------------------------
     def _on_first_plot(self):
diff --git a/spyder/plugins/plots/widgets/figurebrowser.py b/spyder/plugins/plots/widgets/figurebrowser.py
index d56937470dc..6096519c715 100644
--- a/spyder/plugins/plots/widgets/figurebrowser.py
+++ b/spyder/plugins/plots/widgets/figurebrowser.py
@@ -259,13 +259,15 @@ def set_shellwidget(self, shellwidget):
         """Bind the shellwidget instance to the figure browser"""
         self.shellwidget = shellwidget
         self.shellwidget.set_mute_inline_plotting(self.mute_inline_plotting)
-        shellwidget.sig_new_inline_figure.connect(self._handle_new_figure)
+        shellwidget.sig_new_inline_figure.connect(self.add_figure)
         shellwidget.executing.connect(self._handle_new_execution)
 
-    def _handle_new_figure(self, fig, fmt):
+    def add_figure(self, fig, fmt):
         """
-        Handle when a new figure is sent to the IPython console by the
-        kernel.
+        Add a figure to the figure browser.
+
+        This is called when a new figure is sent to the IPython console by the
+        kernel, but can also be called directly.
         """
         self.thumbnails_sb.add_thumbnail(fig, fmt)
 
diff --git a/spyder/plugins/plots/widgets/main_widget.py b/spyder/plugins/plots/widgets/main_widget.py
index 9caf486b02c..c96fc31c0b8 100644
--- a/spyder/plugins/plots/widgets/main_widget.py
+++ b/spyder/plugins/plots/widgets/main_widget.py
@@ -20,6 +20,7 @@
 from spyder.plugins.plots.widgets.figurebrowser import FigureBrowser
 from spyder.utils.misc import getcwd_or_home
 from spyder.utils.palette import QStylePalette
+from spyder.widgets.helperwidgets import PaneEmptyWidget
 
 
 MAIN_BG_COLOR = QStylePalette.COLOR_BACKGROUND_1
@@ -375,6 +376,14 @@ def copy_image(self):
                 # selection
                 self._right_clicked_thumbnail = None
 
+    def add_plot(self, fig, fmt, shellwidget):
+        """
+        Add a plot to the figure browser with the given shellwidget, if any.
+        """
+        fig_browser = self.get_widget_for_shellwidget(shellwidget)
+        if fig_browser and not isinstance(fig_browser, PaneEmptyWidget):
+            fig_browser.add_figure(fig, fmt)
+
     def remove_plot(self):
         """
         Remove currently active plot or plot selected to be removed with
diff --git a/spyder/plugins/plots/widgets/tests/test_plots_widgets.py b/spyder/plugins/plots/widgets/tests/test_plots_widgets.py
index 1bda251e402..783e5caeaf4 100644
--- a/spyder/plugins/plots/widgets/tests/test_plots_widgets.py
+++ b/spyder/plugins/plots/widgets/tests/test_plots_widgets.py
@@ -79,7 +79,7 @@ def add_figures_to_browser(figbrowser, nfig, tmpdir, fmt='image/png'):
     for i in range(nfig):
         figname = osp.join(str(tmpdir), 'mplfig' + str(i) + fext)
         figs.append(create_figure(figname))
-        figbrowser._handle_new_figure(figs[-1], fmt)
+        figbrowser.add_figure(figs[-1], fmt)
 
     assert len(figbrowser.thumbnails_sb._thumbnails) == nfig
     assert figbrowser.thumbnails_sb.get_current_index() == nfig - 1
@@ -102,7 +102,7 @@ def png_to_qimage(png):
 @pytest.mark.order(1)
 @pytest.mark.parametrize("fmt, fext",
                          [('image/png', '.png'), ('image/svg+xml', '.svg')])
-def test_handle_new_figures(figbrowser, tmpdir, fmt, fext):
+def test_add_figures(figbrowser, tmpdir, fmt, fext):
     """
     Test that the figure browser widget display correctly new figures in
     its viewer and thumbnails scrollbar.
@@ -114,7 +114,7 @@ def test_handle_new_figures(figbrowser, tmpdir, fmt, fext):
     for i in range(3):
         figname = osp.join(str(tmpdir), 'mplfig' + str(i) + fext)
         fig = create_figure(figname)
-        figbrowser._handle_new_figure(fig, fmt)
+        figbrowser.add_figure(fig, fmt)
         assert len(figbrowser.thumbnails_sb._thumbnails) == i + 1
         assert figbrowser.thumbnails_sb.get_current_index() == i
         assert figbrowser.thumbnails_sb.current_thumbnail.canvas.fig == fig
@@ -323,7 +323,7 @@ def test_scroll_down_to_newest_plot(figbrowser, tmpdir, qtbot):
     for i in range(8):
         newfig = create_figure(
             osp.join(str(tmpdir), 'new_mplfig{}.png'.format(i)))
-        figbrowser._handle_new_figure(newfig, 'image/png')
+        figbrowser.add_figure(newfig, 'image/png')
         qtbot.wait(500)
 
     # Assert that the scrollbar range was updated correctly and that it's
diff --git a/spyder/plugins/variableexplorer/plugin.py b/spyder/plugins/variableexplorer/plugin.py
index 8d19cdfb080..da87da3a5f5 100644
--- a/spyder/plugins/variableexplorer/plugin.py
+++ b/spyder/plugins/variableexplorer/plugin.py
@@ -26,6 +26,7 @@ class VariableExplorer(SpyderDockablePlugin, ShellConnectPluginMixin):
     """
     NAME = 'variable_explorer'
     REQUIRES = [Plugins.IPythonConsole, Plugins.Preferences]
+    OPTIONAL = [Plugins.Plots]
     TABIFY = None
     WIDGET_CLASS = VariableExplorerWidget
     CONF_SECTION = NAME
@@ -50,9 +51,16 @@ def get_icon(cls):
 
     def on_initialize(self):
         widget = self.get_widget()
-        widget.sig_open_preferences_requested.connect(
-            self._open_preferences
-        )
+        widget.sig_open_preferences_requested.connect(self._open_preferences)
+        widget.sig_show_figure_requested.connect(self._show_figure)
+
+    @on_plugin_available(plugin=Plugins.Plots)
+    def on_plots_available(self):
+        self.get_widget().set_plots_plugin_enabled(True)
+
+    @on_plugin_teardown(plugin=Plugins.Plots)
+    def on_plots_teardown(self):
+        self.get_widget().set_plots_plugin_enabled(False)
 
     @on_plugin_available(plugin=Plugins.Preferences)
     def on_preferences_available(self):
@@ -75,3 +83,32 @@ def _open_preferences(self):
             dlg = container.dialog
             index = dlg.get_index_by_name(self.NAME)
             dlg.set_current_index(index)
+
+    def _show_figure(self, image, mime_type, shellwidget):
+        """
+        Show figure in Plots plugin.
+
+        This shows the figure in the figure browser associated with the given
+        shellwidget. The shellwidget is activated and the Plots plugin and the
+        window containing the Plots plugin are both raised so that the user
+        will see the figure.
+
+        Parameters
+        ----------
+        image: bytes
+            The image to show. SVG images should be encoded so that the type
+            of this parameter is `bytes`, not `str`.
+        mime_type: str
+            The image's mime type.
+        shellwidget: ShellWidget
+            The shellwidget associated with the figure.
+        """
+        ipython_console = self.get_plugin(Plugins.IPythonConsole)
+        if ipython_console:
+            ipython_console.set_current_shellwidget(shellwidget)
+
+        plots = self.get_plugin(Plugins.Plots)
+        if plots:
+            if mime_type == 'image/svg+xml':
+                image = image.decode()
+            plots.add_plot(image, mime_type, shellwidget)
diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py
index ee4666bb611..4be56040b46 100644
--- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py
+++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py
@@ -43,8 +43,9 @@ class CollectionsDelegate(QItemDelegate, SpyderFontsMixin):
     sig_editor_creation_started = Signal()
     sig_editor_shown = Signal()
 
-    def __init__(self, parent=None):
+    def __init__(self, parent=None, namespacebrowser=None):
         QItemDelegate.__init__(self, parent)
+        self.namespacebrowser = namespacebrowser
         self._editors = {}  # keep references on opened editors
 
     def get_value(self, index):
@@ -161,7 +162,8 @@ def createEditor(self, parent, option, index, object_explorer=False):
         # CollectionsEditor for a list, tuple, dict, etc.
         elif isinstance(value, (list, set, tuple, dict)) and not object_explorer:
             from spyder.widgets.collectionseditor import CollectionsEditor
-            editor = CollectionsEditor(parent=parent)
+            editor = CollectionsEditor(
+                parent=parent, namespacebrowser=self.namespacebrowser)
             editor.setup(value, key, icon=self.parent().windowIcon(),
                          readonly=readonly)
             self.create_dialog(editor, dict(model=index.model(), editor=editor,
@@ -269,6 +271,7 @@ def createEditor(self, parent, option, index, object_explorer=False):
                 value,
                 name=key,
                 parent=parent,
+                namespacebrowser=self.namespacebrowser,
                 readonly=readonly)
             self.create_dialog(editor, dict(model=index.model(),
                                             editor=editor,
@@ -411,8 +414,8 @@ def updateEditorGeometry(self, editor, option, index):
 
 class ToggleColumnDelegate(CollectionsDelegate):
     """ToggleColumn Item Delegate"""
-    def __init__(self, parent=None):
-        CollectionsDelegate.__init__(self, parent)
+    def __init__(self, parent=None, namespacebrowser=None):
+        CollectionsDelegate.__init__(self, parent, namespacebrowser)
         self.current_index = None
         self.old_obj = None
 
@@ -467,7 +470,8 @@ def createEditor(self, parent, option, index):
         # CollectionsEditor for a list, tuple, dict, etc.
         if isinstance(value, (list, set, tuple, dict)):
             from spyder.widgets.collectionseditor import CollectionsEditor
-            editor = CollectionsEditor(parent=parent)
+            editor = CollectionsEditor(
+                parent=parent, namespacebrowser=self.namespacebrowser)
             editor.setup(value, key, icon=self.parent().windowIcon(),
                          readonly=readonly)
             self.create_dialog(editor, dict(model=index.model(), editor=editor,
diff --git a/spyder/plugins/variableexplorer/widgets/main_widget.py b/spyder/plugins/variableexplorer/widgets/main_widget.py
index 47a89bb1ef9..c52012342e6 100644
--- a/spyder/plugins/variableexplorer/widgets/main_widget.py
+++ b/spyder/plugins/variableexplorer/widgets/main_widget.py
@@ -51,7 +51,7 @@ class VariableExplorerWidgetActions:
 
 class VariableExplorerWidgetOptionsMenuSections:
     Display = 'excludes_section'
-    Highlight = 'highlight_section'    
+    Highlight = 'highlight_section'
     Resize = 'resize_section'
 
 
@@ -105,6 +105,20 @@ class VariableExplorerWidget(ShellConnectMainWidget):
     Signal to open the variable explorer preferences.
     """
 
+    sig_show_figure_requested = Signal(bytes, str, object)
+    """
+    This is emitted to request that a figure be shown in the Plots plugin.
+
+    Parameters
+    ----------
+    image: bytes
+        The image to show.
+    mime_type: str
+        The image's mime type.
+    shellwidget: ShellWidget
+        The shellwidget associated with the figure.
+    """
+
     def __init__(self, name=None, plugin=None, parent=None):
         super().__init__(name, plugin, parent)
 
@@ -115,6 +129,7 @@ def __init__(self, name=None, plugin=None, parent=None):
 
         # Attributes
         self._is_filter_button_checked = True
+        self.plots_plugin_enabled = False
 
     # ---- PluginMainWidget API
     # ------------------------------------------------------------------------
@@ -401,7 +416,7 @@ def setup(self):
                 menu=self.context_menu,
                 section=VariableExplorerContextMenuSections.Filter,
             )
-        
+
         for item in [self.view_action, self.plot_action, self.hist_action,
                      self.imshow_action]:
             self.add_item_to_menu(
@@ -451,6 +466,19 @@ def on_section_conf_change(self, section):
             if widget:
                 widget.setup()
 
+    def set_plots_plugin_enabled(self, value: bool):
+        """
+        Change whether the Plots plugin is enabled.
+
+        This stores the information in this widget and propagates it to every
+        NamespaceBrowser.
+        """
+        self.plots_plugin_enabled = value
+        for index in range(self.count()):
+            nsb = self._stack.widget(index)
+            if nsb:
+                nsb.plots_plugin_enabled = value
+
     # ---- Stack accesors
     # ------------------------------------------------------------------------
     def switch_widget(self, nsb, old_nsb):
@@ -466,7 +494,9 @@ def create_new_widget(self, shellwidget):
         nsb.sig_free_memory_requested.connect(self.free_memory)
         nsb.sig_start_spinner_requested.connect(self.start_spinner)
         nsb.sig_stop_spinner_requested.connect(self.stop_spinner)
+        nsb.sig_show_figure_requested.connect(self.sig_show_figure_requested)
         nsb.set_shellwidget(shellwidget)
+        nsb.plots_plugin_enabled = self.plots_plugin_enabled
         nsb.setup()
         self._set_actions_and_menus(nsb)
 
@@ -481,6 +511,8 @@ def close_widget(self, nsb):
         nsb.sig_free_memory_requested.disconnect(self.free_memory)
         nsb.sig_start_spinner_requested.disconnect(self.start_spinner)
         nsb.sig_stop_spinner_requested.disconnect(self.stop_spinner)
+        nsb.sig_show_figure_requested.disconnect(
+            self.sig_show_figure_requested)
         nsb.shellwidget.sig_kernel_state_arrived.disconnect(nsb.update_view)
         nsb.shellwidget.sig_config_spyder_kernel.disconnect(
             nsb.setup_kernel)
diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py
index ac321e3833e..ec58ffbb000 100644
--- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py
+++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py
@@ -29,6 +29,7 @@
 from spyder_kernels.utils.nsview import REMOTE_SETTINGS
 
 # Local imports
+from spyder.api.plugins import Plugins
 from spyder.api.translations import _
 from spyder.api.widgets.mixins import SpyderWidgetMixin
 from spyder.config.utils import IMPORT_EXT
@@ -59,6 +60,20 @@ class NamespaceBrowser(QWidget, SpyderWidgetMixin):
     sig_stop_spinner_requested = Signal()
     sig_hide_finder_requested = Signal()
 
+    sig_show_figure_requested = Signal(bytes, str, object)
+    """
+    This is emitted to request that a figure be shown in the Plots plugin.
+
+    Parameters
+    ----------
+    image: bytes
+        The image to show.
+    mime_type: str
+        The image's mime type.
+    shellwidget: ShellWidget
+        The shellwidget associated with the figure.
+    """
+
     def __init__(self, parent):
         if PYQT5:
             super().__init__(parent=parent, class_parent=parent)
@@ -68,6 +83,7 @@ def __init__(self, parent):
 
         # Attributes
         self.filename = None
+        self.plots_plugin_enabled = False
 
         # Widgets
         self.editor = None
@@ -186,7 +202,7 @@ def refresh_table(self):
     def update_view(self, kernel_state):
         """
         Update namespace view and other properties from a new kernel state.
-        
+
         Parameters
         ----------
         kernel_state: dict
@@ -301,7 +317,7 @@ def import_data(self, filenames=None):
                     return
 
             load_func = iofunctions.load_funcs[extension]
-                
+
             # 'import_wizard' (self.setup_io)
             if isinstance(load_func, str):
                 # Import data with import wizard
@@ -322,7 +338,7 @@ def import_data(self, filenames=None):
                 error_message = self.load_data(self.filename, extension)
                 QApplication.restoreOverrideCursor()
                 QApplication.processEvents()
-    
+
             if error_message is not None:
                 QMessageBox.critical(self, title,
                                      _("<b>Unable to load '%s'</b>"
@@ -432,3 +448,69 @@ def save_namespace(self, filename):
             return msg
         except (UnpicklingError, RuntimeError, CommError):
             return None
+
+    def plot(self, data, funcname):
+        """
+        Plot data.
+
+        If all the following conditions are met:
+        * the Plots plugin is enabled, and
+        * the setting "Mute inline plotting" in the Plots plugin is set, and
+        * the graphics backend in the IPython Console preferences is set
+          to "inline",
+        then call `plot_in_plots_plugin`, else call `plot_in_window`.
+        """
+        if (self.plots_plugin_enabled
+                and self.get_conf('mute_inline_plotting', section='plots')
+                and self.get_conf('pylab/backend',
+                                  section='ipython_console') == 0):
+            self.plot_in_plots_plugin(data, funcname)
+        else:
+            self.plot_in_window(data, funcname)
+
+    def plot_in_plots_plugin(self, data, funcname):
+        """
+        Plot data in Plots plugin.
+
+        Plot the given data to a PNG image and show the plot in the Plots
+        plugin.
+        """
+        import spyder.pyplot as plt
+        from IPython.core.pylabtools import print_figure
+
+        if self.get_conf('pylab/inline/figure_format',
+                         section='ipython_console') == 1:
+            figure_format = 'svg'
+            mime_type = 'image/svg+xml'
+        else:
+            figure_format = 'png'
+            mime_type = 'image/png'
+        resolution = self.get_conf('pylab/inline/resolution',
+                                   section='ipython_console')
+        width = self.get_conf('pylab/inline/width',
+                              section='ipython_console')
+        height = self.get_conf('pylab/inline/height',
+                               section='ipython_console')
+        if self.get_conf('pylab/inline/bbox_inches',
+                         section='ipython_console'):
+            bbox_inches = 'tight'
+        else:
+            bbox_inches = None
+
+        fig, ax = plt.subplots(figsize=(width, height))
+        getattr(ax, funcname)(data)
+        image = print_figure(fig, fmt=figure_format, bbox_inches=bbox_inches,
+                             dpi=resolution)
+        if figure_format == 'svg':
+            image = image.encode()
+        self.sig_show_figure_requested.emit(image, mime_type, self.shellwidget)
+
+    def plot_in_window(self, data, funcname):
+        """
+        Plot data in new Qt window.
+        """
+        import spyder.pyplot as plt
+
+        plt.figure()
+        getattr(plt, funcname)(data)
+        plt.show()
diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py
index 7efe13f88c5..5d896cb6f8e 100644
--- a/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py
+++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/objectexplorer.py
@@ -13,7 +13,7 @@
 import traceback
 
 # Third-party imports
-from qtpy.QtCore import Slot, QModelIndex, QPoint, QSize, Qt
+from qtpy.QtCore import Signal, Slot, QModelIndex, QPoint, QSize, Qt
 from qtpy.QtGui import QKeySequence, QTextOption
 from qtpy.QtWidgets import (QAbstractItemView, QAction, QButtonGroup,
                             QGroupBox, QHBoxLayout, QHeaderView,
@@ -52,6 +52,7 @@ def __init__(self,
                  expanded=False,
                  resize_to_contents=True,
                  parent=None,
+                 namespacebrowser=None,
                  attribute_columns=DEFAULT_ATTR_COLS,
                  attribute_details=DEFAULT_ATTR_DETAILS,
                  readonly=None,
@@ -59,11 +60,13 @@ def __init__(self,
         """
         Constructor
 
+        :param obj: any Python object or variable
         :param name: name of the object as it will appear in the root node
         :param expanded: show the first visible root element expanded
         :param resize_to_contents: resize columns to contents ignoring width
             of the attributes
-        :param obj: any Python object or variable
+        :param namespacebrowser: the NamespaceBrowser that the object
+            originates from, if any
         :param attribute_columns: list of AttributeColumn objects that
             define which columns are present in the table and their defaults
         :param attribute_details: list of AttributeDetails objects that define
@@ -100,7 +103,7 @@ def __init__(self,
         # self._proxy_tree_model.setSortCaseSensitivity(Qt.CaseInsensitive)
 
         # Tree widget
-        self.obj_tree = ToggleColumnTreeView()
+        self.obj_tree = ToggleColumnTreeView(namespacebrowser)
         self.obj_tree.setAlternatingRowColors(True)
         self.obj_tree.setModel(self._proxy_tree_model)
         self.obj_tree.setSelectionBehavior(QAbstractItemView.SelectRows)
diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py
index c44d7c82354..b2071f2af3f 100644
--- a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py
+++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py
@@ -155,12 +155,13 @@ class ToggleColumnTreeView(QTreeView, ToggleColumnMixIn):
     show/hide columns.
     """
 
-    def __init__(self, readonly=False):
+    def __init__(self, namespacebrowser=None, readonly=False):
         QTreeView.__init__(self)
         self.readonly = readonly
         from spyder.plugins.variableexplorer.widgets.collectionsdelegate \
             import ToggleColumnDelegate
-        self.setItemDelegate(ToggleColumnDelegate(self))
+        self.delegate = ToggleColumnDelegate(self, namespacebrowser)
+        self.setItemDelegate(self.delegate)
         self.setEditTriggers(QAbstractItemView.DoubleClicked)
         self.expanded.connect(self.resize_columns_to_contents)
         self.collapsed.connect(self.resize_columns_to_contents)
diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
index 027edb2d640..abbc5ca1632 100644
--- a/spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
+++ b/spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
@@ -9,7 +9,7 @@
 
 # Standard library imports
 import string
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
 
 # Third party imports
 from flaky import flaky
@@ -17,6 +17,7 @@
 from qtpy.QtCore import Qt, QPoint, QModelIndex
 
 # Local imports
+from spyder.config.manager import CONF
 from spyder.plugins.variableexplorer.widgets.namespacebrowser import (
     NamespaceBrowser)
 from spyder.widgets.collectionseditor import ROWS_TO_LOAD
@@ -203,5 +204,51 @@ def test_filtering_with_large_rows(namespacebrowser, qtbot):
     assert model.rowCount() == 1
 
 
+def test_namespacebrowser_plot_with_mute_inline_plotting_true(
+        namespacebrowser, qtbot):
+    """
+    Test that plotting a list from the namespace browser sends a signal
+    with the plot if `mute_inline_plotting` is set to `True`.
+    """
+    CONF.set('plots', 'mute_inline_plotting', True)
+    namespacebrowser.plots_plugin_enabled = True
+    my_list = [4, 2]
+    mock_figure = Mock()
+    mock_axis = Mock()
+    mock_png = b'fake png'
+
+    with (patch('spyder.pyplot.subplots',
+                return_value=(mock_figure, mock_axis)),
+          patch('IPython.core.pylabtools.print_figure',
+                return_value=mock_png) as mock_print_figure,
+          qtbot.waitSignal(namespacebrowser.sig_show_figure_requested)
+          as blocker):
+        namespacebrowser.plot(my_list, 'plot')
+
+    mock_axis.plot.assert_called_once_with(my_list)
+    mock_print_figure.assert_called_once_with(
+        mock_figure, fmt='png', bbox_inches='tight', dpi=72)
+    expected_args = [mock_png, 'image/png', namespacebrowser.shellwidget]
+    assert blocker.args == expected_args
+
+
+def test_namespacebrowser_plot_with_mute_inline_plotting_false(namespacebrowser):
+    """
+    Test that plotting a list from the namespace browser shows a plot if
+    `mute_inline_plotting` is set to `False`.
+    """
+    CONF.set('plots', 'mute_inline_plotting', False)
+    my_list = [4, 2]
+
+    with (patch('spyder.pyplot.figure') as mock_figure,
+          patch('spyder.pyplot.plot') as mock_plot,
+          patch('spyder.pyplot.show') as mock_show):
+        namespacebrowser.plot(my_list, 'plot')
+
+    mock_figure.assert_called_once_with()
+    mock_plot.assert_called_once_with(my_list)
+    mock_show.assert_called_once_with()
+
+
 if __name__ == "__main__":
     pytest.main()
diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py
index 43f6474b377..cfb268a16a3 100644
--- a/spyder/widgets/collectionseditor.py
+++ b/spyder/widgets/collectionseditor.py
@@ -1315,10 +1315,11 @@ def paste(self):
 class CollectionsEditorTableView(BaseTableView):
     """CollectionsEditor table view"""
 
-    def __init__(self, parent, data, readonly=False, title="",
-                 names=False):
+    def __init__(self, parent, data, namespacebrowser=None,  readonly=False,
+                 title="", names=False):
         BaseTableView.__init__(self, parent)
         self.dictfilter = None
+        self.namespacebrowser = namespacebrowser
         self.readonly = readonly or isinstance(data, (tuple, set))
         CollectionsModelClass = (ReadOnlyCollectionsModel if self.readonly
                                  else CollectionsModel)
@@ -1331,7 +1332,7 @@ def __init__(self, parent, data, readonly=False, title="",
         )
         self.model = self.source_model
         self.setModel(self.source_model)
-        self.delegate = CollectionsDelegate(self)
+        self.delegate = CollectionsDelegate(self, namespacebrowser)
         self.setItemDelegate(self.delegate)
 
         self.setup_table()
@@ -1419,10 +1420,7 @@ def oedit(self, key):
     def plot(self, key, funcname):
         """Plot item"""
         data = self.source_model.get_data()
-        import spyder.pyplot as plt
-        plt.figure()
-        getattr(plt, funcname)(data[key])
-        plt.show()
+        self.namespacebrowser.plot(data[key], funcname)
 
     def imshow(self, key):
         """Show item's image"""
@@ -1446,14 +1444,15 @@ def set_filter(self, dictfilter=None):
 class CollectionsEditorWidget(QWidget):
     """Dictionary Editor Widget"""
 
-    def __init__(self, parent, data, readonly=False, title="", remote=False):
+    def __init__(self, parent, data, namespacebrowser=None, readonly=False,
+                 title="", remote=False):
         QWidget.__init__(self, parent)
         if remote:
-            self.editor = RemoteCollectionsEditorTableView(self, data,
-                                                           readonly)
+            self.editor = RemoteCollectionsEditorTableView(
+                self, data, readonly)
         else:
-            self.editor = CollectionsEditorTableView(self, data, readonly,
-                                                     title)
+            self.editor = CollectionsEditorTableView(
+                self, data, namespacebrowser, readonly, title)
 
         toolbar = SpyderToolbar(parent=None, title='Editor toolbar')
         toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET))
@@ -1481,7 +1480,7 @@ def get_title(self):
 class CollectionsEditor(BaseDialog):
     """Collections Editor Dialog"""
 
-    def __init__(self, parent=None):
+    def __init__(self, parent=None, namespacebrowser=None):
         super().__init__(parent)
 
         # Destroying the C++ object right after closing the dialog box,
@@ -1490,6 +1489,7 @@ def __init__(self, parent=None):
         # a segmentation fault on UNIX or an application crash on Windows
         self.setAttribute(Qt.WA_DeleteOnClose)
 
+        self.namespacebrowser = namespacebrowser
         self.data_copy = None
         self.widget = None
         self.btn_save_and_close = None
@@ -1520,9 +1520,9 @@ def setup(self, data, title='', readonly=False, remote=False,
         if type(self.data_copy) != type(data):
             readonly = True
 
-        self.widget = CollectionsEditorWidget(self, self.data_copy,
-                                              title=title, readonly=readonly,
-                                              remote=remote)
+        self.widget = CollectionsEditorWidget(
+            self, self.data_copy, self.namespacebrowser, title=title,
+            readonly=readonly, remote=remote)
         self.widget.editor.source_model.sig_setting_data.connect(
             self.save_and_close_enable)
         layout = QVBoxLayout()
@@ -1580,8 +1580,8 @@ def get_value(self):
 class RemoteCollectionsDelegate(CollectionsDelegate):
     """CollectionsEditor Item Delegate"""
 
-    def __init__(self, parent=None):
-        CollectionsDelegate.__init__(self, parent)
+    def __init__(self, parent=None, namespacebrowser=None):
+        CollectionsDelegate.__init__(self, parent, namespacebrowser)
 
     def get_value(self, index):
         if index.isValid():
@@ -1630,7 +1630,7 @@ def __init__(self, parent, data, shellwidget=None, remote_editing=False,
 
         self.hideColumn(4)  # Column 4 for Score
 
-        self.delegate = RemoteCollectionsDelegate(self)
+        self.delegate = RemoteCollectionsDelegate(self, self.namespacebrowser)
         self.delegate.sig_free_memory_requested.connect(
             self.sig_free_memory_requested)
         self.delegate.sig_editor_creation_started.connect(
diff --git a/spyder/widgets/tests/test_collectioneditor.py b/spyder/widgets/tests/test_collectioneditor.py
index a75cfbfce06..fadbd61d767 100644
--- a/spyder/widgets/tests/test_collectioneditor.py
+++ b/spyder/widgets/tests/test_collectioneditor.py
@@ -29,8 +29,9 @@
 # Local imports
 from spyder.config.manager import CONF
 from spyder.widgets.collectionseditor import (
-    RemoteCollectionsEditorTableView, CollectionsEditorTableView,
-    CollectionsModel, CollectionsEditor, LARGE_NROWS, ROWS_TO_LOAD, natsort)
+    CollectionsEditor, CollectionsEditorTableView, CollectionsEditorWidget,
+    CollectionsModel, LARGE_NROWS, natsort, RemoteCollectionsEditorTableView,
+    ROWS_TO_LOAD)
 from spyder.plugins.variableexplorer.widgets.tests.test_dataframeeditor import (
     generate_pandas_indexes)
 from spyder_kernels.utils.nsview import get_size
@@ -922,5 +923,21 @@ def test_dicts_natural_sorting_mixed_types():
                                     ['(0,)', 2, 3, str_size]]
 
 
+def test_collectioneditor_plot(qtbot):
+    """
+    Test that plotting a list from the collection editor calls the .plot()
+    function in the associated namespace browser.
+    """
+    my_list = [4, 2]
+    mock_namespacebrowser = Mock()
+    cew = CollectionsEditorWidget(
+        None, {'list': my_list}, namespacebrowser=mock_namespacebrowser)
+    qtbot.addWidget(cew)
+
+    cew.editor.plot('list', 'plot')
+
+    mock_namespacebrowser.plot.assert_called_once_with(my_list, 'plot')
+
+
 if __name__ == "__main__":
     pytest.main()


Code review comments:
diff_hunk: @@ -534,6 +534,10 @@ def get_current_shellwidget(self):
         """Return the shellwidget of the current client"""
         return self.get_widget().get_current_shellwidget()
 
+    def set_current_shellwidget(self, shellwidget):
+        """Activate client associated to given shellwidget"""
+        return self.get_widget().select_tab(shellwidget)
path: spyder/plugins/ipythonconsole/plugin.py
text: @user1:
```suggestion
        """Activate client associated to given shellwidget."""
        self.get_widget().select_tab(shellwidget)
```

I think the `return` is not necessary because `select_tab` is simply setting the tab corresponding to `shellwidget`.

diff_hunk: @@ -203,5 +204,51 @@ def test_filtering_with_large_rows(namespacebrowser, qtbot):
     assert model.rowCount() == 1
 
 
+def test_namespacebrowser_plot_with_mute_inline_plotting_true(
+        namespacebrowser, qtbot):
+    """
+    Test that plotting a list from the namespace browser sends a signal
+    with the plot if `mute_inline_plotting` is set to `True`.
+    """
+    CONF.set('plots', 'mute_inline_plotting', True)
path: spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
text: @user1:
```suggestion
    namespacebrowser.set_conf('mute_inline_plotting', True, section='plots')
```

`NamespaceBrowser` has `set_conf`/`get_conf`.

diff_hunk: @@ -203,5 +204,51 @@ def test_filtering_with_large_rows(namespacebrowser, qtbot):
     assert model.rowCount() == 1
 
 
+def test_namespacebrowser_plot_with_mute_inline_plotting_true(
+        namespacebrowser, qtbot):
+    """
+    Test that plotting a list from the namespace browser sends a signal
+    with the plot if `mute_inline_plotting` is set to `True`.
+    """
+    CONF.set('plots', 'mute_inline_plotting', True)
+    namespacebrowser.plots_plugin_enabled = True
+    my_list = [4, 2]
+    mock_figure = Mock()
+    mock_axis = Mock()
+    mock_png = b'fake png'
+
+    with (patch('spyder.pyplot.subplots',
+                return_value=(mock_figure, mock_axis)),
+          patch('IPython.core.pylabtools.print_figure',
+                return_value=mock_png) as mock_print_figure,
+          qtbot.waitSignal(namespacebrowser.sig_show_figure_requested)
+          as blocker):
+        namespacebrowser.plot(my_list, 'plot')
+
+    mock_axis.plot.assert_called_once_with(my_list)
+    mock_print_figure.assert_called_once_with(
+        mock_figure, fmt='png', bbox_inches='tight', dpi=72)
+    expected_args = [mock_png, 'image/png', namespacebrowser.shellwidget]
+    assert blocker.args == expected_args
+
+
+def test_namespacebrowser_plot_with_mute_inline_plotting_false(namespacebrowser):
+    """
+    Test that plotting a list from the namespace browser shows a plot if
+    `mute_inline_plotting` is set to `False`.
+    """
+    CONF.set('plots', 'mute_inline_plotting', False)
path: spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
text: @user1:
```suggestion
    namespacebrowser.set_conf('mute_inline_plotting', False, section='plots')
```

diff_hunk: @@ -1315,10 +1315,11 @@ def paste(self):
 class CollectionsEditorTableView(BaseTableView):
     """CollectionsEditor table view"""
 
-    def __init__(self, parent, data, readonly=False, title="",
-                 names=False):
+    def __init__(self, parent, data, namespacebrowser=None,  readonly=False,
path: spyder/widgets/collectionseditor.py
text: @user1:
```suggestion
    def __init__(self, parent, data, namespacebrowser=None, readonly=False,
```

diff_hunk: @@ -9,14 +9,15 @@
 
 # Standard library imports
 import string
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
 
 # Third party imports
 from flaky import flaky
 import pytest
 from qtpy.QtCore import Qt, QPoint, QModelIndex
 
 # Local imports
+from spyder.config.manager import CONF
path: spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
text: @user1:
```suggestion
```

No need of this (see below).

diff_hunk: @@ -432,3 +448,69 @@ def save_namespace(self, filename):
             return msg
         except (UnpicklingError, RuntimeError, CommError):
             return None
+
+    def plot(self, data, funcname):
+        """
+        Plot data.
+
+        If all the following conditions are met:
+        * the Plots plugin is enabled, and
+        * the setting "Mute inline plotting" in the Plots plugin is set, and
+        * the graphics backend in the IPython Console preferences is set
+          to "inline",
+        then call `plot_in_plots_plugin`, else call `plot_in_window`.
+        """
+        if (self.plots_plugin_enabled
+                and self.get_conf('mute_inline_plotting', section='plots')
+                and self.get_conf('pylab/backend',
+                                  section='ipython_console') == 0):
+            self.plot_in_plots_plugin(data, funcname)
+        else:
+            self.plot_in_window(data, funcname)
+
+    def plot_in_plots_plugin(self, data, funcname):
+        """
+        Plot data in Plots plugin.
+
+        Plot the given data to a PNG image and show the plot in the Plots
+        plugin.
path: spyder/plugins/variableexplorer/widgets/namespacebrowser.py
text: @user1:
```suggestion
        Plot the given data to a PNG or SVG image and show the plot in the
        Plots plugin.
```

diff_hunk: @@ -2388,6 +2388,50 @@ def test_tight_layout_option_for_inline_plot(main_window, qtbot, tmpdir):
     assert compare_images(savefig_figname, inline_figname, 0.1) is None
 
 
+def test_plot_from_collectioneditor(main_window, qtbot):
+    """
+    Create a variable with value `[[1, 2, 3], [4, 5, 6]]`, use the variable
+    explorer to open a collection editor and plot the first sublist. Check
+    that a plot is displayed in the Plots pane.
+    """
+    CONF.set('plots', 'mute_inline_plotting', True)
+    shell = main_window.ipyconsole.get_current_shellwidget()
+    figbrowser = main_window.plots.current_widget()
+    nsb = main_window.variableexplorer.current_widget()
+
+    # Check that we start with no plots
+    assert len(figbrowser.thumbnails_sb._thumbnails) == 0
+
+    # Wait until the window console is fully up
+    qtbot.waitUntil(
+        lambda: shell.spyder_kernel_ready and shell._prompt_html is not None,
+        timeout=SHELL_TIMEOUT)
+
+    # Create variable
+    with qtbot.waitSignal(shell.executed):
+        shell.execute(('nested_list = [[1, 2, 3], [4, 5, 6]]'))
path: spyder/app/tests/test_mainwindow.py
text: @user1:
```suggestion
        shell.execute('nested_list = [[1, 2, 3], [4, 5, 6]]')
```

I think the double parenthesis is not necessary here.

diff_hunk: @@ -432,3 +448,69 @@ def save_namespace(self, filename):
             return msg
         except (UnpicklingError, RuntimeError, CommError):
             return None
+
+    def plot(self, data, funcname):
+        """
+        Plot data.
+
+        If all the following conditions are met:
+        * the Plots plugin is enabled, and
+        * the setting "Mute inline plotting" in the Plots plugin is set, and
+        * the graphics backend in the IPython Console preferences is set
+          to "inline",
+        then call `plot_in_plots_plugin`, else call `plot_in_window`.
+        """
+        if (self.plots_plugin_enabled
+                and self.get_conf('mute_inline_plotting', section='plots')
+                and self.get_conf('pylab/backend',
+                                  section='ipython_console') == 0):
path: spyder/plugins/variableexplorer/widgets/namespacebrowser.py
text: @user1:
```suggestion
        if (
            self.plots_plugin_enabled
            and self.get_conf('mute_inline_plotting', section='plots')
            and self.get_conf('pylab/backend', section='ipython_console') == 0
        ):
```

Style improvement.