From 578450f4e9b2551ae677d65cbb2ce64c6a9df934 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Wed, 12 Aug 2026 14:27:32 -0500 Subject: [PATCH 1/2] Add light/dark auto-switch --- mne/gui/tests/test_gui_api.py | 8 +++++ mne/viz/backends/_qt.py | 68 ++++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/mne/gui/tests/test_gui_api.py b/mne/gui/tests/test_gui_api.py index af34cec09f4..06644f8eab4 100644 --- a/mne/gui/tests/test_gui_api.py +++ b/mne/gui/tests/test_gui_api.py @@ -61,6 +61,14 @@ def test_gui_api_notebook(renderer_notebook, nbexec, *, backend="qt"): renderer._layout_add_widget(central_layout, widget, row=0, col=0) renderer._window_initialize(window=window, central_layout=central_layout) + # an OS light/dark mode switch re-applies the theme (gh-9182) + from qtpy.QtCore import QEvent + from qtpy.QtGui import QIcon + + QIcon.setThemeName("bogus") + window.event(QEvent(QEvent.PaletteChange)) + assert QIcon.themeName() in ("dark", "light") + from unittest.mock import Mock mock = Mock() diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index a0ae27387ba..943c08b2707 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -224,17 +224,7 @@ def _set_focus(self): self.setFocus() def _set_theme(self, theme=None): - if theme is None: - default_theme = _qt_detect_theme() - else: - default_theme = theme - theme = get_config("MNE_3D_OPTION_THEME", default_theme) - stylesheet = _qt_get_stylesheet(theme) - self.setStyleSheet(stylesheet) - if _qt_is_dark(self): - QIcon.setThemeName("dark") - else: - QIcon.setThemeName("light") + _qt_set_theme(self, theme) def _set_size(self, width=None, height=None): if width: @@ -682,22 +672,51 @@ def _set_size(self, width=None, height=None): # ------- -# In theory we should be able to set the theme later (e.g., in -# _window_initialize() below), but at least on Qt6 this has to be done -# earlier. So let's do it immediately upon instantiation of the QMainWindow -# class (see _AppWindow.__init__'s self._set_theme() call below). -# TODO: This should eventually allow us to handle -# https://github.com/mne-tools/mne-python/issues/9182 +def _qt_set_theme(window, theme=None): + """(Re)apply a theme to a window, remembering any explicitly requested one.""" + if theme is not None: + window._mne_theme = theme + theme = getattr(window, "_mne_theme", None) + if theme is None: + theme = _qt_detect_theme() + theme = get_config("MNE_3D_OPTION_THEME", theme) + stylesheet = _qt_get_stylesheet(theme) + # our own setStyleSheet emits PaletteChange; without this the signal recurses + window._mne_theme_updating = True + try: + # re-setting an unchanged sheet costs styled children (sliders) native rendering + if stylesheet != window.styleSheet(): + window.setStyleSheet(stylesheet) + QIcon.setThemeName("dark" if _qt_is_dark(window) else "light") + # not a no-op: setStyleSheet re-parses, re-resolving palette(...) refs that a + # palette change alone leaves stale + for widget in window.findChildren(QWidget): + if child_stylesheet := widget.styleSheet(): + widget.setStyleSheet(child_stylesheet) + finally: + window._mne_theme_updating = False + + class _MNEMainWindow(MainWindow): + signal_theme_change = Signal() + def __init__(self, parent=None, title=None, size=None): MainWindow.__init__(self, parent=parent, title=title, size=size) self.setAttribute(Qt.WA_ShowWithoutActivating, True) self.setAttribute(Qt.WA_DeleteOnClose, True) + self._mne_theme = None + self._mne_theme_updating = False from . import renderer if renderer.MNE_3D_BACKEND_TESTING: self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnBottomHint) + def event(self, ev): + """Turn OS light/dark mode switches into a signal (macOS only for now).""" + if ev.type() == QEvent.PaletteChange and not self._mne_theme_updating: + self.signal_theme_change.emit() + return super().event(ev) + class _AppWindow(_AbstractAppWindow, _Widget, _MNEMainWindow, metaclass=_BaseWidget): def __init__(self, size=None, fullscreen=False): @@ -710,6 +729,7 @@ def __init__(self, size=None, fullscreen=False): self.setWindowState(Qt.WindowFullScreen) self._set_theme() + self.signal_theme_change.connect(self._set_theme) self.setLocale(QLocale(QLocale.Language.English)) self.signal_close.connect(self._clean) @@ -1514,6 +1534,8 @@ def _window_initialize(self, *, window=None, central_layout=None, fullscreen=Fal central_widget.setLayout(central_layout) self._window_load_icons() self._window_set_theme() + if hasattr(self._window, "signal_theme_change"): # not for a foreign window + self._window.signal_theme_change.connect(self._window_set_theme) self._window.setLocale(QLocale(QLocale.Language.English)) self._window.signal_close.connect(self._window_clean) self._window_before_close_callbacks = list() @@ -1675,17 +1697,7 @@ def _window_ensure_minimum_sizes(self): _qt_activate_layouts(self._window, self._interactor) def _window_set_theme(self, theme=None): - if theme is None: - default_theme = _qt_detect_theme() - else: - default_theme = theme - theme = get_config("MNE_3D_OPTION_THEME", default_theme) - stylesheet = _qt_get_stylesheet(theme) - self._window.setStyleSheet(stylesheet) - if _qt_is_dark(self._window): - QIcon.setThemeName("dark") - else: - QIcon.setThemeName("light") + _qt_set_theme(self._window, theme) def _window_create(self): return _MNEMainWindow() From 7d12ec6601d0e13bd63befca40d1293175355fd1 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Wed, 12 Aug 2026 18:12:34 -0500 Subject: [PATCH 2/2] Simplify theme resolution --- doc/changes/dev/14151.newfeature.rst | 1 + mne/gui/tests/test_gui_api.py | 13 +++---------- mne/viz/backends/_qt.py | 13 +++++++------ 3 files changed, 11 insertions(+), 16 deletions(-) create mode 100644 doc/changes/dev/14151.newfeature.rst diff --git a/doc/changes/dev/14151.newfeature.rst b/doc/changes/dev/14151.newfeature.rst new file mode 100644 index 00000000000..39065331e3d --- /dev/null +++ b/doc/changes/dev/14151.newfeature.rst @@ -0,0 +1 @@ +3D plot windows (e.g., :meth:`mne.SourceEstimate.plot`) now follow light/dark mode switches of the operating system (macOS only for now) while they are open, by `Eric Larson`_. diff --git a/mne/gui/tests/test_gui_api.py b/mne/gui/tests/test_gui_api.py index 06644f8eab4..96b77e7b64a 100644 --- a/mne/gui/tests/test_gui_api.py +++ b/mne/gui/tests/test_gui_api.py @@ -15,9 +15,7 @@ def test_gui_api_notebook(renderer_notebook, nbexec, *, backend="qt"): """Test GUI API.""" import contextlib - import os import warnings - from unittest import mock import mne @@ -33,14 +31,9 @@ def test_gui_api_notebook(renderer_notebook, nbexec, *, backend="qt"): mne.viz.set_3d_backend("notebook") renderer = mne.viz.backends.renderer._get_renderer(size=(300, 300)) - # theme -- drop the MNE_3D_OPTION_THEME that the options_3d fixture pins to - # "light" (it takes precedence via get_config), so the bad path is actually - # used and warns. - with ( - mock.patch.dict(os.environ), - warnings.catch_warnings(record=True) as w, - ): - os.environ.pop("MNE_3D_OPTION_THEME", None) + # theme -- an explicit theme= takes precedence over the MNE_3D_OPTION_THEME that + # the options_3d fixture pins to "light", so the bad path is used and warns + with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") renderer._window_set_theme("/does/not/exist") if backend == "qt": diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 943c08b2707..aeeac01117a 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -675,18 +675,19 @@ def _set_size(self, width=None, height=None): def _qt_set_theme(window, theme=None): """(Re)apply a theme to a window, remembering any explicitly requested one.""" if theme is not None: + # remembered so that reapplying on an OS theme switch keeps honoring it window._mne_theme = theme - theme = getattr(window, "_mne_theme", None) - if theme is None: + elif remembered := getattr(window, "_mne_theme", None): + theme = remembered # an explicit theme= from an earlier call + elif config_theme := get_config("MNE_3D_OPTION_THEME", None): + theme = config_theme + else: theme = _qt_detect_theme() - theme = get_config("MNE_3D_OPTION_THEME", theme) stylesheet = _qt_get_stylesheet(theme) # our own setStyleSheet emits PaletteChange; without this the signal recurses window._mne_theme_updating = True try: - # re-setting an unchanged sheet costs styled children (sliders) native rendering - if stylesheet != window.styleSheet(): - window.setStyleSheet(stylesheet) + window.setStyleSheet(stylesheet) QIcon.setThemeName("dark" if _qt_is_dark(window) else "light") # not a no-op: setStyleSheet re-parses, re-resolving palette(...) refs that a # palette change alone leaves stale