From c87a6cfce9c36588b32f5279e63072bc2646c36f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 14 Oct 2025 10:54:45 +0200 Subject: [PATCH] feat(image_roi_tree): compact mode added --- .../image/setting_widgets/image_roi_tree.py | 65 +++++++-- tests/unit_tests/test_image_roi_tree.py | 125 ++++++++++++++++++ 2 files changed, 179 insertions(+), 11 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 c7fa5cad..3bff28af 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 @@ -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) diff --git a/tests/unit_tests/test_image_roi_tree.py b/tests/unit_tests/test_image_roi_tree.py index 6480e870..d00866b2 100644 --- a/tests/unit_tests/test_image_roi_tree.py +++ b/tests/unit_tests/test_image_roi_tree.py @@ -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()