diff --git a/.circleci/config.yml b/.circleci/config.yml
index 1ac8e04562e..1215f83b7e3 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -5,6 +5,7 @@
# Tagging a commit with the following overrides these behaviors:
# - [circle front] will run the front page examples and perform test-doc
# - [circle full] will run all examples and perform test-doc
+# - [circle lite] will also build the JupyterLite site in a partial build
# - [circle linkcheck] will run our linkcheck job
# - [circle deploy] on a main or maint/* commit will try to immediately build
# and deploy docs rather than waiting for the nightly build
diff --git a/doc/changes/dev/14333.newfeature.rst b/doc/changes/dev/14333.newfeature.rst
new file mode 100644
index 00000000000..3fad2a7d78d
--- /dev/null
+++ b/doc/changes/dev/14333.newfeature.rst
@@ -0,0 +1 @@
+The ``jupyterlite_notebook`` 3D backend was renamed ``notebook_js`` and now supports most 3D visualizations, by `Eric Larson`_.
diff --git a/doc/conf.py b/doc/conf.py
index e64423e5635..50994932e6d 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -494,7 +494,8 @@
sphinx_gallery_parallel = int(os.getenv("MNE_DOC_BUILD_N_JOBS", "1"))
# The JupyterLite site, and the data it serves (about 1 GB), only belong in a
-# full build: `make html` turns this on, pattern and noplot builds leave it off.
+# full build: `make html` turns this on, pattern and noplot builds leave it off
+# (unless CircleCI's [circle lite] tag sets it).
build_jupyterlite = os.getenv("MNE_DOC_BUILD_JUPYTERLITE", "0") == "1"
if build_jupyterlite:
from jupyterlite_data import stage_lite_data # noqa: E402
@@ -670,8 +671,8 @@
# plot_field needs the interactive viewer that the browser renderer skips
"examples/visualization/mne_helmet.py",
# Tier 4: mne.viz.Brain features the browser renderer lacks (see the TODOs
- # in mne/viz/_brain/_brain.py). Brain itself draws (static, one time point,
- # one view), and the fNIRS tutorials and 50_background_freesurfer_mne run
+ # in mne/viz/_brain/_brain.py). Brain itself draws (one view, with the time
+ # viewer), and the fNIRS tutorials and 50_background_freesurfer_mne run
# in full, but these lean on add_annotation's hover callback,
# brain.screenshot, legends, silhouettes or the flatmap, so most of their
# cells fail.
diff --git a/doc/development/contributing.rst b/doc/development/contributing.rst
index acbbc54a8ef..cb06c8d23dc 100644
--- a/doc/development/contributing.rst
+++ b/doc/development/contributing.rst
@@ -1202,6 +1202,11 @@ down the road. Here are the guidelines:
nicely formatted) and the resulting output and figures will be rendered
as part of the tutorial/example.
+ - ``[circle lite]`` also builds the JupyterLite site in a partial
+ documentation build, so the "Open in JupyterLite" buttons work on the
+ tutorials/examples it renders (combine with ``[circle front]`` to
+ render the front page ones).
+
- Examples and tutorials should execute as quickly and with as low memory usage as
possible while still conveying necessary information. To see current execution
times and memory usage, visit the `sg_execution_times page`_. To see unused API
diff --git a/mne/conftest.py b/mne/conftest.py
index 8e30823515c..3b6734a4e11 100644
--- a/mne/conftest.py
+++ b/mne/conftest.py
@@ -732,7 +732,7 @@ def browser_backend(request, garbage_collect, monkeypatch):
@pytest.fixture(
params=[
pytest.param("pyvistaqt", marks=pytest.mark.pvtest),
- pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest),
+ pytest.param("notebook_js", marks=pytest.mark.pvtest),
]
)
def renderer(request, options_3d, garbage_collect):
@@ -755,7 +755,7 @@ def renderer_notebook(request, options_3d):
yield renderer
-@pytest.fixture(params=[pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest)])
+@pytest.fixture(params=[pytest.param("notebook_js", marks=pytest.mark.pvtest)])
def renderer_lite(request, options_3d):
"""Yield the JupyterLite (vtk.js) renderer alone, for its own tests."""
with _use_backend(request.param, interactive=False) as renderer:
@@ -815,9 +815,12 @@ def _use_backend(backend_name, interactive):
def _check_skip_backend(name):
from mne.viz.backends._utils import _notebook_vtk_works
- if name == "jupyterlite_notebook":
+ if name == "notebook_js":
# draws with vtk.js in a browser: no VTK, no Qt, no ffmpeg
pytest.importorskip("pyvista_js")
+ # ... but the same ipywidgets GUI as the notebook backend
+ pytest.importorskip("ipympl")
+ pytest.importorskip("ipyevents")
return
pytest.importorskip("pyvista")
pytest.importorskip("imageio_ffmpeg")
diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py
index de218f124b3..8582d9cbb4f 100644
--- a/mne/gui/_coreg.py
+++ b/mne/gui/_coreg.py
@@ -1738,7 +1738,7 @@ def _update_fiducials_label(self):
self._forward_widget_command("mri_fiducials_label", "set_value", text)
def _configure_dock(self):
- if self._renderer._kind == "notebook":
+ if self._renderer._kind.startswith("notebook"): # either notebook backend
collapse = True # collapsible and collapsed
else:
collapse = None # not collapsible
diff --git a/mne/gui/_dipolefit.py b/mne/gui/_dipolefit.py
index 7d0065761a9..360120ad66b 100644
--- a/mne/gui/_dipolefit.py
+++ b/mne/gui/_dipolefit.py
@@ -359,7 +359,7 @@ def __init__(
# window has actually appeared on screen (see `_qt_safe_window`).
self._renderer.figure.splash = self._splash
self._renderer.show()
- if block and self._renderer._kind != "notebook":
+ if block and not self._renderer._kind.startswith("notebook"):
_qt_app_exec(self._renderer.figure.store["app"])
@property
diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py
index de13e212e01..88b8e166405 100644
--- a/mne/viz/_3d.py
+++ b/mne/viz/_3d.py
@@ -2750,10 +2750,7 @@ def _check_st_tv(show_traces, time_viewer, times):
extra="when a string",
)
if time_viewer == "auto":
- from .backends.renderer import _get_3d_backend
-
- # the browser backend writes a static scene, so there is no slider to show
- time_viewer = _get_3d_backend() != "jupyterlite_notebook"
+ time_viewer = True
if show_traces == "auto":
show_traces = time_viewer and times is not None and len(times) > 1
if show_traces and not time_viewer:
diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py
index 46567fb21c0..b622be94197 100644
--- a/mne/viz/_brain/_brain.py
+++ b/mne/viz/_brain/_brain.py
@@ -569,9 +569,8 @@ def _on_time_change(self, event):
if event.time == self._current_time:
return
time_idx = self._to_time_index(event.time)
- self._update_current_time_idx(time_idx)
+ self._update_current_time_idx(time_idx) # renders
publish(self, _TimeUpdated())
- self._renderer._update()
def _on_colormap_range(self, event):
"""Respond to the colormap_range UI event."""
@@ -581,10 +580,8 @@ def _on_colormap_range(self, event):
# Check if limits have changed at all.
if all(val is None or val == self._data[key] for key, val in lims.items()):
return
- # Update the render.
- self._update_colormap_range(**lims)
+ self._update_colormap_range(**lims) # renders
publish(self, _ColormapRangeUpdated())
- self._renderer._update()
def _clear_callbacks(self):
# Remove the default key binding
@@ -1212,7 +1209,7 @@ def _add_volume_data(self, hemi, src, volume_options):
scalars = np.zeros(np.prod(dimensions))
scalars[vertices] = 1.0 # for the outer mesh
# TODO: reaches into VTK through the renderer, which the
- # jupyterlite_notebook backend cannot offer (its pages are excluded
+ # notebook_js backend cannot offer (its pages are excluded
# in doc/conf.py); refactor the renderer interface so Brain only
# uses its abstract methods. Same for the time label and glyph
# actors below.
diff --git a/mne/viz/_brain/_time_viewer.py b/mne/viz/_brain/_time_viewer.py
index 05f6969c2f0..df780f6c8ab 100644
--- a/mne/viz/_brain/_time_viewer.py
+++ b/mne/viz/_brain/_time_viewer.py
@@ -139,9 +139,13 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
self._active_data_key = next(iter(self._all_data))
self._configure_time_label()
self._configure_scalar_bar()
- self._configure_shortcuts()
- self._configure_picking()
- self._configure_hover()
+ # keyboard shortcuts, picking and hover all need mouse and key events
+ # from the interactor, plus VTK actors (see the TODO in _add_volume_data),
+ # which a page drawn by the notebook_js backend has neither of
+ if self._renderer._kind != "notebook_js":
+ self._configure_shortcuts()
+ self._configure_picking()
+ self._configure_hover()
self._configure_dock()
self._configure_tool_bar()
self._configure_status_bar()
@@ -270,7 +274,9 @@ def current_time_func():
def _configure_dock_orientation_widget(self, name):
layout = self._renderer._dock_add_group_box(name, collapse=True)
# Renderer widget
- rends = [str(i) for i in range(len(self._renderer._all_renderers))]
+ # one entry per subplot, which is what VTK's renderers are (asking the
+ # renderer for them would reach past its interface, see _add_volume_data)
+ rends = [str(i) for i in range(int(np.prod(self._subplot_shape)))]
if len(rends) > 1:
@_auto_weakref
diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py
index 9179fb074bd..602c060d207 100644
--- a/mne/viz/_brain/tests/test_brain.py
+++ b/mne/viz/_brain/tests/test_brain.py
@@ -2309,13 +2309,20 @@ def test_auto_scalar_bar_fmt():
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")
+ # each event must render once: a notebook backend redraws the whole scene
+ renders, update = list(), brain._renderer._update
+ brain._renderer._update = lambda: (renders.append(1), update())
- ui_events.publish(brain, ui_events.TimeChange(time=1))
- assert brain._current_time == 1
+ new_time = brain._times[-1]
+ assert brain._current_time != new_time # so this is a change
+ ui_events.publish(brain, ui_events.TimeChange(time=new_time))
+ assert brain._current_time == new_time
+ assert len(renders) == 1
ui_events.publish(brain, ui_events.VertexSelect(hemi="lh", vertex_id=1))
assert 1 in brain.get_picked_points()["lh"]
+ n_renders = len(renders)
ui_events.publish(
brain,
ui_events.ColormapRange(
@@ -2323,6 +2330,7 @@ def test_brain_ui_events(renderer_interactive_pyvistaqt, brain_gc):
),
)
assert_array_equal(brain._data["ctable"][:3, 3], [0, 2, 4])
+ assert len(renders) == n_renders + 1
# This event should be ignored.
ui_events.publish(
diff --git a/mne/viz/backends/_jupyterlite.py b/mne/viz/backends/_jupyterlite.py
index 73516996994..f7e6a09354a 100644
--- a/mne/viz/backends/_jupyterlite.py
+++ b/mne/viz/backends/_jupyterlite.py
@@ -60,7 +60,7 @@ def setup_notebook(data_path="/tmp/mne_data"): # nosec B108
from ...viz import set_3d_backend
try:
- set_3d_backend("jupyterlite_notebook")
+ set_3d_backend("notebook_js")
except Exception as exc:
print(f"[JupyterLite] could not select the pyvista-js renderer: {exc!r}")
diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py
index 368f702c0c8..269e49a71f5 100644
--- a/mne/viz/backends/_lite.py
+++ b/mne/viz/backends/_lite.py
@@ -3,27 +3,32 @@
MNE's 3D functions do their geometry and coordinate-frame work in numpy and only
hand the result to a renderer, so swapping that last step is enough to draw in
a browser kernel, where VTK cannot load. Selected with
-``mne.viz.set_3d_backend("jupyterlite_notebook")``.
+``mne.viz.set_3d_backend("notebook_js")``.
Supported: meshes, surfaces, spheres, tubes and glyphs, plus per-vertex RGB(A)
colors, which is how :class:`mne.viz.Brain` paints its surface, so ``stc.plot()``
-gives a static picture (one time point, no time viewer, colorbar or split
-layout). Not supported: scalar colormaps and contours, subplots (``hemi="split"``,
-several views), volume source estimates, vector glyphs, time labels, screenshots,
-and figure size (pyvista-js writes a 600x400 canvas).
+gives a picture of one time point, and the ipywidgets GUI of the ``notebook``
+backend (``_notebook.py``), which the scene is drawn into as a whole whenever
+it changes. Not supported: scalar colormaps and contours, subplots
+(``hemi="split"``, several views), volume source estimates, vector glyphs,
+screenshots, and mouse and key events.
"""
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
+import asyncio
+import html
import inspect
import weakref
from contextlib import nullcontext
import numpy as np
import pyvista_js as pv
+from ipywidgets import HTML, Button, GridBox, Layout
from matplotlib.colors import to_rgb
+from pyvista_js.rendering import scene_to_json
from ...surface import _tessellate_sphere
from ...transforms import (
@@ -35,6 +40,7 @@
)
from ...utils import _check_option, _validate_type
from ._abstract import Figure3D, _AbstractRenderer
+from ._notebook import _IpyRenderer
from ._utils import ALLOWED_QUIVER_MODES, LIGHTS, _to_pos, _vtk_faces
# vtk.js places text in normalized window coordinates; PyVista takes these names
@@ -45,6 +51,67 @@
"upper_right": (0.65, 0.90),
}
_DEFAULT_COLOR = (0.5, 0.5, 0.5) # what color=None draws as
+# pyvista-js writes a 600x400 box into its page; this fills the frame instead
+_LITE_SIZE_CSS = """
+"""
+# Seconds to wait for a page to report it painted before giving up on it
+_LITE_ACK_TIMEOUT = 10.0
+# Appended to each page: once painted, show its frame (same origin, so it can
+# reach it) and hide the other, unless a newer page (SEQ) already did, then
+# tell the kernel by clicking its (hidden) button
+_LITE_SWAP_JS = """
+"""
+# 2D text as a DOM overlay rather than in the page: a time label that changes
+# every step must not cost a new page (bottom-left origin, like vtk.js)
+_LITE_TEXT_HTML = (
+ '{text}'
+)
+_LITE_TEXT_SHIFT = dict(left=0, center=50, right=100) # % of the text's width
+# Dims the frames with a spinner from a redraw request until the page paints
+_LITE_BUSY_HTML = """
+
+"""
# Nothing in a notebook closes figures, and each live scene holds its meshes in
# the WASM heap, JS and GPU buffers (20_source_alignment makes six, enough to run
# the tab out of memory), so only the newest few stay live
@@ -61,16 +128,42 @@ def _lite_unsupported(what):
raise NotImplementedError(f"{what} is not supported in the browser.")
+class _LiteText(pv.Text):
+ """A text actor answering the VTK calls Brain makes on its time label.
+
+ Brain reaches into VTK to place, style and update that label (see the TODO in
+ ``_brain.py``); the GUI's DOM overlay draws what these calls set.
+ """
+
+ justification = "left"
+ bold = False
+
+ def SetInput(self, text):
+ self.input = str(text)
+
+ def SetPosition(self, x, y):
+ self.position = (float(x), float(y))
+
+ def GetTextProperty(self):
+ return self # the style calls below are no-ops, so they can land here
+
+ def SetJustificationToCentered(self):
+ self.justification = "center"
+
+ def BoldOn(self):
+ self.bold = True
+
+
def _lite_add_text(plotter, text, position, size, color):
- actor = pv.Text(str(text), position=tuple(float(coord) for coord in position))
+ actor = _LiteText(str(text), position=tuple(float(coord) for coord in position))
actor.prop.font_size = 14 if size is None else int(size) # Brain passes None
actor.prop.color = _rgb(color)
plotter.add_text(actor)
return actor
-def _lite_view_angles(plotter, rigid=None):
- """Return the (azimuth, elevation) in degrees the plotter looks from, or None.
+def _lite_view_angles(figure, rigid=None):
+ """Return the (azimuth, elevation) in degrees the figure looks from, or None.
The view is kept as ``view_vector``, a camera position that vtk.js aims at
the origin and then frames with ``resetCamera()``, rather than as a camera
@@ -78,7 +171,7 @@ def _lite_view_angles(plotter, rigid=None):
``rigid`` is the frame the angles are expressed in (Brain's canonical
rotation), as in ``_pyvista._get_user_camera_direction``.
"""
- view_vector = plotter._renderer._view_vector # pyvista-js 0.15
+ view_vector = figure._view_vector # pyvista-js takes a view but has no getter
if view_vector is None: # nothing set yet, so vtk.js chooses
return None
position = np.asarray(view_vector, float)
@@ -88,11 +181,11 @@ def _lite_view_angles(plotter, rigid=None):
return float(np.rad2deg(phi)) % 360, float(np.rad2deg(theta)) % 180
-def _lite_set_view(plotter, azimuth=None, elevation=None, rigid=None):
- """Point the plotter, keeping the angle not given as _pyvista._set_3d_view does."""
+def _lite_set_view(figure, azimuth=None, elevation=None, rigid=None):
+ """Point the figure, keeping the angle not given as _pyvista._set_3d_view does."""
if azimuth is None and elevation is None:
return
- current = _lite_view_angles(plotter, rigid) or (90.0, 90.0) # plot_alignment
+ current = _lite_view_angles(figure, rigid) or (90.0, 90.0) # plot_alignment
phi = np.deg2rad(current[0] if azimuth is None else azimuth)
theta = np.deg2rad(current[1] if elevation is None else elevation)
# view up flips near the poles, matching _set_3d_view
@@ -106,12 +199,13 @@ def _lite_set_view(plotter, azimuth=None, elevation=None, rigid=None):
rigid_inv = np.linalg.inv(rigid)
position = apply_trans(rigid_inv, position, move=False)
up = apply_trans(rigid_inv, up, move=False)
- plotter.view_vector(tuple(position), viewup=tuple(up))
+ figure._view_vector = tuple(position)
+ figure.plotter.view_vector(figure._view_vector, viewup=tuple(up))
-def _lite_get_view(plotter, rigid=None):
+def _lite_get_view(figure, rigid=None):
"""Return (roll, distance, azimuth, elevation, focalpoint) as _get_3d_view does."""
- azimuth, elevation = _lite_view_angles(plotter, rigid) or (0.0, 0.0)
+ azimuth, elevation = _lite_view_angles(figure, rigid) or (0.0, 0.0)
return (0.0, 1.0, azimuth, elevation, np.zeros(3))
@@ -167,11 +261,22 @@ class _LitePolyData(pv.PolyData):
vtk.js only uses an array as colors directly when it is uint8.
"""
+ _lite_version = 0 # bumped by each recoloring, so a redraw can send just that
+
def __setitem__(self, name, array):
array = np.asarray(array)
if array.ndim == 2 and array.shape[1] in (3, 4) and array.dtype != np.uint8:
array = np.round(np.clip(array, 0, 1) * 255).astype(np.uint8)
super().__setitem__(name, array)
+ self._lite_version += 1
+
+
+class _LiteData(HTML):
+ """An HTML widget whose repr leaves out its value, a page or its point data."""
+
+ def _repr_keys(self):
+ # megabytes of mesh data, which a display() outside a kernel prints
+ return (key for key in super()._repr_keys() if key != "value")
class _LiteFigure(Figure3D):
@@ -182,6 +287,7 @@ def __init__(self):
def _init(self, plotter):
self._plotter = plotter
+ self._view_vector = None # the camera position last set, see _lite_set_view
return self
@@ -189,7 +295,7 @@ class _LiteRenderer(_AbstractRenderer):
"""MNE 3D renderer backed by pyvista-js."""
# not "notebook": that backend has VTK, a filesystem and OS threads
- _kind = "jupyterlite_notebook"
+ _kind = "notebook_js"
def __init__(
self,
@@ -205,11 +311,14 @@ def __init__(
splash=False,
multi_samples=None,
):
- # _PyVistaRenderer's signature, but size, shape, name and show cannot
- # be honored: the canvas is fixed and written only when show() runs
+ # _PyVistaRenderer's signature, but shape, name and show cannot be
+ # honored: the page is written only when show() runs
# an int is a figure number, which just means a new figure here
_validate_type(fig, (None, int, _LiteFigure), "fig")
+ self._size = (size, size) if np.isscalar(size) else tuple(size)
self._close_callbacks = {False: [], True: []} # keyed by ``after``
+ self._viewer = None # the widget the scene is drawn into, once shown
+ self._texts = [] # 2D text the GUI renderer draws over the frames
if isinstance(fig, _LiteFigure): # plot_alignment(fig=...) composites into it
self._figure = fig
return
@@ -238,9 +347,6 @@ def figure(self):
def scene(self):
return self._figure
- def show(self):
- self.plotter.show()
-
# -- geometry -----------------------------------------------------------
def _glyph_template(
self, kind, radius=None, height=None, center=None, resolution=None
@@ -552,15 +658,9 @@ def text2d(
return _lite_add_text(self.plotter, text, (x_window, y_window), size, color)
def remove_mesh(self, mesh_data):
- # the renderer keeps the actor dict and the plotter a dict pointing at
- # it, so drop both; instanced_mesh hands back one actor per color
- actor, _ = mesh_data
- actors = actor if isinstance(actor, list) else [actor]
- plotter = self.plotter
- plotter.actors[:] = [a for a in plotter.actors if a["actor"] not in actors]
- plotter._renderer.actors[:] = [
- a for a in plotter._renderer.actors if a not in actors
- ]
+ actor, _ = mesh_data # instanced_mesh hands back one actor per color
+ for this_actor in actor if isinstance(actor, list) else [actor]:
+ self.plotter.remove_actor(this_actor)
# -- nothing to do in a browser -----------------------------------------
def set_interaction(self, interaction):
@@ -583,8 +683,16 @@ def subplot(self, x, y):
if (x, y) != (0, 0):
_lite_unsupported("Subplots") # one scene is one canvas
- def _update(self):
- pass # the page paints after the cell finishes
+ def _index_to_loc(self, idx):
+ assert idx == 0, idx # so there is only ever the one view
+ return (0, 0)
+
+ def _loc_to_index(self, loc):
+ assert tuple(loc) == (0, 0), loc
+ return 0
+
+ def _process_events(self, *args, **kwargs):
+ pass # the page runs the event loop
def _window_close_connect(self, func, *, after=True):
# an output cell has no close event, so close() runs these; ui_events
@@ -620,15 +728,6 @@ def contour(self, *args, **kwargs):
def legend(self, *args, **kwargs):
_lite_unsupported("Drawing a legend")
- def _process_events(self, *args, **kwargs):
- _lite_unsupported("Draining the event loop") # the page runs it
-
- def _window_set_cursor(self, *args, **kwargs):
- _lite_unsupported("Setting the cursor")
-
- def _enable_time_interaction(self, *args, **kwargs):
- _lite_unsupported("The time slider") # needs dock widgets
-
def project(self, xyz, ch_names):
_lite_unsupported("Projecting 3D positions onto the scene")
@@ -639,7 +738,7 @@ def screenshot(self, mode="rgb", filename=None, *, scale=1):
# -- camera -------------------------------------------------------------
def get_camera(self, *, rigid=None):
- return _lite_get_view(self.plotter, rigid)
+ return _lite_get_view(self._figure, rigid)
def set_camera(
self,
@@ -653,11 +752,199 @@ def set_camera(
update=True,
):
# distance, focalpoint and roll go unused: vtk.js frames the scene
- _lite_set_view(self.plotter, azimuth, elevation, rigid)
+ _lite_set_view(self._figure, azimuth, elevation, rigid)
+
+
+class _Renderer(_IpyRenderer, _LiteRenderer):
+ """pyvista-js drawing with the ipywidgets GUI of the notebook backend."""
+
+ # an HTML widget has no close event, so keep _LiteRenderer's real callbacks
+ # rather than the no-ops _IpyWindow provides for a window-less backend
+ _window_close_connect = _LiteRenderer._window_close_connect
+ _window_close_disconnect = _LiteRenderer._window_close_disconnect
+
+ def __init__(self, *args, **kwargs):
+ fullscreen = kwargs.pop("fullscreen", False)
+ super().__init__(*args, **kwargs)
+ self._window_initialize(fullscreen=fullscreen)
+
+ def _display_default_tool_bar(self):
+ pass # its one button takes a screenshot, which pyvista-js cannot
+
+ def close(self):
+ super().close() # after the close callbacks, which still use the widgets
+ self._close_widgets()
+
+ def _viewer_widget(self):
+ """Return the widget the scene is drawn into, as ``show`` asks for it."""
+ # Two frames in one grid cell, in HTML widgets: a redraw loads into the
+ # hidden one, which shows itself once painted (_LITE_SWAP_JS), so
+ # nothing blank is seen. Not Output widgets: the front end echoes what
+ # those capture back to the kernel, and the server drops the websocket
+ # for a message over 10 MiB, which a Brain surface is several times over
+ self._pages = [_LiteData(layout=Layout(grid_area="view")) for _ in range(2)]
+ self._busy = HTML( # last, so on top
+ _LITE_BUSY_HTML, layout=Layout(grid_area="view", visibility="hidden")
+ )
+ self._painted = Button(layout=Layout(display="none")) # the page's ack
+ self._painted.add_class("mne-lite-painted")
+ self._painted.on_click(self._on_painted)
+ self._updates = _LiteData(layout=Layout(display="none")) # point data, to apply
+ self._updates.add_class("mne-lite-update")
+ self._labels = HTML(layout=Layout(grid_area="view")) # 2D text, on top
+ self._labels.add_class("mne-lite-labels")
+ self._viewer = GridBox(
+ [*self._pages, self._labels, self._busy, self._painted, self._updates],
+ layout=Layout(grid_template_areas='"view"'),
+ )
+ self._dirty = False # a change since the last draw
+ self._in_flight = None # the timeout handle while a page is on its way
+ self._n_drawn = self._n_updates = 0
+ self._page_key = None # what the page on screen was drawn from
+ self._draw_scene()
+ return self._viewer
+
+ def text2d(
+ self,
+ x_window,
+ y_window,
+ text,
+ size=14,
+ color="white",
+ justification=None,
+ font_file=None,
+ ):
+ _check_option("justification", justification, (None, *_LITE_TEXT_SHIFT))
+ actor = _LiteText(str(text), position=(float(x_window), float(y_window)))
+ actor.prop.font_size = 14 if size is None else int(size)
+ actor.prop.color = _rgb(color)
+ actor.justification = justification or "left"
+ self._texts.append(actor)
+ return actor
+
+ def _draw_labels(self):
+ spans = [
+ _LITE_TEXT_HTML.format(
+ x=100 * actor.position[0],
+ y=100 * actor.position[1],
+ shift=_LITE_TEXT_SHIFT[actor.justification],
+ weight="bold" if actor.bold else "normal",
+ # PyVista's add_text, which _pyvista.py uses, doubles viewport text
+ size=2 * actor.prop.font_size,
+ r=int(255 * actor.prop.color[0]),
+ g=int(255 * actor.prop.color[1]),
+ b=int(255 * actor.prop.color[2]),
+ text=html.escape(actor.input),
+ )
+ for actor in self._texts
+ ]
+ self._labels.value = ( # clipped like VTK text, so it never scrolls the box
+ ''
+ f"{''.join(spans)}
"
+ )
+
+ def _scene_key(self):
+ """Return what a page depends on besides point data, plus mesh versions."""
+ actors = self.plotter.actors
+ key = tuple(
+ (id(info["mesh"]), tuple(info.get("color") or ()), info.get("opacity"))
+ for info in actors
+ ) + (self._figure._view_vector, tuple(self.plotter.background_color))
+ versions = tuple(getattr(info["mesh"], "_lite_version", 0) for info in actors)
+ return key, versions
+
+ def _send_updates(self, changed):
+ """Send the recolored meshes' point data to the page shown."""
+ actors = self.plotter.actors
+ updates = [
+ self.plotter.update_actor(
+ idx, point_data=dict(actors[idx]["mesh"].point_data.items()), send=False
+ )
+ for idx in changed
+ ]
+ self._n_updates += 1
+ self._updates.value = (
+ f''
+ f"{html.escape(scene_to_json(updates))}
"
+ )
+
+ def _loop(self):
+ """Return the kernel's event loop, or None outside a kernel."""
+ try:
+ return asyncio.get_running_loop()
+ except RuntimeError:
+ return None
+
+ def _schedule_draw(self):
+ # once the kernel is done with the message (a cell or a widget event)
+ # that made the change, since Brain asks several times per change
+ loop = self._loop()
+ if loop is None:
+ self._draw_scene()
+ else:
+ loop.call_soon(self._draw_scene)
+
+ def _on_painted(self, *args):
+ """Take the page's report that it painted, or the timeout in its place."""
+ if self._in_flight is not None:
+ self._in_flight.cancel()
+ self._in_flight = None
+ if self._dirty:
+ self._schedule_draw()
+ else:
+ self._busy.layout.visibility = "hidden"
+
+ def _draw_scene(self):
+ # the page pyvista-js writes for a browser, framed so the vtk.js it
+ # loads and the scene data it embeds stay out of the notebook document
+ width, height = self._size
+ self._dirty = False
+ self._draw_labels()
+ key, versions = self._scene_key()
+ if self._page_key is not None and self._page_key[0] == key:
+ changed = [i for i, v in enumerate(versions) if v != self._page_key[1][i]]
+ if not changed: # Brain asks again for the same time
+ if self._in_flight is None:
+ self._busy.layout.visibility = "hidden"
+ return
+ self._send_updates(changed)
+ self._page_key = (key, versions)
+ loop = self._loop()
+ if loop is not None:
+ self._in_flight = loop.call_later(_LITE_ACK_TIMEOUT, self._on_painted)
+ return
+ self._page_key = (key, versions)
+ page = self.plotter.generate_standalone_html()
+ self._n_drawn += 1
+ page = page.replace("", "" + _LITE_SIZE_CSS, 1)
+ swap = _LITE_SWAP_JS.replace("SEQ", str(self._n_drawn))
+ page = html.escape(page.replace("", swap + ""), quote=True)
+ # into the frame that is hidden (or not yet drawn), until it has painted
+ self._pages[self._n_drawn % 2].value = (
+ f''
+ )
+ loop = self._loop() # no kernel, no browser to hear back from
+ if loop is not None:
+ self._in_flight = loop.call_later(_LITE_ACK_TIMEOUT, self._on_painted)
+
+ def _window_get_size(self):
+ return self._size
+
+ def _update(self):
+ # pyvista-js serializes the whole scene into the page once, with no way
+ # to update it afterward, so a change means drawing it again: now, or
+ # once the page on its way has painted (_on_painted), so that the frame
+ # on screen is never the one drawn into
+ if self._viewer is None or self._dirty: # a draw is already coming
+ return
+ self._dirty = True
+ self._busy.layout.visibility = "visible"
+ if self._in_flight is None:
+ self._schedule_draw()
# -- the module surface renderer.py expects of a 3D backend -----------------
-_Renderer = _LiteRenderer
_testing_context = nullcontext # nothing draws differently under test
@@ -671,7 +958,7 @@ def _set_3d_view(
rigid=None,
update=True,
):
- _lite_set_view(figure.plotter, azimuth, elevation, rigid)
+ _lite_set_view(figure, azimuth, elevation, rigid)
def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left"):
diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py
index fd842459952..8a367743b52 100644
--- a/mne/viz/backends/_notebook.py
+++ b/mne/viz/backends/_notebook.py
@@ -1,4 +1,4 @@
-"""Notebook implementation of _Renderer and GUI."""
+"""ipywidgets GUI shared by the notebook 3D backends (see _trame.py, _lite.py)."""
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
@@ -6,8 +6,7 @@
import os
import os.path as op
-import warnings
-from contextlib import contextmanager, nullcontext
+from contextlib import contextmanager
from ipyevents import Event
from IPython.display import clear_output, display
@@ -75,18 +74,6 @@
_AbstractWidgetList,
_AbstractWindow,
)
-from ._pyvista import (
- Plotter,
- _check_3d_figure, # noqa: F401
- _clear_3d_figure, # noqa: F401
- _close_3d_figure, # noqa: F401
- _close_all, # noqa: F401
- _PyVistaRenderer,
- _set_3d_title, # noqa: F401
- _set_3d_view, # noqa: F401
- _take_3d_screenshot, # noqa: F401
-)
-from ._utils import _notebook_vtk_works
from .renderer import _TimeInteraction
# dict values are icon names from: https://fontawesome.com/icons
@@ -112,46 +99,6 @@
_BASE_MIN_SIZE = "20px"
_BASE_KWARGS = dict(layout=Layout(min_width=_BASE_MIN_SIZE, min_height=_BASE_MIN_SIZE))
-_JUPYTER_BACKEND = "trame"
-
-
-class _NotebookPlotter(Plotter):
- """PyVista ``Plotter`` for the notebook backend.
-
- Validate the object returned by show, as PyVista silently falls back static PIL
- when the trame Jupyter backend cannot be loaded.
- """
-
- def show(
- self, *args, jupyter_backend=_JUPYTER_BACKEND, return_viewer=False, **kwargs
- ):
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- viewer = super().show(
- *args,
- jupyter_backend=jupyter_backend,
- return_viewer=return_viewer,
- **kwargs,
- )
- if not isinstance(viewer, Widget):
- reasons = "\n".join(
- f"- {w.message}"
- for w in caught
- if any(key in str(w.message) for key in ("backend", "trame", "static"))
- )
- raise RuntimeError(
- f'The notebook 3D backend is not functional: the "{jupyter_backend}" '
- "PyVista Jupyter backend returned a "
- f"{type(viewer).__module__}.{type(viewer).__qualname__} instead of an "
- "interactive widget. This usually means the installed trame packages "
- "(trame, trame-vtk, trame-vuetify, trame-pyvista) are missing or "
- "mutually incompatible."
- + (f"\n\nPyVista reported:\n{reasons}" if reasons else "")
- )
- if return_viewer:
- return viewer
-
-
# %%
# Widgets
# -------
@@ -670,8 +617,7 @@ def _handle_scroll(self, scroll=None):
return kwargs
def _add_widget(self, widget):
- # if pyvista plotter, needs to be shown
- if isinstance(widget, Plotter):
+ if not isinstance(widget, Widget): # a PyVista plotter, which needs showing
widget = widget.show(return_viewer=True)
if hasattr(widget, "layout"):
widget.layout.width = None # unlock the fixed layout
@@ -767,25 +713,6 @@ def _close(self):
clear_output()
-class _3DRenderer(_PyVistaRenderer):
- _kind = "notebook"
-
- def __init__(self, *args, **kwargs):
- kwargs["notebook"] = True
- super().__init__(*args, **kwargs)
- if "show" in kwargs and kwargs["show"]:
- self.show()
-
- @contextmanager
- def _ensure_minimum_sizes(self):
- yield
-
- def show(self):
- viewer = self.plotter.show(return_viewer=True)
- viewer.layout.width = None # unlock the fixed layout
- display(viewer)
-
-
# ------------------------------------
# Non-object-based Widget Abstractions
# ------------------------------------
@@ -970,6 +897,7 @@ def _layout_initialize(self, max_width):
def _layout_add_widget(self, layout, widget, stretch=0, *, row=None, col=None):
widget.layout.margin = "2px 0px 2px 0px"
+ widget.layout.max_width = "100%" # e.g. a slider's 300px default
if not isinstance(widget, Play):
widget.layout.min_width = "0px"
if isinstance(layout, Accordion):
@@ -1044,6 +972,7 @@ def _dock_add_label(
widget = HTML(value=value, disabled=True)
widget.layout.width = "100px"
self._layout_add_widget(layout, widget, row=row, col=col)
+ widget.layout.min_width = "min-content" # shrink, but not below its text
return _IpyWidget(widget)
def _dock_add_button(
@@ -1340,16 +1269,19 @@ def _playback_initialize(self, func, timeout, value, rng, time_widget, play_widg
play.max = rng[1]
play.value = round(value)
slider = time_widget._widget
- jsdlink((play, "value"), (slider, "value"))
+ # a widget too, which holds both ends open
+ self._gui_widgets.append(jsdlink((play, "value"), (slider, "value")))
class _IpyMplInterface(_AbstractMplInterface):
def _mpl_initialize(self):
_soft_import("ipympl", "Drawing figures into a notebook.", strict=True)
- import ipympl.backend_nbagg
+ # the submodule, as `import ipympl` alone does not bring it in
+ from ipympl.backend_nbagg import Canvas, FigureManager
- self.canvas = ipympl.backend_nbagg.Canvas(self.fig)
- self.manager = ipympl.backend_nbagg.FigureManager(self.canvas, 0)
+ self.canvas = Canvas(self.fig)
+ self.canvas.header_visible = False # "Figure 0", as these are not numbered
+ self.manager = FigureManager(self.canvas, 0)
class _IpyMplCanvas(_AbstractMplCanvas, _IpyMplInterface):
@@ -1359,11 +1291,17 @@ def __init__(self, width, height, dpi):
class _IpyBrainMplCanvas(_AbstractBrainMplCanvas, _IpyMplInterface):
+ _in_layout = False # whether _IpyRenderer.show() put it under the scene
+
def __init__(self, brain, width, height, dpi):
super().__init__(brain, width, height, dpi)
self._mpl_initialize()
self._connect()
+ def show(self):
+ if not self._in_layout:
+ super().show()
+
class _IpyWindow(_AbstractWindow):
def _window_initialize(self, *, window=None, central_layout=None, fullscreen=False):
@@ -1402,7 +1340,9 @@ def _window_get_size(self):
return self.figure.plotter.window_size
def _window_get_simple_canvas(self, width, height, dpi):
- return _IpyMplCanvas(width, height, dpi)
+ canvas = _IpyMplCanvas(width, height, dpi)
+ self._gui_widgets.append(canvas.canvas)
+ return canvas
def _window_get_help_canvas(self, pairs, mouse_pairs=None):
text1, text2 = zip(*pairs, *(mouse_pairs or []))
@@ -1424,6 +1364,8 @@ def _window_get_mplcanvas(
self._show_traces = show_traces
self._separate_canvas = separate_canvas
self._mplcanvas = _IpyBrainMplCanvas(brain, w, h, self._window_get_dpi())
+ # now, as Brain's mpl_canvas.clear() drops it before the GUI is closed
+ self._gui_widgets.append(self._mplcanvas.canvas)
return self._mplcanvas
def _window_adjust_mplcanvas_layout(self):
@@ -1548,8 +1490,7 @@ def set_shortcut(self, shortcut):
pass
-class _Renderer(
- _PyVistaRenderer,
+class _IpyRenderer(
_IpyDock,
_IpyToolBar,
_IpyMenuBar,
@@ -1560,24 +1501,22 @@ class _Renderer(
_IpyKeyPress,
_TimeInteraction,
):
- _kind = "notebook"
+ """The ipywidgets GUI the notebook backends put around their drawing renderer.
- def __init__(self, *args, **kwargs):
+ A variant mixes this in *before* its drawing ``_AbstractRenderer``, so that
+ ``show`` here wins over the drawing one, and supplies the widget the scene is
+ drawn into through ``_viewer_widget``. Nothing else here collides, so the
+ drawing renderer is reached by inheritance as usual.
+ """
+
+ def _window_initialize(self, *, window=None, central_layout=None, fullscreen=False):
self._docks = None
self._menu_bar = None
self._tool_bar = None
self._status_bar = None
self._file_picker = _FilePckr(rows=10)
- kwargs["notebook"] = True
- fullscreen = kwargs.pop("fullscreen", False)
- if not _notebook_vtk_works():
- raise RuntimeError(
- "Using the notebook backend on Linux requires a compatible "
- "VTK setup. Consider using Xfvb or xvfb-run to set up a "
- "working virtual display, or install VTK with OSMesa enabled."
- )
- super().__init__(*args, **kwargs)
- self._window_initialize(fullscreen=fullscreen)
+ self._gui_widgets = list() # the roots _close_widgets closes from
+ super()._window_initialize(fullscreen=fullscreen)
def _display_default_tool_bar(self):
self._tool_bar_initialize()
@@ -1586,34 +1525,60 @@ def _display_default_tool_bar(self):
desc="Take a screenshot",
func=self.screenshot,
)
- display(self._tool_bar)
+ self._display_widget(self._tool_bar)
def show(self):
# menu bar
if self._menu_bar is not None:
- display(self._menu_bar)
+ self._display_widget(self._menu_bar)
# tool bar
if self._tool_bar is not None:
- display(self._tool_bar)
+ self._display_widget(self._tool_bar)
else:
self._display_default_tool_bar()
# viewer
- viewer = self.plotter.show(return_viewer=True)
+ viewer = self._viewer_widget()
+ self.figure.display = viewer
+ mplcanvas = getattr(self, "_mplcanvas", None)
+ if mplcanvas is not None and not self._separate_canvas:
+ # the traces go under the scene, in its column, as in _qt.py
+ mplcanvas._in_layout = True
+ viewer = VBox([viewer, mplcanvas.canvas])
rendering_row = list()
if self._docks is not None and "left" in self._docks:
rendering_row.append(self._docks["left"][0])
rendering_row.append(viewer)
if self._docks is not None and "right" in self._docks:
rendering_row.append(self._docks["right"][0])
- display(HBox(rendering_row))
- self.figure.display = viewer
+ self._display_widget(HBox(rendering_row))
# status bar
if self._status_bar is not None:
- display(self._status_bar)
+ self._display_widget(self._status_bar)
# file picker
self._file_picker.hide()
- display(self._file_picker._widget)
+ self._display_widget(self._file_picker._widget)
return self.scene()
-
-_testing_context = nullcontext
+ def _display_widget(self, widget):
+ self._gui_widgets.append(widget)
+ display(widget)
+
+ def _close_widgets(self):
+ """Close the GUI's widgets, which ipywidgets otherwise keeps alive.
+
+ Their callbacks point back at the figure, so until then it cannot die.
+ """
+ for widget in self._gui_widgets:
+ _close_widget_tree(widget)
+ self._gui_widgets.clear()
+
+
+def _close_widget_tree(widget):
+ # closing a widget leaves open the ones it holds: a box's children, a
+ # widget's layout and style, a link's ends, ipympl's toolbar
+ widget.close()
+ for key in widget.keys:
+ values = getattr(widget, key)
+ for value in values if isinstance(values, tuple) else (values,):
+ if isinstance(value, Widget) and value.comm is not None:
+ _close_widget_tree(value)
diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py
index 337e65b2e71..fbe4d5cc003 100644
--- a/mne/viz/backends/_pyvista.py
+++ b/mne/viz/backends/_pyvista.py
@@ -135,7 +135,7 @@ def _init(
if "app_window_class" in signature(_SafeBackgroundPlotter).parameters:
self.store["app_window_class"] = _MNEMainWindow
else:
- from ._notebook import _NotebookPlotter
+ from ._trame import _NotebookPlotter
self._plotter_class = _NotebookPlotter
diff --git a/mne/viz/backends/_trame.py b/mne/viz/backends/_trame.py
new file mode 100644
index 00000000000..62d1b981845
--- /dev/null
+++ b/mne/viz/backends/_trame.py
@@ -0,0 +1,163 @@
+"""The ``notebook`` 3D backend: VTK drawn through PyVista and trame."""
+
+# Authors: The MNE-Python contributors.
+# License: BSD-3-Clause
+# Copyright the MNE-Python contributors.
+
+import asyncio
+import threading
+import warnings
+from contextlib import contextmanager, nullcontext
+
+from IPython.display import display
+from ipywidgets import Widget
+
+from ._notebook import _IpyRenderer
+from ._pyvista import (
+ Plotter,
+ _check_3d_figure, # noqa: F401
+ _clear_3d_figure, # noqa: F401
+ _close_3d_figure, # noqa: F401
+ _close_all, # noqa: F401
+ _PyVistaRenderer,
+ _set_3d_title, # noqa: F401
+ _set_3d_view, # noqa: F401
+ _take_3d_screenshot, # noqa: F401
+)
+from ._utils import _notebook_vtk_works
+
+_JUPYTER_BACKEND = "trame"
+
+
+class _NotebookPlotter(Plotter):
+ """PyVista ``Plotter`` for the notebook backend.
+
+ Validate the object returned by show, as PyVista silently falls back static PIL
+ when the trame Jupyter backend cannot be loaded.
+ """
+
+ def show(
+ self, *args, jupyter_backend=_JUPYTER_BACKEND, return_viewer=False, **kwargs
+ ):
+ if jupyter_backend == "none": # asked to display nothing (the app path)
+ return super().show(*args, jupyter_backend="none", **kwargs)
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ viewer = super().show(
+ *args,
+ jupyter_backend=jupyter_backend,
+ return_viewer=return_viewer,
+ **kwargs,
+ )
+ if not isinstance(viewer, Widget):
+ reasons = "\n".join(
+ f"- {w.message}"
+ for w in caught
+ if any(key in str(w.message) for key in ("backend", "trame", "static"))
+ )
+ raise RuntimeError(
+ f'The notebook 3D backend is not functional: the "{jupyter_backend}" '
+ "PyVista Jupyter backend returned a "
+ f"{type(viewer).__module__}.{type(viewer).__qualname__} instead of an "
+ "interactive widget. This usually means the installed trame packages "
+ "(trame, trame-vtk, trame-vuetify, trame-pyvista) are missing or "
+ "mutually incompatible."
+ + (f"\n\nPyVista reported:\n{reasons}" if reasons else "")
+ )
+ if return_viewer:
+ return viewer
+
+
+class _3DRenderer(_PyVistaRenderer):
+ _kind = "notebook"
+
+ def __init__(self, *args, **kwargs):
+ kwargs["notebook"] = True
+ super().__init__(*args, **kwargs)
+ if "show" in kwargs and kwargs["show"]:
+ self.show()
+
+ @contextmanager
+ def _ensure_minimum_sizes(self):
+ yield
+
+ def show(self):
+ viewer = self.plotter.show(return_viewer=True)
+ viewer.layout.width = None # unlock the fixed layout
+ display(viewer)
+
+
+class _Renderer(_IpyRenderer, _PyVistaRenderer):
+ """VTK drawing through PyVista and trame, with the shared ipywidgets GUI."""
+
+ _kind = "notebook"
+
+ def __init__(self, *args, **kwargs):
+ kwargs["notebook"] = True
+ fullscreen = kwargs.pop("fullscreen", False)
+ if not _notebook_vtk_works():
+ raise RuntimeError(
+ "Using the notebook backend on Linux requires a compatible "
+ "VTK setup. Consider using Xfvb or xvfb-run to set up a "
+ "working virtual display, or install VTK with OSMesa enabled."
+ )
+ super().__init__(*args, **kwargs)
+ self._window_initialize(fullscreen=fullscreen)
+
+ def _viewer_widget(self):
+ try: # the kernel's loop, on the main thread, which owns the GL context
+ self._main_loop = asyncio.get_running_loop()
+ except RuntimeError:
+ self._main_loop = None
+ self._render_pending = False
+ try:
+ return self._app_widget()
+ except (ImportError, RuntimeError): # trame-pyvista without the app
+ return self.plotter.show(return_viewer=True)
+
+ def _app_widget(self):
+ """Return the widget of trame-pyvista's application showing the plotter.
+
+ Server rendering streamed by trame-rca, with the application's own
+ toolbar and a switch to VTK compiled to WebAssembly in the browser.
+ """
+ import pyvista
+ from trame_pyvista import module
+ from trame_pyvista.apps import SimpleViewer
+ from trame_pyvista.jupyter import elegantly_launch
+ from trame_pyvista.widgets import get_server
+
+ name = pyvista.global_theme.trame.jupyter_server_name
+ server = get_server(name)
+ # the application's styles are served only if enabled before launching
+ server.enable_module(module)
+ if not server.running:
+ elegantly_launch(name)
+ # the bookkeeping show() does (first render, no longer "first time"),
+ # without PyVista's Jupyter backend displaying anything
+ self.plotter.show(jupyter_backend="none", return_viewer=False, auto_close=False)
+ self._trame_app = SimpleViewer(self.plotter, name, mode="remote")
+ width, height = self.plotter.window_size # the iframe's own size, in px
+ self._trame_app.ui.iframe_style = (
+ f"width: {width}px; height: {height}px; border: none"
+ )
+ return self._trame_app.ui.ipywidget
+
+ def _update(self):
+ # JupyterLab (>= 4.4) delivers widget events to kernel subshells, which
+ # run on their own threads; VTK can only render on the thread that owns
+ # its OpenGL context, so render from the main loop instead. Brain asks
+ # several times per event, so once.
+ loop = getattr(self, "_main_loop", None)
+ if loop is None or threading.current_thread() is threading.main_thread():
+ super()._update()
+ elif not self._render_pending:
+ self._render_pending = True
+ loop.call_soon_threadsafe(self._update_from_main)
+
+ def _update_from_main(self):
+ self._render_pending = False
+ super()._update()
+
+
+_testing_context = nullcontext
diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py
index e57bb46b5a3..29485bb38f7 100644
--- a/mne/viz/backends/_utils.py
+++ b/mne/viz/backends/_utils.py
@@ -29,7 +29,7 @@
VALID_3D_BACKENDS = (
"pyvistaqt", # default 3d backend
"notebook",
- "jupyterlite_notebook",
+ "notebook_js",
)
# The backends _get_3d_backend() falls back to when none has been set. The
# JupyterLite one is left out on purpose: it draws through vtk.js and only
diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py
index 905f9dacf88..f88d05799ba 100644
--- a/mne/viz/backends/renderer.py
+++ b/mne/viz/backends/renderer.py
@@ -19,6 +19,7 @@
get_config,
logger,
verbose_static,
+ warn,
)
from .._3d import _get_3d_option
from ..utils import safe_event
@@ -30,8 +31,8 @@
_backend_name_map = dict(
pyvistaqt="._qt",
- notebook="._notebook",
- jupyterlite_notebook="._lite",
+ notebook="._trame",
+ notebook_js="._lite",
)
backend = None
@@ -57,6 +58,13 @@ def _get_renderer(*args, **kwargs):
def _check_3d_backend_name(backend_name):
_validate_type(backend_name, str, "backend_name")
backend_name = "pyvistaqt" if backend_name == "pyvista" else backend_name
+ if backend_name == "jupyterlite_notebook":
+ warn(
+ 'The "jupyterlite_notebook" 3D backend was renamed "notebook_js" in '
+ "1.14 and the old name will be removed in 1.16.",
+ FutureWarning,
+ )
+ backend_name = "notebook_js"
_check_option("backend_name", backend_name, VALID_3D_BACKENDS)
return backend_name
@@ -73,10 +81,12 @@ def set_3d_backend(backend_name, verbose=None):
backend_name : str
The 3d backend to select. See Notes for the capabilities of each
backend (``'pyvistaqt'``, ``'notebook'`` and
- ``'jupyterlite_notebook'``).
+ ``'notebook_js'``).
.. versionchanged:: 0.24
The ``'pyvista'`` backend was renamed ``'pyvistaqt'``.
+ .. versionchanged:: 1.14
+ The ``'jupyterlite_notebook'`` backend was renamed ``'notebook_js'``.
verbose : bool | str | int | None
Control verbosity of the logging output. If ``None``, use the default
verbosity level. See the :ref:`logging documentation ` and
@@ -93,13 +103,15 @@ def set_3d_backend(backend_name, verbose=None):
To use PyVista, set ``backend_name`` to ``pyvistaqt`` but the value
``pyvista`` is still supported for backward compatibility.
- The ``jupyterlite_notebook`` backend is not in the table below because it is
+ The ``notebook_js`` backend is not in the table below because it is
not a desktop choice: it draws with vtk.js rather than VTK, which has no
WebAssembly build, and it is what the documentation's browser notebooks run
on. It covers the static 3D figures, so :func:`plot_alignment` (without
channel-name labels) and :func:`plot_sparse_source_estimates` work, and
- :class:`mne.viz.Brain` draws a single time point with no time viewer,
- colorbar or split-hemisphere layout, while :func:`plot_evoked_field` and
+ :class:`mne.viz.Brain` has the same ipywidgets time viewer GUI as the
+ ``notebook`` backend, minus the keyboard shortcuts, picking and hover that
+ need mouse and key events from VTK, but no colorbar or split-hemisphere
+ layout. :func:`plot_evoked_field` and
:func:`snapshot_brain_montage` do not work. On a desktop the other two are
better in every way, so it is never selected automatically.
@@ -226,7 +238,7 @@ def use_3d_backend(backend_name): # numpydoc ignore=YD01
Parameters
----------
- backend_name : {'pyvistaqt', 'notebook', 'jupyterlite_notebook'}
+ backend_name : {'pyvistaqt', 'notebook', 'notebook_js'}
The 3d backend to use in the context.
"""
old_backend = set_3d_backend(backend_name)
@@ -570,7 +582,8 @@ def shift_time(direction):
TimeChange(time=self._current_time_func() + direction * amount),
)
- if self.plotter.iren is not None:
+ # a plotter drawing for a browser has no interactor to bind keys on
+ if getattr(self.plotter, "iren", None) is not None:
self.plotter.add_key_event("n", partial(shift_time, direction=1))
self.plotter.add_key_event("b", partial(shift_time, direction=-1))
diff --git a/mne/viz/backends/tests/test_abstract.py b/mne/viz/backends/tests/test_abstract.py
index 9209cd52b2a..1cf2edaf55c 100644
--- a/mne/viz/backends/tests/test_abstract.py
+++ b/mne/viz/backends/tests/test_abstract.py
@@ -7,7 +7,8 @@
from mne.viz.backends.renderer import _get_backend
-def _do_widget_tests(backend):
+def _do_widget_tests(backend, renderer_class=None):
+ """Test a backend's widgets; its 3D renderer can live in another module."""
# testing utils
widget_checks = set()
@@ -21,7 +22,8 @@ def callback(x=None):
window = backend._AppWindow()
central_layout = backend._VBoxLayout(scroll=(500, 500))
- renderer = backend._3DRenderer(name="test", size=(200, 200))
+ renderer_class = renderer_class or backend._3DRenderer
+ renderer = renderer_class(name="test", size=(200, 200))
renderer.sphere([0, 0, 0], "red", 1)
central_layout._add_widget(renderer.plotter)
canvas = backend._Canvas(5, 5, 96)
@@ -119,13 +121,15 @@ def test_widget_abstraction_notebook(renderer_notebook, nbexec):
from IPython import get_ipython
from mne.viz import set_3d_backend
+ from mne.viz.backends import _notebook
from mne.viz.backends.renderer import _get_backend
from mne.viz.backends.tests.test_abstract import _do_widget_tests
set_3d_backend("notebook")
backend = _get_backend()
- assert Path(backend.__file__).stem == "_notebook"
+ assert Path(backend.__file__).stem == "_trame"
ipython = get_ipython()
ipython.run_line_magic(magic_name="matplotlib", line="widget")
- _do_widget_tests(backend)
+ # the widgets are shared with the notebook_js backend
+ _do_widget_tests(_notebook, backend._3DRenderer)
diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py
index 78d2d5acf56..17d95c4011a 100644
--- a/mne/viz/backends/tests/test_renderer.py
+++ b/mne/viz/backends/tests/test_renderer.py
@@ -2,11 +2,15 @@
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
+import asyncio
+import gc
+import json
import os
import platform
import subprocess
import sys
from contextlib import nullcontext
+from functools import partial
import numpy as np
import pytest
@@ -18,14 +22,14 @@
from mne.transforms import quat_to_rot, rot_to_quat
from mne.viz import Figure3D, get_3d_backend, set_3d_backend
from mne.viz.backends._utils import ALLOWED_QUIVER_MODES
-from mne.viz.backends.renderer import _get_renderer
+from mne.viz.backends.renderer import _check_3d_backend_name, _get_renderer
_data_path = testing.data_path(download=False)
def _unsupported(renderer):
"""Return a context for what the browser backend says it cannot draw."""
- if renderer.get_3d_backend() == "jupyterlite_notebook":
+ if renderer.get_3d_backend() == "notebook_js":
return pytest.raises(NotImplementedError, match="browser")
return nullcontext()
@@ -185,7 +189,7 @@ def test_3d_backend(renderer):
# highlighting/hover) without rebuilding the actor or its geometry; the
# browser backend merges instances into solid meshes, so it has no such
# per-instance colors to update
- if renderer.get_3d_backend() != "jupyterlite_notebook":
+ if renderer.get_3d_backend() != "notebook_js":
inst_cloud.point_data["colors"][0] = [0, 0, 255, 255]
inst_cloud.Modified()
@@ -298,7 +302,7 @@ def test_renderer(renderer, monkeypatch):
"backend = mne.viz.get_3d_backend(); "
f"assert backend == {repr(backend)}, backend; "
# the browser backend must never import VTK, since there is none there
- f"assert backend != 'jupyterlite_notebook' or 'vtk' not in sys.modules",
+ f"assert backend != 'notebook_js' or 'vtk' not in sys.modules",
]
monkeypatch.setenv("MNE_3D_BACKEND", backend)
try:
@@ -310,9 +314,13 @@ def test_renderer(renderer, monkeypatch):
def test_set_3d_backend_bad(monkeypatch, tmp_path):
"""Test that the error emitted when a bad backend name is used."""
- match = "Allowed values are 'pyvistaqt', 'notebook', and 'jupyterlite_notebook'"
+ match = "Allowed values are 'pyvistaqt', 'notebook', and 'notebook_js'"
with pytest.raises(ValueError, match=match):
set_3d_backend("invalid")
+ with pytest.warns(
+ FutureWarning, match='renamed "notebook_js"'
+ ): # TODO VERSION: 1.16
+ assert _check_3d_backend_name("jupyterlite_notebook") == "notebook_js"
# gh-9607
def fail(x):
@@ -363,7 +371,7 @@ def test_3d_warning(renderer_pyvistaqt, monkeypatch):
assert not _pyvista._is_osmesa(plotter)
-# -- jupyterlite_notebook (pyvista-js) backend --------------------------------
+# -- notebook_js (pyvista-js) backend --------------------------------
# What the shared tests above cannot pin down, mostly geometry, since nothing
# here can be screenshotted. A unit square, split into two triangles:
_RR = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], float)
@@ -371,8 +379,10 @@ def test_3d_warning(renderer_pyvistaqt, monkeypatch):
def _scene(rend):
- """Return the scene as vtk.js will receive it."""
- return rend.plotter._renderer._build_scene_data()
+ """Return the scene as vtk.js will receive it, read back from the page."""
+ html = rend.plotter.generate_standalone_html()
+ start = html.index('id="scene-data">') + len('id="scene-data">')
+ return json.loads(html[start : html.index("", start)])
def test_lite_camera(renderer_lite):
@@ -517,41 +527,43 @@ def test_lite_notebook_kernel(renderer_lite, nbexec):
from mne.viz.backends import renderer
- renderer.set_3d_backend("jupyterlite_notebook")
+ renderer.set_3d_backend("notebook_js")
rend = renderer._get_renderer(bgcolor="white")
rr = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]])
rend.mesh(*rr.T, [[0, 1, 2], [0, 2, 3]], color="red")
renderer.set_3d_view(rend.scene(), azimuth=90, elevation=90)
- scene = rend.plotter._renderer._build_scene_data()
+ html = rend.plotter.generate_standalone_html() # what the page will run
+ start = html.index('id="scene-data">') + len('id="scene-data">')
+ scene = json.loads(html[start : html.index("", start)])
source = scene["actors"][0]["source"]
- points = source["points"]
- if hasattr(points, "tolist"): # pyvista-js >= 0.17 wraps it in a _Float32Array
- points = points.tolist()
- np.testing.assert_allclose(np.reshape(points, (-1, 3)), rr, atol=1e-6)
- assert source["polys"] == [3, 0, 1, 2, 3, 0, 2, 3]
+ np.testing.assert_allclose(np.reshape(source["points"], (-1, 3)), rr, atol=1e-6)
+ assert list(source["polys"]) == [3, 0, 1, 2, 3, 0, 2, 3]
np.testing.assert_allclose(scene["camera"]["viewVector"], [0, 1, 0], atol=1e-12)
- html = rend.plotter.generate_standalone_html() # what the page will run
- assert json.dumps(points).replace(" ", "") in html.replace(" ", "")
@testing.requires_testing_data
-def test_lite_brain(renderer_lite):
+def test_lite_brain(renderer_lite, monkeypatch):
"""Test Brain draws a static, per-vertex-colored surface through the backend."""
+ from ipywidgets import GridBox
+
import mne
stc = mne.read_source_estimate(
_data_path / "MEG" / "sample" / "sample_audvis_trunc-meg", "sample"
)
kwargs = dict(subject="sample", subjects_dir=_data_path / "subjects", hemi="lh")
- brain = stc.plot(views="lat", initial_time=0.1, **kwargs) # time_viewer="auto"
+ brain = stc.plot(views="lat", initial_time=0.1, time_viewer=False, **kwargs)
assert isinstance(brain, mne.viz.Brain)
assert brain.time_viewer is False and brain._scalar_bar is None
+ # the time label ends at x=0.95, in PyVista's font and doubled size
+ assert "translateX(-100%)" in brain._renderer._labels.value
+ assert "28px Arial" in brain._renderer._labels.value
(actor,) = brain._renderer.plotter.actors
colors = actor["mesh"].point_data["Data"]
# curvature plus activation, as uint8 RGBA vtk.js uses directly
assert colors.dtype == np.uint8 and colors.shape == (len(brain.geo["lh"].coords), 4)
assert len(np.unique(colors, axis=0)) > 2
- scene = brain._renderer.plotter._renderer._build_scene_data()
+ scene = _scene(brain._renderer)
assert scene["actors"][0]["scalars"]["direct"] is True
# Brain's canonical rotation reaches the camera through `rigid`
assert brain._renderer.get_camera(rigid=brain._rigid)[2:4] == pytest.approx(
@@ -563,6 +575,66 @@ def test_lite_brain(renderer_lite):
mne.viz.Brain(surf="inflated", **{**kwargs, "hemi": "split"})
assert list(mne.viz.Brain._instances) == [brain] # the failed one died
+ # time_viewer="auto" builds the ipywidgets GUI shared with the notebook backend
+ brain = stc.plot(views="lat", initial_time=0.1, **kwargs)
+ rend = brain._renderer
+ assert "left" in rend._docks and "time_slider" in rend._widgets
+ assert isinstance(rend._viewer, GridBox) # the widget the scene is drawn into
+ assert rend._pages[rend._n_drawn % 2].value.startswith("