From 3015f641650849db67294070cd1e69f25cb676eb Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 14 Jul 2026 09:24:19 +0200 Subject: [PATCH 1/9] phase 1 of modernizing brain GUI, for details see the PR message --- doc/changes/dev/14046.newfeature.rst | 1 + mne/viz/_brain/_brain.py | 73 +++++++++++++++------------- mne/viz/_brain/tests/test_brain.py | 35 ++++++++++++- mne/viz/backends/_abstract.py | 7 ++- mne/viz/backends/_notebook.py | 15 +++++- mne/viz/backends/_pyvista.py | 18 ++++--- mne/viz/backends/_qt.py | 42 +++++++++++++++- mne/viz/utils.py | 35 +++++++++++++ 8 files changed, 179 insertions(+), 47 deletions(-) create mode 100644 doc/changes/dev/14046.newfeature.rst diff --git a/doc/changes/dev/14046.newfeature.rst b/doc/changes/dev/14046.newfeature.rst new file mode 100644 index 00000000000..8744bb6471c --- /dev/null +++ b/doc/changes/dev/14046.newfeature.rst @@ -0,0 +1 @@ +The colorbar in the :class:`mne.viz.Brain` viewer now automatically switches to scientific notation for tick labels, and has more tick-label spacing. The Help dialog now uses a Qt dialog instead of a ``matplotlib`` popup, and the "Press ? for help" status bar message is now clickable, and "Help" text was removed from the top left of the GUI by `Payam Sadeghi-Shabestari`_. diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index fbb369e9064..498fef14775 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -83,7 +83,6 @@ _generate_default_filename, _get_color_list, _save_ndarray_img, - _show_help_fig, concatenate_images, safe_event, ) @@ -139,8 +138,10 @@ class Brain: size : int | array-like, shape (2,) The size of the window, in pixels. can be one number to specify a square window, or a length-2 sequence to specify (width, height). - background : tuple(int, int, int) - The color definition of the background: (red, green, blue). + background : matplotlib color + The color of the background, e.g. ``"black"`` (default), ``"w"``, a + hex string such as ``"#000000"``, or an RGB tuple of floats between + 0 and 1, e.g. ``(0, 0, 0)``. foreground : matplotlib color Color of the foreground (will be used for colorbars and text). None (default) will use black or white depending on the value @@ -625,7 +626,6 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): self._configure_picking() self._configure_dock() self._configure_tool_bar() - self._configure_menu() self._configure_status_bar() self._configure_help() # show everything at the end @@ -1277,23 +1277,10 @@ def _configure_shortcuts(self): self.plotter.clear_events_for_key(key) self.plotter.add_key_event(key, partial(self._rotate_camera, which, amt)) - def _configure_menu(self): - self._renderer._menu_initialize() - self._renderer._menu_add_submenu( - name="help", - desc="Help", - ) - self._renderer._menu_add_button( - menu_name="help", - name="help", - desc="Show MNE key bindings\t?", - func=self.help, - ) - def _configure_status_bar(self): self._renderer._status_bar_initialize() self.status_msg = self._renderer._status_bar_add_label( - self.default_status_bar_msg, stretch=1 + self.default_status_bar_msg, stretch=1, on_click=self.help ) self.status_progress = self._renderer._status_bar_add_progress_bar() if self.status_progress is not None: @@ -1677,19 +1664,7 @@ def _configure_help(self): ("Left", "Decrease camera azimuth angle"), ("Right", "Increase camera azimuth angle"), ] - text1, text2 = zip(*pairs) - text1 = "\n".join(text1) - text2 = "\n".join(text2) - self.help_canvas = self._renderer._window_get_simple_canvas( - width=5, height=2, dpi=80 - ) - _show_help_fig( - col1=text1, - col2=text2, - fig_help=self.help_canvas.fig, - ax=self.help_canvas.axes, - show=False, - ) + self.help_canvas = self._renderer._window_get_help_canvas(pairs) def help(self): """Display the help window.""" @@ -1885,8 +1860,12 @@ def add_data( Original clim arguments. %(src_volume_options)s colorbar_kwargs : dict | None - Options to pass to ``pyvista.Plotter.add_scalar_bar`` - (e.g., ``dict(title_font_size=10)``). + Options to pass to :meth:`pyvista.Plotter.add_scalar_bar`, for + example ``dict(label_font_size=10)``. By default a ``fmt`` + tick-label format is chosen automatically based on the data + range. Other options that are often useful include ``n_labels`` + (number of ticks), ``label_font_size``, ``width``/``height`` + (as a fraction of the window), and ``position_x``/``position_y``. key : str Key used to identify this data overlay in ``Brain.layered_meshes``. Defaults to ``"data"``. When multiple @@ -2026,6 +2005,7 @@ def add_data( self._all_data[key]["fmin"] = fmin self._all_data[key]["fmid"] = fmid self._all_data[key]["fmax"] = fmax + self._all_data[key]["colorbar_fmt"] = (colorbar_kwargs or {}).get("fmt") self.set_time_interpolation(self.time_interpolation) self._update_colormap_range() @@ -2077,6 +2057,7 @@ def add_data( n_labels=8, color=self._fg_color, bgcolor=self._brain_color[:3], + fmt=_auto_scalar_bar_fmt(self._cmap_range), ) kwargs.update(colorbar_kwargs or {}) self._scalar_bar = self._renderer.scalarbar(**kwargs) @@ -3588,6 +3569,7 @@ def _update_colormap_range(self, fmin=None, fmid=None, fmax=None, alpha=None): # update our values rng = self._cmap_range ctable = self._data["ctable"] + fmt = self._data["colorbar_fmt"] or _auto_scalar_bar_fmt(rng) for hemi in ["lh", "rh", "vol"]: hemi_data = self._data.get(hemi) if hemi_data is not None: @@ -3600,7 +3582,12 @@ def _update_colormap_range(self, fmin=None, fmid=None, fmax=None, alpha=None): rng=rng, ) self._renderer._set_colormap_range( - mesh._actor, ctable, self._scalar_bar, rng, self._brain_color + mesh._actor, + ctable, + self._scalar_bar, + rng, + self._brain_color, + fmt=fmt, ) grid_volume_pos = hemi_data.get("grid_volume_pos") @@ -3613,13 +3600,14 @@ def _update_colormap_range(self, fmin=None, fmid=None, fmax=None, alpha=None): hemi_data["alpha"], self._scalar_bar, rng, + fmt=fmt, ) glyph_actor = hemi_data.get("glyph_actor") if glyph_actor is not None: for glyph_actor_ in glyph_actor: self._renderer._set_colormap_range( - glyph_actor_, ctable, self._scalar_bar, rng + glyph_actor_, ctable, self._scalar_bar, rng, fmt=fmt ) self._renderer._update() @@ -4381,6 +4369,21 @@ def _update_monotonic(lims, fmin, fmid, fmax): assert lims["fmin"] <= lims["fmid"] <= lims["fmax"] +def _auto_scalar_bar_fmt(rng): + """Choose a scalar bar tick label format based on the data magnitude. + + Neural data commonly spans many orders of magnitude (e.g. ~1e-10 A·m + dipole moments vs. ~1-10 dSPM/t-values), so a fixed-point format either + prints unreadable strings of zeros or rounds everything to 0. Switch to + scientific notation once the range falls outside what is comfortably + readable in fixed-point. + """ + abs_max = max(abs(rng[0]), abs(rng[1])) + if abs_max != 0 and not (1e-2 <= abs_max < 1e5): + return "%.2e" + return "%.3g" + + def _get_range(brain): """Get the data limits. diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 828348bfffe..27057abf782 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -40,6 +40,7 @@ from mne.utils import catch_logging, check_version from mne.viz import ui_events from mne.viz._brain import Brain, LayeredMesh, _BrainScraper, _LinkViewer +from mne.viz._brain._brain import _auto_scalar_bar_fmt from mne.viz._brain.colormap import calculate_lut from mne.viz.utils import _get_cmap @@ -391,6 +392,8 @@ def __init__(self): vertices=hemi_vertices, ) assert len(brain._actors["data"]) == 4 + assert not brain._scalar_bar.GetTitle() + assert brain._scalar_bar.GetLabelFormat() == _auto_scalar_bar_fmt([fmin, fmax]) # exercise the public LayeredMesh API via Brain.layered_meshes assert "lh" in brain.layered_meshes lm = brain.layered_meshes["lh"] @@ -417,6 +420,21 @@ def __init__(self): assert "data" in brain._all_data and "overlay2" in brain._all_data assert "data" in lm._overlays and "overlay2" in lm._overlays assert brain._active_data_key == "overlay2" + assert brain._all_data["overlay2"]["colorbar_fmt"] is None + brain.add_data( + hemi_data, + fmin=fmin, + hemi="lh", + fmax=fmax, + colormap="Blues", + vertices=hemi_vertices, + smoothing_steps="nearest", + colorbar=False, + key="overlay3", + remove_existing=False, + colorbar_kwargs=dict(fmt="%.1f"), + ) + assert brain._all_data["overlay3"]["colorbar_fmt"] == "%.1f" brain.remove_data() assert "data" not in brain._actors assert "time_change" not in ui_events._get_event_channel(brain) @@ -1036,9 +1054,13 @@ def test_brain_time_viewer(renderer_interactive_pyvistaqt, pixel_ratio, brain_gc brain.reset() assert brain.help_canvas is not None - assert not brain.help_canvas.canvas.isVisible() + assert not brain.help_canvas.isVisible() brain.help() - assert brain.help_canvas.canvas.isVisible() + assert brain.help_canvas.isVisible() + brain.help_canvas.hide() + assert not brain.help_canvas.isVisible() + brain.status_msg.widget.mousePressEvent(None) + assert brain.help_canvas.isVisible() # screenshot # Need to turn the interface back on otherwise the window is too wide @@ -1531,6 +1553,15 @@ def test_calculate_lut(): calculate_lut(colormap, alpha, 1, 0, 2) +def test_auto_scalar_bar_fmt(): + """Test the automatic scalar bar tick-label format.""" + assert _auto_scalar_bar_fmt([0, 1]) == "%.3g" + assert _auto_scalar_bar_fmt([-5, 5]) == "%.3g" + assert _auto_scalar_bar_fmt([0, 0]) == "%.3g" + assert _auto_scalar_bar_fmt([0, 4.2e-10]) == "%.2e" + assert _auto_scalar_bar_fmt([-1e6, 1e6]) == "%.2e" + + def test_brain_ui_events(renderer_interactive_pyvistaqt, brain_gc): """Test responding to Brain related UI events.""" brain = _create_testing_brain(hemi="lh", show_traces="vertex") diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 2c4ad2e7174..d46cca185aa 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -1277,7 +1277,7 @@ def _status_bar_initialize(self, window=None): pass @abstractmethod - def _status_bar_add_label(self, value, *, stretch=0): + def _status_bar_add_label(self, value, *, stretch=0, on_click=None): pass @abstractmethod @@ -1584,6 +1584,11 @@ def _window_get_mplcanvas_size(self, fraction): def _window_get_simple_canvas(self, width, height, dpi): pass + @abstractmethod + def _window_get_help_canvas(self, pairs): + """Return a widget listing ``(key, description)`` keyboard-shortcut pairs.""" + pass + @abstractmethod def _window_get_mplcanvas( self, brain, interactor_fraction, show_traces, separate_canvas diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index 1771203b823..b5ceb76aa61 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -37,6 +37,7 @@ ) from ...utils import _soft_import +from ..utils import _show_help_fig from ._abstract import ( _AbstractAction, _AbstractAppWindow, @@ -1311,7 +1312,7 @@ def _status_bar_initialize(self, window=None): self._status_bar = HBox() self._layout_initialize(None) - def _status_bar_add_label(self, value, *, stretch=0): + def _status_bar_add_label(self, value, *, stretch=0, on_click=None): widget = Text(value=value, disabled=True) self._layout_add_widget(self._status_bar, widget) return _IpyWidget(widget) @@ -1393,6 +1394,18 @@ def _window_get_size(self): def _window_get_simple_canvas(self, width, height, dpi): return _IpyMplCanvas(width, height, dpi) + def _window_get_help_canvas(self, pairs): + text1, text2 = zip(*pairs) + canvas = self._window_get_simple_canvas(width=5, height=2, dpi=80) + _show_help_fig( + col1="\n".join(text1), + col2="\n".join(text2), + fig_help=canvas.fig, + ax=canvas.axes, + show=False, + ) + return canvas + def _window_get_mplcanvas( self, brain, interactor_fraction, show_traces, separate_canvas ): diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index d020f468e5a..7d3a1e39144 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -799,7 +799,6 @@ def scalarbar( self, source, color="white", - title=None, n_labels=4, bgcolor=None, **extra_kwargs, @@ -812,22 +811,25 @@ def scalarbar( mapper = None kwargs = dict( color=color, - title=title, + title="", n_labels=n_labels, use_opacity=False, n_colors=256, position_x=0.15, position_y=0.05, width=0.7, + height=0.10, shadow=False, - bold=True, - label_font_size=22, + bold=False, + label_font_size=16, font_family=self.font_family, background_color=bgcolor, mapper=mapper, ) + extra_kwargs.pop("title", None) kwargs.update(extra_kwargs) actor = self.plotter.add_scalar_bar(**kwargs) + actor.SetTextPad(10) _hide_testing_actor(actor) return actor @@ -932,7 +934,7 @@ def _update_picking_callback( self._picker.SetVolumeOpacityIsovalue(0.0) def _set_colormap_range( - self, actor, ctable, scalar_bar, rng=None, background_color=None + self, actor, ctable, scalar_bar, rng=None, background_color=None, fmt=None ): if rng is not None: mapper = actor.GetMapper() @@ -946,8 +948,10 @@ def _set_colormap_range( ctable = _alpha_blend_background(ctable, background_color) lut.SetTable(numpy_to_vtk(ctable, array_type=VTK_UNSIGNED_CHAR)) lut.SetRange(*rng) + if fmt is not None: + scalar_bar.SetLabelFormat(fmt) - def _set_volume_range(self, volume, ctable, alpha, scalar_bar, rng): + def _set_volume_range(self, volume, ctable, alpha, scalar_bar, rng, fmt=None): color_tf = vtkColorTransferFunction() opacity_tf = vtkPiecewiseFunction() for loc, color in zip(np.linspace(*rng, num=len(ctable)), ctable): @@ -963,6 +967,8 @@ def _set_volume_range(self, volume, ctable, alpha, scalar_bar, rng): lut.SetRange(*rng) lut.SetTable(numpy_to_vtk(ctable)) scalar_bar.SetLookupTable(lut) + if fmt is not None: + scalar_bar.SetLabelFormat(fmt) def _sphere(self, center, color, radius): mesh = pyvista.Sphere( diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 1bbbb2fdc39..3288bcda334 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -33,10 +33,13 @@ QButtonGroup, QCheckBox, QComboBox, + QDialog, + QDialogButtonBox, # non-object-based-abstraction-only, remove QDockWidget, QDoubleSpinBox, QFileDialog, + QFrame, QGridLayout, QGroupBox, QHBoxLayout, @@ -54,6 +57,7 @@ QSpinBox, QStyle, QStyleOptionSlider, + QTextBrowser, QToolButton, QVBoxLayout, QWidget, @@ -61,7 +65,7 @@ from ...fixes import _compare_version from ...utils import _check_option, get_config -from ..utils import safe_event +from ..utils import _pairs_to_html, safe_event from ._abstract import ( _AbstractAction, _AbstractAppWindow, @@ -1400,8 +1404,17 @@ def _status_bar_initialize(self, window=None): window = self._window if window is None else window self._status_bar = window.statusBar() - def _status_bar_add_label(self, value, *, stretch=0): + def _status_bar_add_label(self, value, *, stretch=0, on_click=None): widget = QLabel(value) + if on_click is not None: + widget.setCursor(QCursor(Qt.PointingHandCursor)) + widget.setToolTip("Click for help") + + @safe_event + def mousePressEvent(event): + on_click() + + widget.mousePressEvent = mousePressEvent self._layout_add_widget(self._status_bar.layout(), widget, stretch) return _QtWidget(widget) @@ -1450,6 +1463,28 @@ def __init__(self, brain, width, height, dpi): self._connect() +class _QtHelpDialog(QDialog): + """Non-modal dialog listing keyboard shortcuts. + + Uses native Qt widgets so the text picks up the OS font, + DPI scaling, and light/dark theme automatically. + """ + + def __init__(self, pairs, parent=None): + super().__init__(parent) + self.setWindowTitle("MNE Key Bindings") + browser = QTextBrowser(self) + browser.setFrameShape(QFrame.NoFrame) + browser.setOpenExternalLinks(False) + browser.setHtml(_pairs_to_html(pairs)) + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.close) + layout = QVBoxLayout(self) + layout.addWidget(browser) + layout.addWidget(buttons) + self.resize(460, 360) + + class _QtWindow(_AbstractWindow): def _window_initialize(self, *, window=None, central_layout=None, fullscreen=False): super()._window_initialize() @@ -1544,6 +1579,9 @@ def _window_get_size(self): def _window_get_simple_canvas(self, width, height, dpi): return _QtMplCanvas(width, height, dpi) + def _window_get_help_canvas(self, pairs): + return _QtHelpDialog(pairs, parent=self._window) + def _window_get_mplcanvas( self, brain, interactor_fraction, show_traces, separate_canvas ): diff --git a/mne/viz/utils.py b/mne/viz/utils.py index 7cfb117b823..aab94fc7239 100644 --- a/mne/viz/utils.py +++ b/mne/viz/utils.py @@ -675,6 +675,41 @@ def _show_help_fig(col1, col2, fig_help, ax, show): pass +def _pairs_to_html(pairs): + """Format ``(key, description)`` pairs as an HTML definition table. + + Colors are set via the Qt-specific ``palette()`` CSS function (rather + than fixed colors) so the result adapts to the app's light/dark theme. + """ + from html import escape + + # Note: the key "badge" background/padding is set on the itself + # rather than on a nested . Qt's rich-text engine does not clip + # inline-element backgrounds properly, which left a stray grey box + # bleeding behind neighboring glyphs when the background lived on a + # instead. + rows = "".join( + "" + "" + "" + # a concrete font stack (rather than the bare generic "monospace") + # avoids Qt's one-time "Populating font family aliases" console + # warning when it resolves the generic family to a real one + '" + "
{escape(key)}
" + "" + "{escape(desc)}" + "" + for key, desc in pairs + ) + return f"{rows}
" + + def _key_press(event): """Handle key press in dialog.""" import matplotlib.pyplot as plt From c11c80d0fcea2609f80667f1f35d01d962ad6a36 Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 14 Jul 2026 09:57:24 +0200 Subject: [PATCH 2/9] fix vulture for help-dialog helpers --- tools/vulture_allowlist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index 563525c6c95..c95813e709e 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -168,3 +168,5 @@ _qt_raise_window _qt_disable_paint _qt_get_stylesheet +_show_help_fig +_pairs_to_html From 1bbfe5df7051fcc8d8f5af65fa99b5fae745d93b Mon Sep 17 00:00:00 2001 From: payam Date: Wed, 15 Jul 2026 12:52:06 +0200 Subject: [PATCH 3/9] switch the style to (almost) match mne-qt-browser --- ...46.newfeature.rst => 14054.newfeature.rst} | 0 mne/viz/_brain/_brain.py | 7 ++- mne/viz/backends/_abstract.py | 8 ++- mne/viz/backends/_notebook.py | 4 +- mne/viz/backends/_qt.py | 63 +++++++++++++------ 5 files changed, 59 insertions(+), 23 deletions(-) rename doc/changes/dev/{14046.newfeature.rst => 14054.newfeature.rst} (100%) diff --git a/doc/changes/dev/14046.newfeature.rst b/doc/changes/dev/14054.newfeature.rst similarity index 100% rename from doc/changes/dev/14046.newfeature.rst rename to doc/changes/dev/14054.newfeature.rst diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 498fef14775..677268d626d 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -1664,7 +1664,12 @@ def _configure_help(self): ("Left", "Decrease camera azimuth angle"), ("Right", "Increase camera azimuth angle"), ] - self.help_canvas = self._renderer._window_get_help_canvas(pairs) + mouse_pairs = [ + ("Left-click-and-drag", "Rotate the view"), + ("Middle-click-and-drag", "Pan the view"), + ("Right-click-and-drag / scroll", "Zoom the view"), + ] + self.help_canvas = self._renderer._window_get_help_canvas(pairs, mouse_pairs) def help(self): """Display the help window.""" diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index d46cca185aa..4e88b47a8ac 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -1585,8 +1585,12 @@ def _window_get_simple_canvas(self, width, height, dpi): pass @abstractmethod - def _window_get_help_canvas(self, pairs): - """Return a widget listing ``(key, description)`` keyboard-shortcut pairs.""" + def _window_get_help_canvas(self, pairs, mouse_pairs=None): + """Return a widget listing ``(key, description)`` pairs. + + ``pairs`` are keyboard shortcuts; ``mouse_pairs``, if given, are + ``(action, description)`` pairs shown in a separate section. + """ pass @abstractmethod diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index b5ceb76aa61..badd15ef64a 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -1394,8 +1394,8 @@ def _window_get_size(self): def _window_get_simple_canvas(self, width, height, dpi): return _IpyMplCanvas(width, height, dpi) - def _window_get_help_canvas(self, pairs): - text1, text2 = zip(*pairs) + def _window_get_help_canvas(self, pairs, mouse_pairs=None): + text1, text2 = zip(*pairs, *(mouse_pairs or [])) canvas = self._window_get_simple_canvas(width=5, height=2, dpi=80) _show_help_fig( col1="\n".join(text1), diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 3288bcda334..013c6bcc9e0 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -34,12 +34,11 @@ QCheckBox, QComboBox, QDialog, - QDialogButtonBox, # non-object-based-abstraction-only, remove QDockWidget, QDoubleSpinBox, QFileDialog, - QFrame, + QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, @@ -57,7 +56,6 @@ QSpinBox, QStyle, QStyleOptionSlider, - QTextBrowser, QToolButton, QVBoxLayout, QWidget, @@ -65,7 +63,7 @@ from ...fixes import _compare_version from ...utils import _check_option, get_config -from ..utils import _pairs_to_html, safe_event +from ..utils import safe_event from ._abstract import ( _AbstractAction, _AbstractAppWindow, @@ -1466,23 +1464,52 @@ def __init__(self, brain, width, height, dpi): class _QtHelpDialog(QDialog): """Non-modal dialog listing keyboard shortcuts. - Uses native Qt widgets so the text picks up the OS font, - DPI scaling, and light/dark theme automatically. + Styled after :class:`mne_qt_browser._dialogs.HelpDialog` (a bold + section header plus a plain :class:`~qtpy.QtWidgets.QFormLayout` in a + scroll area, no button box) so MNE-Python's Qt-based viewers share a + consistent help-dialog look. """ - def __init__(self, pairs, parent=None): + def __init__(self, pairs, mouse_pairs=None, parent=None): super().__init__(parent) self.setWindowTitle("MNE Key Bindings") - browser = QTextBrowser(self) - browser.setFrameShape(QFrame.NoFrame) - browser.setOpenExternalLinks(False) - browser.setHtml(_pairs_to_html(pairs)) - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(self.close) layout = QVBoxLayout(self) - layout.addWidget(browser) - layout.addWidget(buttons) - self.resize(460, 360) + + scroll_area = QScrollArea(self) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll_area.setWidgetResizable(True) + scroll_widget = QWidget() + scroll_layout = QVBoxLayout(scroll_widget) + self._add_help_section(scroll_layout, "Keyboard Shortcuts", pairs) + if mouse_pairs: + self._add_help_section( + scroll_layout, "Mouse Interaction", mouse_pairs, suffix=":" + ) + scroll_area.setWidget(scroll_widget) + layout.addWidget(scroll_area) + + # avoid clipping/horizontal scrolling of the longer rows + scroll_area.setMinimumWidth( + scroll_widget.minimumSizeHint().width() + + scroll_area.verticalScrollBar().width() + ) + self.resize(scroll_area.minimumWidth() + 40, 420) + + @staticmethod + def _add_help_section(layout, title, pairs, suffix=""): + header = QLabel(title) + font = header.font() + font.setPointSize(16) + font.setBold(True) + header.setFont(font) + layout.addWidget(header) + + form_layout = QFormLayout() + form_layout.setLabelAlignment(Qt.AlignLeft) + form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignTop) + for key, description in pairs: + form_layout.addRow(f"{key}{suffix}", QLabel(description)) + layout.addLayout(form_layout) class _QtWindow(_AbstractWindow): @@ -1579,8 +1606,8 @@ def _window_get_size(self): def _window_get_simple_canvas(self, width, height, dpi): return _QtMplCanvas(width, height, dpi) - def _window_get_help_canvas(self, pairs): - return _QtHelpDialog(pairs, parent=self._window) + def _window_get_help_canvas(self, pairs, mouse_pairs=None): + return _QtHelpDialog(pairs, mouse_pairs=mouse_pairs, parent=self._window) def _window_get_mplcanvas( self, brain, interactor_fraction, show_traces, separate_canvas From 3f50072a302f31aa32a8134de0f81f243caa7d61 Mon Sep 17 00:00:00 2001 From: payam Date: Wed, 15 Jul 2026 12:56:04 +0200 Subject: [PATCH 4/9] switch the style to (almost) match mne-qt-browser --- doc/changes/dev/14054.newfeature.rst | 2 +- mne/viz/utils.py | 35 ---------------------------- tools/vulture_allowlist.py | 1 - 3 files changed, 1 insertion(+), 37 deletions(-) diff --git a/doc/changes/dev/14054.newfeature.rst b/doc/changes/dev/14054.newfeature.rst index 8744bb6471c..9e071d068bf 100644 --- a/doc/changes/dev/14054.newfeature.rst +++ b/doc/changes/dev/14054.newfeature.rst @@ -1 +1 @@ -The colorbar in the :class:`mne.viz.Brain` viewer now automatically switches to scientific notation for tick labels, and has more tick-label spacing. The Help dialog now uses a Qt dialog instead of a ``matplotlib`` popup, and the "Press ? for help" status bar message is now clickable, and "Help" text was removed from the top left of the GUI by `Payam Sadeghi-Shabestari`_. +The colorbar in the :class:`mne.viz.Brain` viewer now automatically switches to scientific notation for tick labels, and has more tick-label spacing. The Help dialog now uses a Qt dialog styled to match ``mne-qt-browser``'s help dialog instead of a ``matplotlib`` popup, and the "Press ? for help" status bar message is now clickable, and "Help" text was removed from the top left of the GUI by `Payam Sadeghi-Shabestari`_. diff --git a/mne/viz/utils.py b/mne/viz/utils.py index aab94fc7239..7cfb117b823 100644 --- a/mne/viz/utils.py +++ b/mne/viz/utils.py @@ -675,41 +675,6 @@ def _show_help_fig(col1, col2, fig_help, ax, show): pass -def _pairs_to_html(pairs): - """Format ``(key, description)`` pairs as an HTML definition table. - - Colors are set via the Qt-specific ``palette()`` CSS function (rather - than fixed colors) so the result adapts to the app's light/dark theme. - """ - from html import escape - - # Note: the key "badge" background/padding is set on the itself - # rather than on a nested . Qt's rich-text engine does not clip - # inline-element backgrounds properly, which left a stray grey box - # bleeding behind neighboring glyphs when the background lived on a - # instead. - rows = "".join( - "" - "" - "" - # a concrete font stack (rather than the bare generic "monospace") - # avoids Qt's one-time "Populating font family aliases" console - # warning when it resolves the generic family to a real one - '" - "
{escape(key)}
" - "" - "{escape(desc)}" - "" - for key, desc in pairs - ) - return f"{rows}
" - - def _key_press(event): """Handle key press in dialog.""" import matplotlib.pyplot as plt diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index c95813e709e..7a40acdaf52 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -169,4 +169,3 @@ _qt_disable_paint _qt_get_stylesheet _show_help_fig -_pairs_to_html From 6aea15319b007d785c78471de5994f844daa4e09 Mon Sep 17 00:00:00 2001 From: payam Date: Fri, 17 Jul 2026 13:34:38 +0200 Subject: [PATCH 5/9] ticks + colorbar title + mouse hover loc --- mne/icons/dark/actions/information.svg | 1 + mne/icons/light/actions/information.svg | 1 + mne/viz/_brain/_brain.py | 97 ++++++++++++++++++++++++- mne/viz/_brain/tests/test_brain.py | 62 ++++++++++++++++ mne/viz/backends/_abstract.py | 7 ++ mne/viz/backends/_pyvista.py | 58 ++++++++++++++- mne/viz/backends/_qt.py | 1 + 7 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 mne/icons/dark/actions/information.svg create mode 100644 mne/icons/light/actions/information.svg diff --git a/mne/icons/dark/actions/information.svg b/mne/icons/dark/actions/information.svg new file mode 100644 index 00000000000..9618932d7cd --- /dev/null +++ b/mne/icons/dark/actions/information.svg @@ -0,0 +1 @@ + diff --git a/mne/icons/light/actions/information.svg b/mne/icons/light/actions/information.svg new file mode 100644 index 00000000000..bd40cd613eb --- /dev/null +++ b/mne/icons/light/actions/information.svg @@ -0,0 +1 @@ + diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 677268d626d..9b4b43d73d0 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -398,6 +398,7 @@ def __init__( else: self.silhouette = silhouette self._scalar_bar = None + self._scalar_bar_ticks = None # for now only one time label can be added # since it is the same for all figures self._time_label_added = False @@ -589,6 +590,8 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): self._picked_patches = {key: list() for key in all_keys} self._picked_points = dict() self._mouse_no_mvt = -1 + self._show_hover_info = False + self._hover_caption = None # Derived parameters: self.playback_speed = self.default_playback_speed_value @@ -624,6 +627,7 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): self._configure_scalar_bar() self._configure_shortcuts() self._configure_picking() + self._configure_hover() self._configure_dock() self._configure_tool_bar() self._configure_status_bar() @@ -685,6 +689,8 @@ def _clean(self): "picked_renderer", "act_data_smooth", "_scalar_bar", + "_scalar_bar_ticks", + "_hover_caption", "actions", "widgets", "geo", @@ -773,6 +779,8 @@ def _configure_scalar_bar(self): self._scalar_bar.SetHeight(0.6) self._scalar_bar.SetWidth(0.05) self._scalar_bar.SetPosition(0.02, 0.2) + # the tick actor repositions itself on every render (see + # _add_scalarbar_ticks), so no explicit update is needed here def _configure_dock_playback_widget(self, name): len_time = len(self._data["time"]) - 1 @@ -1196,6 +1204,74 @@ def _configure_picking(self): ) subscribe(self, "vertex_select", self._on_vertex_select) + def _configure_hover(self): + self._hover_caption = self._create_caption() + self.plotter.add_actor( + self._hover_caption, + name=None, + culling=False, + pickable=False, + reset_camera=False, + render=False, + ) + + @_auto_weakref + def on_surface_hover(iren, event): + self._on_surface_hover(iren, event) + + self.plotter.AddObserver("MouseMoveEvent", on_surface_hover) + + def _on_surface_hover(self, iren, event): # event == "MouseMoveEvent" + if not self._show_hover_info: + return + from pyvista import DataSetMapper + + x, y = iren.GetEventPosition() + picked_renderer = iren.FindPokedRenderer(x, y) + vtk_picker = self._renderer._picker + vtk_picker.Pick(x, y, 0, picked_renderer) + cell_id = vtk_picker.GetCellId() + mapper = vtk_picker.GetMapper() + if not isinstance(mapper, DataSetMapper) or cell_id == -1: + if self._hover_caption.GetVisibility(): + self._hover_caption.SetVisibility(False) + self._renderer._update() + return # didn't find a mesh + for _, this_mesh in self.layered_meshes.items(): + if this_mesh._polydata is mapper.dataset: + mesh = this_mesh._polydata + break + else: + return + pos = np.array(vtk_picker.GetPickPosition()) + vtk_cell = mesh.GetCell(cell_id) + cell = [ + vtk_cell.GetPointId(point_id) + for point_id in range(vtk_cell.GetNumberOfPoints()) + ] + vert_pos = mesh.points[cell] + vertex_id = cell[np.argmin(np.linalg.norm(vert_pos - pos, axis=1))] + _, _, azimuth, elevation, _ = self._renderer.get_camera(rigid=self._rigid) + text = ( + f"vertex {vertex_id}\n" + f"({pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f}) mm\n" + f"az {azimuth:.0f}\N{DEGREE SIGN} el {elevation:.0f}\N{DEGREE SIGN}" + ) + self._hover_caption.SetCaption(text) + self._hover_caption.SetAttachmentPoint(*pos) + self._hover_caption.SetVisibility(True) + actor = self._hover_caption.GetTextActor() + wh = np.zeros(2) + actor.GetSize(self.plotter.renderer, wh) + self._hover_caption.SetPosition2(wh) + self._renderer._update() + + def _toggle_hover_info(self): + self._show_hover_info = not self._show_hover_info + if not self._show_hover_info and self._hover_caption is not None: + self._hover_caption.SetVisibility(False) + self._renderer._update() + def _configure_tool_bar(self): if not hasattr(self._renderer, "_tool_bar") or self._renderer._tool_bar is None: self._renderer._tool_bar_initialize(name="Toolbar") @@ -1238,6 +1314,12 @@ def save_movie(filename): desc="Clear traces", func=self.clear_glyphs, ) + self._renderer._tool_bar_add_button( + name="hover_info", + desc="Toggle vertex/camera hover info", + func=self._toggle_hover_info, + icon_name="information", + ) self._renderer._tool_bar_add_spacer() self._renderer._tool_bar_add_button( name="help", @@ -1261,13 +1343,14 @@ def _rotate_camera(self, which, value): def _configure_shortcuts(self): # Remove the default key binding - if getattr(self, "iren", None) is not None: + if getattr(self.plotter, "iren", None) is not None: self.plotter.iren.clear_key_event_callbacks() # Then, we add our own: self.plotter.add_key_event("i", self.toggle_interface) self.plotter.add_key_event("s", self.apply_auto_scaling) self.plotter.add_key_event("r", self.restore_user_scaling) self.plotter.add_key_event("c", self.clear_glyphs) + self.plotter.add_key_event("v", self._toggle_hover_info) for key, which, amt in ( ("Left", "azimuth", 10), ("Right", "azimuth", -10), @@ -1656,6 +1739,7 @@ def _configure_help(self): ("s", "Apply auto-scaling"), ("r", "Restore original clim"), ("c", "Clear all traces"), + ("v", "Toggle vertex/camera hover info"), ("n", "Shift the time forward by the playback speed"), ("b", "Shift the time backward by the playback speed"), ("Space", "Start/Pause playback"), @@ -2011,6 +2095,9 @@ def add_data( self._all_data[key]["fmid"] = fmid self._all_data[key]["fmax"] = fmax self._all_data[key]["colorbar_fmt"] = (colorbar_kwargs or {}).get("fmt") + self._all_data[key]["colorbar_title"] = (colorbar_kwargs or {}).get( + "title", key if key != "data" else None + ) self.set_time_interpolation(self.time_interpolation) self._update_colormap_range() @@ -2065,7 +2152,9 @@ def add_data( fmt=_auto_scalar_bar_fmt(self._cmap_range), ) kwargs.update(colorbar_kwargs or {}) - self._scalar_bar = self._renderer.scalarbar(**kwargs) + self._scalar_bar, self._scalar_bar_ticks = self._renderer.scalarbar( + **kwargs + ) self._set_camera(**views_dicts[hemi][v]) # 4) update the scalar bar and opacity (and render) @@ -3575,6 +3664,10 @@ def _update_colormap_range(self, fmin=None, fmid=None, fmax=None, alpha=None): rng = self._cmap_range ctable = self._data["ctable"] fmt = self._data["colorbar_fmt"] or _auto_scalar_bar_fmt(rng) + if self._scalar_bar is not None: + self._renderer.set_scalarbar_title( + self._scalar_bar, self._data["colorbar_title"] + ) for hemi in ["lh", "rh", "vol"]: hemi_data = self._data.get(hemi) if hemi_data is not None: diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 27057abf782..1d4e9d6be78 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -659,6 +659,68 @@ def GetPickPosition(self): brain.close() +@testing.requires_testing_data +def test_scalar_bar_ticks_and_title(renderer_interactive_pyvistaqt, brain_gc): + """Test scalar bar tick marks and title truncation.""" + long_title = "a" * 40 + brain = _create_testing_brain( + hemi="lh", + show_traces=False, + add_data_kwargs=dict(colorbar_kwargs=dict(title=long_title)), + ) + n_labels = brain._scalar_bar.GetNumberOfLabels() + ticks = brain._scalar_bar_ticks + assert ticks.GetNumberOfLabels() == n_labels + assert ticks.GetTickVisibility() + assert not ticks.GetLabelVisibility() + title = brain._scalar_bar.GetTitle() + assert title.endswith("…") + assert len(title) <= 20 + brain.close() + + +@testing.requires_testing_data +def test_surface_hover(renderer_interactive_pyvistaqt, brain_gc): + """Test vertex/camera hover info toggle.""" + brain = _create_testing_brain(hemi="lh", show_traces=False) + assert brain._show_hover_info is False + + class MockIren: + def GetEventPosition(self): + return 50, 50 + + def FindPokedRenderer(self, x, y): + return brain.plotter.renderers[0] + + class MockPicker: + def Pick(self, x, y, z, renderer): + pass + + def GetCellId(self): + return 0 + + def GetMapper(self): + return brain.plotter.mapper + + def GetPickPosition(self): + return np.zeros(3) + + brain._renderer._picker = MockPicker() + brain._on_surface_hover(MockIren(), "MouseMoveEvent") + assert not brain._hover_caption.GetVisibility() # toggle is off + + brain._toggle_hover_info() + assert brain._show_hover_info is True + brain._on_surface_hover(MockIren(), "MouseMoveEvent") + assert brain._hover_caption.GetVisibility() + assert "vertex" in brain._hover_caption.GetCaption() + + brain._toggle_hover_info() + assert brain._show_hover_info is False + assert not brain._hover_caption.GetVisibility() + brain.close() + + @testing.requires_testing_data def test_add_sensors_scales(renderer_interactive_pyvistaqt): """Test sensor_scales parameter.""" diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 4e88b47a8ac..abe00c1af81 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -619,6 +619,13 @@ def scalarbar(self, source, color="white", title=None, n_labels=4, bgcolor=None) The number of labels to display on the scalar bar. bgcolor : tuple | str The color of the background when there is transparency. + + Returns + ------- + actor + The scalar bar actor. + tick_actor + The actor drawing tick marks along the scalar bar. """ pass diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 7d3a1e39144..dded038889f 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -799,6 +799,7 @@ def scalarbar( self, source, color="white", + title=None, n_labels=4, bgcolor=None, **extra_kwargs, @@ -811,7 +812,7 @@ def scalarbar( mapper = None kwargs = dict( color=color, - title="", + title=_truncate_scalar_bar_title(title), n_labels=n_labels, use_opacity=False, n_colors=256, @@ -826,12 +827,57 @@ def scalarbar( background_color=bgcolor, mapper=mapper, ) - extra_kwargs.pop("title", None) kwargs.update(extra_kwargs) actor = self.plotter.add_scalar_bar(**kwargs) actor.SetTextPad(10) _hide_testing_actor(actor) - return actor + tick_actor = self._add_scalarbar_ticks(actor, kwargs["n_labels"]) + return actor, tick_actor + + def _add_scalarbar_ticks(self, bar_actor, n_labels): + from vtkmodules.vtkRenderingAnnotation import vtkAxisActor2D + + axis = vtkAxisActor2D() + axis.GetPositionCoordinate().SetCoordinateSystemToDisplay() + axis.GetPosition2Coordinate().SetCoordinateSystemToDisplay() + axis.SetNumberOfLabels(n_labels) + # otherwise VTK rounds the tick count to "nice" values, desyncing the + # marks from the scalar bar's own label positions + axis.SetAdjustLabels(False) + axis.SetTickLength(5) + axis.SetLabelVisibility(False) + axis.SetTitleVisibility(False) + axis.SetAxisVisibility(False) # only the tick marks, no connecting line + axis.SetTickVisibility(True) + axis.GetProperty().SetColor(*bar_actor.GetLabelTextProperty().GetColor()) + + def reposition(caller, event): + self.reposition_scalarbar_ticks(bar_actor, axis) + + self.plotter.iren.add_observer(vtkCommand.RenderEvent, reposition) + self.plotter.renderer.AddActor(axis) + _hide_testing_actor(axis) + return axis + + def set_scalarbar_title(self, bar_actor, title): + bar_actor.SetTitle(_truncate_scalar_bar_title(title)) + + def reposition_scalarbar_ticks(self, bar_actor, tick_actor): + rect = [0, 0, 0, 0] + bar_actor.GetScalarBarRect(rect, self.plotter.renderer) + x0, y0, width, height = rect + horizontal = bar_actor.GetOrientation() == 0 + inset_low, inset_high = 4, 22 + if horizontal: + tick_actor.GetPositionCoordinate().SetValue(x0 + inset_low, y0 + height) + tick_actor.GetPosition2Coordinate().SetValue( + x0 + width - inset_high, y0 + height + ) + else: + tick_actor.GetPositionCoordinate().SetValue(x0 + width, y0 + inset_low) + tick_actor.GetPosition2Coordinate().SetValue( + x0 + width, y0 + height - inset_high + ) def show(self): self.plotter.show() @@ -1138,6 +1184,12 @@ def _hide_testing_actor(actor): actor.SetVisibility(False) +def _truncate_scalar_bar_title(title, max_chars=20): + if title is None or len(title) <= max_chars: + return title + return title[: max_chars - 1] + "…" + + def _to_pos(azimuth, elevation): theta = azimuth * np.pi / 180.0 phi = (90.0 - elevation) * np.pi / 180.0 diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 013c6bcc9e0..7299218080e 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -1572,6 +1572,7 @@ def _window_load_icons(self): self._icons["visibility_on"] = _qicon("visibility_on") self._icons["visibility_off"] = _qicon("visibility_off") self._icons["folder"] = _qicon("folder") + self._icons["information"] = _qicon("information") def _window_clean(self): self.figure._plotter = None From 494dfe0edb388d2d302aaf2263b3108fbd257e28 Mon Sep 17 00:00:00 2001 From: payam Date: Sat, 18 Jul 2026 13:45:05 +0200 Subject: [PATCH 6/9] hopefully this will fix pyvista AddObserver/iren API --- doc/changes/dev/14062.newfeature.rst | 1 + mne/viz/_brain/_brain.py | 2 +- mne/viz/backends/_pyvista.py | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 doc/changes/dev/14062.newfeature.rst diff --git a/doc/changes/dev/14062.newfeature.rst b/doc/changes/dev/14062.newfeature.rst new file mode 100644 index 00000000000..b3b2b65d464 --- /dev/null +++ b/doc/changes/dev/14062.newfeature.rst @@ -0,0 +1 @@ +The colorbar in the :class:`mne.viz.Brain` viewer now shows tick marks aligned with its labels, and its title now defaults to the active overlay's ``key`` (see :meth:`~mne.viz.Brain.add_data`). The interactive viewer can show vertex coordinates and camera orientation, toggled via the :kbd:`v` key or a new toolbar button, by `Payam Sadeghi-Shabestari`_. diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 9b4b43d73d0..cbc02caef8c 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -1219,7 +1219,7 @@ def _configure_hover(self): def on_surface_hover(iren, event): self._on_surface_hover(iren, event) - self.plotter.AddObserver("MouseMoveEvent", on_surface_hover) + self.plotter.iren.add_observer("MouseMoveEvent", on_surface_hover) def _on_surface_hover(self, iren, event): # event == "MouseMoveEvent" if not self._show_hover_info: diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index dded038889f..243598ea921 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -851,10 +851,12 @@ def _add_scalarbar_ticks(self, bar_actor, n_labels): axis.SetTickVisibility(True) axis.GetProperty().SetColor(*bar_actor.GetLabelTextProperty().GetColor()) - def reposition(caller, event): + def reposition(_caller, _event): self.reposition_scalarbar_ticks(bar_actor, axis) - self.plotter.iren.add_observer(vtkCommand.RenderEvent, reposition) + self.reposition_scalarbar_ticks(bar_actor, axis) + if self.plotter.iren is not None: + self.plotter.iren.add_observer(vtkCommand.RenderEvent, reposition) self.plotter.renderer.AddActor(axis) _hide_testing_actor(axis) return axis From 9c747330c72eff43cb578268eb2b2dd55f3336ae Mon Sep 17 00:00:00 2001 From: payam Date: Sat, 18 Jul 2026 14:06:00 +0200 Subject: [PATCH 7/9] forgot to add information for notebook --- mne/viz/backends/_notebook.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index badd15ef64a..bef1a7bcf1d 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -1374,6 +1374,7 @@ def _window_load_icons(self): "visibility_on", "visibility_off", "folder", + "information", ): # noqa: E501 self._icons[key] = _ICON_LUT[key] self._icons["play"] = None From 44d6763a748a879cf661c81a9872c9c5d798905f Mon Sep 17 00:00:00 2001 From: payam Date: Sun, 19 Jul 2026 15:11:21 +0200 Subject: [PATCH 8/9] merged 2 tests into 1 --- mne/viz/_brain/tests/test_brain.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 1d4e9d6be78..45810f30862 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -660,8 +660,8 @@ def GetPickPosition(self): @testing.requires_testing_data -def test_scalar_bar_ticks_and_title(renderer_interactive_pyvistaqt, brain_gc): - """Test scalar bar tick marks and title truncation.""" +def test_scalar_bar_ticks_title_and_hover(renderer_interactive_pyvistaqt, brain_gc): + """Test scalar bar tick marks, title truncation, and hover info toggle.""" long_title = "a" * 40 brain = _create_testing_brain( hemi="lh", @@ -676,13 +676,7 @@ def test_scalar_bar_ticks_and_title(renderer_interactive_pyvistaqt, brain_gc): title = brain._scalar_bar.GetTitle() assert title.endswith("…") assert len(title) <= 20 - brain.close() - -@testing.requires_testing_data -def test_surface_hover(renderer_interactive_pyvistaqt, brain_gc): - """Test vertex/camera hover info toggle.""" - brain = _create_testing_brain(hemi="lh", show_traces=False) assert brain._show_hover_info is False class MockIren: From 15cac5da088c9cef037e4876dd74ad050a8131e9 Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 21 Jul 2026 08:17:57 +0200 Subject: [PATCH 9/9] fix scalarbar title --- mne/viz/backends/_pyvista.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index c1aa9f567d7..46c8ab78882 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -799,6 +799,7 @@ def scalarbar( self, source, color="white", + title=None, n_labels=4, bgcolor=None, **extra_kwargs, @@ -826,7 +827,6 @@ def scalarbar( background_color=bgcolor, mapper=mapper, ) - extra_kwargs.pop("title", None) kwargs.update(extra_kwargs) actor = self.plotter.add_scalar_bar(**kwargs) actor.SetTextPad(10)