Repository: mne-tools/mne-python

Issue:
Matplotlib plot in jupyterlab using Qt hangs except if an inline plot was produced before 
### Description of the problem

Plotting the ICA components in jupyter-lab, I use the interactive Qt backend with `%matplotlib qt` to label components.
If an inline figure is plotted prior to this, then everything works.
If not, closing the window does hang the interpreter and I have to send a keyboard interrupt to keep going. The ICA components labeled before the interrupt arrive in `ica.exclude` safely.

### Steps to reproduce

With inline plot before, working and correct behavior:

```Python

Cell 1:


from mne.datasets import sample
from mne.io import read_raw_fif
from mne.preprocessing import ICA


directory = sample.data_path() / "MEG" / "sample"
raw = read_raw_fif(directory / "sample_audvis_raw.fif", preload=False)
raw.crop(0, 20)
raw.pick_types(eeg=True)
raw.load_data()
raw.filter(1., 100.)
ica = ICA(n_components=10, method="picard")
ica.fit(raw)
```

Cell 2:

```
ica.plot_components()
```

This last command plots the figure inline.
Edit 'Cell 2' and rerun:

```
%matplotlib qt
ica.plot_components()
```

Closing the figure correctly gives me back control of the interpreter.

----------------

Bug behavior:

Cell 1 is similar as before.
Cell 2 directly:

```
%matplotlib qt
ica.plot_components()
```

Without any prior matplotlib plots. The interpreter now hangs.

### Additional information

```
Platform:         macOS-13.0.1-x86_64-i386-64bit
Python:           3.10.10 (main, Feb 13 2023, 03:30:25) [Clang 14.0.0 (clang-1400.0.29.202)]
Executable:       /Users/sierrabrandts/pyvenv/eeg-flow/bin/python
CPU:              i386: 8 cores
Memory:           8.0 GB

mne:              1.4.dev0
numpy:            1.23.5 {OpenBLAS 0.3.20 with 4 threads}
scipy:            1.10.0
matplotlib:       3.7.0 {backend=MacOSX}

sklearn:          1.2.1
numba:            0.56.4
nibabel:          5.0.1
nilearn:          0.10.0
dipy:             1.6.0
openmeeg:         Not found
cupy:             Not found
pandas:           1.5.3
pyvista:          0.38.2 {OpenGL 4.1 INTEL-20.2.44 via Intel(R) Iris(TM) Plus Graphics OpenGL Engine}
pyvistaqt:        0.9.1
ipyvtklink:       0.2.3
vtk:              9.2.6
qtpy:             2.3.0 {PyQt5=5.15.2}
ipympl:           0.9.2
pyqtgraph:        0.13.1
pooch:            v1.6.0

mne_bids:         0.13.dev0
mne_nirs:         Not found
mne_features:     Not found
mne_qt_browser:   0.5.dev0
mne_connectivity: Not found
mne_icalabel:     0.5.dev0
```

pinging @mscheltienne who investigated quickly.


Discussion:
Hello! 👋 Thanks for opening your first issue here! ❤️ We will try to get back to you soon. 🚴🏽‍♂️

I can reproduce this on Windows with the latest main, and it seems to happen only with the `ica.plot_components()` plot. I tried with:

Cell 1:
```
from mne import make_fixed_length_epochs
from mne.datasets import sample
from mne.io import read_raw_fif


directory = sample.data_path() / "MEG" / "sample"
raw = read_raw_fif(directory / "sample_audvis_raw.fif", preload=False)
raw.crop(0, 20)
raw.pick_types(eeg=True)
raw.load_data()
raw.filter(1., 100.)

epochs = make_fixed_length_epochs(raw)
```

Cell 2:
```
%matplotlib qt
epochs.average().plot()
```

And this interactive matplotlib plot works fine and does not get the interpreter stuck.

Resolving patch:
diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py
--- a/mne/viz/_mpl_figure.py
+++ b/mne/viz/_mpl_figure.py
@@ -75,14 +75,12 @@
 )
 
 name = "matplotlib"
-plt.ion()
-BACKEND = get_backend()
-#   This  ↑↑↑↑↑↑↑↑↑↑↑↑↑  does weird things:
+with plt.ion():
+    BACKEND = get_backend()
+#   This      ↑↑↑↑↑↑↑↑↑↑↑↑↑ does weird things:
 #   https://github.com/matplotlib/matplotlib/issues/23298
-#   but wrapping it in ion() context makes it go away (can't actually use
-#   `with plt.ion()` as context manager, though, for compat reasons).
+#   but wrapping it in ion() context makes it go away.
 #   Moving this bit to a separate function in ../../fixes.py doesn't work.
-plt.ioff()
 
 # CONSTANTS (inches)
 ANNOTATION_FIG_PAD = 0.1
diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py
--- a/mne/viz/backends/_notebook.py
+++ b/mne/viz/backends/_notebook.py
@@ -718,9 +718,8 @@ def __init__(self, width, height, dpi):
         _Widget.__init__(self)
         _AbstractCanvas.__init__(self, width=width, height=height, dpi=dpi)
         HBox.__init__(self, **_BASE_KWARGS)
-        plt.ioff()
-        self.fig, self.ax = plt.subplots(dpi=dpi)
-        plt.ion()
+        with plt.ioff():
+            self.fig, self.ax = plt.subplots(dpi=dpi)
         self.children = (self.fig.canvas,)
 
     def _set_size(self, width=None, height=None):
diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py
--- a/mne/viz/topomap.py
+++ b/mne/viz/topomap.py
@@ -1540,8 +1540,6 @@ def plot_ica_components(
             "fit the ICA or add the corresponding info object."
         )
 
-    n_components = ica.mixing_matrix_.shape[1]
-
     # for backward compat, nrow='auto' ncol='auto' should yield 4 rows 5 cols
     # and create multiple figures if more than 20 components requested
     if nrows == "auto" and ncols == "auto":
@@ -1549,32 +1547,76 @@ def plot_ica_components(
         max_subplots = 20
     elif nrows == "auto" or ncols == "auto":
         # user provided incomplete row/col spec; put all in one figure
-        max_subplots = n_components
+        max_subplots = ica.n_components_
     else:
         max_subplots = nrows * ncols
 
     # handle ch_type=None
     ch_type = _get_ch_type(ica, ch_type)
 
+    figs = []
     if picks is None:
-        figs = []
-        cut_points = range(max_subplots, n_components, max_subplots)
-        pick_groups = np.split(range(n_components), cut_points)
-        for k, _picks in enumerate(pick_groups):
-            _axes = axes.flatten() if isinstance(axes, np.ndarray) else axes
-            try:  # either an iterable, 1D numpy array or others
-                _axes = _axes[k * max_subplots : (k + 1) * max_subplots]
-            except TypeError:  # None or Axes
-                _axes = axes
-            fig = plot_ica_components(
-                ica,
-                picks=_picks,
+        cut_points = range(max_subplots, ica.n_components_, max_subplots)
+        pick_groups = np.split(range(ica.n_components_), cut_points)
+    else:
+        pick_groups = [_picks_to_idx(ica.n_components_, picks, picks_on="components")]
+
+    axes = axes.flatten() if isinstance(axes, np.ndarray) else axes
+    for k, picks in enumerate(pick_groups):
+        try:  # either an iterable, 1D numpy array or others
+            _axes = axes[k * max_subplots : (k + 1) * max_subplots]
+        except TypeError:  # None or Axes
+            _axes = axes
+
+        (
+            data_picks,
+            pos,
+            merge_channels,
+            names,
+            ch_type,
+            sphere,
+            clip_origin,
+        ) = _prepare_topomap_plot(ica, ch_type, sphere=sphere)
+
+        cmap = _setup_cmap(cmap, n_axes=len(picks))
+        names = _prepare_sensor_names(names, show_names)
+        outlines = _make_head_outlines(sphere, pos, outlines, clip_origin)
+
+        data = np.dot(
+            ica.mixing_matrix_[:, picks].T, ica.pca_components_[: ica.n_components_]
+        )
+        data = np.atleast_2d(data)
+        data = data[:, data_picks]
+
+        if title is None:
+            title = "ICA components"
+        user_passed_axes = _axes is not None
+        if not user_passed_axes:
+            fig, _axes, _, _ = _prepare_trellis(len(data), ncols=ncols, nrows=nrows)
+            fig.suptitle(title)
+        else:
+            _axes = [_axes] if isinstance(_axes, Axes) else _axes
+            fig = _axes[0].get_figure()
+
+        subplot_titles = list()
+        for ii, data_, ax in zip(picks, data, _axes):
+            kwargs = dict(color="gray") if ii in ica.exclude else dict()
+            comp_title = ica._ica_names[ii]
+            if len(set(ica.get_channel_types())) > 1:
+                comp_title += f" ({ch_type})"
+            subplot_titles.append(ax.set_title(comp_title, fontsize=12, **kwargs))
+            if merge_channels:
+                data_, names_ = _merge_ch_data(data_, ch_type, copy.copy(names))
+            # ↓↓↓ NOTE: we intentionally use the default norm=False here, so that
+            # ↓↓↓ we get vlims that are symmetric-about-zero, even if the data for
+            # ↓↓↓ a given component happens to be one-sided.
+            _vlim = _setup_vmin_vmax(data_, *vlim)
+            im = plot_topomap(
+                data_.flatten(),
+                pos,
                 ch_type=ch_type,
-                inst=inst,
-                plot_std=plot_std,
-                reject=reject,
                 sensors=sensors,
-                show_names=show_names,
+                names=names,
                 contours=contours,
                 outlines=outlines,
                 sphere=sphere,
@@ -1583,164 +1625,88 @@ def plot_ica_components(
                 border=border,
                 res=res,
                 size=size,
-                cmap=cmap,
-                vlim=vlim,
+                cmap=cmap[0],
+                vlim=_vlim,
                 cnorm=cnorm,
-                colorbar=colorbar,
-                cbar_fmt=cbar_fmt,
-                axes=_axes,
-                title=title,
-                nrows=nrows,
-                ncols=ncols,
-                show=show,
-                image_args=image_args,
-                psd_args=psd_args,
-                verbose=verbose,
-            )
-            figs.append(fig)
-        return figs
-    else:
-        picks = _picks_to_idx(ica.n_components_, picks, picks_on="components")
-
-    (
-        data_picks,
-        pos,
-        merge_channels,
-        names,
-        ch_type,
-        sphere,
-        clip_origin,
-    ) = _prepare_topomap_plot(ica, ch_type, sphere=sphere)
-
-    cmap = _setup_cmap(cmap, n_axes=len(picks))
-    names = _prepare_sensor_names(names, show_names)
-    outlines = _make_head_outlines(sphere, pos, outlines, clip_origin)
-
-    data = np.dot(
-        ica.mixing_matrix_[:, picks].T, ica.pca_components_[: ica.n_components_]
-    )
-    data = np.atleast_2d(data)
-    data = data[:, data_picks]
+                axes=ax,
+                show=False,
+            )[0]
+
+            im.axes.set_label(ica._ica_names[ii])
+            if colorbar:
+                cbar, cax = _add_colorbar(
+                    ax, im, cmap, title="AU", side="right", pad=0.05, format=cbar_fmt
+                )
+                cbar.ax.tick_params(labelsize=12)
+                cbar.set_ticks(_vlim)
+            _hide_frame(ax)
+        del pos
+        if not user_passed_axes:
+            tight_layout(fig=fig)
+            fig.subplots_adjust(top=0.88, bottom=0.0)
+        fig.canvas.draw()
 
-    if title is None:
-        title = "ICA components"
-    user_passed_axes = axes is not None
-    if not user_passed_axes:
-        fig, axes, _, _ = _prepare_trellis(len(data), ncols=ncols, nrows=nrows)
-        fig.suptitle(title)
-    else:
-        axes = axes.flatten() if isinstance(axes, np.ndarray) else axes
-        axes = [axes] if isinstance(axes, Axes) else axes
-        fig = axes[0].get_figure()
+        # add title selection interactivity
+        def onclick_title(event, ica=ica, titles=subplot_titles):
+            # check if any title was pressed
+            title_pressed = None
+            for title in titles:
+                if title.contains(event)[0]:
+                    title_pressed = title
+                    break
+            # title was pressed -> identify the IC
+            if title_pressed is not None:
+                label = title_pressed.get_text()
+                ic = int(label.split(" ")[0][-3:])
+                # add or remove IC from exclude depending on current state
+                if ic in ica.exclude:
+                    ica.exclude.remove(ic)
+                    title_pressed.set_color("k")
+                else:
+                    ica.exclude.append(ic)
+                    title_pressed.set_color("gray")
+                fig.canvas.draw()
 
-    subplot_titles = list()
-    for ii, data_, ax in zip(picks, data, axes):
-        kwargs = dict(color="gray") if ii in ica.exclude else dict()
-        comp_title = ica._ica_names[ii]
-        if len(set(ica.get_channel_types())) > 1:
-            comp_title += f" ({ch_type})"
-        subplot_titles.append(ax.set_title(comp_title, fontsize=12, **kwargs))
-        if merge_channels:
-            data_, names_ = _merge_ch_data(data_, ch_type, copy.copy(names))
-        # ↓↓↓ NOTE: we intentionally use the default norm=False here, so that
-        # ↓↓↓ we get vlims that are symmetric-about-zero, even if the data for
-        # ↓↓↓ a given component happens to be one-sided.
-        _vlim = _setup_vmin_vmax(data_, *vlim)
-        im = plot_topomap(
-            data_.flatten(),
-            pos,
-            ch_type=ch_type,
-            sensors=sensors,
-            names=names,
-            contours=contours,
-            outlines=outlines,
-            sphere=sphere,
-            image_interp=image_interp,
-            extrapolate=extrapolate,
-            border=border,
-            res=res,
-            size=size,
-            cmap=cmap[0],
-            vlim=_vlim,
-            cnorm=cnorm,
-            axes=ax,
-            show=False,
-        )[0]
+        fig.canvas.mpl_connect("button_press_event", onclick_title)
 
-        im.axes.set_label(ica._ica_names[ii])
-        if colorbar:
-            cbar, cax = _add_colorbar(
-                ax, im, cmap, title="AU", side="right", pad=0.05, format=cbar_fmt
+        # add plot_properties interactivity only if inst was passed
+        if isinstance(inst, (BaseRaw, BaseEpochs)):
+            topomap_args = dict(
+                sensors=sensors,
+                contours=contours,
+                outlines=outlines,
+                sphere=sphere,
+                image_interp=image_interp,
+                extrapolate=extrapolate,
+                border=border,
+                res=res,
+                cmap=cmap[0],
+                vmin=vlim[0],
+                vmax=vlim[1],
             )
-            cbar.ax.tick_params(labelsize=12)
-            cbar.set_ticks(_vlim)
-        _hide_frame(ax)
-    del pos
-    if not user_passed_axes:
-        tight_layout(fig=fig)
-        fig.subplots_adjust(top=0.88, bottom=0.0)
-    fig.canvas.draw()
-
-    # add title selection interactivity
-    def onclick_title(event, ica=ica, titles=subplot_titles):
-        # check if any title was pressed
-        title_pressed = None
-        for title in titles:
-            if title.contains(event)[0]:
-                title_pressed = title
-                break
-        # title was pressed -> identify the IC
-        if title_pressed is not None:
-            label = title_pressed.get_text()
-            ic = int(label.split(" ")[0][-3:])
-            # add or remove IC from exclude depending on current state
-            if ic in ica.exclude:
-                ica.exclude.remove(ic)
-                title_pressed.set_color("k")
-            else:
-                ica.exclude.append(ic)
-                title_pressed.set_color("gray")
-            fig.canvas.draw()
 
-    fig.canvas.mpl_connect("button_press_event", onclick_title)
-
-    # add plot_properties interactivity only if inst was passed
-    if isinstance(inst, (BaseRaw, BaseEpochs)):
-        topomap_args = dict(
-            sensors=sensors,
-            contours=contours,
-            outlines=outlines,
-            sphere=sphere,
-            image_interp=image_interp,
-            extrapolate=extrapolate,
-            border=border,
-            res=res,
-            cmap=cmap[0],
-            vmin=vlim[0],
-            vmax=vlim[1],
-        )
-
-        def onclick_topo(event, ica=ica, inst=inst):
-            # check which component to plot
-            if event.inaxes is not None:
-                label = event.inaxes.get_label()
-                if label.startswith("ICA"):
-                    ic = int(label.split(" ")[0][-3:])
-                    ica.plot_properties(
-                        inst,
-                        picks=ic,
-                        show=True,
-                        plot_std=plot_std,
-                        topomap_args=topomap_args,
-                        image_args=image_args,
-                        psd_args=psd_args,
-                        reject=reject,
-                    )
-
-        fig.canvas.mpl_connect("button_press_event", onclick_topo)
+            def onclick_topo(event, ica=ica, inst=inst):
+                # check which component to plot
+                if event.inaxes is not None:
+                    label = event.inaxes.get_label()
+                    if label.startswith("ICA"):
+                        ic = int(label.split(" ")[0][-3:])
+                        ica.plot_properties(
+                            inst,
+                            picks=ic,
+                            show=True,
+                            plot_std=plot_std,
+                            topomap_args=topomap_args,
+                            image_args=image_args,
+                            psd_args=psd_args,
+                            reject=reject,
+                        )
+
+            fig.canvas.mpl_connect("button_press_event", onclick_topo)
+        figs.append(fig)
 
     plt_show(show)
-    return fig
+    return figs[0] if len(figs) == 1 else figs
 
 
 @fill_doc


Test patch: