From de2220fd4d6899192b944c491333431697f94daf Mon Sep 17 00:00:00 2001 From: Cedric Conday <277679649+CedricConday@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:21:01 +0000 Subject: [PATCH 1/2] ENH: Add block argument to stc.plot() `stc.plot()` returns immediately, so running it in a script leaves no opportunity to interact with the figure before the interpreter exits. Unlike `raw.plot()` there was no way to ask for the call to halt until the window is closed. Add `block=False` to `plot_source_estimates`, and thread it through `SourceEstimate.plot` and `VolSourceEstimate.plot_3d`, which share its docstring. The default preserves the current non-blocking behaviour, which is what makes the function usable for generating figures in a script or report. Blocking reuses the mechanism the coregistration GUI already used; that code is now shared as `_qt_block` rather than duplicated. The matplotlib backend blocks via `plt_show`. Closes gh-14105 --- mne/gui/_coreg.py | 6 ++--- mne/source_estimate.py | 4 ++++ mne/viz/_3d.py | 14 ++++++++--- mne/viz/backends/_utils.py | 13 +++++++++++ mne/viz/backends/tests/test_utils.py | 35 ++++++++++++++++++++++++++++ mne/viz/tests/test_3d.py | 2 ++ 6 files changed, 68 insertions(+), 6 deletions(-) diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py index 21e71423a72..a61edeb54a0 100644 --- a/mne/gui/_coreg.py +++ b/mne/gui/_coreg.py @@ -65,7 +65,7 @@ _plot_mri_fiducials, _plot_sensors_3d, ) -from ..viz.backends._utils import _qt_app_exec, _qt_safe_window +from ..viz.backends._utils import _qt_block, _qt_safe_window from ..viz.utils import safe_event @@ -381,8 +381,8 @@ def _get_default(var, val): self._trans_modified = False self._mri_fids_modified = False self._mri_scale_modified = False - if block and self._renderer._kind != "notebook": - _qt_app_exec(self._renderer.figure.store["app"]) + if block: + _qt_block(self._renderer) def _set_subjects_dir(self, subjects_dir): if subjects_dir is None or not subjects_dir: diff --git a/mne/source_estimate.py b/mne/source_estimate.py index ee11b2bfa62..890a627fb68 100644 --- a/mne/source_estimate.py +++ b/mne/source_estimate.py @@ -778,6 +778,7 @@ def plot( view_layout="vertical", add_data_kwargs=None, brain_kwargs=None, + block=False, verbose=None, ): from .viz import plot_source_estimates @@ -813,6 +814,7 @@ def plot( view_layout=view_layout, add_data_kwargs=add_data_kwargs, brain_kwargs=brain_kwargs, + block=block, verbose=verbose, ) return brain @@ -2345,6 +2347,7 @@ def plot_3d( view_layout="vertical", add_data_kwargs=None, brain_kwargs=None, + block=False, verbose=None, ): return super().plot( @@ -2377,6 +2380,7 @@ def plot_3d( view_layout=view_layout, add_data_kwargs=add_data_kwargs, brain_kwargs=brain_kwargs, + block=block, verbose=verbose, ) diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 13d0c577da0..19c3b79df3d 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -2168,6 +2168,7 @@ def _plot_mpl_stc( time_viewer=False, colorbar=True, transparent=True, + block=False, ): """Plot source estimate using mpl.""" import matplotlib.pyplot as plt @@ -2307,7 +2308,7 @@ def _plot_mpl_stc( cax.tick_params(labelsize=16) cb.ax.set_facecolor("0.5") cax.set(xlim=(scale_pts[0], scale_pts[2])) - plt_show(True) + plt_show(True, block=block) return fig @@ -2416,6 +2417,7 @@ def plot_source_estimates( view_layout="vertical", add_data_kwargs=None, brain_kwargs=None, + block=False, verbose=None, ): """Plot SourceEstimate. @@ -2517,6 +2519,7 @@ def plot_source_estimates( %(view_layout)s %(add_data_kwargs)s %(brain_kwargs)s + %(block)s %(verbose)s Returns @@ -2536,12 +2539,14 @@ def plot_source_estimates( - https://openwetware.org/wiki/Beauchamp:FreeSurfer """ # noqa: E501 from ..source_estimate import _BaseSourceEstimate, _check_stc_src + from .backends._utils import _qt_block from .backends.renderer import _get_3d_backend, use_3d_backend _check_stc_src(stc, src) _validate_type(stc, _BaseSourceEstimate, "stc", "source estimate") subjects_dir = get_subjects_dir(subjects_dir=subjects_dir, raise_error=True) subject = _check_subject(stc.subject, subject) + _validate_type(block, bool, "block") _check_option("backend", backend, ["auto", "matplotlib", "pyvistaqt", "notebook"]) plot_mpl = backend == "matplotlib" if not plot_mpl: @@ -2572,10 +2577,10 @@ def plot_source_estimates( transparent=transparent, ) if plot_mpl: - return _plot_mpl_stc(stc, spacing=spacing, **kwargs) + return _plot_mpl_stc(stc, spacing=spacing, block=block, **kwargs) else: with use_3d_backend(backend): - return _plot_stc( + brain = _plot_stc( stc, overlay_alpha=alpha, brain_alpha=alpha, @@ -2593,6 +2598,9 @@ def plot_source_estimates( title=title, **kwargs, ) + if block: + _qt_block(brain._renderer) + return brain def _plot_stc( diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index 8201a2a7a7c..0d19168df4d 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -275,6 +275,19 @@ def _qt_app_exec(app): signal.signal(signal.SIGINT, old_signal) +def _qt_block(renderer): + """Halt execution until the renderer's window is closed. + + Does nothing for backends that have no Qt application to run, such as the + notebook backend. + """ + if renderer._kind == "notebook": + return + app = renderer.figure.store.get("app") + if app is not None: + _qt_app_exec(app) + + def _qt_detect_theme(): try: import darkdetect diff --git a/mne/viz/backends/tests/test_utils.py b/mne/viz/backends/tests/test_utils.py index 2cf949829e4..a909098354c 100644 --- a/mne/viz/backends/tests/test_utils.py +++ b/mne/viz/backends/tests/test_utils.py @@ -2,6 +2,8 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from types import SimpleNamespace + import numpy as np import pytest @@ -11,6 +13,7 @@ _check_color, _get_colormap_from_array, _pixmap_to_ndarray, + _qt_block, _qt_is_dark, ) from mne.viz.utils import _is_dark @@ -30,6 +33,38 @@ def test_get_colormap_from_array(): assert isinstance(cmap, ListedColormap) +def _fake_renderer(kind, store): + return SimpleNamespace(_kind=kind, figure=SimpleNamespace(store=store)) + + +def test_qt_block_without_qt_app(): + """Test that _qt_block is a no-op when there is no Qt app to run.""" + # the notebook backend never has a Qt application + _qt_block(_fake_renderer("notebook", {})) + # neither does a renderer whose plotter was supplied by the caller + _qt_block(_fake_renderer("qt", {})) + + +def test_qt_block_runs_event_loop(): + """Test that _qt_block does not return until the Qt application quits.""" + pytest.importorskip("qtpy") + from qtpy.QtCore import QTimer + from qtpy.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + quit_ran = [] + + def _quit(): + quit_ran.append(True) + app.quit() + + # If _qt_block returned without running the event loop, the timer would never + # fire and quit_ran would still be empty when we check it. + QTimer.singleShot(100, _quit) + _qt_block(_fake_renderer("qt", {"app": app})) + assert quit_ran == [True] + + def test_check_color(): """Test color format.""" assert _check_color("red") == (1.0, 0.0, 0.0) diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 106b71e846f..846c44b131f 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -1057,6 +1057,8 @@ def test_process_clim_plot(renderer_interactive, brain_gc): brain = stc.plot(**kwargs) assert brain.data["center"] is None brain.close() + with pytest.raises(TypeError, match="block must be an instance of bool"): + stc.plot(block="yes", **kwargs) brain = stc.plot(clim=dict(pos_lims=(10, 50, 90)), **kwargs) assert brain.data["center"] == 0.0 brain.close() From d567a873772179264adec713eed3dfa7603d55f3 Mon Sep 17 00:00:00 2001 From: Cedric Conday <277679649+CedricConday@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:04:44 +0000 Subject: [PATCH 2/2] DOC: Add changelog entry for gh-14185 --- doc/changes/dev/14185.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14185.newfeature.rst diff --git a/doc/changes/dev/14185.newfeature.rst b/doc/changes/dev/14185.newfeature.rst new file mode 100644 index 00000000000..022d2225571 --- /dev/null +++ b/doc/changes/dev/14185.newfeature.rst @@ -0,0 +1 @@ +Add a ``block`` parameter to :func:`mne.viz.plot_source_estimates`, :meth:`mne.SourceEstimate.plot` and :meth:`mne.VolSourceEstimate.plot_3d` to halt execution until the figure is closed, by `Cedric Conday`_.