mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-04 16:02:51 +01:00
feat(image_roi_tree): compact mode added
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
@@ -73,11 +73,16 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
- Children: type, line-width (spin box), coordinates (auto-updating).
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): Parent widget. Defaults to None.
|
||||
image_widget (Image): The main Image widget that displays the ImageItem.
|
||||
Provides ``plot_item`` and owns an ROIController already.
|
||||
controller (ROIController, optional): Optionally pass an external controller.
|
||||
If None, the manager uses ``image_widget.roi_controller``.
|
||||
parent (QWidget, optional): Parent widget. Defaults to None.
|
||||
compact (bool, optional): If True, use a compact mode with no tree view,
|
||||
only a toolbar with draw actions. Defaults to False.
|
||||
compact_orientation (str, optional): Orientation of the toolbar in compact mode.
|
||||
Either "vertical" or "horizontal". Defaults to "vertical".
|
||||
compact_color (str, optional): Color of the single active ROI in compact mode.
|
||||
"""
|
||||
|
||||
PLUGIN = False
|
||||
@@ -92,11 +97,18 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
parent: QWidget = None,
|
||||
image_widget: Image,
|
||||
controller: ROIController | None = None,
|
||||
compact: bool = False,
|
||||
compact_orientation: Literal["vertical", "horizontal"] = "vertical",
|
||||
compact_color: str = "#f0f0f0",
|
||||
):
|
||||
|
||||
super().__init__(
|
||||
parent=parent, config=ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
)
|
||||
self.compact = compact
|
||||
self.compact_orient = compact_orientation
|
||||
self.compact_color = compact_color
|
||||
self.single_active_roi: BaseROI | None = None
|
||||
|
||||
if controller is None:
|
||||
# Use the controller already belonging to the Image widget
|
||||
@@ -112,22 +124,29 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self._init_toolbar()
|
||||
self._init_tree()
|
||||
if not self.compact:
|
||||
self._init_tree()
|
||||
else:
|
||||
self.tree = None
|
||||
|
||||
# connect controller
|
||||
self.controller.roiAdded.connect(self._on_roi_added)
|
||||
self.controller.roiRemoved.connect(self._on_roi_removed)
|
||||
self.controller.cleared.connect(self.tree.clear)
|
||||
if not self.compact:
|
||||
self.controller.cleared.connect(self.tree.clear)
|
||||
|
||||
# initial load
|
||||
for r in self.controller.rois:
|
||||
self._on_roi_added(r)
|
||||
|
||||
self.tree.collapseAll()
|
||||
if not self.compact:
|
||||
self.tree.collapseAll()
|
||||
|
||||
# --------------------------------------------------------------------- UI
|
||||
def _init_toolbar(self):
|
||||
tb = self.toolbar = ModularToolBar(self, orientation="horizontal")
|
||||
tb = self.toolbar = ModularToolBar(
|
||||
self, orientation=self.compact_orient if self.compact else "horizontal"
|
||||
)
|
||||
self._draw_actions: dict[str, MaterialIconAction] = {}
|
||||
# --- ROI draw actions (toggleable) ---
|
||||
|
||||
@@ -157,6 +176,17 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
for mode, act in self._draw_actions.items():
|
||||
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
|
||||
|
||||
if self.compact:
|
||||
tb.show_bundles(["roi_draw"])
|
||||
self.layout.addWidget(tb)
|
||||
|
||||
# ROI drawing state (needed even in compact mode)
|
||||
self._roi_draw_mode = None
|
||||
self._roi_start_pos = None
|
||||
self._temp_roi = None
|
||||
self.plot.scene().installEventFilter(self)
|
||||
return
|
||||
|
||||
# Expand/Collapse toggle
|
||||
self.expand_toggle = MaterialIconAction(
|
||||
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
|
||||
@@ -327,13 +357,21 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self._set_roi_draw_mode(None)
|
||||
# register via controller
|
||||
self.controller.add_roi(final_roi)
|
||||
if self.compact:
|
||||
final_roi.line_color = self.compact_color
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
# --------------------------------------------------------- controller slots
|
||||
def _on_roi_added(self, roi: BaseROI):
|
||||
if self.compact:
|
||||
roi.line_color = self.compact_color
|
||||
if self.single_active_roi is not None and self.single_active_roi is not roi:
|
||||
self.controller.remove_roi(self.single_active_roi)
|
||||
self.single_active_roi = roi
|
||||
return
|
||||
# check the global setting from the toolbar
|
||||
if self.lock_all_action.action.isChecked():
|
||||
if hasattr(self, "lock_all_action") and self.lock_all_action.action.isChecked():
|
||||
roi.movable = False
|
||||
# parent row with blank action column, name in ROI column
|
||||
parent = QTreeWidgetItem(self.tree, ["", "", ""])
|
||||
@@ -424,6 +462,10 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
roi.movable = not roi.movable
|
||||
|
||||
def _on_roi_removed(self, roi: BaseROI):
|
||||
if self.compact:
|
||||
if self.single_active_roi is roi:
|
||||
self.single_active_roi = None
|
||||
return
|
||||
item = self.roi_items.pop(roi, None)
|
||||
if item:
|
||||
idx = self.tree.indexOfTopLevelItem(item)
|
||||
@@ -449,8 +491,9 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.controller.remove_roi(roi)
|
||||
|
||||
def cleanup(self):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
if hasattr(self, "cmap"):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
if self.controller and hasattr(self.controller, "rois"):
|
||||
for roi in self.controller.rois: # disconnect all signals from ROIs
|
||||
try:
|
||||
@@ -491,8 +534,8 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# Add the image widget on the left
|
||||
ml.addWidget(image_widget)
|
||||
|
||||
# ROI manager linked to that image
|
||||
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget)
|
||||
# ROI manager linked to that image with compact mode
|
||||
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget, compact=True)
|
||||
mgr.setFixedWidth(350)
|
||||
ml.addWidget(mgr)
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ def roi_tree(qtbot, image_widget):
|
||||
yield tree
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compact_roi_tree(qtbot, image_widget):
|
||||
tree = create_widget(
|
||||
qtbot, ROIPropertyTree, image_widget=image_widget, compact=True, compact_color="#00BCD4"
|
||||
)
|
||||
yield tree
|
||||
|
||||
|
||||
def test_initialization(roi_tree, image_widget):
|
||||
"""Test that the widget initializes correctly with the right components."""
|
||||
# Check the widget has the right structure
|
||||
@@ -431,3 +439,120 @@ def test_cleanup_disconnect_signals(roi_tree, image_widget):
|
||||
# Verify that the tree item was not updated
|
||||
assert item.text(roi_tree.COL_ROI) == initial_name
|
||||
assert item.child(2).text(roi_tree.COL_PROPS) == initial_coord
|
||||
|
||||
|
||||
def test_compact_initialization_minimal_toolbar(compact_roi_tree):
|
||||
assert compact_roi_tree.compact is True
|
||||
assert compact_roi_tree.tree is None
|
||||
|
||||
# Draw actions exist
|
||||
assert compact_roi_tree.toolbar.components.get_action("roi_rectangle")
|
||||
assert compact_roi_tree.toolbar.components.get_action("roi_circle")
|
||||
assert compact_roi_tree.toolbar.components.get_action("roi_ellipse")
|
||||
|
||||
# Full-mode actions are absent
|
||||
import pytest
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
compact_roi_tree.toolbar.components.get_action("expand_toggle")
|
||||
with pytest.raises(KeyError):
|
||||
compact_roi_tree.toolbar.components.get_action("lock_unlock_all")
|
||||
with pytest.raises(KeyError):
|
||||
compact_roi_tree.toolbar.components.get_action("roi_tree_cmap")
|
||||
|
||||
assert not hasattr(compact_roi_tree, "lock_all_action")
|
||||
|
||||
|
||||
def test_compact_single_roi_enforced_programmatic(compact_roi_tree, image_widget):
|
||||
# Add first ROI
|
||||
roi1 = image_widget.add_roi(kind="rect", name="r1")
|
||||
assert len(image_widget.roi_controller.rois) == 1
|
||||
assert roi1.line_color == "#00BCD4"
|
||||
|
||||
# Add second ROI; the first should be removed automatically
|
||||
roi2 = image_widget.add_roi(kind="circle", name="c1")
|
||||
rois = image_widget.roi_controller.rois
|
||||
assert len(rois) == 1
|
||||
assert rois[0] is roi2
|
||||
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI
|
||||
|
||||
assert isinstance(rois[0], CircularROI)
|
||||
assert rois[0].line_color == "#00BCD4"
|
||||
|
||||
|
||||
def test_compact_add_roi_from_toolbar_single_enforced(qtbot, compact_roi_tree, image_widget):
|
||||
# Ensure view is ready
|
||||
plot_item = image_widget.plot_item
|
||||
view = plot_item.vb.scene().views()[0]
|
||||
qtbot.waitExposed(view)
|
||||
|
||||
# Activate rectangle drawing
|
||||
rect_action = compact_roi_tree.toolbar.components.get_action("roi_rectangle").action
|
||||
rect_action.setChecked(True)
|
||||
|
||||
# Draw rectangle
|
||||
start_pos = QPointF(10, 10)
|
||||
end_pos = QPointF(50, 40)
|
||||
start_scene = plot_item.vb.mapViewToScene(start_pos)
|
||||
end_scene = plot_item.vb.mapViewToScene(end_pos)
|
||||
start_widget = view.mapFromScene(start_scene)
|
||||
end_widget = view.mapFromScene(end_scene)
|
||||
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_widget)
|
||||
qtbot.mouseMove(view.viewport(), pos=end_widget)
|
||||
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_widget)
|
||||
qtbot.wait(100)
|
||||
|
||||
rois = image_widget.roi_controller.rois
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
|
||||
|
||||
assert len(rois) == 1
|
||||
assert isinstance(rois[0], RectangularROI)
|
||||
assert rois[0].line_color == "#00BCD4"
|
||||
|
||||
# Now draw a circle; rectangle should be removed automatically
|
||||
rect_action.setChecked(False)
|
||||
circle_action = compact_roi_tree.toolbar.components.get_action("roi_circle").action
|
||||
circle_action.setChecked(True)
|
||||
|
||||
start_pos = QPointF(20, 20)
|
||||
end_pos = QPointF(40, 40)
|
||||
start_scene = plot_item.vb.mapViewToScene(start_pos)
|
||||
end_scene = plot_item.vb.mapViewToScene(end_pos)
|
||||
start_widget = view.mapFromScene(start_scene)
|
||||
end_widget = view.mapFromScene(end_scene)
|
||||
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_widget)
|
||||
qtbot.mouseMove(view.viewport(), pos=end_widget)
|
||||
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_widget)
|
||||
qtbot.wait(100)
|
||||
|
||||
rois = image_widget.roi_controller.rois
|
||||
assert len(rois) == 1
|
||||
assert isinstance(rois[0], CircularROI)
|
||||
assert rois[0].line_color == "#00BCD4"
|
||||
|
||||
|
||||
def test_compact_draw_mode_toggle(compact_roi_tree):
|
||||
# Initially no draw mode
|
||||
assert compact_roi_tree._roi_draw_mode is None
|
||||
|
||||
rect_action = compact_roi_tree.toolbar.components.get_action("roi_rectangle").action
|
||||
circle_action = compact_roi_tree.toolbar.components.get_action("roi_circle").action
|
||||
|
||||
# Toggle rect on
|
||||
rect_action.toggle()
|
||||
assert compact_roi_tree._roi_draw_mode == "rect"
|
||||
assert rect_action.isChecked()
|
||||
assert not circle_action.isChecked()
|
||||
|
||||
# Toggle circle on; rect should toggle off
|
||||
circle_action.toggle()
|
||||
assert compact_roi_tree._roi_draw_mode == "circle"
|
||||
assert circle_action.isChecked()
|
||||
assert not rect_action.isChecked()
|
||||
|
||||
# Toggle circle off → none
|
||||
circle_action.toggle()
|
||||
assert compact_roi_tree._roi_draw_mode is None
|
||||
assert not rect_action.isChecked()
|
||||
assert not circle_action.isChecked()
|
||||
|
||||
Reference in New Issue
Block a user