1
0
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:
2025-10-14 10:54:45 +02:00
committed by Jan Wyzula
parent 3d807eaa63
commit c87a6cfce9
2 changed files with 179 additions and 11 deletions

View File

@@ -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)

View File

@@ -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()