diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index 6efadd6aca7..0eef6ac2775 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -31,7 +31,7 @@ Bugs - Fix bug where plots produced using the ``'qt'`` / ``mne_qt_browser`` backend could not be added using :meth:`mne.Report.add_figure` (:gh:`10485` by `Eric Larson`_) -- Fix bug where ``theme`` was not handled properly in :meth:`mne.io.Raw.plot` (:gh:`10487` by `Mathieu Scheltienne`_) +- Fix bug where ``theme`` was not handled properly in :meth:`mne.io.Raw.plot` (:gh:`10487`, :gh:`10500` by `Mathieu Scheltienne`_ and `Eric Larson`_) - Fix behavior for the ``pyvista`` 3D renderer's ``quiver3D`` function so that default arguments plot a glyph in ``arrow`` mode (:gh:`10493` by `Alex Rockhill`_) diff --git a/mne/icons/toolbar_move_horizontal@2x.png b/mne/icons/toolbar_move_horizontal@2x.png new file mode 100644 index 00000000000..143b62ec558 Binary files /dev/null and b/mne/icons/toolbar_move_horizontal@2x.png differ diff --git a/mne/icons/toolbar_move_vertical@2x.png b/mne/icons/toolbar_move_vertical@2x.png new file mode 100644 index 00000000000..453d7b71f96 Binary files /dev/null and b/mne/icons/toolbar_move_vertical@2x.png differ diff --git a/mne/icons/toolbar_separator_horizontal.png b/mne/icons/toolbar_separator_horizontal.png new file mode 100644 index 00000000000..ecf2ab7d0cf Binary files /dev/null and b/mne/icons/toolbar_separator_horizontal.png differ diff --git a/mne/icons/toolbar_separator_horizontal@2x.png b/mne/icons/toolbar_separator_horizontal@2x.png new file mode 100644 index 00000000000..ac2b3432646 Binary files /dev/null and b/mne/icons/toolbar_separator_horizontal@2x.png differ diff --git a/mne/icons/toolbar_separator_vertical@2x.png b/mne/icons/toolbar_separator_vertical@2x.png new file mode 100644 index 00000000000..2f66e93d346 Binary files /dev/null and b/mne/icons/toolbar_separator_vertical@2x.png differ diff --git a/mne/viz/_scraper.py b/mne/viz/_scraper.py index 6056e3f0fb1..bff5013bf2b 100644 --- a/mne/viz/_scraper.py +++ b/mne/viz/_scraper.py @@ -4,9 +4,8 @@ from contextlib import contextmanager -import numpy as np - from ..utils import _pl +from .backends._utils import _pixmap_to_ndarray class _PyQtGraphScraper: @@ -78,12 +77,6 @@ def _mne_qt_browser_screenshot(browser, inst=None, return_type='pixmap'): pixmap = browser.grab() assert return_type in ('pixmap', 'ndarray') if return_type == 'ndarray': - img = pixmap.toImage() - img = img.convertToFormat(img.Format_RGBA8888) - ptr = img.bits() - ptr.setsize(img.height() * img.width() * 4) - data = np.frombuffer(ptr, dtype=np.uint8).copy() - data.shape = (img.height(), img.width(), 4) - return data / 255. + return _pixmap_to_ndarray(pixmap) else: return pixmap, inst diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index 8a820384561..40911704803 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -9,6 +9,7 @@ import collections.abc from colorsys import rgb_to_hls from contextlib import contextmanager +import functools import platform import signal import sys @@ -75,6 +76,7 @@ def _alpha_blend_background(ctable, background_color): return (use_table * alphas) + background_color * (1 - alphas) +@functools.lru_cache(1) def _qt_init_icons(): from qtpy.QtGui import QIcon icons_path = f"{Path(__file__).parent.parent.parent}/icons" @@ -200,20 +202,69 @@ def _qt_detect_theme(): def _qt_get_stylesheet(theme): - from ..utils import logger, warn, _validate_type + from ...fixes import _compare_version + from ...utils import logger, warn, _validate_type _validate_type(theme, ('path-like',), 'theme') theme = str(theme) + system_theme = None if theme == 'auto': - theme = _qt_detect_theme() + theme = system_theme = _qt_detect_theme() if theme in ('dark', 'light'): - if theme == 'light': - stylesheet = '' + if system_theme is None: + system_theme = _qt_detect_theme() + if sys.platform == 'darwin' and theme == system_theme: + from qtpy import QtCore + try: + qt_version = QtCore.__version__ # PySide + except AttributeError: + qt_version = QtCore.QT_VERSION_STR # PyQt + if theme == 'dark' and _compare_version(qt_version, '<', '5.13'): + # Taken using "Digital Color Meter" on macOS 12.2.1 looking at + # Meld, and also adapting (MIT-licensed) + # https://github.com/ColinDuquesnoy/QDarkStyleSheet/blob/master/qdarkstyle/dark/style.qss # noqa: E501 + # Something around rgb(51, 51, 51) worked as the bgcolor here, + # but it's easy enough just to set it transparent and inherit + # the bgcolor of the window (which is the same). We also take + # the separator images from QDarkStyle (MIT). + icons_path = _qt_init_icons() + stylesheet = """\ +QStatusBar { + border: 1px solid rgb(76, 76, 75); + background: transparent; +} +QStatusBar QLabel { + background: transparent; +} +QToolBar { + background-color: transparent; + border-bottom: 1px solid rgb(99, 99, 99); +} +QToolBar::separator:horizontal { + width: 16px; + image: url("%(icons_path)s/toolbar_separator_horizontal@2x.png"); +} +QToolBar::separator:vertical { + height: 16px; + image: url("%(icons_path)s/toolbar_separator_vertical@2x.png"); +} +QToolBar::handle:horizontal { + width: 16px; + image: url("%(icons_path)s/toolbar_move_horizontal@2x.png"); +} +QToolBar::handle:vertical { + height: 16px; + image: url("%(icons_path)s/toolbar_move_vertical@2x.png"); +} +""" % dict(icons_path=icons_path) + else: + stylesheet = '' else: try: import qdarkstyle except ModuleNotFoundError: - logger.info('For Dark-Mode, "qdarkstyle" has to be installed! ' - 'You can install it with `pip install qdarkstyle`') + logger.info( + f'To use {theme} mode, "qdarkstyle" has to be installed! ' + 'You can install it with `pip install qdarkstyle`') stylesheet = '' else: klass = getattr(getattr(qdarkstyle, theme).palette, @@ -250,3 +301,13 @@ def _qt_is_dark(widget): win = widget.window() bgcolor = win.palette().color(win.backgroundRole()).getRgbF()[:3] return rgb_to_hls(*bgcolor)[1] < 0.5 + + +def _pixmap_to_ndarray(pixmap): + img = pixmap.toImage() + img = img.convertToFormat(img.Format_RGBA8888) + ptr = img.bits() + ptr.setsize(img.height() * img.width() * 4) + data = np.frombuffer(ptr, dtype=np.uint8).copy() + data.shape = (img.height(), img.width(), 4) + return data / 255. diff --git a/mne/viz/backends/tests/test_utils.py b/mne/viz/backends/tests/test_utils.py index 65bddcd707c..9574ad74217 100644 --- a/mne/viz/backends/tests/test_utils.py +++ b/mne/viz/backends/tests/test_utils.py @@ -5,8 +5,15 @@ # # License: Simplified BSD +from colorsys import rgb_to_hls + +import numpy as np import pytest -from mne.viz.backends._utils import _get_colormap_from_array, _check_color + +from mne import create_info +from mne.io import RawArray +from mne.viz.backends._utils import (_get_colormap_from_array, _check_color, + _qt_is_dark, _pixmap_to_ndarray) def test_get_colormap_from_array(): @@ -39,3 +46,35 @@ def test_check_color(): _check_color(['foo', 'bar', 'foo']) with pytest.raises(TypeError, match='Expected type'): _check_color(None) + + +@pytest.mark.parametrize('theme', ('auto', 'light', 'dark')) +def test_theme_colors(pg_backend, theme, monkeypatch, tmp_path): + """Test that theme colors propagate properly.""" + darkdetect = pytest.importorskip('darkdetect') + monkeypatch.setenv('_MNE_FAKE_HOME_DIR', str(tmp_path)) + monkeypatch.delenv('MNE_BROWSER_THEME', raising=False) + raw = RawArray(np.zeros((1, 1000)), create_info(1, 1000., 'eeg')) + fig = raw.plot(theme=theme) + is_dark = _qt_is_dark(fig) + if theme == 'dark': + assert is_dark, theme + elif theme == 'light': + assert not is_dark, theme + else: + got_dark = darkdetect.theme().lower() == 'dark' + assert is_dark is got_dark + + def assert_correct_darkness(widget, want_dark): + __tracebackhide__ = True # noqa + # This should work, but it just picks up the parent in the errant case! + bgcolor = widget.palette().color(widget.backgroundRole()).getRgbF()[:3] + dark = rgb_to_hls(*bgcolor)[1] < 0.5 + assert dark == want_dark, f'{widget} dark={dark} want_dark={want_dark}' + # ... so we use a more direct test + colors = _pixmap_to_ndarray(widget.grab())[:, :, :3] + dark = colors.mean() < 0.5 + assert dark == want_dark, f'{widget} dark={dark} want_dark={want_dark}' + + for widget in (fig.mne.toolbar, fig.statusBar()): + assert_correct_darkness(widget, is_dark)