0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

feat(image_roi_tree): lock/unlock rois possible through the ROIPropertyTree

This commit is contained in:
2025-06-10 15:30:53 +02:00
committed by Jan Wyzula
parent 46d7e3f517
commit 594185dde9
3 changed files with 135 additions and 6 deletions

View File

@ -8,6 +8,7 @@ from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor from qtpy.QtGui import QColor
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QColorDialog, QColorDialog,
QHBoxLayout,
QHeaderView, QHeaderView,
QSpinBox, QSpinBox,
QToolButton, QToolButton,
@ -35,6 +36,28 @@ if TYPE_CHECKING:
from bec_widgets.widgets.plots.image.image import Image 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): class ROIPropertyTree(BECWidget, QWidget):
""" """
Two-column tree: [ROI] [Properties] 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.toggled.connect(_exp_toggled)
self.expand_toggle.action.setChecked(False) 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 # colormap widget
self.cmap = BECColorMapWidget(cmap=self.controller.colormap) self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
tb.addWidget(QWidget()) # spacer tb.addWidget(QWidget()) # spacer
@ -241,11 +282,24 @@ class ROIPropertyTree(BECWidget, QWidget):
# --------------------------------------------------------- controller slots # --------------------------------------------------------- controller slots
def _on_roi_added(self, roi: BaseROI): 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 row with blank action column, name in ROI column
parent = QTreeWidgetItem(self.tree, ["", "", ""]) parent = QTreeWidgetItem(self.tree, ["", "", ""])
parent.setText(self.COL_ROI, roi.label) parent.setText(self.COL_ROI, roi.label)
parent.setFlags(parent.flags() | Qt.ItemIsEditable) 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() del_btn = QToolButton()
delete_icon = material_icon( delete_icon = material_icon(
"delete", "delete",
@ -255,8 +309,11 @@ class ROIPropertyTree(BECWidget, QWidget):
color=self.DELETE_BUTTON_COLOR, color=self.DELETE_BUTTON_COLOR,
) )
del_btn.setIcon(delete_icon) 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)) 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 button
color_btn = ColorButtonNative(parent=self, color=roi.line_color) color_btn = ColorButtonNative(parent=self, color=roi.line_color)
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn) self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
@ -308,6 +365,12 @@ class ROIPropertyTree(BECWidget, QWidget):
for c in range(3): for c in range(3):
self.tree.resizeColumnToContents(c) 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): def _on_roi_removed(self, roi: BaseROI):
item = self.roi_items.pop(roi, None) item = self.roi_items.pop(roi, None)
if item: if item:
@ -344,7 +407,7 @@ if __name__ == "__main__": # pragma: no cover
import sys import sys
import numpy as np 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 from bec_widgets.widgets.plots.image.image import Image

View File

@ -104,6 +104,7 @@ class BaseROI(BECConnector):
nameChanged = Signal(str) nameChanged = Signal(str)
penChanged = Signal() penChanged = Signal()
movableChanged = Signal(bool)
USER_ACCESS = [ USER_ACCESS = [
"label", "label",
"label.setter", "label.setter",
@ -224,6 +225,7 @@ class BaseROI(BECConnector):
self.add_scale_handle() # add custom scale handles self.add_scale_handle() # add custom scale handles
else: else:
self.remove_scale_handles() # remove custom scale handles self.remove_scale_handles() # remove custom scale handles
self.movableChanged.emit(value)
@property @property
def label(self) -> str: def label(self) -> str:

View File

@ -148,10 +148,10 @@ def test_delete_roi_button(roi_tree, image_widget, qtbot):
roi = image_widget.add_roi(kind="rect", name="to_delete") roi = image_widget.add_roi(kind="rect", name="to_delete")
item = roi_tree.roi_items[roi] item = roi_tree.roi_items[roi]
# Get the delete button action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
del_btn = 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() del_btn.click()
qtbot.wait(200) qtbot.wait(200)
@ -331,3 +331,67 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
# Verify it's a circle ROI # Verify it's a circle ROI
assert isinstance(new_roi, CircularROI) 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)