diff --git a/doc/changes/dev/14149.newfeature.rst b/doc/changes/dev/14149.newfeature.rst new file mode 100644 index 00000000000..b75f9d030ba --- /dev/null +++ b/doc/changes/dev/14149.newfeature.rst @@ -0,0 +1 @@ +Added a trace-list sidebar to the :class:`mne.viz.Brain` GUI, replacing legend by `Payam Sadeghi-Shabestari`_. diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 3f935ec682c..51884ba2275 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -583,6 +583,8 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): self.rms = None self._picked_patches = {key: list() for key in all_keys} self._picked_points = dict() + self._peak_vertices = {} + self._trace_meta = {} self._mouse_no_mvt = -1 self._show_hover_info = False self._hover_caption = None @@ -1119,9 +1121,19 @@ def _configure_dock(self): self._configure_dock_colormap_widget(name="Color Limits") self._configure_dock_orientation_widget(name="Orientation") self._configure_dock_surface_widget(name="Surface") - self._configure_dock_trace_widget(name="Trace") + self._configure_dock_trace_widget(name="Atlas") + self._configure_dock_trace_list_widget(name="Trace List") self._renderer._dock_finalize() + def _configure_dock_trace_list_widget(self, name): + if not self.show_traces or self.mpl_canvas is None: + return + add_trace_list = getattr(self._renderer, "_dock_add_trace_list", None) + if add_trace_list is None: + return + self.mpl_canvas._trace_list = add_trace_list(name, collapse=True) + self.mpl_canvas.sync_traces() + def _configure_mplcanvas(self): # Get the fractional components for the brain and mpl self.mpl_canvas = self._renderer._window_get_mplcanvas( @@ -1151,6 +1163,7 @@ def _configure_vertex_time_course(self): # Plot one RMS curve per overlay so the viewer shows all overlays. self.rms = [] + self._peak_vertices = {} multi = len(self._all_data) > 1 for overlay_key, overlay_data in self._all_data.items(): y_parts = [] @@ -1173,12 +1186,11 @@ def _configure_vertex_time_course(self): (line,) = self.mpl_canvas.axes.plot( overlay_data["time"], rms, - lw=3, + lw=3.5, label=label, zorder=3, color=next(self.color_cycle), alpha=0.5, - ls=":", ) self.rms.append(line) @@ -1207,9 +1219,11 @@ def _configure_vertex_time_course(self): ind = np.unravel_index( np.argmax(np.abs(use_data), axis=None), use_data.shape ) + vertex_id = vertices[ind[0]] + self._peak_vertices[hemi] = vertex_id publish( self, - VertexSelect(hemi=hemi, vertex_id=vertices[ind[0]], source_id=ind[0]), + VertexSelect(hemi=hemi, vertex_id=vertex_id, source_id=ind[0]), ) def _configure_picking(self): @@ -1653,6 +1667,7 @@ def _remove_vertex_glyph(self, *, hemi, vertex_id, render=True): return color, line = spheres[0]["color"], spheres[0]["line"] line.remove() + self._trace_meta.pop(line, None) self.mpl_canvas.update_plot() with warnings.catch_warnings(record=True): @@ -1666,6 +1681,42 @@ def _remove_vertex_glyph(self, *, hemi, vertex_id, render=True): if render: self._renderer._update() + def _set_trace_visible(self, line, visible): + """Toggle a trace's 3D glyph visibility to match its plot visibility.""" + for spheres in self._picked_points.values(): + if spheres[0]["line"] is line: + for sphere in spheres: + sphere["actor"].SetVisibility(visible) + self._renderer._update() + return + + def _set_trace_highlight(self, line): + """Dim the 3D glyphs of every picked trace except the highlighted one.""" + if not self._picked_points: + return + for spheres in self._picked_points.values(): + opacity = 1.0 if line in (None, spheres[0]["line"]) else 0.3 + for sphere in spheres: + sphere["actor"].GetProperty().SetOpacity(opacity) + self._renderer._update() + + def _trace_display_label(self, line): + """Return a short, dock-friendly trace-list label. + + The vertex auto-picked at peak activation for each hemisphere gets a + "Peak (LH)"-style name; other picked vertices get a compact + "LH 1000"-style name instead of the full MNI-coordinate string (still + available as the row's tooltip). RMS curves are returned unchanged. + """ + meta = self._trace_meta.get(line) + if meta is None: + return line.get_label() + hemi, vertex_id = meta + hemi_names = {"lh": "LH", "rh": "RH", "vol": "Vol"} + if self._peak_vertices.get(hemi) == vertex_id: + return f"Peak ({hemi_names[hemi]})" + return f"{hemi_names[hemi]} {vertex_id}" + def clear_glyphs(self): """Clear the picking glyphs.""" if not self.time_viewer: @@ -1680,6 +1731,7 @@ def clear_glyphs(self): if self.rms is not None: for line in self.rms: line.remove() + self.color_cycle.restore(line.get_color()) self.rms = None self._renderer._update() @@ -1739,11 +1791,14 @@ def plot_time_course(self, hemi, vertex_id, color, update=True): time, act_data, label=label, - lw=1.0, + lw=2.4, color=color, zorder=4, - update=update, + update=False, ) + self._trace_meta[line] = (hemi, vertex_id) + if update: + self.mpl_canvas.update_plot() return line @fill_doc @@ -1764,7 +1819,9 @@ def plot_time_line(self, update=True): x=current_time, label="time", color=self._fg_color, - lw=1, + lw=1.5, + ls="--", + alpha=0.7, update=update, ) self.time_line.set_xdata([current_time]) diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 7cddbfcd4ef..044e4bb0277 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -1465,6 +1465,100 @@ def test_brain_traces_vertex( assert_allclose(img.shape[0], screenshot_all.shape[0], atol=1) +@testing.requires_testing_data +def test_brain_native_trace_list(renderer_interactive_pyvistaqt, brain_gc): + """Test the native Qt trace-list sidebar that replaces the mpl legend.""" + from qtpy.QtWidgets import QLabel + + brain = _create_testing_brain(hemi="lh", show_traces=True, initial_time=0) + canvas = brain.mpl_canvas + assert canvas._legend_in_figure is False + trace_list = canvas._trace_list + assert trace_list is not None + + def row_text(row): + return row.findChild(QLabel, "trace_label").text() + + rows = trace_list._rows_layout + row_lines = [rows.itemAt(i).widget()._line for i in range(rows.count())] + assert row_lines == [ + line for line in canvas.axes.get_lines() if line is not brain.time_line + ] + + # the auto-picked peak-activation vertex gets a friendly display label, + # distinct from the underlying matplotlib line label + peak_line = next( + ln for ln in row_lines if brain._trace_meta.get(ln, (None,))[0] == "lh" + ) + peak_row = rows.itemAt(row_lines.index(peak_line)).widget() + assert row_text(peak_row) == "Peak (LH)" + assert row_text(peak_row) != peak_line.get_label() + + # picking a new vertex should grow the sidebar to match, and the new + # row's displayed label must be correct immediately -- this guards + # against a real bug where the label lookup ran before the line was + # tagged with its hemi/vertex_id, showing the raw label for one redraw + picked = set(brain.get_picked_points()["lh"]) + n_verts = len(brain.geo["lh"].coords) + vertex_id = next(v for v in range(n_verts) if v not in picked) + ui_events.publish(brain, ui_events.VertexSelect(hemi="lh", vertex_id=vertex_id)) + assert rows.count() == len(row_lines) + 1 + row = rows.itemAt(rows.count() - 1).widget() + line = row._line + assert str(vertex_id) in line.get_label() + assert row_text(row) == f"LH {vertex_id}" + + # toggling a row hides the trace and its 3D glyph together, without + # rebuilding the row list (sync() must skip unchanged trace sets -- + # the whole point of the native list was to stop rebuilding on every + # update, which is what caused the original matplotlib-legend lag) + assert line.get_visible() + row._on_toggle() + assert not line.get_visible() + assert rows.itemAt(rows.count() - 1).widget() is row # not rebuilt + sphere = next(s[0] for s in brain._picked_points.values() if s[0]["line"] is line) + assert not sphere["actor"].GetVisibility() + row._on_toggle() + assert line.get_visible() + assert sphere["actor"].GetVisibility() + assert rows.itemAt(rows.count() - 1).widget() is row # still not rebuilt + + # hovering a row dims the other traces without disturbing the RMS + # curve's own (deliberately non-default) alpha + rms_line = next( + ln for ln in canvas.axes.get_lines() if ln.get_label().startswith("RMS") + ) + assert rms_line.get_alpha() == 0.5 + canvas.set_trace_highlight(line) + assert line.get_alpha() == 1.0 + assert rms_line.get_alpha() == 0.25 + canvas.set_trace_highlight(None) + assert rms_line.get_alpha() == 0.5 # restored, not clobbered to 1.0 + + # hovering a *hidden* trace must not dim its still-visible siblings + row._on_toggle() # hide it again + assert not line.get_visible() + canvas.set_trace_highlight(line) + assert rms_line.get_alpha() == 0.5 # untouched, not dimmed to 0.25 + row._on_toggle() + + # switching to Atlas/label mode and back to "None" must not shift trace + # colors -- regression: clear_glyphs() used to drop RMS lines without + # returning their color to brain.color_cycle, leaking a color (and + # shifting every subsequent one) on each round trip. Only RMS/peak are + # compared: the manually-added second pick above is legitimately not + # restored by a mode switch, only the auto-picked peak vertex is. + rms_colors = [ln.get_color() for ln in brain.rms] + peak_color = peak_line.get_color() + brain.widgets["annotation"].set_value("aparc") + brain.widgets["annotation"].set_value("None") + assert [ln.get_color() for ln in brain.rms] == rms_colors + new_peak_line = next(iter(brain._picked_points.values()))[0]["line"] + assert new_peak_line.get_color() == peak_color + + brain.close() + + def test_brain_traces_colormap(renderer_interactive_pyvistaqt, brain_gc): """Test colormap selection.""" brain = _create_testing_brain( diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 5634cd126e4..f86e4d47ca8 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -1435,16 +1435,38 @@ def update_plot(self): def set_color(self, bg_color, fg_color): """Set the widget colors.""" + from matplotlib.ticker import AutoMinorLocator + self.axes.set_facecolor(bg_color) + self.fig.patch.set_facecolor(bg_color) + + self.axes.spines["top"].set_visible(False) + self.axes.spines["right"].set_visible(False) + for side in ("bottom", "left"): + spine = self.axes.spines[side] + spine.set_color(fg_color) + spine.set_linewidth(2.0) + self.axes.xaxis.label.set_color(fg_color) self.axes.yaxis.label.set_color(fg_color) - self.axes.spines["top"].set_color(fg_color) - self.axes.spines["bottom"].set_color(fg_color) - self.axes.spines["left"].set_color(fg_color) - self.axes.spines["right"].set_color(fg_color) - self.axes.tick_params(axis="x", colors=fg_color) - self.axes.tick_params(axis="y", colors=fg_color) - self.fig.patch.set_facecolor(bg_color) + self.axes.xaxis.label.set_fontsize(14) + self.axes.yaxis.label.set_fontsize(14) + + self.axes.tick_params( + axis="both", + colors=fg_color, + labelsize=13, + length=6, + width=1.5, + direction="out", + ) + + self.axes.xaxis.set_minor_locator(AutoMinorLocator()) + self.axes.yaxis.set_minor_locator(AutoMinorLocator()) + self.axes.tick_params(which="minor", length=3, width=1.0, colors=fg_color) + self.axes.grid(which="major", color=fg_color, alpha=0.18, linewidth=0.9) + self.axes.grid(which="minor", color=fg_color, alpha=0.08, linewidth=0.6) + self.axes.set_axisbelow(True) def show(self): """Show the canvas.""" @@ -1471,23 +1493,65 @@ def on_resize(self, event): class _AbstractBrainMplCanvas(_AbstractMplCanvas): + _legend_in_figure = True + def __init__(self, brain, width, height, dpi): """Initialize the MplCanvas.""" super().__init__(width, height, dpi) self.brain = brain + self._hovered_line = None + self._trace_base_alpha = {} def update_plot(self): """Update the plot.""" - leg = self.axes.legend( - prop={"family": "monospace", "size": "small"}, - framealpha=0.5, - handlelength=1.0, - facecolor=self.brain._bg_color, - ) - for text in leg.get_texts(): - text.set_color(self.brain._fg_color) + if self._legend_in_figure: + leg = self.axes.legend( + prop={"family": "monospace", "size": "small"}, + framealpha=0.5, + handlelength=1.0, + facecolor=self.brain._bg_color, + ) + for text in leg.get_texts(): + text.set_color(self.brain._fg_color) + self.sync_traces() super().update_plot() + def sync_traces(self): + """Refresh a native trace-list widget; no-op unless a backend provides one.""" + + def set_trace_visible(self, line, visible): + """Toggle one trace's visibility, in the plot and on its 3D glyph.""" + line.set_visible(visible) + self.brain._set_trace_visible(line, visible) + self.update_plot() + + def set_trace_highlight(self, line): + """Highlight one trace (or none), dimming the plot's other traces.""" + if line is not None and not line.get_visible(): + line = None + if line is self._hovered_line: + return + time_line = getattr(self.brain, "time_line", None) + origlines = [ + origline + for origline in self.axes.get_lines() + if origline is not time_line and origline.get_visible() + ] + if self._hovered_line is None and line is not None: + self._trace_base_alpha = { + origline: origline.get_alpha() for origline in origlines + } + self._hovered_line = line + for origline in origlines: + if line is None: + origline.set_alpha(self._trace_base_alpha.get(origline)) + else: + origline.set_alpha(1.0 if origline is line else 0.25) + if line is None: + self._trace_base_alpha = {} + self.canvas.draw_idle() + self.brain._set_trace_highlight(line) + def on_button_press(self, event): """Handle button presses.""" # left click (and maybe drag) in progress in axes @@ -1501,6 +1565,8 @@ def clear(self): """Clear internal variables.""" super().clear() self.brain = None + self._hovered_line = None + self._trace_base_alpha = {} class _AbstractWindow(ABC): diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index a0ae27387ba..3bdd14e33f2 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -16,6 +16,7 @@ import pyvista from matplotlib.backends.backend_qtagg import FigureCanvas +from matplotlib.colors import to_hex from matplotlib.figure import Figure from pyvistaqt.plotting import FileDialog, MainWindow from qtpy.QtCore import ( @@ -23,12 +24,13 @@ QLibraryInfo, QLocale, QObject, + QSize, Qt, QTimer, # non-object-based-abstraction-only, remove Signal, ) -from qtpy.QtGui import QCursor, QGuiApplication, QIcon, QKeyEvent +from qtpy.QtGui import QCursor, QFont, QGuiApplication, QIcon, QKeyEvent from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, @@ -39,6 +41,8 @@ QDoubleSpinBox, QFileDialog, QFormLayout, + QFrame, + QGraphicsOpacityEffect, QGridLayout, QGroupBox, QHBoxLayout, @@ -1166,6 +1170,19 @@ def _toggle_visibility(checked, content=content, toggle=toggle, name=name): self._layout_add_widget(layout, widget) return hlayout + def _dock_add_trace_list(self, name, *, collapse=True, layout=None): + """Add a collapsible group box holding the live trace-visibility list. + + Unlike the other ``_dock_add_*`` widgets this isn't backed by a single + value, it mirrors ``self._mplcanvas``'s current traces and grows or + shrinks, so it's Qt-specific rather than part + of the cross-backend :class:`_AbstractDock` interface. + """ + group_layout = self._dock_add_group_box(name, collapse=collapse, layout=layout) + trace_list = _QtTraceList(self._mplcanvas) + self._layout_add_widget(group_layout, trace_list) + return trace_list + def _dock_add_text(self, name, value, placeholder, *, callback=None, layout=None): layout = self._dock_layout if layout is None else layout widget = QLineEdit(str(value)) @@ -1427,16 +1444,145 @@ def __init__(self, width, height, dpi): self._mpl_initialize() +class _QtTraceRow(QWidget): + """One row of the trace list: a color swatch, a label, a visibility toggle.""" + + def __init__(self, canvas, line): + super().__init__() + self._canvas = canvas + self._line = line + + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(8) + + swatch = QLabel() + swatch.setFixedSize(13, 13) + radius = 3 if line.get_label().startswith("RMS") else 6 + swatch.setStyleSheet( + f"background-color: {to_hex(line.get_color())}; border-radius: {radius}px;" + ) + layout.addWidget(swatch) + + brain = canvas.brain + text = QLabel(brain._trace_display_label(line) if brain else line.get_label()) + text.setObjectName("trace_label") + text.setStyleSheet("font-size: 12pt;") + text.setToolTip(line.get_label()) + text.setWordWrap(True) + layout.addWidget(text, 1) + + self._toggle = QToolButton() + self._toggle.setAutoRaise(True) + self._toggle.setIconSize(QSize(18, 18)) + self._toggle.setFixedSize(28, 28) + self._toggle.setCursor(Qt.PointingHandCursor) + self._toggle.setToolTip("Show/hide this trace") + self._toggle.setStyleSheet( + "QToolButton { border: none; border-radius: 4px; }" + "QToolButton:hover { background-color: palette(midlight); }" + ) + self._toggle.clicked.connect(self._on_toggle) + layout.addWidget(self._toggle) + + self._opacity = QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self._opacity) + self._sync_visibility() + + def _sync_visibility(self): + visible = self._line.get_visible() + self._toggle.setIcon(_qicon("visibility_on" if visible else "visibility_off")) + self._opacity.setOpacity(1.0 if visible else 0.45) + + def _on_toggle(self): + self._canvas.set_trace_visible(self._line, not self._line.get_visible()) + self._sync_visibility() + + def _repolish(self): + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def enterEvent(self, event): + """Highlight this trace when the row is hovered.""" + self.setStyleSheet("_QtTraceRow { background-color: palette(alternate-base); }") + self._repolish() + self._canvas.set_trace_highlight(self._line) + super().enterEvent(event) + + def leaveEvent(self, event): + """Clear the highlight when the mouse leaves the row.""" + self.setStyleSheet("") + self._repolish() + self._canvas.set_trace_highlight(None) + super().leaveEvent(event) + + +class _QtTraceList(QWidget): + """Live-updating list of the trace panel's traces, for the "Trace List" dock. + + A plain widget so it reads as part of the dock's + normal flow, matching the other collapsible sections, the side dock as + a whole already scrolls if its total content outgrows the window. + """ + + def __init__(self, canvas): + super().__init__() + self._canvas = canvas + self._rows_layout = QVBoxLayout(self) + self._rows_layout.setContentsMargins(0, 0, 0, 0) + self._rows_layout.setSpacing(2) + self._synced_lines = None + + def sync(self, lines): + """Rebuild the row list to match the canvas's current lines. + + A no-op unless the set of traces actually changed (added/removed), + called on every plot update, including once per time step during + playback, so a per-row visibility/color change must not pay for a + full rebuild here; rows refresh themselves directly instead. + """ + if lines == self._synced_lines: + return + self._synced_lines = list(lines) + while self._rows_layout.count(): + widget = self._rows_layout.takeAt(0).widget() + if widget is not None: + widget.deleteLater() + if not lines: + placeholder = QLabel( + "Set Atlas to None to see\nvertex and RMS traces here." + ) + placeholder.setStyleSheet( + "color: palette(disabled-text); font-style: italic; font-size: 9pt;" + ) + self._rows_layout.addWidget(placeholder) + return + for line in lines: + self._rows_layout.addWidget(_QtTraceRow(self._canvas, line)) + + class _QtBrainMplCanvas(_AbstractBrainMplCanvas, _QtMplInterface): + _legend_in_figure = False + def __init__(self, brain, width, height, dpi): super().__init__(brain, width, height, dpi) self._mpl_initialize() + self._trace_list = None if brain.separate_canvas: self.canvas.setParent(None) else: self.canvas.setParent(brain._renderer._window) self._connect() + def sync_traces(self): + """Refresh the trace-list dock widget with the canvas's current lines.""" + if self._trace_list is None: + return + time_line = getattr(self.brain, "time_line", None) + lines = [line for line in self.axes.get_lines() if line is not time_line] + self._trace_list.sync(lines) + class _QtHelpDialog(QDialog): """Non-modal dialog listing keyboard shortcuts. @@ -1894,19 +2040,34 @@ def _create_dock_widget(window, name, area, *, max_width=None): dock = QDockWidget(name) # add scroll area scroll = QScrollArea(dock) + scroll.setFrameShape(QFrame.NoFrame) dock.setWidget(scroll) # give the scroll area a child widget widget = QWidget(scroll) scroll.setWidget(widget) scroll.setWidgetResizable(True) dock.setAllowedAreas(area) - dock.setTitleBarWidget(QLabel(name)) + + title = QLabel(name.upper()) + title_font = title.font() + title_font.setBold(True) + title_font.setPointSize(max(title_font.pointSize() - 1, 8)) + title_font.setLetterSpacing(QFont.AbsoluteSpacing, 1.1) + title.setFont(title_font) + title.setStyleSheet( + "QLabel {" + " color: palette(mid);" + " padding: 7px 10px 6px 10px;" + " border-bottom: 1px solid palette(midlight);" + " }" + ) + dock.setTitleBarWidget(title) window.addDockWidget(area, dock) dock_layout = QVBoxLayout() widget.setLayout(dock_layout) # Fix resize grip size # https://stackoverflow.com/a/65050468/2175965 - styles = ["margin: 4px;"] + styles = ["margin: 4px;", "border: none;"] if max_width is not None: styles.append(f"max-width: {max_width};") style_sheet = "QDockWidget { " + " \n".join(styles) + "\n}" diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index 02c903f209d..2bc97a3e092 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -127,6 +127,9 @@ _.set_fmax _.set_fmid _.set_fmin +_._set_trace_visible +_._set_trace_highlight +_._trace_display_label _.EnterEvent _.MouseMoveEvent _.LeaveEvent