diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3a78057..09c92551 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,8 @@ jobs: exclude: # for now, this combo segfaults too much to be worth the hassle of debugging # when we unpin and update pygfx, reconsider - - gui: pyside - canvas: pygfx + # - gui: pyside + # canvas: pygfx # wxpython does not build wheels for ubuntu or macos-latest py3.10 - os: ubuntu-latest gui: wxpython @@ -88,7 +88,7 @@ jobs: sudo apt-get update -y -qq sudo apt install -y libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers - name: Test - run: uv run --exact --no-dev --extra=${{ matrix.canvas }} --group=${{ matrix.gui }} coverage run -p -m pytest --color=yes -p no:faulthandler + run: uv run --exact --no-dev --extra=${{ matrix.canvas }} --group=${{ matrix.gui }} coverage run -p -m pytest --color=yes - name: Upload coverage uses: actions/upload-artifact@v6 diff --git a/pyproject.toml b/pyproject.toml index e5eb8fe4..1a24ca2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,20 +50,16 @@ jupyter = [ pyqt = ["pyqt6 >=6.7", "qtpy >=2", "superqt[iconify] >=0.7.2"] pyside = [ # defer to superqt's pyside6 restrictions - "superqt[iconify,pyside6] >=0.7.2,<0.7.5", - # https://github.com/pyapp-kit/ndv/issues/59 - "pyside6 ==6.6.3; sys_platform == 'win32'", - "numpy >=1.23,<2; sys_platform == 'win32' and python_version < '3.13'", # needed for pyside6.6 - "pyside6 >=6.4", - "pyside6 >=6.6; python_version >= '3.12' and sys_platform == 'win32'", - "pyside6 >=6.7; python_version >= '3.12' and sys_platform != 'win32'", + "superqt[iconify,pyside6] >=0.8.0", + # note: still may need to fix windows https://github.com/pyapp-kit/ndv/issues/59 + "pyside6 >=6.7", "qtpy >=2", ] wxpython = ["pyconify>=0.2.1", "wxpython >=4.2.2"] # Supported Canavs backends vispy = ["vispy>=0.16", "pyopengl >=3.1"] -pygfx = ["pygfx==0.9.0", "rendercanvas ==2.0.1", "wgpu==0.19.3"] +pygfx = ["pygfx>=0.16.0", "rendercanvas>=2.6.1", "wgpu>=0.31.0"] # ready to go bundles with pygfx qt = ["ndv[pygfx,pyqt]", "imageio[tifffile] >=2.20"] diff --git a/src/ndv/views/_pygfx/_array_canvas.py b/src/ndv/views/_pygfx/_array_canvas.py index 498aefc7..458cd536 100755 --- a/src/ndv/views/_pygfx/_array_canvas.py +++ b/src/ndv/views/_pygfx/_array_canvas.py @@ -25,15 +25,9 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence - from typing import TypeAlias from pygfx.materials import ImageBasicMaterial from pygfx.resources import Texture - from wgpu.gui.jupyter import JupyterWgpuCanvas - from wgpu.gui.qt import QWgpuCanvas - from wgpu.gui.wx import WxWgpuCanvas - - WgpuCanvas: TypeAlias = "QWgpuCanvas | JupyterWgpuCanvas | WxWgpuCanvas" def _is_inside(bounding_box: np.ndarray | None, pos: Sequence[float]) -> bool: @@ -387,9 +381,9 @@ def __init__(self, viewer_model: ArrayViewerModel) -> None: self._disconnect_mouse_events = filter_mouse_events(self._canvas, self) self._renderer = pygfx.renderers.WgpuRenderer(self._canvas) - self._renderer.blend_mode = "additive" self._scene = pygfx.Scene() + self._scene.add(pygfx.Background(None, pygfx.BackgroundMaterial("black"))) self._camera: pygfx.Camera | None = None self._ndim: Literal[2, 3] | None = None @@ -398,6 +392,8 @@ def __init__(self, viewer_model: ArrayViewerModel) -> None: # Maintain a weak reference to the last ROI created. self._last_roi_created: ReferenceType[PyGFXRectangle] | None = None + self._canvas.add_event_handler(lambda e: self.refresh(), "resize") + def frontend_widget(self) -> Any: return self._canvas @@ -450,8 +446,7 @@ def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle: tex = pygfx.Texture(data, dim=2) image = pygfx.Image( pygfx.Geometry(grid=tex), - # depth_test=False for additive-like blending - pygfx.ImageBasicMaterial(depth_test=False), + pygfx.ImageBasicMaterial(depth_test=False, alpha_mode="add"), ) self._scene.add(image) @@ -475,8 +470,9 @@ def add_volume(self, data: np.ndarray | None = None) -> PyGFXImageHandle: tex = pygfx.Texture(data, dim=3) vol = pygfx.Volume( pygfx.Geometry(grid=tex), - # depth_test=False for additive-like blending - pygfx.VolumeRayMaterial(interpolation="nearest", depth_test=False), + pygfx.VolumeRayMaterial( + interpolation="nearest", depth_test=False, alpha_mode="add" + ), ) self._scene.add(vol) diff --git a/src/ndv/views/_pygfx/_histogram.py b/src/ndv/views/_pygfx/_histogram.py index a71026d1..c9715149 100644 --- a/src/ndv/views/_pygfx/_histogram.py +++ b/src/ndv/views/_pygfx/_histogram.py @@ -17,14 +17,9 @@ if TYPE_CHECKING: from collections.abc import Sequence - from typing import TypeAlias import cmap import numpy.typing as npt - from wgpu.gui.jupyter import JupyterWgpuCanvas - from wgpu.gui.qt import QWgpuCanvas - - WgpuCanvas: TypeAlias = "QWgpuCanvas | JupyterWgpuCanvas" MIN_GAMMA: np.float64 = np.float64(1e-6) @@ -126,8 +121,6 @@ def __init__(self, *, vertical: bool = False) -> None: self._renderer = pygfx.renderers.WgpuRenderer(self._canvas) - self._renderer.blend_mode = "ordered1" - # Note that we split the view up into multiple scenes, each with their # own camera and renderer. # diff --git a/src/ndv/views/_pygfx/_util.py b/src/ndv/views/_pygfx/_util.py index 9dabde2e..f0cc5b43 100644 --- a/src/ndv/views/_pygfx/_util.py +++ b/src/ndv/views/_pygfx/_util.py @@ -23,11 +23,8 @@ def sizeHint(self) -> QSize: return rendercanvas.jupyter.JupyterRenderCanvas # type: ignore[no-any-return] if frontend == GuiFrontend.WX: - # ...still not working - # import rendercanvas.wx - # return rendercanvas.wx.WxRenderWidget - from wgpu.gui.wx import WxWgpuCanvas + import rendercanvas.wx - return WxWgpuCanvas # type: ignore[no-any-return] + return rendercanvas.wx.WxRenderCanvas # type: ignore[no-any-return] raise ValueError(f"Unsupported frontend: {frontend}") # pragma: no cover diff --git a/src/ndv/views/_wx/_array_view.py b/src/ndv/views/_wx/_array_view.py index 61b03e4c..2802b84b 100644 --- a/src/ndv/views/_wx/_array_view.py +++ b/src/ndv/views/_wx/_array_view.py @@ -587,6 +587,10 @@ def __init__(self, canvas_widget: wx.Window, parent: wx.Window | None = None): if (parent := canvas_widget.GetParent()) and parent is not self: canvas_widget.Reparent(self) # Reparent canvas_widget to this frame if parent: + # Close the rendercanvas wrapper before destroying it so + # rendercanvas removes it from its internal tracking loop. + if hasattr(parent, "close"): + parent.close() parent.Destroy() canvas_widget.Show() diff --git a/tests/conftest.py b/tests/conftest.py index 3c923111..5cac075b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,15 +91,24 @@ def _catch_qt_leaks(request: FixtureRequest, qapp: QApplication) -> Iterator[Non # if the test failed, don't worry about checking widgets if request.session.testsfailed - failures_before: return + allow: list[type] = [] try: from vispy.app.backends._qt import CanvasBackendDesktop - allow: tuple[type, ...] = (CanvasBackendDesktop,) + allow.append(CanvasBackendDesktop) except (ImportError, RuntimeError): - allow = () + pass + try: + # This is a known widget that is not cleaned up properly + # but it's not clear how to fix it + from rendercanvas.qt import QRenderWidget + + allow.append(QRenderWidget) + except (ImportError, RuntimeError): + pass # This is a known widget that is not cleaned up properly - remaining = [w for w in qapp.topLevelWidgets() if not isinstance(w, allow)] + remaining = [w for w in qapp.topLevelWidgets() if not isinstance(w, tuple(allow))] if len(remaining) > nbefore: test_node = request.node diff --git a/tests/test_controller.py b/tests/test_controller.py index 71e8c6c4..c6dd936a 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -319,6 +319,8 @@ def test_array_viewer_histogram() -> None: @pytest.mark.usefixtures("any_app") def test_roi_controller() -> None: ctrl = ArrayViewer() + ctrl.show() + _app.process_events() roi = RectangularROIModel() viewer = ctrl._viewer_model @@ -342,6 +344,7 @@ def test_roi_controller() -> None: (world_pos[0] + 1, world_pos[1] + 1), ) assert viewer.interaction_mode == InteractionMode.PAN_ZOOM + ctrl._canvas.close() @no_type_check @@ -352,6 +355,8 @@ def test_roi_interaction() -> None: return ctrl = ArrayViewer() + ctrl.show() + _app.process_events() roi = RectangularROIModel() ctrl.roi = roi roi_view = ctrl._roi_view @@ -428,6 +433,7 @@ def test_roi_interaction() -> None: (canvas_roi_start[1] + canvas_roi_end[1]) / 2, ) assert roi_view.get_cursor(mme) == CursorType.ALL_ARROW + ctrl._canvas.close() @pytest.mark.allow_leaks diff --git a/tests/views/_pygfx/test_histogram.py b/tests/views/_pygfx/test_histogram.py index 8e329237..705be122 100644 --- a/tests/views/_pygfx/test_histogram.py +++ b/tests/views/_pygfx/test_histogram.py @@ -15,6 +15,16 @@ from ndv.views._pygfx._histogram import PyGFXHistogramCanvas +# ugly hack +def _force_canvas_size( + canvas: PyGFXHistogramCanvas, w: int = 600, h: int = 600 +) -> None: + """Force the rendercanvas to report a valid size (needed before show).""" + rc = canvas._canvas + target = getattr(rc, "_subwidget", rc) + target._size_info.set_physical_size(w, h, 1.0) + + @pytest.mark.usefixtures("any_app") def test_hscroll() -> None: model = LUTModel( @@ -23,6 +33,7 @@ def test_hscroll() -> None: # gamma=2, ) histogram = PyGFXHistogramCanvas() + _force_canvas_size(histogram) histogram.set_range(x=(0, 10), y=(0, 1)) histogram.model = model left, right = 0, 10 @@ -85,6 +96,7 @@ def test_interaction() -> None: # gamma=2, ) histogram = PyGFXHistogramCanvas() + _force_canvas_size(histogram) histogram.set_range(x=(0, 10), y=(0, 1)) histogram.model = model left, right = 0, 10