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:
@ -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
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
Reference in New Issue
Block a user