Skip to content

Commit 61cc2df

Browse files
cbrnrCopilotpre-commit-ci[bot]
authored
Add dark theme to Matplotlib-based raw.plot (#13861)
Co-authored-by: Copilot <copilot@github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 8f30cf1 commit 61cc2df

5 files changed

Lines changed: 228 additions & 15 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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`_.

mne/utils/docs.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4576,7 +4576,9 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
45764576

45774577
docdict["theme_pg"] = """
45784578
{theme}
4579-
Only supported by the ``'qt'`` backend.
4579+
For the ``"matplotlib"`` backend, only ``"light"``, ``"dark"``,
4580+
and ``"auto"`` are supported. For the ``"qt"`` backend, a path-like to a custom
4581+
stylesheet is also accepted.
45804582
""".format(theme=_theme.format(config_option="MNE_BROWSER_THEME"))
45814583

45824584
docdict["thresh"] = """

mne/viz/_mpl_figure.py

Lines changed: 161 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
import datetime
3939
import platform
40-
from collections import OrderedDict
40+
from collections import OrderedDict, defaultdict
4141
from contextlib import contextmanager
4242
from functools import partial
4343

@@ -55,6 +55,7 @@
5555
channel_indices_by_type,
5656
pick_types,
5757
)
58+
from ..defaults import DEFAULTS
5859
from ..fixes import _close_event
5960
from ..utils import Bunch, _click_ch_name, check_version, logger
6061
from ._figure import BrowserBase
@@ -82,6 +83,91 @@
8283
ANNOTATION_FIG_CHECKBOX_COLUMN_W = 0.5
8384
_OLD_BUTTONS = not check_version("matplotlib", "3.7")
8485

86+
# DARK THEME COLORS
87+
# These colors are duplicated from mne-qt-browser (_dark_dict). If you change one, make
88+
# sure to update the other as well.
89+
_DARK_BGCOLOR = "#1e1e1e"
90+
_DARK_FGCOLOR = "#d0d0d0"
91+
_DARK_BAD_COLOR = "#696969"
92+
_DARK_BUTTON_COLOR = "#3a3a3a"
93+
_DARK_EVENT_COLOR = "#008b8b"
94+
_DARK_CHANNEL_OVERRIDES = {
95+
# "k" (black) channels → white in dark mode
96+
"eeg": "#ffffff",
97+
"eog": "#ffffff",
98+
"emg": "#ffffff",
99+
"misc": "#ffffff",
100+
"stim": "#ffffff",
101+
"resp": "#ffffff",
102+
"chpi": "#ffffff",
103+
"exci": "#ffffff",
104+
"ias": "#ffffff",
105+
"syst": "#ffffff",
106+
"dipole": "#ffffff",
107+
"gof": "#ffffff",
108+
"bio": "#ffffff",
109+
"ecog": "#ffffff",
110+
"fnirs_cw_amplitude": "#ffffff",
111+
"fnirs_fd_ac_amplitude": "#ffffff",
112+
"fnirs_fd_phase": "#ffffff",
113+
"fnirs_od": "#ffffff",
114+
"csd": "#ffffff",
115+
"whitened": "#ffffff",
116+
"eyegaze": "#ffffff",
117+
"pupil": "#ffffff",
118+
"mag": "#add8e6",
119+
"grad": "#6495ed",
120+
"ref_meg": "#b0c4de",
121+
"ecg": "#ee82ee",
122+
"seeg": "#f4a460",
123+
"dbs": "#20b2aa",
124+
"hbo": "#ff69b4",
125+
"hbr": "#6495ed",
126+
"gsr": "#b0b055",
127+
"temperature": "#aa6666",
128+
}
129+
130+
131+
def _resolve_mpl_theme(theme):
132+
"""Resolve "auto" theme to "light" or "dark" using darkdetect."""
133+
if theme == "auto":
134+
from .backends._utils import _qt_detect_theme
135+
136+
return _qt_detect_theme()
137+
return theme
138+
139+
140+
def _apply_mpl_theme_to_kwargs(kwargs):
141+
"""Apply dark theme colors to browser kwargs in-place."""
142+
theme = kwargs.get("theme", "auto")
143+
if _resolve_mpl_theme(theme) != "dark":
144+
return
145+
# bgcolor: override if absent or still at the light default "w"
146+
if kwargs.get("bgcolor", "w") == "w":
147+
kwargs["bgcolor"] = _DARK_BGCOLOR
148+
# fgcolor: inject if not explicitly set by the caller
149+
kwargs.setdefault("fgcolor", _DARK_FGCOLOR)
150+
# bad channel colors: override if still at the light default
151+
for key in ("bad_color", "ch_color_bad"):
152+
if kwargs.get(key, "lightgray") == "lightgray":
153+
kwargs[key] = _DARK_BAD_COLOR
154+
# channel type colors: override only entries still at their light defaults
155+
if "ch_color_dict" in kwargs:
156+
light_defaults = DEFAULTS["color"]
157+
for ch_type, dark_color in _DARK_CHANNEL_OVERRIDES.items():
158+
if ch_type in kwargs["ch_color_dict"] and kwargs["ch_color_dict"][
159+
ch_type
160+
] == light_defaults.get(ch_type):
161+
kwargs["ch_color_dict"][ch_type] = dark_color
162+
# event color: override if still at the light default "cyan"
163+
if "event_color_dict" in kwargs:
164+
d = kwargs["event_color_dict"]
165+
if hasattr(d, "default_factory") and d.default_factory is not None:
166+
if d.default_factory() == "cyan":
167+
new_d = defaultdict(lambda: _DARK_EVENT_COLOR)
168+
new_d.update(d)
169+
kwargs["event_color_dict"] = new_d
170+
85171

86172
class MNEFigure(Figure):
87173
"""Base class for 2D figures & dialogs; wraps matplotlib.figure.Figure."""
@@ -360,6 +446,8 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs):
360446

361447
kwargs.update({"inst": inst, "figsize": figsize, "ica": ica, "xlabel": xlabel})
362448

449+
_apply_mpl_theme_to_kwargs(kwargs)
450+
363451
BrowserBase.__init__(self, **kwargs)
364452
MNEFigure.__init__(self, **kwargs)
365453

@@ -567,6 +655,24 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs):
567655
vline_text=vline_text,
568656
)
569657

658+
# apply theme colors (dark mode only)
659+
if self.mne.bgcolor == _DARK_BGCOLOR:
660+
self.patch.set_facecolor(self.mne.bgcolor)
661+
for _ax in (ax_hscroll, ax_vscroll):
662+
_ax.set_facecolor(self.mne.bgcolor)
663+
for _ax in (ax_main, ax_hscroll):
664+
for _spine in _ax.spines.values():
665+
_spine.set_color(self.mne.fgcolor)
666+
_ax.tick_params(colors=self.mne.fgcolor, labelcolor=self.mne.fgcolor)
667+
_ax.xaxis.label.set_color(self.mne.fgcolor)
668+
self.mne.button_help.ax.set_facecolor(_DARK_BUTTON_COLOR)
669+
self.mne.button_help.color = _DARK_BUTTON_COLOR
670+
self.mne.button_help.label.set_color(self.mne.fgcolor)
671+
if ax_proj is not None:
672+
self.mne.button_proj.ax.set_facecolor(_DARK_BUTTON_COLOR)
673+
self.mne.button_proj.color = _DARK_BUTTON_COLOR
674+
self.mne.button_proj.label.set_color(self.mne.fgcolor)
675+
570676
def _get_size(self):
571677
return self.get_size_inches()
572678

@@ -873,6 +979,8 @@ def _create_ch_context_fig(self, idx):
873979

874980
def _new_child_figure(self, fig_name, *, layout=None, **kwargs):
875981
"""Instantiate a new MNE dialog figure (with event listeners)."""
982+
kwargs.setdefault("bgcolor", self.mne.bgcolor)
983+
kwargs.setdefault("fgcolor", self.mne.fgcolor)
876984
fig = _figure(
877985
toolbar=False,
878986
parent_fig=self,
@@ -919,8 +1027,13 @@ def _create_help_fig(self):
9191027
ax = fig.add_axes((0.01, 0.01, 0.98, 0.98))
9201028
ax.set_axis_off()
9211029
kwargs = dict(va="top", linespacing=1.5, usetex=False)
922-
ax.text(0.42, 1, keys, ma="right", ha="right", **kwargs)
923-
ax.text(0.42, 1, vals, ma="left", ha="left", **kwargs)
1030+
txt_keys = ax.text(0.42, 1, keys, ma="right", ha="right", **kwargs)
1031+
txt_vals = ax.text(0.42, 1, vals, ma="left", ha="left", **kwargs)
1032+
# apply theme colors
1033+
fig.patch.set_facecolor(fig.mne.bgcolor)
1034+
ax.set_facecolor(fig.mne.bgcolor)
1035+
txt_keys.set_color(fig.mne.fgcolor)
1036+
txt_vals.set_color(fig.mne.fgcolor)
9241037

9251038
def _toggle_help_fig(self, event):
9261039
"""Show/hide the help dialog window."""
@@ -1060,7 +1173,7 @@ def _create_annotation_fig(self):
10601173
r"$\mathbf{Esc:}$ exit annotation mode & close this window",
10611174
]
10621175
)
1063-
instructions_ax.text(
1176+
instr_text = instructions_ax.text(
10641177
0, 1, instructions, va="top", ha="left", linespacing=1.7, usetex=False
10651178
) # force use of MPL mathtext parser
10661179
instructions_ax.set_axis_off()
@@ -1070,7 +1183,7 @@ def _create_annotation_fig(self):
10701183
size=Fixed(3 * ANNOTATION_FIG_PAD),
10711184
pad=Fixed(ANNOTATION_FIG_PAD),
10721185
)
1073-
text_entry_ax.text(
1186+
new_label_text = text_entry_ax.text(
10741187
0.4, 0.5, "New label:", va="center", ha="right", weight="bold"
10751188
)
10761189
fig.label = text_entry_ax.text(0.5, 0.5, "BAD_", va="center", ha="left")
@@ -1089,7 +1202,7 @@ def _create_annotation_fig(self):
10891202
drag_ax = div.append_axes(
10901203
"bottom", size=Fixed(drag_ax_height), pad=Fixed(ANNOTATION_FIG_PAD)
10911204
)
1092-
check_kwargs = _get_check_kwargs()
1205+
check_kwargs = _get_check_kwargs(fgcolor=fig.mne.fgcolor)
10931206
checkbox = CheckButtons(
10941207
drag_ax,
10951208
labels=("Draggable edges?",),
@@ -1118,6 +1231,12 @@ def _create_annotation_fig(self):
11181231
text.set(position=(3 * _pad + _size, 0.45), va="center")
11191232
for artist in lines + (rect, text):
11201233
artist.set_transform(drag_ax.transData)
1234+
rect.set_edgecolor(fig.mne.fgcolor)
1235+
for line in lines:
1236+
line.set_color(fig.mne.fgcolor)
1237+
text.set_color(fig.mne.fgcolor)
1238+
else:
1239+
checkbox.labels[0].set_color(fig.mne.fgcolor)
11211240
# setup interactivity in plot window
11221241
if fig.mne.radio_ax.buttons is None:
11231242
col = "#ff0000"
@@ -1138,6 +1257,18 @@ def _create_annotation_fig(self):
11381257
"motion_notify_event", self._hover
11391258
)
11401259

1260+
# apply theme colors to annotation dialog (dark mode only)
1261+
if fig.mne.bgcolor == _DARK_BGCOLOR:
1262+
fig.patch.set_facecolor(fig.mne.bgcolor)
1263+
for _ax in fig.axes:
1264+
_ax.set_facecolor(fig.mne.bgcolor)
1265+
fig.button.ax.set_facecolor(_DARK_BUTTON_COLOR)
1266+
fig.button.color = _DARK_BUTTON_COLOR
1267+
for _artist in (instr_text, new_label_text, fig.label, fig.button.label):
1268+
_artist.set_color(fig.mne.fgcolor)
1269+
fig.mne.radio_ax._left_title.set_color(fig.mne.fgcolor)
1270+
fig.mne.show_hide_ax._right_title.set_color(fig.mne.fgcolor)
1271+
11411272
def _toggle_visible_annotations(self, event):
11421273
"""Enable/disable display of annotations on a per-label basis."""
11431274
checkboxes = self.mne.show_hide_annotation_checkboxes
@@ -1167,7 +1298,7 @@ def _update_annotation_fig(self, *, draw=True):
11671298
# populate center axes with labels & radio buttons
11681299
ax.clear()
11691300
title = "Existing labels:" if len(labels) else "No existing labels"
1170-
ax.set_title(title, size=None, loc="left")
1301+
ax.set_title(title, size=None, loc="left").set_color(fig.mne.fgcolor)
11711302
if len(labels):
11721303
if _OLD_BUTTONS:
11731304
ax.buttons = RadioButtons(ax, labels, **_BLIT_KWARGS)
@@ -1200,6 +1331,9 @@ def _update_annotation_fig(self, *, draw=True):
12001331
)
12011332
else:
12021333
ax.buttons = None
1334+
if ax.buttons is not None:
1335+
for _lbl in ax.buttons.labels:
1336+
_lbl.set_color(fig.mne.fgcolor)
12031337
# adjust xlim to keep equal aspect & full width (keep circles round)
12041338
aspect = (
12051339
ANNOTATION_FIG_W - ANNOTATION_FIG_CHECKBOX_COLUMN_W - 3 * ANNOTATION_FIG_PAD
@@ -1228,14 +1362,16 @@ def _update_annotation_fig(self, *, draw=True):
12281362
check_values.update(self.mne.visible_annotations) # existing checks
12291363
actives = [check_values[label] for label in labels]
12301364
# regenerate checkboxes
1231-
check_kwargs = _get_check_kwargs()
1365+
check_kwargs = _get_check_kwargs(fgcolor=fig.mne.fgcolor)
12321366
checkboxes = CheckButtons(
12331367
ax=fig.mne.show_hide_ax, labels=labels, actives=actives, **check_kwargs
12341368
)
12351369
checkboxes.on_clicked(self._toggle_visible_annotations)
12361370
# add title, hide labels
12371371
show_hide_title = "show/\nhide " if len(labels) else ""
1238-
show_hide_ax.set_title(show_hide_title, size=None, loc="right")
1372+
show_hide_ax.set_title(show_hide_title, size=None, loc="right").set_color(
1373+
fig.mne.fgcolor
1374+
)
12391375
for label in checkboxes.labels:
12401376
label.set_visible(False)
12411377
show_hide_ax.set_axis_off()
@@ -1253,9 +1389,11 @@ def _update_annotation_fig(self, *, draw=True):
12531389
bounds = (aspect, bbox.ymin, -bbox.width, bbox.height)
12541390
rect.set_bounds(bounds)
12551391
rect.set_clip_on(False)
1392+
rect.set_edgecolor(fig.mne.fgcolor)
12561393
for line in np.array(checkboxes.lines).ravel():
12571394
line.set_transform(show_hide_ax.transData)
12581395
line.set_xdata(aspect + 0.05 - np.array(line.get_xdata()))
1396+
line.set_color(fig.mne.fgcolor)
12591397
# store state
12601398
self.mne.visible_annotations = check_values
12611399
self.mne.show_hide_annotation_checkboxes = checkboxes
@@ -1301,7 +1439,7 @@ def _add_annotation_label(self, event):
13011439
f"Existing labels: (duplicate label: {repr(text)})",
13021440
size=None,
13031441
loc="left",
1304-
)
1442+
).set_color(self.mne.fig_annotation.mne.fgcolor)
13051443
self.mne.fig_annotation.canvas.draw()
13061444
return
13071445
self.mne.new_annotation_labels.append(text)
@@ -1602,7 +1740,7 @@ def _create_proj_fig(self):
16021740
ax,
16031741
labels=labels,
16041742
actives=self.mne.projs_on,
1605-
**_get_check_kwargs(labels=labels),
1743+
**_get_check_kwargs(labels=labels, fgcolor=fig.mne.fgcolor),
16061744
)
16071745
# gray-out already applied projectors
16081746
if _OLD_BUTTONS:
@@ -1909,6 +2047,8 @@ def _xtick_formatter(self, x, pos=None, ax_type="main"):
19092047
return str(round(x, digits))
19102048
# format as timestamp
19112049
meas_date = self.mne.inst.info["meas_date"]
2050+
if meas_date is None:
2051+
return str(round(x, digits))
19122052
first_time = datetime.timedelta(seconds=self.mne.inst.first_time)
19132053
xtime = datetime.timedelta(seconds=x)
19142054
xdatetime = meas_date + first_time + xtime
@@ -1919,6 +2059,8 @@ def _xtick_formatter(self, x, pos=None, ax_type="main"):
19192059

19202060
def _toggle_time_format(self):
19212061
if self.mne.time_format == "float":
2062+
if self.mne.inst.info["meas_date"] is None:
2063+
return # can't show clock time without a measurement date
19222064
self.mne.time_format = "clock"
19232065
x_axis_label = "Time (HH:MM:SS)"
19242066
else:
@@ -2517,13 +2659,17 @@ def _init_browser(**kwargs):
25172659
return fig
25182660

25192661

2520-
def _get_check_kwargs(labels=None):
2662+
def _get_check_kwargs(labels=None, fgcolor=None):
25212663
check_kwargs = dict()
25222664
if not _OLD_BUTTONS:
25232665
check_kwargs.update(
25242666
check_props=dict(s=144, clip_on=False),
25252667
frame_props=dict(s=144, clip_on=False),
25262668
)
2669+
if fgcolor is not None:
2670+
# Color check marks (unfilled 'x' marker uses facecolor) and frame borders
2671+
check_kwargs["check_props"].update(facecolor=fgcolor)
2672+
check_kwargs["frame_props"].update(edgecolor=fgcolor)
25272673
if labels is not None:
25282674
textcolor = list()
25292675
checkcolor = list()
@@ -2532,8 +2678,9 @@ def _get_check_kwargs(labels=None):
25322678
textcolor.append("0.5")
25332679
checkcolor.append("0.7")
25342680
else:
2535-
textcolor.append("k")
2536-
checkcolor.append("k")
2681+
_clr = fgcolor if fgcolor is not None else "k"
2682+
textcolor.append(_clr)
2683+
checkcolor.append(_clr)
25372684
check_kwargs["check_props"].update(facecolor=checkcolor, linewidth=1)
25382685
check_kwargs["frame_props"].update(edgecolor=checkcolor, linewidth=1)
25392686
check_kwargs["label_props"] = dict(color=textcolor)

0 commit comments

Comments
 (0)