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()