From 594185dde9c73991489f2154507f1c3d3822c5b4 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 10 Jun 2025 15:30:53 +0200 Subject: [PATCH] feat(image_roi_tree): lock/unlock rois possible through the ROIPropertyTree --- .../image/setting_widgets/image_roi_tree.py | 69 +++++++++++++++++- bec_widgets/widgets/plots/roi/image_roi.py | 2 + tests/unit_tests/test_image_roi_tree.py | 70 ++++++++++++++++++- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py b/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py index 7a64c49c..50f497b4 100644 --- a/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py +++ b/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py @@ -8,6 +8,7 @@ from qtpy.QtCore import QEvent, Qt from qtpy.QtGui import QColor from qtpy.QtWidgets import ( QColorDialog, + QHBoxLayout, QHeaderView, QSpinBox, QToolButton, @@ -35,6 +36,28 @@ if TYPE_CHECKING: from bec_widgets.widgets.plots.image.image import Image +class ROILockButton(QToolButton): + """Keeps its icon and checked state in sync with a single ROI.""" + + def __init__(self, roi: BaseROI, parent=None): + super().__init__(parent) + self.setCheckable(True) + self._roi = roi + self.clicked.connect(self._toggle) + roi.movableChanged.connect(lambda _: self._sync()) + self._sync() + + def _toggle(self): + # checked -> locked -> movable = False + self._roi.movable = not self.isChecked() + + def _sync(self): + movable = self._roi.movable + self.setChecked(not movable) + icon = "lock_open_right" if movable else "lock" + self.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False)) + + class ROIPropertyTree(BECWidget, QWidget): """ Two-column tree: [ROI] [Properties] @@ -124,6 +147,24 @@ class ROIPropertyTree(BECWidget, QWidget): self.expand_toggle.action.toggled.connect(_exp_toggled) self.expand_toggle.action.setChecked(False) + + # Lock/Unlock all ROIs + self.lock_all_action = MaterialIconAction( + "lock_open_right", "Lock/Unlock all ROIs", checkable=True, parent=self + ) + tb.add_action("Lock/Unlock all ROIs", self.lock_all_action, self) + + def _lock_all(checked: bool): + # checked -> everything locked (movable = False) + for r in self.controller.rois: + r.movable = not checked + new_icon = material_icon( + "lock" if checked else "lock_open_right", size=(20, 20), convert_to_pixmap=False + ) + self.lock_all_action.action.setIcon(new_icon) + + self.lock_all_action.action.toggled.connect(_lock_all) + # colormap widget self.cmap = BECColorMapWidget(cmap=self.controller.colormap) tb.addWidget(QWidget()) # spacer @@ -241,11 +282,24 @@ class ROIPropertyTree(BECWidget, QWidget): # --------------------------------------------------------- controller slots def _on_roi_added(self, roi: BaseROI): + # check the global setting from the toolbar + if self.lock_all_action.action.isChecked(): + roi.movable = False # parent row with blank action column, name in ROI column parent = QTreeWidgetItem(self.tree, ["", "", ""]) parent.setText(self.COL_ROI, roi.label) parent.setFlags(parent.flags() | Qt.ItemIsEditable) - # --- delete button in actions column --- + # --- actions widget (lock/unlock + delete) --- + actions_widget = QWidget() + actions_layout = QHBoxLayout(actions_widget) + actions_layout.setContentsMargins(0, 0, 0, 0) + actions_layout.setSpacing(3) + + # lock / unlock toggle + lock_btn = ROILockButton(roi, parent=self) + actions_layout.addWidget(lock_btn) + + # delete button del_btn = QToolButton() delete_icon = material_icon( "delete", @@ -255,8 +309,11 @@ class ROIPropertyTree(BECWidget, QWidget): color=self.DELETE_BUTTON_COLOR, ) del_btn.setIcon(delete_icon) - self.tree.setItemWidget(parent, self.COL_ACTION, del_btn) del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r)) + actions_layout.addWidget(del_btn) + + # install composite widget into the tree + self.tree.setItemWidget(parent, self.COL_ACTION, actions_widget) # color button color_btn = ColorButtonNative(parent=self, color=roi.line_color) self.tree.setItemWidget(parent, self.COL_PROPS, color_btn) @@ -308,6 +365,12 @@ class ROIPropertyTree(BECWidget, QWidget): for c in range(3): self.tree.resizeColumnToContents(c) + def _toggle_movable(self, roi: BaseROI): + """ + Toggle the `movable` property of the given ROI. + """ + roi.movable = not roi.movable + def _on_roi_removed(self, roi: BaseROI): item = self.roi_items.pop(roi, None) if item: @@ -344,7 +407,7 @@ if __name__ == "__main__": # pragma: no cover import sys import numpy as np - from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout + from qtpy.QtWidgets import QApplication from bec_widgets.widgets.plots.image.image import Image diff --git a/bec_widgets/widgets/plots/roi/image_roi.py b/bec_widgets/widgets/plots/roi/image_roi.py index 50eaedf4..242e4e0d 100644 --- a/bec_widgets/widgets/plots/roi/image_roi.py +++ b/bec_widgets/widgets/plots/roi/image_roi.py @@ -104,6 +104,7 @@ class BaseROI(BECConnector): nameChanged = Signal(str) penChanged = Signal() + movableChanged = Signal(bool) USER_ACCESS = [ "label", "label.setter", @@ -224,6 +225,7 @@ class BaseROI(BECConnector): self.add_scale_handle() # add custom scale handles else: self.remove_scale_handles() # remove custom scale handles + self.movableChanged.emit(value) @property def label(self) -> str: diff --git a/tests/unit_tests/test_image_roi_tree.py b/tests/unit_tests/test_image_roi_tree.py index 6a6f6a7d..ce2745a8 100644 --- a/tests/unit_tests/test_image_roi_tree.py +++ b/tests/unit_tests/test_image_roi_tree.py @@ -148,10 +148,10 @@ def test_delete_roi_button(roi_tree, image_widget, qtbot): roi = image_widget.add_roi(kind="rect", name="to_delete") item = roi_tree.roi_items[roi] - # Get the delete button - del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION) + action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION) + layout = action_widget.layout() - # Click the delete button + del_btn = layout.itemAt(1).widget() del_btn.click() qtbot.wait(200) @@ -331,3 +331,67 @@ def test_add_roi_from_toolbar(qtbot, mocked_client): # Verify it's a circle ROI assert isinstance(new_roi, CircularROI) + + +def test_roi_lock_button(roi_tree, image_widget, qtbot): + """Verify the individual lock button toggles ROI.movable.""" + roi = image_widget.add_roi(kind="rect", name="lock_test") + item = roi_tree.roi_items[roi] + + # Lock button is the first widget in the Actions layout + action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION) + lock_btn = action_widget.layout().itemAt(0).widget() + + # Initially unlocked + assert roi.movable + assert not lock_btn.isChecked() + + # Lock it + lock_btn.click() + qtbot.wait(200) + assert not roi.movable + assert lock_btn.isChecked() + + # Unlock again + lock_btn.click() + qtbot.wait(200) + assert roi.movable + assert not lock_btn.isChecked() + + +def test_global_lock_all_button(roi_tree, image_widget, qtbot): + """Verify the toolbar lock-all action locks/unlocks every ROI.""" + roi1 = image_widget.add_roi(kind="rect", name="g1") + roi2 = image_widget.add_roi(kind="circle", name="g2") + + lock_all = roi_tree.lock_all_action.action + + # Start unlocked + assert roi1.movable and roi2.movable + assert not lock_all.isChecked() + + # Toggle → lock everything + lock_all.trigger() + qtbot.wait(200) + assert lock_all.isChecked() + assert not roi1.movable and not roi2.movable + + # Toggle again → unlock everything + lock_all.trigger() + qtbot.wait(200) + assert not lock_all.isChecked() + assert roi1.movable and roi2.movable + + +def test_new_roi_respects_global_lock(roi_tree, image_widget, qtbot): + """When the global lock-all toggle is active, newly added ROIs start locked.""" + # Enable global lock + roi_tree.lock_all_action.action.setChecked(True) + qtbot.wait(100) + + # Add ROI after lock enabled + roi = image_widget.add_roi(kind="rect", name="new_locked") + + assert not roi.movable + # Disable global lock again + roi_tree.lock_all_action.action.setChecked(False)