Skip to content
Open
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
69 changes: 41 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,22 +672,52 @@ 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic seems a bit convoluted:

  1. if a theme was passed in, set window._mne_theme to it
  2. replace value of theme variable with window._mne_theme, or
  3. if it's None, with _qt_detect_theme()
  4. override value of theme with the config variable.

I think I get why we do step 4 (user set an MNE-specific preference, AKA "don't follow system theme"). But the other 3 steps I can't quite follow... Could this be re-ordered, simplified, or just commented a bit better? Naively I'd think that something like this would be clearer:

theme = (
    get_config("MNE_3D_OPTION_THEME", None)
    or theme
    or getattr(window, "_mne_theme", None)
    or _qt_detect_theme()
)

...but IDK what the significance is of changing window._mne_theme to be the passed-in value and not one of the other values

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the logic was unnecessarily complicated... simplified now.

# 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):
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):
Expand All @@ -710,6 +730,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 +1535,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 +1698,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