Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/14151.newfeature.rst
Original file line number Diff line number Diff line change
@@ -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`_.
21 changes: 11 additions & 10 deletions mne/gui/tests/test_gui_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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":
Expand All @@ -61,6 +54,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()
Expand Down
72 changes: 44 additions & 28 deletions mne/viz/backends/_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -682,14 +672,41 @@ 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:
# remembered so that reapplying on an OS theme switch keeps honoring it
window._mne_theme = theme
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()
stylesheet = _qt_get_stylesheet(theme)
Comment thread
larsoner marked this conversation as resolved.
# our own setStyleSheet emits PaletteChange; without this the signal recurses
window._mne_theme_updating = True
try:
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):
# before the base __init__: Qt can dispatch event() while the C++
# constructor is still running (PaletteChange is delivered during
# construction), and these must already exist by then
self._mne_theme = None
self._mne_theme_updating = False
MainWindow.__init__(self, parent=parent, title=title, size=size)
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.setAttribute(Qt.WA_DeleteOnClose, True)
Expand All @@ -698,6 +715,12 @@ def __init__(self, parent=None, title=None, size=None):
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):
Expand All @@ -710,6 +733,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)

Expand Down Expand Up @@ -1514,6 +1538,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()
Expand Down Expand Up @@ -1675,17 +1701,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()
Expand Down
Loading