Repository: mne-tools/mne-python

Issue:
Argument `theme` of `raw.plot()` or config key does not work on macOS
As spotted following the discussion here https://github.com/mne-tools/mne-qt-browser/issues/83 the argument `theme` does not change the theme on either my macOS or Windows computer.

Sample code:

```
import mne


folder = mne.datasets.sample.data_path() / 'MEG' / 'sample'
raw = mne.io.read_raw(folder / 'sample_audvis_filt-0-40_raw.fif', preload=True)
raw.plot(theme='dark')
```


Discussion:
@larsoner can you re-open this one?
I am still getting a dark theme no matter the `theme` argument on my macOS with dark-mode enabled a the system-level.
Setting it in the MNE config with `mne.set_config('MNE_BROWSER_THEME', 'light')` does not get me the light theme either.
Same issue as https://github.com/mne-tools/mne-qt-browser/issues/100 ?
I don't think so. I can give it a shot with the latest version of `mne-qt-browser` later; but in my case, the UI stays in dark mode no matter what. The entire UI.
Yes please test with latest MNE-Python and mne-qt-browser `main`
Fresh environment with both `mne-python` and `mne-qt-browser` installed from `main`. No luck, there is something wrong here.

```
folder = mne.datasets.sample.data_path() / 'MEG' / 'sample'
raw = mne.io.read_raw(folder / 'sample_audvis_filt-0-40_raw.fif', preload=True)
raw.plot(theme='light')
```

<img width="1260" alt="Screenshot 2022-04-07 at 15 55 12" src="https://user-images.githubusercontent.com/73893616/162215777-26a2a15a-aeae-4798-a6b1-ac2ca6b27522.png">

Can you investigate a bit? If not, I can try to look in a bit
I can look at it tomorrow

Resolving patch:
diff --git a/mne/viz/_scraper.py b/mne/viz/_scraper.py
--- 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
--- 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.


Test patch:
diff --git a/mne/viz/backends/tests/test_utils.py b/mne/viz/backends/tests/test_utils.py
--- 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)