From 988d0d00c949b9add38aac04ef50dcb5a76c3b90 Mon Sep 17 00:00:00 2001 From: Jan Gleixner Date: Mon, 9 Nov 2026 14:36:19 +0100 Subject: [PATCH 1/3] Qt6 compatibility: scatter widget mouse handlers and tests PyQt6 no longer converts QPoint to QPointF implicitly, so ROI drawing in the scatter widget raised TypeError in mapSceneToView / sceneBoundingRect().contains and created no ROI. The handlers now compute the scene position once as self.mapToScene(event.pos()) (a QPointF on every binding) instead of using the viewport position event.pos() as a scene position (same values: pyqtgraph's autoPixelRange keeps scene == viewport pixels). Also fix a log message missing its f-prefix. Tests: pass a QPointF to QMouseEvent, and patch QMessageBox.question via qtpy instead of the PyQt5 module path. --- src/napari_spatialdata/_scatterwidgets.py | 26 ++++++++++++----------- tests/test_scatterwidgets.py | 4 ++-- tests/test_widgets.py | 2 +- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/napari_spatialdata/_scatterwidgets.py b/src/napari_spatialdata/_scatterwidgets.py index 42bbb58b..98169149 100644 --- a/src/napari_spatialdata/_scatterwidgets.py +++ b/src/napari_spatialdata/_scatterwidgets.py @@ -923,11 +923,11 @@ def mousePressEvent(self, event: Any) -> None: logger.debug("Left press detected") # Get the position of the click in the scene - scene_pos = event.pos() + scene_pos = self.mapToScene(event.pos()) # Check if the click is within the scatter plot view box if not self.scatter_plot.vb.sceneBoundingRect().contains(scene_pos): - logger.debug("Click outside scatter plot view box, scene position: {scene_pos}") + logger.debug(f"Click outside scatter plot view box, scene position: {scene_pos}") return pen = pg.mkPen(color="gray", width=2) @@ -938,7 +938,7 @@ def mousePressEvent(self, event: Any) -> None: if self.drawing: - plot_pos = self.scatter_plot.vb.mapSceneToView(event.pos()) + plot_pos = self.scatter_plot.vb.mapSceneToView(scene_pos) self.current_roi = pg.PolyLineROI( [], closed=True, removable=True, pen=pen, handlePen=handlePen, hoverPen=hoverPen @@ -946,7 +946,7 @@ def mousePressEvent(self, event: Any) -> None: self.scatter_plot.addItem(self.current_roi) self.current_points = [(plot_pos.x(), plot_pos.y())] - self.last_pos = (event.pos().x(), event.pos().y()) + self.last_pos = (scene_pos.x(), scene_pos.y()) event.accept() @@ -954,7 +954,7 @@ def mousePressEvent(self, event: Any) -> None: logger.info("Rectangle") - plot_pos = self.scatter_plot.vb.mapSceneToView(event.pos()) + plot_pos = self.scatter_plot.vb.mapSceneToView(scene_pos) self.current_roi = pg.RectROI( pos=(plot_pos.x(), plot_pos.y()), size=(0, 0), @@ -979,16 +979,17 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: if self.drawing and self.current_roi is not None: if self.last_pos is not None: - dist = np.sqrt((event.pos().x() - self.last_pos[0]) ** 2 + (event.pos().y() - self.last_pos[1]) ** 2) + scene_pos = self.mapToScene(event.pos()) + dist = np.sqrt((scene_pos.x() - self.last_pos[0]) ** 2 + (scene_pos.y() - self.last_pos[1]) ** 2) if dist > self.vertex_add_threshold: - plot_pos = self.scatter_plot.vb.mapSceneToView(event.pos()) + plot_pos = self.scatter_plot.vb.mapSceneToView(scene_pos) self.current_points.append((plot_pos.x(), plot_pos.y())) self.current_roi.setPoints(self.current_points) - self.last_pos = (event.pos().x(), event.pos().y()) + self.last_pos = (scene_pos.x(), scene_pos.y()) logger.debug(f"Updated points for PolyLineROI: {self.current_points}") @@ -996,7 +997,7 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: elif self.rectangle and self.current_roi is not None and self.initial_pos is not None: - plot_pos = self.scatter_plot.vb.mapSceneToView(event.pos()) + plot_pos = self.scatter_plot.vb.mapSceneToView(self.mapToScene(event.pos())) width = plot_pos.x() - self.initial_pos.x() # type: ignore[attr-defined] height = plot_pos.y() - self.initial_pos.y() # type: ignore[attr-defined] self.current_roi.setSize([width, height]) @@ -1010,7 +1011,7 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: super().mouseMoveEvent(event) # Get cursor position - scene_pos = event.pos() + scene_pos = self.mapToScene(event.pos()) plot_pos = self.scatter_plot.vb.mapSceneToView(scene_pos) # Update the cursor position label @@ -1021,7 +1022,8 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: def mouseReleaseEvent(self, event: Any) -> None: - logger.debug(f"Mouse release event detected at {self.scatter_plot.vb.mapSceneToView(event.pos())}") + scene_pos = self.mapToScene(event.pos()) + logger.debug(f"Mouse release event detected at {self.scatter_plot.vb.mapSceneToView(scene_pos)}") if self.drawing and event.button() == Qt.LeftButton: @@ -1049,7 +1051,7 @@ def mouseReleaseEvent(self, event: Any) -> None: def mouseDoubleClickEvent(self, event: Any) -> None: if event.button() == Qt.LeftButton: - plot_pos = self.scatter_plot.vb.mapSceneToView(event.pos()) + plot_pos = self.scatter_plot.vb.mapSceneToView(self.mapToScene(event.pos())) point = Point(plot_pos.x(), plot_pos.y()) self.remove_roi(point=point) diff --git a/tests/test_scatterwidgets.py b/tests/test_scatterwidgets.py index df18566e..4a1704e9 100644 --- a/tests/test_scatterwidgets.py +++ b/tests/test_scatterwidgets.py @@ -182,8 +182,8 @@ def plot_coords_to_viewport(plot_widget, x, y): def send_mouse_event(event_type, widget, pos, button=QtCore.Qt.LeftButton): """Send a mouse event to a widget.""" - # necessary because of https://bugreports.qt.io/browse/QTBUG-5232 - event = QtGui.QMouseEvent(event_type, pos, button, button, QtCore.Qt.NoModifier) + # necessary because of https://bugreports.qt.io/browse/QTBUG-5232; Qt6 bindings need a QPointF position + event = QtGui.QMouseEvent(event_type, QtCore.QPointF(pos), button, button, QtCore.Qt.NoModifier) QtWidgets.QApplication.sendEvent(widget, event) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index ede04bb5..f0db306e 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -316,7 +316,7 @@ def test_old_annotation(adata_labels, annotation_values, mocker): col_name = scatter_widget.model.adata.obs.columns[0] mocker.patch.object(scatter_widget, "open_annotation_dialog", return_value=col_name) mocker.patch.object(scatter_widget.plot_widget, "get_selection", return_value=annotation_values) - mocker.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) + mocker.patch.object(QtWidgets.QMessageBox, "question", return_value=QtWidgets.QMessageBox.Yes) scatter_widget.export() From 8a70f26065f8391d1dbe8116b46466707842cbea Mon Sep 17 00:00:00 2001 From: Jan Gleixner Date: Mon, 9 Nov 2026 14:41:46 +0100 Subject: [PATCH 2/3] Install PyQt6 via napari[pyqt6] in the all extra; drop PyQt5 from tox napari 0.10 requires Qt>=6.7 and no longer has a pyqt5 extra (#405), so napari-spatialdata[all] installed no Qt binding. Pin the binding explicitly to the one napari-spatialdata is tested with (napari pins compatible PyQt6/Qt versions), rather than napari[qt], which would follow napari to a future binding untested here. CI now gets Qt from the extra, like users do. --- README.md | 2 +- setup.cfg | 2 +- tox.ini | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 16799b93..6a7a6216 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ You can install `napari-spatialdata` via [pip]: pip install napari-spatialdata[all] -The `all` command will install the qt bindings `PyQt5`. +The `all` extra also installs the Qt bindings `PyQt6` (via `napari[pyqt6]`). Napari now also includes multiple triangulation backends. These improve the speed by which a napari 'Shapes' layer gets loaded (this does not improve the speed of editing large numbers of shapes yet!). See also the napari diff --git a/setup.cfg b/setup.cfg index 5ff2f17e..61787483 100644 --- a/setup.cfg +++ b/setup.cfg @@ -102,7 +102,7 @@ pre = spatialdata>=0.7.3a1 all = - napari[pyqt5] + napari[pyqt6] [options.packages.find] where = src diff --git a/tox.ini b/tox.ini index b62b2031..f1cffd50 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,6 @@ deps = pytest-cov pytest-qt pytest-mock - PyQt5 passenv = CI GITHUB_ACTIONS From 38986af707cd822853f6a1b7c4684c5ac6aa9b06 Mon Sep 17 00:00:00 2001 From: Jan Gleixner Date: Mon, 9 Nov 2026 14:36:19 +0100 Subject: [PATCH 3/3] Scatter widget: only log the release position for QMouseEvents mouseReleaseEvent is also connected to scene().sigMouseClicked, which passes a pyqtgraph MouseClickEvent. Its pos() is item-local (or fails if no item took the click) and is a QPointF that mapToScene can't take, so the always-evaluated f-string raised on every ordinary click. --- src/napari_spatialdata/_scatterwidgets.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/napari_spatialdata/_scatterwidgets.py b/src/napari_spatialdata/_scatterwidgets.py index 98169149..8caf894f 100644 --- a/src/napari_spatialdata/_scatterwidgets.py +++ b/src/napari_spatialdata/_scatterwidgets.py @@ -1022,8 +1022,10 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: def mouseReleaseEvent(self, event: Any) -> None: - scene_pos = self.mapToScene(event.pos()) - logger.debug(f"Mouse release event detected at {self.scatter_plot.vb.mapSceneToView(scene_pos)}") + # also called by scene().sigMouseClicked with a pyqtgraph MouseClickEvent, whose pos() mapToScene can't take + if isinstance(event, QtGui.QMouseEvent): + scene_pos = self.mapToScene(event.pos()) + logger.debug(f"Mouse release event detected at {self.scatter_plot.vb.mapSceneToView(scene_pos)}") if self.drawing and event.button() == Qt.LeftButton: