From 586376b09e6fc4849c2e41c1a076f0ace952d89d Mon Sep 17 00:00:00 2001 From: Clemens Brunner Date: Thu, 23 Apr 2026 11:10:08 +0200 Subject: [PATCH 1/8] Add dark theme to Matplotlib-based raw.plot Co-authored-by: Copilot --- mne/utils/docs.py | 4 +- mne/viz/_mpl_figure.py | 151 ++++++++++++++++++++++++++++++++++---- mne/viz/tests/test_raw.py | 62 ++++++++++++++++ 3 files changed, 203 insertions(+), 14 deletions(-) diff --git a/mne/utils/docs.py b/mne/utils/docs.py index e079e2dd63c..91672285ba4 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -4576,7 +4576,9 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): docdict["theme_pg"] = """ {theme} - Only supported by the ``'qt'`` backend. + For the ``"matplotlib"`` backend, only ``"light"``, ``"dark"``, + and ``"auto"`` are supported. For the ``"qt"`` backend, a path-like to a custom + stylesheet is also accepted. """.format(theme=_theme.format(config_option="MNE_BROWSER_THEME")) docdict["thresh"] = """ diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index f66da6a126d..aa259820c62 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -82,6 +82,75 @@ ANNOTATION_FIG_CHECKBOX_COLUMN_W = 0.5 _OLD_BUTTONS = not check_version("matplotlib", "3.7") +# DARK THEME COLORS +_DARK_BGCOLOR = "#1c1c1c" +_DARK_FGCOLOR = "#d0d0d0" +_DARK_BAD_COLOR = "#4a4a4a" +_DARK_BUTTON_COLOR = "#3a3a3a" +_DARK_CHANNEL_OVERRIDES = { + "eeg": "#c8c8c8", + "eog": "#c8c8c8", + "emg": "#c8c8c8", + "misc": "#c8c8c8", + "stim": "#c8c8c8", + "resp": "#c8c8c8", + "chpi": "#c8c8c8", + "exci": "#c8c8c8", + "ias": "#c8c8c8", + "syst": "#c8c8c8", + "dipole": "#c8c8c8", + "gof": "#c8c8c8", + "bio": "#c8c8c8", + "ecog": "#c8c8c8", + "fnirs_cw_amplitude": "#c8c8c8", + "fnirs_fd_ac_amplitude": "#c8c8c8", + "fnirs_fd_phase": "#c8c8c8", + "fnirs_od": "#c8c8c8", + "csd": "#c8c8c8", + "whitened": "#c8c8c8", + "eyegaze": "#c8c8c8", + "pupil": "#c8c8c8", + "mag": "#7aade0", + "gsr": "#b0b055", + "temperature": "#aa6666", +} + + +def _resolve_mpl_theme(theme): + """Resolve "auto" theme to "light" or "dark" using darkdetect.""" + if theme == "auto": + from .backends._utils import _qt_detect_theme + + return _qt_detect_theme() + return theme + + +def _apply_mpl_theme_to_kwargs(kwargs): + """Apply dark theme colors to browser kwargs in-place.""" + theme = kwargs.get("theme", "auto") + if _resolve_mpl_theme(theme) != "dark": + return + # bgcolor: override if absent or still at the light default "w" + if kwargs.get("bgcolor", "w") == "w": + kwargs["bgcolor"] = _DARK_BGCOLOR + # fgcolor: inject if not explicitly set by the caller + kwargs.setdefault("fgcolor", _DARK_FGCOLOR) + # bad channel colors: override if still at the light default + for key in ("bad_color", "ch_color_bad"): + if kwargs.get(key, "lightgray") == "lightgray": + kwargs[key] = _DARK_BAD_COLOR + # channel type colors: override only entries still at their light defaults + if "ch_color_dict" in kwargs: + from ..defaults import DEFAULTS + + light_defaults = DEFAULTS["color"] + for ch_type, dark_color in _DARK_CHANNEL_OVERRIDES.items(): + if ( + ch_type in kwargs["ch_color_dict"] + and kwargs["ch_color_dict"][ch_type] == light_defaults.get(ch_type) + ): + kwargs["ch_color_dict"][ch_type] = dark_color + class MNEFigure(Figure): """Base class for 2D figures & dialogs; wraps matplotlib.figure.Figure.""" @@ -360,6 +429,8 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs): kwargs.update({"inst": inst, "figsize": figsize, "ica": ica, "xlabel": xlabel}) + _apply_mpl_theme_to_kwargs(kwargs) + BrowserBase.__init__(self, **kwargs) MNEFigure.__init__(self, **kwargs) @@ -567,6 +638,23 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs): vline_text=vline_text, ) + # apply theme colors (dark mode only) + if self.mne.bgcolor == _DARK_BGCOLOR: + self.patch.set_facecolor(self.mne.bgcolor) + for _ax in (ax_hscroll, ax_vscroll): + _ax.set_facecolor(self.mne.bgcolor) + for _ax in (ax_main, ax_hscroll): + for _spine in _ax.spines.values(): + _spine.set_color(self.mne.fgcolor) + _ax.tick_params(colors=self.mne.fgcolor, labelcolor=self.mne.fgcolor) + self.mne.button_help.ax.set_facecolor(_DARK_BUTTON_COLOR) + self.mne.button_help.color = _DARK_BUTTON_COLOR + self.mne.button_help.label.set_color(self.mne.fgcolor) + if ax_proj is not None: + self.mne.button_proj.ax.set_facecolor(_DARK_BUTTON_COLOR) + self.mne.button_proj.color = _DARK_BUTTON_COLOR + self.mne.button_proj.label.set_color(self.mne.fgcolor) + def _get_size(self): return self.get_size_inches() @@ -873,6 +961,8 @@ def _create_ch_context_fig(self, idx): def _new_child_figure(self, fig_name, *, layout=None, **kwargs): """Instantiate a new MNE dialog figure (with event listeners).""" + kwargs.setdefault("bgcolor", self.mne.bgcolor) + kwargs.setdefault("fgcolor", self.mne.fgcolor) fig = _figure( toolbar=False, parent_fig=self, @@ -919,8 +1009,13 @@ def _create_help_fig(self): ax = fig.add_axes((0.01, 0.01, 0.98, 0.98)) ax.set_axis_off() kwargs = dict(va="top", linespacing=1.5, usetex=False) - ax.text(0.42, 1, keys, ma="right", ha="right", **kwargs) - ax.text(0.42, 1, vals, ma="left", ha="left", **kwargs) + txt_keys = ax.text(0.42, 1, keys, ma="right", ha="right", **kwargs) + txt_vals = ax.text(0.42, 1, vals, ma="left", ha="left", **kwargs) + # apply theme colors + fig.patch.set_facecolor(fig.mne.bgcolor) + ax.set_facecolor(fig.mne.bgcolor) + txt_keys.set_color(fig.mne.fgcolor) + txt_vals.set_color(fig.mne.fgcolor) def _toggle_help_fig(self, event): """Show/hide the help dialog window.""" @@ -1060,7 +1155,7 @@ def _create_annotation_fig(self): r"$\mathbf{Esc:}$ exit annotation mode & close this window", ] ) - instructions_ax.text( + instr_text = instructions_ax.text( 0, 1, instructions, va="top", ha="left", linespacing=1.7, usetex=False ) # force use of MPL mathtext parser instructions_ax.set_axis_off() @@ -1070,7 +1165,7 @@ def _create_annotation_fig(self): size=Fixed(3 * ANNOTATION_FIG_PAD), pad=Fixed(ANNOTATION_FIG_PAD), ) - text_entry_ax.text( + new_label_text = text_entry_ax.text( 0.4, 0.5, "New label:", va="center", ha="right", weight="bold" ) fig.label = text_entry_ax.text(0.5, 0.5, "BAD_", va="center", ha="left") @@ -1089,7 +1184,7 @@ def _create_annotation_fig(self): drag_ax = div.append_axes( "bottom", size=Fixed(drag_ax_height), pad=Fixed(ANNOTATION_FIG_PAD) ) - check_kwargs = _get_check_kwargs() + check_kwargs = _get_check_kwargs(fgcolor=fig.mne.fgcolor) checkbox = CheckButtons( drag_ax, labels=("Draggable edges?",), @@ -1118,6 +1213,12 @@ def _create_annotation_fig(self): text.set(position=(3 * _pad + _size, 0.45), va="center") for artist in lines + (rect, text): artist.set_transform(drag_ax.transData) + rect.set_edgecolor(fig.mne.fgcolor) + for line in lines: + line.set_color(fig.mne.fgcolor) + text.set_color(fig.mne.fgcolor) + else: + checkbox.labels[0].set_color(fig.mne.fgcolor) # setup interactivity in plot window if fig.mne.radio_ax.buttons is None: col = "#ff0000" @@ -1138,6 +1239,18 @@ def _create_annotation_fig(self): "motion_notify_event", self._hover ) + # apply theme colors to annotation dialog (dark mode only) + if fig.mne.bgcolor == _DARK_BGCOLOR: + fig.patch.set_facecolor(fig.mne.bgcolor) + for _ax in fig.axes: + _ax.set_facecolor(fig.mne.bgcolor) + fig.button.ax.set_facecolor(_DARK_BUTTON_COLOR) + fig.button.color = _DARK_BUTTON_COLOR + for _artist in (instr_text, new_label_text, fig.label, fig.button.label): + _artist.set_color(fig.mne.fgcolor) + fig.mne.radio_ax._left_title.set_color(fig.mne.fgcolor) + fig.mne.show_hide_ax._right_title.set_color(fig.mne.fgcolor) + def _toggle_visible_annotations(self, event): """Enable/disable display of annotations on a per-label basis.""" checkboxes = self.mne.show_hide_annotation_checkboxes @@ -1167,7 +1280,7 @@ def _update_annotation_fig(self, *, draw=True): # populate center axes with labels & radio buttons ax.clear() title = "Existing labels:" if len(labels) else "No existing labels" - ax.set_title(title, size=None, loc="left") + ax.set_title(title, size=None, loc="left").set_color(fig.mne.fgcolor) if len(labels): if _OLD_BUTTONS: ax.buttons = RadioButtons(ax, labels, **_BLIT_KWARGS) @@ -1200,6 +1313,9 @@ def _update_annotation_fig(self, *, draw=True): ) else: ax.buttons = None + if ax.buttons is not None: + for _lbl in ax.buttons.labels: + _lbl.set_color(fig.mne.fgcolor) # adjust xlim to keep equal aspect & full width (keep circles round) aspect = ( ANNOTATION_FIG_W - ANNOTATION_FIG_CHECKBOX_COLUMN_W - 3 * ANNOTATION_FIG_PAD @@ -1228,14 +1344,16 @@ def _update_annotation_fig(self, *, draw=True): check_values.update(self.mne.visible_annotations) # existing checks actives = [check_values[label] for label in labels] # regenerate checkboxes - check_kwargs = _get_check_kwargs() + check_kwargs = _get_check_kwargs(fgcolor=fig.mne.fgcolor) checkboxes = CheckButtons( ax=fig.mne.show_hide_ax, labels=labels, actives=actives, **check_kwargs ) checkboxes.on_clicked(self._toggle_visible_annotations) # add title, hide labels show_hide_title = "show/\nhide " if len(labels) else "" - show_hide_ax.set_title(show_hide_title, size=None, loc="right") + show_hide_ax.set_title(show_hide_title, size=None, loc="right").set_color( + fig.mne.fgcolor + ) for label in checkboxes.labels: label.set_visible(False) show_hide_ax.set_axis_off() @@ -1253,9 +1371,11 @@ def _update_annotation_fig(self, *, draw=True): bounds = (aspect, bbox.ymin, -bbox.width, bbox.height) rect.set_bounds(bounds) rect.set_clip_on(False) + rect.set_edgecolor(fig.mne.fgcolor) for line in np.array(checkboxes.lines).ravel(): line.set_transform(show_hide_ax.transData) line.set_xdata(aspect + 0.05 - np.array(line.get_xdata())) + line.set_color(fig.mne.fgcolor) # store state self.mne.visible_annotations = check_values self.mne.show_hide_annotation_checkboxes = checkboxes @@ -1301,7 +1421,7 @@ def _add_annotation_label(self, event): f"Existing labels: (duplicate label: {repr(text)})", size=None, loc="left", - ) + ).set_color(self.mne.fig_annotation.mne.fgcolor) self.mne.fig_annotation.canvas.draw() return self.mne.new_annotation_labels.append(text) @@ -1602,7 +1722,7 @@ def _create_proj_fig(self): ax, labels=labels, actives=self.mne.projs_on, - **_get_check_kwargs(labels=labels), + **_get_check_kwargs(labels=labels, fgcolor=fig.mne.fgcolor), ) # gray-out already applied projectors if _OLD_BUTTONS: @@ -2517,13 +2637,17 @@ def _init_browser(**kwargs): return fig -def _get_check_kwargs(labels=None): +def _get_check_kwargs(labels=None, fgcolor=None): check_kwargs = dict() if not _OLD_BUTTONS: check_kwargs.update( check_props=dict(s=144, clip_on=False), frame_props=dict(s=144, clip_on=False), ) + if fgcolor is not None: + # Color check marks (unfilled 'x' marker uses facecolor) and frame borders + check_kwargs["check_props"].update(facecolor=fgcolor) + check_kwargs["frame_props"].update(edgecolor=fgcolor) if labels is not None: textcolor = list() checkcolor = list() @@ -2532,8 +2656,9 @@ def _get_check_kwargs(labels=None): textcolor.append("0.5") checkcolor.append("0.7") else: - textcolor.append("k") - checkcolor.append("k") + _clr = fgcolor if fgcolor is not None else "k" + textcolor.append(_clr) + checkcolor.append(_clr) check_kwargs["check_props"].update(facecolor=checkcolor, linewidth=1) check_kwargs["frame_props"].update(edgecolor=checkcolor, linewidth=1) check_kwargs["label_props"] = dict(color=textcolor) diff --git a/mne/viz/tests/test_raw.py b/mne/viz/tests/test_raw.py index 63fbe2247ad..bbf5c32c035 100644 --- a/mne/viz/tests/test_raw.py +++ b/mne/viz/tests/test_raw.py @@ -1364,3 +1364,65 @@ def test_plotting_scalebars(browser_backend, qtbot): else: yvals = this_bar.get_ydata() assert_allclose(yvals, [ci - delta, ci + delta], err_msg=err_msg) + + +@pytest.mark.parametrize("theme", ("light", "dark")) +def test_raw_plot_theme(theme, mpl_backend): + """Test dark/light theme for the matplotlib browser backend.""" + import matplotlib.colors as mcolors + + from mne.viz._mpl_figure import ( + _DARK_BAD_COLOR, + _DARK_BGCOLOR, + _DARK_CHANNEL_OVERRIDES, + _DARK_FGCOLOR, + ) + + sfreq = 100.0 + t = np.arange(500) / sfreq + data = np.sin(2 * np.pi * 1.0 * t)[np.newaxis, :] + info = create_info(["EEG 001"], sfreq, "eeg") + raw = RawArray(data, info) + fig = raw.plot(theme=theme) + + def _hex(c): + return mcolors.to_hex(c) + + if theme == "dark": + assert _hex(fig.mne.bgcolor) == _DARK_BGCOLOR + assert _hex(fig.mne.fgcolor) == _DARK_FGCOLOR + assert _hex(fig.mne.ch_color_bad) == _DARK_BAD_COLOR + assert _hex(fig.mne.ch_color_dict["eeg"]) == _DARK_CHANNEL_OVERRIDES["eeg"] + assert _hex(fig.patch.get_facecolor()) == _DARK_BGCOLOR + assert _hex(fig.mne.ax_main.get_facecolor()) == _DARK_BGCOLOR + assert _hex(fig.mne.ax_hscroll.get_facecolor()) == _DARK_BGCOLOR + else: # light + assert _hex(fig.mne.bgcolor) == _hex("w") + assert _hex(fig.mne.ch_color_dict["eeg"]) == _hex("k") + assert _hex(fig.patch.get_facecolor()) == _hex("w") + assert _hex(fig.mne.ax_main.get_facecolor()) == _hex("w") + plt.close("all") + + +def test_raw_plot_theme_auto(mpl_backend, monkeypatch): + """Test theme="auto" resolves via _resolve_mpl_theme for the mpl backend.""" + import matplotlib.colors as mcolors + + import mne.viz._mpl_figure as mpl_fig + from mne.viz._mpl_figure import _DARK_BGCOLOR + + sfreq = 100.0 + t = np.arange(500) / sfreq + data = np.sin(2 * np.pi * 1.0 * t)[np.newaxis, :] + info = create_info(["EEG 001"], sfreq, "eeg") + raw = RawArray(data, info) + + monkeypatch.setattr(mpl_fig, "_resolve_mpl_theme", lambda theme: "dark") + fig = raw.plot(theme="auto") + assert mcolors.to_hex(fig.mne.bgcolor) == _DARK_BGCOLOR + plt.close("all") + + monkeypatch.setattr(mpl_fig, "_resolve_mpl_theme", lambda theme: "light") + fig = raw.plot(theme="auto") + assert mcolors.to_hex(fig.mne.bgcolor) == mcolors.to_hex("w") + plt.close("all") From c3cbc060b9d39549c79ebdb4bd4d940d3c8a9a48 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:13:07 +0000 Subject: [PATCH 2/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/viz/_mpl_figure.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index aa259820c62..d69502a02e6 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -145,10 +145,9 @@ def _apply_mpl_theme_to_kwargs(kwargs): light_defaults = DEFAULTS["color"] for ch_type, dark_color in _DARK_CHANNEL_OVERRIDES.items(): - if ( - ch_type in kwargs["ch_color_dict"] - and kwargs["ch_color_dict"][ch_type] == light_defaults.get(ch_type) - ): + if ch_type in kwargs["ch_color_dict"] and kwargs["ch_color_dict"][ + ch_type + ] == light_defaults.get(ch_type): kwargs["ch_color_dict"][ch_type] = dark_color From 16d221a947d5262a1b427a7062ae562876601a1d Mon Sep 17 00:00:00 2001 From: Clemens Brunner Date: Thu, 23 Apr 2026 11:13:29 +0200 Subject: [PATCH 3/8] Add changelog entry --- doc/changes/dev/13861.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/13861.newfeature.rst diff --git a/doc/changes/dev/13861.newfeature.rst b/doc/changes/dev/13861.newfeature.rst new file mode 100644 index 00000000000..24be0b2868a --- /dev/null +++ b/doc/changes/dev/13861.newfeature.rst @@ -0,0 +1 @@ +Add dark theme support (``theme="light"``, ``"dark"``, or ``"auto"``) to the ``'matplotlib'`` browser backend used by :meth:`mne.io.Raw.plot`, :meth:`mne.Epochs.plot`, and :meth:`mne.preprocessing.ICA.plot_sources`, by `Clemens Brunner`_. \ No newline at end of file From 0c9e352f1277f763a3787c5a9745da196ffe48b1 Mon Sep 17 00:00:00 2001 From: Clemens Brunner Date: Thu, 23 Apr 2026 11:21:12 +0200 Subject: [PATCH 4/8] Add mpl_backend figture to allowlist --- tools/vulture_allowlist.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index 0a3e58c059a..763b0e8ea1b 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -21,6 +21,7 @@ exitstatus startdir pg_backend +mpl_backend recwarn pytestmark nbexec From b351afbd2461f6375e15a477ed4f688b13a68e68 Mon Sep 17 00:00:00 2001 From: Clemens Brunner Date: Thu, 23 Apr 2026 11:27:09 +0200 Subject: [PATCH 5/8] Fix x-axis label Co-authored-by: Copilot --- mne/viz/_mpl_figure.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index d69502a02e6..a65b3bbe393 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -646,6 +646,7 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs): for _spine in _ax.spines.values(): _spine.set_color(self.mne.fgcolor) _ax.tick_params(colors=self.mne.fgcolor, labelcolor=self.mne.fgcolor) + _ax.xaxis.label.set_color(self.mne.fgcolor) self.mne.button_help.ax.set_facecolor(_DARK_BUTTON_COLOR) self.mne.button_help.color = _DARK_BUTTON_COLOR self.mne.button_help.label.set_color(self.mne.fgcolor) From ec7683fd39e017fbafa35ae065f820d49af13c25 Mon Sep 17 00:00:00 2001 From: Clemens Brunner Date: Thu, 23 Apr 2026 11:34:13 +0200 Subject: [PATCH 6/8] Fix nesting Co-authored-by: Copilot --- mne/viz/_mpl_figure.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index a65b3bbe393..590074b22f1 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -55,6 +55,7 @@ channel_indices_by_type, pick_types, ) +from ..defaults import DEFAULTS from ..fixes import _close_event from ..utils import Bunch, _click_ch_name, check_version, logger from ._figure import BrowserBase @@ -141,8 +142,6 @@ def _apply_mpl_theme_to_kwargs(kwargs): kwargs[key] = _DARK_BAD_COLOR # channel type colors: override only entries still at their light defaults if "ch_color_dict" in kwargs: - from ..defaults import DEFAULTS - light_defaults = DEFAULTS["color"] for ch_type, dark_color in _DARK_CHANNEL_OVERRIDES.items(): if ch_type in kwargs["ch_color_dict"] and kwargs["ch_color_dict"][ From 3e2cf596e67f12c2dd273064086ffb8314ad5a73 Mon Sep 17 00:00:00 2001 From: Clemens Brunner Date: Thu, 23 Apr 2026 11:37:57 +0200 Subject: [PATCH 7/8] Fix toggle time format if meas_date is None Co-authored-by: Copilot --- mne/viz/_mpl_figure.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index 590074b22f1..f81d39944ba 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -2028,6 +2028,8 @@ def _xtick_formatter(self, x, pos=None, ax_type="main"): return str(round(x, digits)) # format as timestamp meas_date = self.mne.inst.info["meas_date"] + if meas_date is None: + return str(round(x, digits)) first_time = datetime.timedelta(seconds=self.mne.inst.first_time) xtime = datetime.timedelta(seconds=x) xdatetime = meas_date + first_time + xtime @@ -2038,6 +2040,8 @@ def _xtick_formatter(self, x, pos=None, ax_type="main"): def _toggle_time_format(self): if self.mne.time_format == "float": + if self.mne.inst.info["meas_date"] is None: + return # can't show clock time without a measurement date self.mne.time_format = "clock" x_axis_label = "Time (HH:MM:SS)" else: From f4ed58f60ed703393a301d2d540d5d827ba6bed0 Mon Sep 17 00:00:00 2001 From: Clemens Brunner Date: Thu, 23 Apr 2026 18:37:37 +0200 Subject: [PATCH 8/8] Use mne-qt-browser dark colors Co-authored-by: Copilot --- mne/viz/_mpl_figure.py | 71 ++++++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index f81d39944ba..283398a1469 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -37,7 +37,7 @@ import datetime import platform -from collections import OrderedDict +from collections import OrderedDict, defaultdict from contextlib import contextmanager from functools import partial @@ -84,34 +84,45 @@ _OLD_BUTTONS = not check_version("matplotlib", "3.7") # DARK THEME COLORS -_DARK_BGCOLOR = "#1c1c1c" +# These colors are duplicated from mne-qt-browser (_dark_dict). If you change one, make +# sure to update the other as well. +_DARK_BGCOLOR = "#1e1e1e" _DARK_FGCOLOR = "#d0d0d0" -_DARK_BAD_COLOR = "#4a4a4a" +_DARK_BAD_COLOR = "#696969" _DARK_BUTTON_COLOR = "#3a3a3a" +_DARK_EVENT_COLOR = "#008b8b" _DARK_CHANNEL_OVERRIDES = { - "eeg": "#c8c8c8", - "eog": "#c8c8c8", - "emg": "#c8c8c8", - "misc": "#c8c8c8", - "stim": "#c8c8c8", - "resp": "#c8c8c8", - "chpi": "#c8c8c8", - "exci": "#c8c8c8", - "ias": "#c8c8c8", - "syst": "#c8c8c8", - "dipole": "#c8c8c8", - "gof": "#c8c8c8", - "bio": "#c8c8c8", - "ecog": "#c8c8c8", - "fnirs_cw_amplitude": "#c8c8c8", - "fnirs_fd_ac_amplitude": "#c8c8c8", - "fnirs_fd_phase": "#c8c8c8", - "fnirs_od": "#c8c8c8", - "csd": "#c8c8c8", - "whitened": "#c8c8c8", - "eyegaze": "#c8c8c8", - "pupil": "#c8c8c8", - "mag": "#7aade0", + # "k" (black) channels → white in dark mode + "eeg": "#ffffff", + "eog": "#ffffff", + "emg": "#ffffff", + "misc": "#ffffff", + "stim": "#ffffff", + "resp": "#ffffff", + "chpi": "#ffffff", + "exci": "#ffffff", + "ias": "#ffffff", + "syst": "#ffffff", + "dipole": "#ffffff", + "gof": "#ffffff", + "bio": "#ffffff", + "ecog": "#ffffff", + "fnirs_cw_amplitude": "#ffffff", + "fnirs_fd_ac_amplitude": "#ffffff", + "fnirs_fd_phase": "#ffffff", + "fnirs_od": "#ffffff", + "csd": "#ffffff", + "whitened": "#ffffff", + "eyegaze": "#ffffff", + "pupil": "#ffffff", + "mag": "#add8e6", + "grad": "#6495ed", + "ref_meg": "#b0c4de", + "ecg": "#ee82ee", + "seeg": "#f4a460", + "dbs": "#20b2aa", + "hbo": "#ff69b4", + "hbr": "#6495ed", "gsr": "#b0b055", "temperature": "#aa6666", } @@ -148,6 +159,14 @@ def _apply_mpl_theme_to_kwargs(kwargs): ch_type ] == light_defaults.get(ch_type): kwargs["ch_color_dict"][ch_type] = dark_color + # event color: override if still at the light default "cyan" + if "event_color_dict" in kwargs: + d = kwargs["event_color_dict"] + if hasattr(d, "default_factory") and d.default_factory is not None: + if d.default_factory() == "cyan": + new_d = defaultdict(lambda: _DARK_EVENT_COLOR) + new_d.update(d) + kwargs["event_color_dict"] = new_d class MNEFigure(Figure):