From db720e8fa46bb2fb10c73afa1b4f039cd256d68b Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 25 Jun 2025 10:49:39 +0200 Subject: [PATCH] refactor(toolbar): split toolbar into components, bundles and connections --- .github/workflows/pytest-matrix.yml | 2 +- bec_widgets/applications/launch_window.py | 2 +- bec_widgets/utils/side_panel.py | 19 +- bec_widgets/utils/toolbar.py | 1088 ----------------- bec_widgets/utils/toolbars/actions.py | 524 ++++++++ bec_widgets/utils/toolbars/bundles.py | 244 ++++ bec_widgets/utils/toolbars/connections.py | 23 + bec_widgets/utils/toolbars/performance.py | 58 + bec_widgets/utils/toolbars/toolbar.py | 513 ++++++++ .../widgets/containers/dock/dock_area.py | 293 +++-- bec_widgets/widgets/plots/image/image.py | 162 ++- bec_widgets/widgets/plots/image/image_base.py | 269 ++-- .../image/setting_widgets/image_roi_tree.py | 58 +- .../image/toolbar_bundles/image_selection.py | 107 -- .../plots/image/toolbar_bundles/processing.py | 92 -- .../__init__.py | 0 .../toolbar_components/image_base_actions.py | 390 ++++++ .../widgets/plots/motor_map/motor_map.py | 122 +- .../toolbar_bundles/motor_selection.py | 70 -- .../__init__.py | 0 .../toolbar_components/motor_selection.py | 51 + .../plots/multi_waveform/multi_waveform.py | 87 +- .../__init__.py | 0 .../monitor_selection.py | 37 +- bec_widgets/widgets/plots/plot_base.py | 141 +-- .../scatter_waveform/scatter_waveform.py | 68 +- .../toolbar_bundles/mouse_interactions.py | 108 -- .../plots/toolbar_bundles/plot_export.py | 81 -- .../plots/toolbar_bundles/roi_bundle.py | 31 - .../plots/toolbar_bundles/save_state.py | 48 - .../__init__.py | 0 .../toolbar_components/axis_settings_popup.py | 94 ++ .../toolbar_components/mouse_interactions.py | 169 +++ .../plots/toolbar_components/plot_export.py | 123 ++ .../widgets/plots/toolbar_components/roi.py | 79 ++ .../settings/curve_settings/curve_tree.py | 64 +- .../widgets/plots/waveform/waveform.py | 73 +- .../widgets/services/bec_queue/bec_queue.py | 33 +- tests/unit_tests/test_bec_dock.py | 27 +- tests/unit_tests/test_curve_settings.py | 8 +- tests/unit_tests/test_image_roi_tree.py | 34 +- tests/unit_tests/test_image_view_next_gen.py | 70 +- tests/unit_tests/test_modular_toolbar.py | 381 +++--- tests/unit_tests/test_motor_map_next_gen.py | 15 +- .../test_multi_waveform_next_gen.py | 14 +- tests/unit_tests/test_plot_base_next_gen.py | 87 +- tests/unit_tests/test_side_menu.py | 12 +- tests/unit_tests/test_waveform_next_gen.py | 11 +- 48 files changed, 3415 insertions(+), 2567 deletions(-) delete mode 100644 bec_widgets/utils/toolbar.py create mode 100644 bec_widgets/utils/toolbars/actions.py create mode 100644 bec_widgets/utils/toolbars/bundles.py create mode 100644 bec_widgets/utils/toolbars/connections.py create mode 100644 bec_widgets/utils/toolbars/performance.py create mode 100644 bec_widgets/utils/toolbars/toolbar.py delete mode 100644 bec_widgets/widgets/plots/image/toolbar_bundles/image_selection.py delete mode 100644 bec_widgets/widgets/plots/image/toolbar_bundles/processing.py rename bec_widgets/widgets/plots/image/{toolbar_bundles => toolbar_components}/__init__.py (100%) create mode 100644 bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py delete mode 100644 bec_widgets/widgets/plots/motor_map/toolbar_bundles/motor_selection.py rename bec_widgets/widgets/plots/motor_map/{toolbar_bundles => toolbar_components}/__init__.py (100%) create mode 100644 bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py rename bec_widgets/widgets/plots/multi_waveform/{toolbar_bundles => toolbar_components}/__init__.py (100%) rename bec_widgets/widgets/plots/multi_waveform/{toolbar_bundles => toolbar_components}/monitor_selection.py (66%) delete mode 100644 bec_widgets/widgets/plots/toolbar_bundles/mouse_interactions.py delete mode 100644 bec_widgets/widgets/plots/toolbar_bundles/plot_export.py delete mode 100644 bec_widgets/widgets/plots/toolbar_bundles/roi_bundle.py delete mode 100644 bec_widgets/widgets/plots/toolbar_bundles/save_state.py rename bec_widgets/widgets/plots/{toolbar_bundles => toolbar_components}/__init__.py (100%) create mode 100644 bec_widgets/widgets/plots/toolbar_components/axis_settings_popup.py create mode 100644 bec_widgets/widgets/plots/toolbar_components/mouse_interactions.py create mode 100644 bec_widgets/widgets/plots/toolbar_components/plot_export.py create mode 100644 bec_widgets/widgets/plots/toolbar_components/roi.py diff --git a/.github/workflows/pytest-matrix.yml b/.github/workflows/pytest-matrix.yml index 9e6bad0f..4a1e4852 100644 --- a/.github/workflows/pytest-matrix.yml +++ b/.github/workflows/pytest-matrix.yml @@ -56,4 +56,4 @@ jobs: - name: Run Pytest run: | pip install pytest pytest-random-order - pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests + pytest -v --junitxml=report.xml --random-order ./tests/unit_tests diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 99cf7c8f..83822f7d 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -27,7 +27,7 @@ from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.name_utils import pascal_to_snake from bec_widgets.utils.plugin_utils import get_plugin_auto_updates from bec_widgets.utils.round_frame import RoundedFrame -from bec_widgets.utils.toolbar import ModularToolBar +from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates from bec_widgets.widgets.containers.dock.dock_area import BECDockArea diff --git a/bec_widgets/utils/side_panel.py b/bec_widgets/utils/side_panel.py index f1c58270..ae81e2d2 100644 --- a/bec_widgets/utils/side_panel.py +++ b/bec_widgets/utils/side_panel.py @@ -16,7 +16,8 @@ from qtpy.QtWidgets import ( QWidget, ) -from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar class SidePanel(QWidget): @@ -61,7 +62,7 @@ class SidePanel(QWidget): self.main_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.setSpacing(0) - self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical") + self.toolbar = ModularToolBar(parent=self, orientation="vertical") self.container = QWidget() self.container.layout = QVBoxLayout(self.container) @@ -92,7 +93,7 @@ class SidePanel(QWidget): self.main_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.setSpacing(0) - self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal") + self.toolbar = ModularToolBar(parent=self, orientation="horizontal") self.container = QWidget() self.container.layout = QVBoxLayout(self.container) @@ -288,8 +289,16 @@ class SidePanel(QWidget): # Add an action to the toolbar if action_id, icon_name, and tooltip are provided if action_id is not None and icon_name is not None and tooltip is not None: - action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True) - self.toolbar.add_action(action_id, action, target_widget=self) + action = MaterialIconAction( + icon_name=icon_name, tooltip=tooltip, checkable=True, parent=self + ) + self.toolbar.components.add_safe(action_id, action) + bundle = ToolbarBundle(action_id, self.toolbar.components) + bundle.add_action(action_id) + self.toolbar.add_bundle(bundle) + shown_bundles = self.toolbar.shown_bundles + shown_bundles.append(action_id) + self.toolbar.show_bundles(shown_bundles) def on_action_toggled(checked: bool): if self.switching_actions: diff --git a/bec_widgets/utils/toolbar.py b/bec_widgets/utils/toolbar.py deleted file mode 100644 index 763e9cfd..00000000 --- a/bec_widgets/utils/toolbar.py +++ /dev/null @@ -1,1088 +0,0 @@ -# pylint: disable=no-name-in-module -from __future__ import annotations - -import os -import sys -from abc import ABC, abstractmethod -from collections import defaultdict -from typing import Dict, List, Literal, Tuple - -from bec_lib.logger import bec_logger -from bec_qthemes._icon.material_icons import material_icon -from qtpy.QtCore import QSize, Qt, QTimer -from qtpy.QtGui import QAction, QColor, QIcon -from qtpy.QtWidgets import ( - QApplication, - QComboBox, - QHBoxLayout, - QLabel, - QMainWindow, - QMenu, - QSizePolicy, - QStyle, - QToolBar, - QToolButton, - QVBoxLayout, - QWidget, -) - -import bec_widgets -from bec_widgets.utils.colors import set_theme -from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton - -MODULE_PATH = os.path.dirname(bec_widgets.__file__) - -logger = bec_logger.logger - -# Ensure that icons are shown in menus (especially on macOS) -QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False) - - -class LongPressToolButton(QToolButton): - def __init__(self, *args, long_press_threshold=500, **kwargs): - super().__init__(*args, **kwargs) - self.long_press_threshold = long_press_threshold - self._long_press_timer = QTimer(self) - self._long_press_timer.setSingleShot(True) - self._long_press_timer.timeout.connect(self.handleLongPress) - self._pressed = False - self._longPressed = False - - def mousePressEvent(self, event): - self._pressed = True - self._longPressed = False - self._long_press_timer.start(self.long_press_threshold) - super().mousePressEvent(event) - - def mouseReleaseEvent(self, event): - self._pressed = False - if self._longPressed: - self._longPressed = False - self._long_press_timer.stop() - event.accept() # Prevent normal click action after a long press - return - self._long_press_timer.stop() - super().mouseReleaseEvent(event) - - def handleLongPress(self): - if self._pressed: - self._longPressed = True - self.showMenu() - - -class ToolBarAction(ABC): - """ - Abstract base class for toolbar actions. - - Args: - icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None. - tooltip (str, optional): The tooltip for the action. Defaults to None. - checkable (bool, optional): Whether the action is checkable. Defaults to False. - """ - - def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False): - self.icon_path = ( - os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None - ) - self.tooltip = tooltip - self.checkable = checkable - self.action = None - - @abstractmethod - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - """Adds an action or widget to a toolbar. - - Args: - toolbar (QToolBar): The toolbar to add the action or widget to. - target (QWidget): The target widget for the action. - """ - - -class SeparatorAction(ToolBarAction): - """Separator action for the toolbar.""" - - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - toolbar.addSeparator() - - -class IconAction(ToolBarAction): - """ - Action with an icon for the toolbar. - - Args: - icon_path (str): The path to the icon file. - tooltip (str): The tooltip for the action. - checkable (bool, optional): Whether the action is checkable. Defaults to False. - """ - - def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False): - super().__init__(icon_path, tooltip, checkable) - - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - icon = QIcon() - icon.addFile(self.icon_path, size=QSize(20, 20)) - self.action = QAction(icon=icon, text=self.tooltip, parent=target) - self.action.setCheckable(self.checkable) - toolbar.addAction(self.action) - - -class QtIconAction(ToolBarAction): - def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None): - super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) - self.standard_icon = standard_icon - self.icon = QApplication.style().standardIcon(standard_icon) - self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent) - self.action.setCheckable(self.checkable) - - def add_to_toolbar(self, toolbar, target): - toolbar.addAction(self.action) - - def get_icon(self): - return self.icon - - -class MaterialIconAction(ToolBarAction): - """ - Action with a Material icon for the toolbar. - - Args: - icon_name (str, optional): The name of the Material icon. Defaults to None. - tooltip (str, optional): The tooltip for the action. Defaults to None. - checkable (bool, optional): Whether the action is checkable. Defaults to False. - filled (bool, optional): Whether the icon is filled. Defaults to False. - color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon. - Defaults to None. - parent (QWidget or None, optional): Parent widget for the underlying QAction. - """ - - def __init__( - self, - icon_name: str = None, - tooltip: str = None, - checkable: bool = False, - filled: bool = False, - color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, - parent=None, - ): - super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) - self.icon_name = icon_name - self.filled = filled - self.color = color - # Generate the icon using the material_icon helper - self.icon = material_icon( - self.icon_name, - size=(20, 20), - convert_to_pixmap=False, - filled=self.filled, - color=self.color, - ) - if parent is None: - logger.warning( - "MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues." - ) - self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent) - self.action.setCheckable(self.checkable) - - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - """ - Adds the action to the toolbar. - - Args: - toolbar(QToolBar): The toolbar to add the action to. - target(QWidget): The target widget for the action. - """ - toolbar.addAction(self.action) - - def get_icon(self): - """ - Returns the icon for the action. - - Returns: - QIcon: The icon for the action. - """ - return self.icon - - -class DeviceSelectionAction(ToolBarAction): - """ - Action for selecting a device in a combobox. - - Args: - label (str): The label for the combobox. - device_combobox (DeviceComboBox): The combobox for selecting the device. - """ - - def __init__(self, label: str | None = None, device_combobox=None): - super().__init__() - self.label = label - self.device_combobox = device_combobox - self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700")) - - def add_to_toolbar(self, toolbar, target): - widget = QWidget(parent=target) - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - if self.label is not None: - label = QLabel(text=f"{self.label}", parent=target) - layout.addWidget(label) - if self.device_combobox is not None: - layout.addWidget(self.device_combobox) - toolbar.addWidget(widget) - - def set_combobox_style(self, color: str): - self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") - - -class SwitchableToolBarAction(ToolBarAction): - """ - A split toolbar action that combines a main action and a drop-down menu for additional actions. - - The main button displays the currently selected action's icon and tooltip. Clicking on the main button - triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an - alternative action is selected, it becomes the new default and its callback is immediately executed. - - This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars. - - Args: - actions (dict): A dictionary mapping a unique key to a ToolBarAction instance. - initial_action (str, optional): The key of the initial default action. If not provided, the first action is used. - tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip. - checkable (bool, optional): Whether the action is checkable. Defaults to True. - parent (QWidget, optional): Parent widget for the underlying QAction. - """ - - def __init__( - self, - actions: Dict[str, ToolBarAction], - initial_action: str = None, - tooltip: str = None, - checkable: bool = True, - default_state_checked: bool = False, - parent=None, - ): - super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) - self.actions = actions - self.current_key = initial_action if initial_action is not None else next(iter(actions)) - self.parent = parent - self.checkable = checkable - self.default_state_checked = default_state_checked - self.main_button = None - self.menu_actions: Dict[str, QAction] = {} - - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - """ - Adds the split action to the toolbar. - - Args: - toolbar (QToolBar): The toolbar to add the action to. - target (QWidget): The target widget for the action. - """ - self.main_button = LongPressToolButton(toolbar) - self.main_button.setPopupMode(QToolButton.MenuButtonPopup) - self.main_button.setCheckable(self.checkable) - default_action = self.actions[self.current_key] - self.main_button.setIcon(default_action.get_icon()) - self.main_button.setToolTip(default_action.tooltip) - self.main_button.clicked.connect(self._trigger_current_action) - menu = QMenu(self.main_button) - for key, action_obj in self.actions.items(): - menu_action = QAction( - icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button - ) - menu_action.setIconVisibleInMenu(True) - menu_action.setCheckable(self.checkable) - menu_action.setChecked(key == self.current_key) - menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k)) - menu.addAction(menu_action) - self.main_button.setMenu(menu) - toolbar.addWidget(self.main_button) - - def _trigger_current_action(self): - """ - Triggers the current action associated with the main button. - """ - action_obj = self.actions[self.current_key] - action_obj.action.trigger() - - def set_default_action(self, key: str): - """ - Sets the default action for the split action. - - Args: - key(str): The key of the action to set as default. - """ - self.current_key = key - new_action = self.actions[self.current_key] - self.main_button.setIcon(new_action.get_icon()) - self.main_button.setToolTip(new_action.tooltip) - # Update check state of menu items - for k, menu_act in self.actions.items(): - menu_act.action.setChecked(False) - new_action.action.trigger() - # Active action chosen from menu is always checked, uncheck through main button - if self.checkable: - new_action.action.setChecked(True) - self.main_button.setChecked(True) - - def block_all_signals(self, block: bool = True): - """ - Blocks or unblocks all signals for the actions in the toolbar. - - Args: - block (bool): Whether to block signals. Defaults to True. - """ - self.main_button.blockSignals(block) - for action in self.actions.values(): - action.action.blockSignals(block) - - def set_state_all(self, state: bool): - """ - Uncheck all actions in the toolbar. - """ - for action in self.actions.values(): - action.action.setChecked(state) - self.main_button.setChecked(state) - - def get_icon(self) -> QIcon: - return self.actions[self.current_key].get_icon() - - -class WidgetAction(ToolBarAction): - """ - Action for adding any widget to the toolbar. - - Args: - label (str|None): The label for the widget. - widget (QWidget): The widget to be added to the toolbar. - """ - - def __init__( - self, - label: str | None = None, - widget: QWidget = None, - adjust_size: bool = True, - parent=None, - ): - super().__init__(icon_path=None, tooltip=label, checkable=False) - self.label = label - self.widget = widget - self.container = None - self.adjust_size = adjust_size - - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - """ - Adds the widget to the toolbar. - - Args: - toolbar (QToolBar): The toolbar to add the widget to. - target (QWidget): The target widget for the action. - """ - self.container = QWidget(parent=target) - layout = QHBoxLayout(self.container) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - if self.label is not None: - label_widget = QLabel(text=f"{self.label}", parent=target) - label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) - label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) - layout.addWidget(label_widget) - - if isinstance(self.widget, QComboBox) and self.adjust_size: - self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) - - size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.widget.setSizePolicy(size_policy) - - self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget)) - - else: - self.widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - - layout.addWidget(self.widget) - - toolbar.addWidget(self.container) - # Store the container as the action to allow toggling visibility. - self.action = self.container - - @staticmethod - def calculate_minimum_width(combo_box: QComboBox) -> int: - font_metrics = combo_box.fontMetrics() - max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) - return max_width + 60 - - -class ExpandableMenuAction(ToolBarAction): - """ - Action for an expandable menu in the toolbar. - - Args: - label (str): The label for the menu. - actions (dict): A dictionary of actions to populate the menu. - icon_path (str, optional): The path to the icon file. Defaults to None. - """ - - def __init__(self, label: str, actions: dict, icon_path: str = None): - super().__init__(icon_path, label) - self.actions = actions - self.widgets = defaultdict(dict) - - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - button = QToolButton(toolbar) - if self.icon_path: - button.setIcon(QIcon(self.icon_path)) - button.setText(self.tooltip) - button.setPopupMode(QToolButton.InstantPopup) - button.setStyleSheet( - """ - QToolButton { - font-size: 14px; - } - QMenu { - font-size: 14px; - } - """ - ) - menu = QMenu(button) - for action_id, action in self.actions.items(): - sub_action = QAction(text=action.tooltip, parent=target) - sub_action.setIconVisibleInMenu(True) - if action.icon_path: - icon = QIcon() - icon.addFile(action.icon_path, size=QSize(20, 20)) - sub_action.setIcon(icon) - elif hasattr(action, "get_icon") and callable(action.get_icon): - sub_icon = action.get_icon() - if sub_icon and not sub_icon.isNull(): - sub_action.setIcon(sub_icon) - sub_action.setCheckable(action.checkable) - menu.addAction(sub_action) - self.widgets[action_id] = sub_action - button.setMenu(menu) - toolbar.addWidget(button) - - -class ToolbarBundle: - """ - Represents a bundle of toolbar actions, keyed by action_id. - Allows direct dictionary-like access: self.actions["some_id"] -> ToolBarAction object. - """ - - def __init__(self, bundle_id: str = None, actions=None): - """ - Args: - bundle_id (str): Unique identifier for the bundle. - actions: Either None or a list of (action_id, ToolBarAction) tuples. - """ - self.bundle_id = bundle_id - self._actions: dict[str, ToolBarAction] = {} - - if actions is not None: - for action_id, action in actions: - self._actions[action_id] = action - - def add_action(self, action_id: str, action: ToolBarAction): - """ - Adds or replaces an action in the bundle. - - Args: - action_id (str): Unique identifier for the action. - action (ToolBarAction): The action to add. - """ - self._actions[action_id] = action - - def remove_action(self, action_id: str): - """ - Removes an action from the bundle by ID. - Ignores if not present. - - Args: - action_id (str): Unique identifier for the action to remove. - """ - self._actions.pop(action_id, None) - - @property - def actions(self) -> dict[str, ToolBarAction]: - """ - Return the internal dictionary of actions so that you can do - bundle.actions["drag_mode"] -> ToolBarAction instance. - """ - return self._actions - - -class ModularToolBar(QToolBar): - """Modular toolbar with optional automatic initialization. - - Args: - parent (QWidget, optional): The parent widget of the toolbar. Defaults to None. - actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None. - target_widget (QWidget, optional): The widget that the actions will target. Defaults to None. - orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal". - background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)". - """ - - def __init__( - self, - parent=None, - actions: dict | None = None, - target_widget=None, - orientation: Literal["horizontal", "vertical"] = "horizontal", - background_color: str = "rgba(0, 0, 0, 0)", - ): - super().__init__(parent=parent) - - self.widgets = defaultdict(dict) - self.background_color = background_color - self.set_background_color(self.background_color) - - # Set the initial orientation - self.set_orientation(orientation) - - # Initialize bundles - self.bundles = {} - self.toolbar_items = [] - - if actions is not None and target_widget is not None: - self.populate_toolbar(actions, target_widget) - - def populate_toolbar(self, actions: dict, target_widget: QWidget): - """Populates the toolbar with a set of actions. - - Args: - actions (dict): A dictionary of action creators to populate the toolbar. - target_widget (QWidget): The widget that the actions will target. - """ - self.clear() - self.toolbar_items.clear() # Reset the order tracking - for action_id, action in actions.items(): - action.add_to_toolbar(self, target_widget) - self.widgets[action_id] = action - self.toolbar_items.append(("action", action_id)) - self.update_separators() # Ensure separators are updated after populating - - def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"): - """ - Sets the background color and other appearance settings. - - Args: - color (str): The background color of the toolbar. - """ - self.setIconSize(QSize(20, 20)) - self.setMovable(False) - self.setFloatable(False) - self.setContentsMargins(0, 0, 0, 0) - self.background_color = color - self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}") - - def set_orientation(self, orientation: Literal["horizontal", "vertical"]): - """Sets the orientation of the toolbar. - - Args: - orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar. - """ - if orientation == "horizontal": - self.setOrientation(Qt.Horizontal) - elif orientation == "vertical": - self.setOrientation(Qt.Vertical) - else: - raise ValueError("Orientation must be 'horizontal' or 'vertical'.") - - def update_material_icon_colors(self, new_color: str | tuple | QColor): - """ - Updates the color of all MaterialIconAction icons. - - Args: - new_color (str | tuple | QColor): The new color. - """ - for action in self.widgets.values(): - if isinstance(action, MaterialIconAction): - action.color = new_color - updated_icon = action.get_icon() - action.action.setIcon(updated_icon) - - def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget): - """ - Adds a new standalone action dynamically. - - Args: - action_id (str): Unique identifier. - action (ToolBarAction): The action to add. - target_widget (QWidget): The target widget. - """ - if action_id in self.widgets: - raise ValueError(f"Action with ID '{action_id}' already exists.") - action.add_to_toolbar(self, target_widget) - self.widgets[action_id] = action - self.toolbar_items.append(("action", action_id)) - self.update_separators() - - def hide_action(self, action_id: str): - """ - Hides a specific action. - - Args: - action_id (str): Unique identifier. - """ - if action_id not in self.widgets: - raise ValueError(f"Action with ID '{action_id}' does not exist.") - action = self.widgets[action_id] - if hasattr(action, "action") and action.action is not None: - action.action.setVisible(False) - self.update_separators() - - def show_action(self, action_id: str): - """ - Shows a specific action. - - Args: - action_id (str): Unique identifier. - """ - if action_id not in self.widgets: - raise ValueError(f"Action with ID '{action_id}' does not exist.") - action = self.widgets[action_id] - if hasattr(action, "action") and action.action is not None: - action.action.setVisible(True) - self.update_separators() - - def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget): - """ - Adds a bundle of actions, separated by a separator. - - Args: - bundle (ToolbarBundle): The bundle. - target_widget (QWidget): The target widget. - """ - if bundle.bundle_id in self.bundles: - raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.") - - if self.toolbar_items: - sep = SeparatorAction() - sep.add_to_toolbar(self, target_widget) - self.toolbar_items.append(("separator", None)) - - for action_id, action_obj in bundle.actions.items(): - action_obj.add_to_toolbar(self, target_widget) - self.widgets[action_id] = action_obj - - self.bundles[bundle.bundle_id] = list(bundle.actions.keys()) - self.toolbar_items.append(("bundle", bundle.bundle_id)) - self.update_separators() - - def add_action_to_bundle(self, bundle_id: str, action_id: str, action, target_widget: QWidget): - """ - Dynamically adds an action to an existing bundle. - - Args: - bundle_id (str): The bundle ID. - action_id (str): Unique identifier. - action (ToolBarAction): The action to add. - target_widget (QWidget): The target widget. - """ - if bundle_id not in self.bundles: - raise ValueError(f"Bundle '{bundle_id}' does not exist.") - if action_id in self.widgets: - raise ValueError(f"Action with ID '{action_id}' already exists.") - - action.add_to_toolbar(self, target_widget) - new_qaction = action.action - self.removeAction(new_qaction) - - bundle_action_ids = self.bundles[bundle_id] - if bundle_action_ids: - last_bundle_action = self.widgets[bundle_action_ids[-1]].action - actions_list = self.actions() - try: - index = actions_list.index(last_bundle_action) - except ValueError: - self.addAction(new_qaction) - else: - if index + 1 < len(actions_list): - before_action = actions_list[index + 1] - self.insertAction(before_action, new_qaction) - else: - self.addAction(new_qaction) - else: - self.addAction(new_qaction) - - self.widgets[action_id] = action - self.bundles[bundle_id].append(action_id) - self.update_separators() - - def remove_action(self, action_id: str): - """ - Completely remove a single action from the toolbar. - - The method takes care of both standalone actions and actions that are - part of an existing bundle. - - Args: - action_id (str): Unique identifier for the action. - """ - if action_id not in self.widgets: - raise ValueError(f"Action with ID '{action_id}' does not exist.") - - # Identify potential bundle membership - parent_bundle = None - for b_id, a_ids in self.bundles.items(): - if action_id in a_ids: - parent_bundle = b_id - break - - # 1. Remove the QAction from the QToolBar and delete it - tool_action = self.widgets.pop(action_id) - if hasattr(tool_action, "action") and tool_action.action is not None: - self.removeAction(tool_action.action) - tool_action.action.deleteLater() - - # 2. Clean bundle bookkeeping if the action belonged to one - if parent_bundle: - self.bundles[parent_bundle].remove(action_id) - # If the bundle becomes empty, get rid of the bundle entry as well - if not self.bundles[parent_bundle]: - self.remove_bundle(parent_bundle) - - # 3. Remove from the ordering list - self.toolbar_items = [ - item - for item in self.toolbar_items - if not (item[0] == "action" and item[1] == action_id) - ] - - self.update_separators() - - def remove_bundle(self, bundle_id: str): - """ - Remove an entire bundle (and all of its actions) from the toolbar. - - Args: - bundle_id (str): Unique identifier for the bundle. - """ - if bundle_id not in self.bundles: - raise ValueError(f"Bundle '{bundle_id}' does not exist.") - - # Remove every action belonging to this bundle - for action_id in list(self.bundles[bundle_id]): # copy the list - if action_id in self.widgets: - tool_action = self.widgets.pop(action_id) - if hasattr(tool_action, "action") and tool_action.action is not None: - self.removeAction(tool_action.action) - tool_action.action.deleteLater() - - # Drop the bundle entry - self.bundles.pop(bundle_id, None) - - # Remove bundle entry and its preceding separator (if any) from the ordering list - cleaned_items = [] - skip_next_separator = False - for item_type, ident in self.toolbar_items: - if item_type == "bundle" and ident == bundle_id: - # mark to skip one following separator if present - skip_next_separator = True - continue - if skip_next_separator and item_type == "separator": - skip_next_separator = False - continue - cleaned_items.append((item_type, ident)) - self.toolbar_items = cleaned_items - - self.update_separators() - - def contextMenuEvent(self, event): - """ - Overrides the context menu event to show toolbar actions with checkboxes and icons. - - Args: - event (QContextMenuEvent): The context menu event. - """ - menu = QMenu(self) - for item_type, identifier in self.toolbar_items: - if item_type == "separator": - menu.addSeparator() - elif item_type == "bundle": - self.handle_bundle_context_menu(menu, identifier) - elif item_type == "action": - self.handle_action_context_menu(menu, identifier) - menu.triggered.connect(self.handle_menu_triggered) - menu.exec_(event.globalPos()) - - def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str): - """ - Adds bundle actions to the context menu. - - Args: - menu (QMenu): The context menu. - bundle_id (str): The bundle identifier. - """ - action_ids = self.bundles.get(bundle_id, []) - for act_id in action_ids: - toolbar_action = self.widgets.get(act_id) - if not isinstance(toolbar_action, ToolBarAction) or not hasattr( - toolbar_action, "action" - ): - continue - qaction = toolbar_action.action - if not isinstance(qaction, QAction): - continue - display_name = qaction.text() or toolbar_action.tooltip or act_id - menu_action = QAction(display_name, self) - menu_action.setCheckable(True) - menu_action.setChecked(qaction.isVisible()) - menu_action.setData(act_id) # Store the action_id - - # Set the icon if available - if qaction.icon() and not qaction.icon().isNull(): - menu_action.setIcon(qaction.icon()) - menu.addAction(menu_action) - - def handle_action_context_menu(self, menu: QMenu, action_id: str): - """ - Adds a single toolbar action to the context menu. - - Args: - menu (QMenu): The context menu to which the action is added. - action_id (str): Unique identifier for the action. - """ - toolbar_action = self.widgets.get(action_id) - if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"): - return - qaction = toolbar_action.action - if not isinstance(qaction, QAction): - return - display_name = qaction.text() or toolbar_action.tooltip or action_id - menu_action = QAction(display_name, self) - menu_action.setCheckable(True) - menu_action.setChecked(qaction.isVisible()) - menu_action.setData(action_id) # Store the action_id - - # Set the icon if available - if qaction.icon() and not qaction.icon().isNull(): - menu_action.setIcon(qaction.icon()) - - menu.addAction(menu_action) - - def handle_menu_triggered(self, action): - """ - Handles the triggered signal from the context menu. - - Args: - action: Action triggered. - """ - action_id = action.data() - if action_id: - self.toggle_action_visibility(action_id, action.isChecked()) - - def toggle_action_visibility(self, action_id: str, visible: bool): - """ - Toggles the visibility of a specific action. - - Args: - action_id (str): Unique identifier. - visible (bool): Whether the action should be visible. - """ - if action_id not in self.widgets: - return - tool_action = self.widgets[action_id] - if hasattr(tool_action, "action") and tool_action.action is not None: - tool_action.action.setVisible(visible) - self.update_separators() - - def update_separators(self): - """ - Hide separators that are adjacent to another separator or have no non-separator actions between them. - """ - toolbar_actions = self.actions() - # First pass: set visibility based on surrounding non-separator actions. - for i, action in enumerate(toolbar_actions): - if not action.isSeparator(): - continue - prev_visible = None - for j in range(i - 1, -1, -1): - if toolbar_actions[j].isVisible(): - prev_visible = toolbar_actions[j] - break - next_visible = None - for j in range(i + 1, len(toolbar_actions)): - if toolbar_actions[j].isVisible(): - next_visible = toolbar_actions[j] - break - if (prev_visible is None or prev_visible.isSeparator()) and ( - next_visible is None or next_visible.isSeparator() - ): - action.setVisible(False) - else: - action.setVisible(True) - # Second pass: ensure no two visible separators are adjacent. - prev = None - for action in toolbar_actions: - if action.isVisible() and action.isSeparator(): - if prev and prev.isSeparator(): - action.setVisible(False) - else: - prev = action - else: - if action.isVisible(): - prev = action - - -class MainWindow(QMainWindow): # pragma: no cover - def __init__(self): - super().__init__() - self.setWindowTitle("Toolbar / ToolbarBundle Demo") - self.central_widget = QWidget() - self.setCentralWidget(self.central_widget) - self.test_label = QLabel(text="This is a test label.") - self.central_widget.layout = QVBoxLayout(self.central_widget) - self.central_widget.layout.addWidget(self.test_label) - - self.toolbar = ModularToolBar(parent=self, target_widget=self) - self.addToolBar(self.toolbar) - - self.add_switchable_button_checkable() - self.add_switchable_button_non_checkable() - self.add_widget_actions() - self.add_bundles() - self.add_menus() - - # For theme testing - - self.dark_button = DarkModeButton(parent=self, toolbar=True) - dark_mode_action = WidgetAction(label=None, widget=self.dark_button) - self.toolbar.add_action("dark_mode", dark_mode_action, self) - - def add_bundles(self): - home_action = MaterialIconAction( - icon_name="home", tooltip="Home", checkable=False, parent=self - ) - settings_action = MaterialIconAction( - icon_name="settings", tooltip="Settings", checkable=True, parent=self - ) - profile_action = MaterialIconAction( - icon_name="person", tooltip="Profile", checkable=True, parent=self - ) - main_actions_bundle = ToolbarBundle( - bundle_id="main_actions", - actions=[ - ("home_action", home_action), - ("settings_action", settings_action), - ("profile_action", profile_action), - ], - ) - self.toolbar.add_bundle(main_actions_bundle, target_widget=self) - home_action.action.triggered.connect(lambda: self.switchable_action.set_state_all(False)) - - search_action = MaterialIconAction( - icon_name="search", tooltip="Search", checkable=False, parent=self - ) - help_action = MaterialIconAction( - icon_name="help", tooltip="Help", checkable=False, parent=self - ) - second_bundle = ToolbarBundle( - bundle_id="secondary_actions", - actions=[("search_action", search_action), ("help_action", help_action)], - ) - self.toolbar.add_bundle(second_bundle, target_widget=self) - - new_action = MaterialIconAction( - icon_name="counter_1", tooltip="New Action", checkable=True, parent=self - ) - self.toolbar.add_action_to_bundle( - "main_actions", "new_action", new_action, target_widget=self - ) - - def add_menus(self): - menu_material_actions = { - "mat1": MaterialIconAction( - icon_name="home", tooltip="Material Home", checkable=True, parent=self - ), - "mat2": MaterialIconAction( - icon_name="settings", tooltip="Material Settings", checkable=True, parent=self - ), - "mat3": MaterialIconAction( - icon_name="info", tooltip="Material Info", checkable=True, parent=self - ), - } - menu_qt_actions = { - "qt1": QtIconAction( - standard_icon=QStyle.SP_FileIcon, tooltip="Qt File", checkable=True, parent=self - ), - "qt2": QtIconAction( - standard_icon=QStyle.SP_DirIcon, tooltip="Qt Directory", checkable=True, parent=self - ), - "qt3": QtIconAction( - standard_icon=QStyle.SP_TrashIcon, tooltip="Qt Trash", checkable=True, parent=self - ), - } - expandable_menu_material = ExpandableMenuAction( - label="Material Menu", actions=menu_material_actions - ) - expandable_menu_qt = ExpandableMenuAction(label="Qt Menu", actions=menu_qt_actions) - - self.toolbar.add_action("material_menu", expandable_menu_material, self) - self.toolbar.add_action("qt_menu", expandable_menu_qt, self) - - def add_switchable_button_checkable(self): - action1 = MaterialIconAction( - icon_name="hdr_auto", tooltip="Action 1", checkable=True, parent=self - ) - action2 = MaterialIconAction( - icon_name="hdr_auto", tooltip="Action 2", checkable=True, filled=True, parent=self - ) - - self.switchable_action = SwitchableToolBarAction( - actions={"action1": action1, "action2": action2}, - initial_action="action1", - tooltip="Switchable Action", - checkable=True, - parent=self, - ) - self.toolbar.add_action("switchable_action", self.switchable_action, self) - - action1.action.toggled.connect( - lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}") - ) - action2.action.toggled.connect( - lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}") - ) - - def add_switchable_button_non_checkable(self): - action1 = MaterialIconAction( - icon_name="counter_1", tooltip="Action 1", checkable=False, parent=self - ) - action2 = MaterialIconAction( - icon_name="counter_2", tooltip="Action 2", checkable=False, parent=self - ) - - switchable_action = SwitchableToolBarAction( - actions={"action1": action1, "action2": action2}, - initial_action="action1", - tooltip="Switchable Action", - checkable=False, - parent=self, - ) - self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self) - - action1.action.triggered.connect( - lambda checked: self.test_label.setText( - f"Action 1 (non-checkable) triggered, checked = {checked}" - ) - ) - action2.action.triggered.connect( - lambda checked: self.test_label.setText( - f"Action 2 (non-checkable) triggered, checked = {checked}" - ) - ) - switchable_action.actions["action1"].action.setChecked(True) - - def add_widget_actions(self): - combo = QComboBox() - combo.addItems(["Option 1", "Option 2", "Option 3"]) - self.toolbar.add_action("device_combo", WidgetAction(label="Device:", widget=combo), self) - - -if __name__ == "__main__": # pragma: no cover - app = QApplication(sys.argv) - set_theme("light") - main_window = MainWindow() - main_window.show() - sys.exit(app.exec_()) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py new file mode 100644 index 00000000..4e915cb8 --- /dev/null +++ b/bec_widgets/utils/toolbars/actions.py @@ -0,0 +1,524 @@ +# pylint: disable=no-name-in-module +from __future__ import annotations + +import os +from abc import ABC, abstractmethod +from contextlib import contextmanager +from typing import Dict, Literal + +from bec_lib.device import ReadoutPriority +from bec_lib.logger import bec_logger +from bec_qthemes._icon.material_icons import material_icon +from qtpy.QtCore import QSize, Qt, QTimer +from qtpy.QtGui import QAction, QColor, QIcon +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QHBoxLayout, + QLabel, + QMenu, + QSizePolicy, + QStyledItemDelegate, + QToolBar, + QToolButton, + QWidget, +) + +import bec_widgets +from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox + +logger = bec_logger.logger + +MODULE_PATH = os.path.dirname(bec_widgets.__file__) + + +class NoCheckDelegate(QStyledItemDelegate): + """To reduce space in combo boxes by removing the checkmark.""" + + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + # Remove any check indicator + option.checkState = Qt.Unchecked + + +class LongPressToolButton(QToolButton): + def __init__(self, *args, long_press_threshold=500, **kwargs): + super().__init__(*args, **kwargs) + self.long_press_threshold = long_press_threshold + self._long_press_timer = QTimer(self) + self._long_press_timer.setSingleShot(True) + self._long_press_timer.timeout.connect(self.handleLongPress) + self._pressed = False + self._longPressed = False + + def mousePressEvent(self, event): + self._pressed = True + self._longPressed = False + self._long_press_timer.start(self.long_press_threshold) + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + self._pressed = False + if self._longPressed: + self._longPressed = False + self._long_press_timer.stop() + event.accept() # Prevent normal click action after a long press + return + self._long_press_timer.stop() + super().mouseReleaseEvent(event) + + def handleLongPress(self): + if self._pressed: + self._longPressed = True + self.showMenu() + + +class ToolBarAction(ABC): + """ + Abstract base class for toolbar actions. + + Args: + icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None. + tooltip (str, optional): The tooltip for the action. Defaults to None. + checkable (bool, optional): Whether the action is checkable. Defaults to False. + """ + + def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False): + self.icon_path = ( + os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None + ) + self.tooltip = tooltip + self.checkable = checkable + self.action = None + + @abstractmethod + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + """Adds an action or widget to a toolbar. + + Args: + toolbar (QToolBar): The toolbar to add the action or widget to. + target (QWidget): The target widget for the action. + """ + + def cleanup(self): + """Cleans up the action, if necessary.""" + pass + + +class SeparatorAction(ToolBarAction): + """Separator action for the toolbar.""" + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + toolbar.addSeparator() + + +class QtIconAction(ToolBarAction): + def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None): + super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) + self.standard_icon = standard_icon + self.icon = QApplication.style().standardIcon(standard_icon) + self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent) + self.action.setCheckable(self.checkable) + + def add_to_toolbar(self, toolbar, target): + toolbar.addAction(self.action) + + def get_icon(self): + return self.icon + + +class MaterialIconAction(ToolBarAction): + """ + Action with a Material icon for the toolbar. + + Args: + icon_name (str, optional): The name of the Material icon. Defaults to None. + tooltip (str, optional): The tooltip for the action. Defaults to None. + checkable (bool, optional): Whether the action is checkable. Defaults to False. + filled (bool, optional): Whether the icon is filled. Defaults to False. + color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon. + Defaults to None. + parent (QWidget or None, optional): Parent widget for the underlying QAction. + """ + + def __init__( + self, + icon_name: str = None, + tooltip: str = None, + checkable: bool = False, + filled: bool = False, + color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, + parent=None, + ): + super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) + self.icon_name = icon_name + self.filled = filled + self.color = color + # Generate the icon using the material_icon helper + self.icon = material_icon( + self.icon_name, + size=(20, 20), + convert_to_pixmap=False, + filled=self.filled, + color=self.color, + ) + if parent is None: + logger.warning( + "MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues." + ) + self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent) + self.action.setCheckable(self.checkable) + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + """ + Adds the action to the toolbar. + + Args: + toolbar(QToolBar): The toolbar to add the action to. + target(QWidget): The target widget for the action. + """ + toolbar.addAction(self.action) + + def get_icon(self): + """ + Returns the icon for the action. + + Returns: + QIcon: The icon for the action. + """ + return self.icon + + +class DeviceSelectionAction(ToolBarAction): + """ + Action for selecting a device in a combobox. + + Args: + label (str): The label for the combobox. + device_combobox (DeviceComboBox): The combobox for selecting the device. + """ + + def __init__(self, label: str | None = None, device_combobox=None): + super().__init__() + self.label = label + self.device_combobox = device_combobox + self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700")) + + def add_to_toolbar(self, toolbar, target): + widget = QWidget(parent=target) + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + if self.label is not None: + label = QLabel(text=f"{self.label}", parent=target) + layout.addWidget(label) + if self.device_combobox is not None: + layout.addWidget(self.device_combobox) + toolbar.addWidget(widget) + + def set_combobox_style(self, color: str): + self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") + + +class SwitchableToolBarAction(ToolBarAction): + """ + A split toolbar action that combines a main action and a drop-down menu for additional actions. + + The main button displays the currently selected action's icon and tooltip. Clicking on the main button + triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an + alternative action is selected, it becomes the new default and its callback is immediately executed. + + This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars. + + Args: + actions (dict): A dictionary mapping a unique key to a ToolBarAction instance. + initial_action (str, optional): The key of the initial default action. If not provided, the first action is used. + tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip. + checkable (bool, optional): Whether the action is checkable. Defaults to True. + parent (QWidget, optional): Parent widget for the underlying QAction. + """ + + def __init__( + self, + actions: Dict[str, ToolBarAction], + initial_action: str = None, + tooltip: str = None, + checkable: bool = True, + default_state_checked: bool = False, + parent=None, + ): + super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) + self.actions = actions + self.current_key = initial_action if initial_action is not None else next(iter(actions)) + self.parent = parent + self.checkable = checkable + self.default_state_checked = default_state_checked + self.main_button = None + self.menu_actions: Dict[str, QAction] = {} + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + """ + Adds the split action to the toolbar. + + Args: + toolbar (QToolBar): The toolbar to add the action to. + target (QWidget): The target widget for the action. + """ + self.main_button = LongPressToolButton(toolbar) + self.main_button.setPopupMode(QToolButton.MenuButtonPopup) + self.main_button.setCheckable(self.checkable) + default_action = self.actions[self.current_key] + self.main_button.setIcon(default_action.get_icon()) + self.main_button.setToolTip(default_action.tooltip) + self.main_button.clicked.connect(self._trigger_current_action) + menu = QMenu(self.main_button) + for key, action_obj in self.actions.items(): + menu_action = QAction( + icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button + ) + menu_action.setIconVisibleInMenu(True) + menu_action.setCheckable(self.checkable) + menu_action.setChecked(key == self.current_key) + menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k)) + menu.addAction(menu_action) + self.main_button.setMenu(menu) + if self.default_state_checked: + self.main_button.setChecked(True) + self.action = toolbar.addWidget(self.main_button) + + def _trigger_current_action(self): + """ + Triggers the current action associated with the main button. + """ + action_obj = self.actions[self.current_key] + action_obj.action.trigger() + + def set_default_action(self, key: str): + """ + Sets the default action for the split action. + + Args: + key(str): The key of the action to set as default. + """ + if self.main_button is None: + return + self.current_key = key + new_action = self.actions[self.current_key] + self.main_button.setIcon(new_action.get_icon()) + self.main_button.setToolTip(new_action.tooltip) + # Update check state of menu items + for k, menu_act in self.actions.items(): + menu_act.action.setChecked(False) + new_action.action.trigger() + # Active action chosen from menu is always checked, uncheck through main button + if self.checkable: + new_action.action.setChecked(True) + self.main_button.setChecked(True) + + def block_all_signals(self, block: bool = True): + """ + Blocks or unblocks all signals for the actions in the toolbar. + + Args: + block (bool): Whether to block signals. Defaults to True. + """ + if self.main_button is not None: + self.main_button.blockSignals(block) + + for action in self.actions.values(): + action.action.blockSignals(block) + + @contextmanager + def signal_blocker(self): + """ + Context manager to block signals for all actions in the toolbar. + """ + self.block_all_signals(True) + try: + yield + finally: + self.block_all_signals(False) + + def set_state_all(self, state: bool): + """ + Uncheck all actions in the toolbar. + """ + for action in self.actions.values(): + action.action.setChecked(state) + if self.main_button is None: + return + self.main_button.setChecked(state) + + def get_icon(self) -> QIcon: + return self.actions[self.current_key].get_icon() + + +class WidgetAction(ToolBarAction): + """ + Action for adding any widget to the toolbar. + Please note that the injected widget must be life-cycled by the parent widget, + i.e., the widget must be properly cleaned up outside of this action. The WidgetAction + will not perform any cleanup on the widget itself, only on the container that holds it. + + Args: + label (str|None): The label for the widget. + widget (QWidget): The widget to be added to the toolbar. + adjust_size (bool): Whether to adjust the size of the widget based on its contents. Defaults to True. + """ + + def __init__( + self, + label: str | None = None, + widget: QWidget = None, + adjust_size: bool = True, + parent=None, + ): + super().__init__(icon_path=None, tooltip=label, checkable=False) + self.label = label + self.widget = widget + self.container = None + self.adjust_size = adjust_size + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + """ + Adds the widget to the toolbar. + + Args: + toolbar (QToolBar): The toolbar to add the widget to. + target (QWidget): The target widget for the action. + """ + self.container = QWidget(parent=target) + layout = QHBoxLayout(self.container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + if self.label is not None: + label_widget = QLabel(text=f"{self.label}", parent=target) + label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) + label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + layout.addWidget(label_widget) + + if isinstance(self.widget, QComboBox) and self.adjust_size: + self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) + + size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.widget.setSizePolicy(size_policy) + + self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget)) + + layout.addWidget(self.widget) + + toolbar.addWidget(self.container) + # Store the container as the action to allow toggling visibility. + self.action = self.container + + def cleanup(self): + """ + Cleans up the action by closing and deleting the container widget. + This method will be called automatically when the toolbar is cleaned up. + """ + if self.container is not None: + self.container.close() + self.container.deleteLater() + return super().cleanup() + + @staticmethod + def calculate_minimum_width(combo_box: QComboBox) -> int: + font_metrics = combo_box.fontMetrics() + max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) + return max_width + 60 + + +class ExpandableMenuAction(ToolBarAction): + """ + Action for an expandable menu in the toolbar. + + Args: + label (str): The label for the menu. + actions (dict): A dictionary of actions to populate the menu. + icon_path (str, optional): The path to the icon file. Defaults to None. + """ + + def __init__(self, label: str, actions: dict, icon_path: str = None): + super().__init__(icon_path, label) + self.actions = actions + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + button = QToolButton(toolbar) + if self.icon_path: + button.setIcon(QIcon(self.icon_path)) + button.setText(self.tooltip) + button.setPopupMode(QToolButton.InstantPopup) + button.setStyleSheet( + """ + QToolButton { + font-size: 14px; + } + QMenu { + font-size: 14px; + } + """ + ) + menu = QMenu(button) + for action_container in self.actions.values(): + action: QAction = action_container.action + action.setIconVisibleInMenu(True) + if action_container.icon_path: + icon = QIcon() + icon.addFile(action_container.icon_path, size=QSize(20, 20)) + action.setIcon(icon) + elif hasattr(action, "get_icon") and callable(action_container.get_icon): + sub_icon = action_container.get_icon() + if sub_icon and not sub_icon.isNull(): + action.setIcon(sub_icon) + action.setCheckable(action_container.checkable) + menu.addAction(action) + button.setMenu(menu) + toolbar.addWidget(button) + + +class DeviceComboBoxAction(WidgetAction): + """ + Action for a device selection combobox in the toolbar. + + Args: + label (str): The label for the combobox. + device_combobox (QComboBox): The combobox for selecting the device. + """ + + def __init__( + self, + target_widget: QWidget, + device_filter: list[BECDeviceFilter] | None = None, + readout_priority_filter: ( + str | ReadoutPriority | list[str] | list[ReadoutPriority] | None + ) = None, + tooltip: str | None = None, + add_empty_item: bool = False, + no_check_delegate: bool = False, + ): + self.combobox = DeviceComboBox( + parent=target_widget, + device_filter=device_filter, + readout_priority_filter=readout_priority_filter, + ) + super().__init__(widget=self.combobox, adjust_size=False) + + if add_empty_item: + self.combobox.addItem("", None) + self.combobox.setCurrentText("") + if tooltip is not None: + self.combobox.setToolTip(tooltip) + if no_check_delegate: + self.combobox.setItemDelegate(NoCheckDelegate(self.combobox)) + + def cleanup(self): + """ + Cleans up the action by closing and deleting the combobox widget. + This method will be called automatically when the toolbar is cleaned up. + """ + if self.combobox is not None: + self.combobox.close() + self.combobox.deleteLater() + return super().cleanup() diff --git a/bec_widgets/utils/toolbars/bundles.py b/bec_widgets/utils/toolbars/bundles.py new file mode 100644 index 00000000..36876995 --- /dev/null +++ b/bec_widgets/utils/toolbars/bundles.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, DefaultDict +from weakref import ReferenceType + +import louie +from bec_lib.logger import bec_logger +from pydantic import BaseModel + +from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction + +if TYPE_CHECKING: + from bec_widgets.utils.toolbars.connections import BundleConnection + from bec_widgets.utils.toolbars.toolbar import ModularToolBar + +logger = bec_logger.logger + + +class ActionInfo(BaseModel): + action: ToolBarAction + toolbar_bundle: ToolbarBundle | None = None + + model_config = {"arbitrary_types_allowed": True} + + +class ToolbarComponents: + def __init__(self, toolbar: ModularToolBar): + """ + Initializes the toolbar components. + + Args: + toolbar (ModularToolBar): The toolbar to which the components will be added. + """ + self.toolbar = toolbar + + self._components: dict[str, ActionInfo] = {} + self.add("separator", SeparatorAction()) + + def add(self, name: str, component: ToolBarAction): + """ + Adds a component to the toolbar. + + Args: + component (ToolBarAction): The component to add. + """ + if name in self._components: + raise ValueError(f"Component with name '{name}' already exists.") + self._components[name] = ActionInfo(action=component, toolbar_bundle=None) + + def add_safe(self, name: str, component: ToolBarAction): + """ + Adds a component to the toolbar, ensuring it does not already exist. + + Args: + name (str): The name of the component. + component (ToolBarAction): The component to add. + """ + if self.exists(name): + logger.info(f"Component with name '{name}' already exists. Skipping addition.") + return + self.add(name, component) + + def exists(self, name: str) -> bool: + """ + Checks if a component exists in the toolbar. + + Args: + name (str): The name of the component to check. + + Returns: + bool: True if the component exists, False otherwise. + """ + return name in self._components + + def get_action_reference(self, name: str) -> ReferenceType[ToolBarAction]: + """ + Retrieves a component by name. + + Args: + name (str): The name of the component to retrieve. + + """ + if not self.exists(name): + raise KeyError(f"Component with name '{name}' does not exist.") + return louie.saferef.safe_ref(self._components[name].action) + + def get_action(self, name: str) -> ToolBarAction: + """ + Retrieves a component by name. + + Args: + name (str): The name of the component to retrieve. + + Returns: + ToolBarAction: The action associated with the given name. + """ + if not self.exists(name): + raise KeyError( + f"Component with name '{name}' does not exist. The following components are available: {list(self._components.keys())}" + ) + return self._components[name].action + + def set_bundle(self, name: str, bundle: ToolbarBundle): + """ + Sets the bundle for a component. + + Args: + name (str): The name of the component. + bundle (ToolbarBundle): The bundle to set. + """ + if not self.exists(name): + raise KeyError(f"Component with name '{name}' does not exist.") + comp = self._components[name] + if comp.toolbar_bundle is not None: + logger.info( + f"Component '{name}' already has a bundle ({comp.toolbar_bundle.name}). Setting it to {bundle.name}." + ) + comp.toolbar_bundle.bundle_actions.pop(name, None) + comp.toolbar_bundle = bundle + + def remove_action(self, name: str): + """ + Removes a component from the toolbar. + + Args: + name (str): The name of the component to remove. + """ + if not self.exists(name): + raise KeyError(f"Action with ID '{name}' does not exist.") + action_info = self._components.pop(name) + if action_info.toolbar_bundle: + action_info.toolbar_bundle.bundle_actions.pop(name, None) + self.toolbar.refresh() + action_info.toolbar_bundle = None + if hasattr(action_info.action, "cleanup"): + # Call cleanup if the action has a cleanup method + action_info.action.cleanup() + + def cleanup(self): + """ + Cleans up the toolbar components by removing all actions and bundles. + """ + for action_info in self._components.values(): + if hasattr(action_info.action, "cleanup"): + # Call cleanup if the action has a cleanup method + action_info.action.cleanup() + self._components.clear() + + +class ToolbarBundle: + def __init__(self, name: str, components: ToolbarComponents): + """ + Initializes a new bundle component. + + Args: + bundle_name (str): Unique identifier for the bundle. + """ + self.name = name + self.components = components + self.bundle_actions: DefaultDict[str, ReferenceType[ToolBarAction]] = defaultdict() + self._connections: dict[str, BundleConnection] = {} + + def add_action(self, name: str): + """ + Adds an action to the bundle. + + Args: + name (str): Unique identifier for the action. + action (ToolBarAction): The action to add. + """ + if name in self.bundle_actions: + raise ValueError(f"Action with name '{name}' already exists in bundle '{self.name}'.") + if not self.components.exists(name): + raise ValueError( + f"Component with name '{name}' does not exist in the toolbar. Please add it first using the `ToolbarComponents.add` method." + ) + self.bundle_actions[name] = self.components.get_action_reference(name) + self.components.set_bundle(name, self) + + def remove_action(self, name: str): + """ + Removes an action from the bundle. + + Args: + name (str): The name of the action to remove. + """ + if name not in self.bundle_actions: + raise KeyError(f"Action with name '{name}' does not exist in bundle '{self.name}'.") + del self.bundle_actions[name] + + def add_separator(self): + """ + Adds a separator action to the bundle. + """ + self.add_action("separator") + + def add_connection(self, name: str, connection): + """ + Adds a connection to the bundle. + + Args: + name (str): Unique identifier for the connection. + connection: The connection to add. + """ + if name in self._connections: + raise ValueError( + f"Connection with name '{name}' already exists in bundle '{self.name}'." + ) + self._connections[name] = connection + + def remove_connection(self, name: str): + """ + Removes a connection from the bundle. + + Args: + name (str): The name of the connection to remove. + """ + if name not in self._connections: + raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.") + self._connections[name].disconnect() + del self._connections[name] + + def get_connection(self, name: str): + """ + Retrieves a connection by name. + + Args: + name (str): The name of the connection to retrieve. + + Returns: + The connection associated with the given name. + """ + if name not in self._connections: + raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.") + return self._connections[name] + + def disconnect(self): + """ + Disconnects all connections in the bundle. + """ + for connection in self._connections.values(): + connection.disconnect() + self._connections.clear() diff --git a/bec_widgets/utils/toolbars/connections.py b/bec_widgets/utils/toolbars/connections.py new file mode 100644 index 00000000..50b6a1e5 --- /dev/null +++ b/bec_widgets/utils/toolbars/connections.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from abc import abstractmethod + +from qtpy.QtCore import QObject + + +class BundleConnection(QObject): + bundle_name: str + + @abstractmethod + def connect(self): + """ + Connects the bundle to the target widget or application. + This method should be implemented by subclasses to define how the bundle interacts with the target. + """ + + @abstractmethod + def disconnect(self): + """ + Disconnects the bundle from the target widget or application. + This method should be implemented by subclasses to define how to clean up connections. + """ diff --git a/bec_widgets/utils/toolbars/performance.py b/bec_widgets/utils/toolbars/performance.py new file mode 100644 index 00000000..e24ce121 --- /dev/null +++ b/bec_widgets/utils/toolbars/performance.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.connections import BundleConnection + +if TYPE_CHECKING: + from bec_widgets.utils.toolbars.toolbar import ToolbarComponents + + +def performance_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a performance toolbar bundle. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The performance toolbar bundle. + """ + components.add_safe( + "fps_monitor", + MaterialIconAction( + icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=components.toolbar + ), + ) + bundle = ToolbarBundle("performance", components) + bundle.add_action("fps_monitor") + return bundle + + +class PerformanceConnection(BundleConnection): + + def __init__(self, components: ToolbarComponents, target_widget=None): + self.bundle_name = "performance" + self.components = components + self.target_widget = target_widget + if not hasattr(self.target_widget, "enable_fps_monitor"): + raise AttributeError("Target widget must implement 'enable_fps_monitor'.") + super().__init__() + self._connected = False + + def connect(self): + self._connected = True + # Connect the action to the target widget's method + self.components.get_action_reference("fps_monitor")().action.toggled.connect( + lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked) + ) + + def disconnect(self): + if not self._connected: + return + # Disconnect the action from the target widget's method + self.components.get_action_reference("fps_monitor")().action.toggled.disconnect( + lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked) + ) diff --git a/bec_widgets/utils/toolbars/toolbar.py b/bec_widgets/utils/toolbars/toolbar.py new file mode 100644 index 00000000..21b3c710 --- /dev/null +++ b/bec_widgets/utils/toolbars/toolbar.py @@ -0,0 +1,513 @@ +# pylint: disable=no-name-in-module +from __future__ import annotations + +import sys +from collections import defaultdict +from typing import DefaultDict, Literal + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtGui import QAction, QColor +from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget + +from bec_widgets.utils.colors import get_theme_name, set_theme +from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection + +logger = bec_logger.logger + +# Ensure that icons are shown in menus (especially on macOS) +QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False) + + +class ModularToolBar(QToolBar): + """Modular toolbar with optional automatic initialization. + + Args: + parent (QWidget, optional): The parent widget of the toolbar. Defaults to None. + actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None. + target_widget (QWidget, optional): The widget that the actions will target. Defaults to None. + orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal". + background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)". + """ + + def __init__( + self, + parent=None, + orientation: Literal["horizontal", "vertical"] = "horizontal", + background_color: str = "rgba(0, 0, 0, 0)", + ): + super().__init__(parent=parent) + + self.background_color = background_color + self.set_background_color(self.background_color) + + # Set the initial orientation + self.set_orientation(orientation) + + self.components = ToolbarComponents(self) + + # Initialize bundles + self.bundles: dict[str, ToolbarBundle] = {} + self.shown_bundles: list[str] = [] + + ######################### + # outdated items... remove + self.available_widgets: DefaultDict[str, ToolBarAction] = defaultdict() + ######################## + + def new_bundle(self, name: str) -> ToolbarBundle: + """ + Creates a new bundle component. + + Args: + name (str): Unique identifier for the bundle. + + Returns: + ToolbarBundle: The new bundle component. + """ + if name in self.bundles: + raise ValueError(f"Bundle with name '{name}' already exists.") + bundle = ToolbarBundle(name=name, components=self.components) + self.bundles[name] = bundle + return bundle + + def add_bundle(self, bundle: ToolbarBundle): + """ + Adds a bundle component to the toolbar. + + Args: + bundle (ToolbarBundle): The bundle component to add. + """ + if bundle.name in self.bundles: + raise ValueError(f"Bundle with name '{bundle.name}' already exists.") + self.bundles[bundle.name] = bundle + if not bundle.bundle_actions: + logger.warning(f"Bundle '{bundle.name}' has no actions.") + + def remove_bundle(self, name: str): + """ + Removes a bundle component by name. + + Args: + name (str): The name of the bundle to remove. + """ + if name not in self.bundles: + raise KeyError(f"Bundle with name '{name}' does not exist.") + del self.bundles[name] + if name in self.shown_bundles: + self.shown_bundles.remove(name) + logger.info(f"Bundle '{name}' removed from the toolbar.") + + def get_bundle(self, name: str) -> ToolbarBundle: + """ + Retrieves a bundle component by name. + + Args: + name (str): The name of the bundle to retrieve. + + Returns: + ToolbarBundle: The bundle component. + """ + if name not in self.bundles: + raise KeyError( + f"Bundle with name '{name}' does not exist. Available bundles: {list(self.bundles.keys())}" + ) + return self.bundles[name] + + def show_bundles(self, bundle_names: list[str]): + """ + Sets the bundles to be shown for the toolbar. + + Args: + bundle_names (list[str]): A list of bundle names to show. If a bundle is not in this list, its actions will be hidden. + """ + self.clear() + for requested_bundle in bundle_names: + bundle = self.get_bundle(requested_bundle) + for bundle_action in bundle.bundle_actions.values(): + action = bundle_action() + if action is None: + logger.warning( + f"Action for bundle '{requested_bundle}' has been deleted. Skipping." + ) + continue + action.add_to_toolbar(self, self.parent()) + separator = self.components.get_action_reference("separator")() + if separator is not None: + separator.add_to_toolbar(self, self.parent()) + self.update_separators() # Ensure separators are updated after showing bundles + self.shown_bundles = bundle_names + + def add_action(self, action_name: str, action: ToolBarAction): + """ + Adds a single action to the toolbar. It will create a new bundle + with the same name as the action. + + Args: + action_name (str): Unique identifier for the action. + action (ToolBarAction): The action to add. + """ + self.components.add_safe(action_name, action) + bundle = ToolbarBundle(name=action_name, components=self.components) + bundle.add_action(action_name) + self.add_bundle(bundle) + + def hide_action(self, action_name: str): + """ + Hides a specific action in the toolbar. + + Args: + action_name (str): Unique identifier for the action to hide. + """ + action = self.components.get_action(action_name) + if hasattr(action, "action") and action.action is not None: + action.action.setVisible(False) + self.update_separators() + + def show_action(self, action_name: str): + """ + Shows a specific action in the toolbar. + + Args: + action_name (str): Unique identifier for the action to show. + """ + action = self.components.get_action(action_name) + if hasattr(action, "action") and action.action is not None: + action.action.setVisible(True) + self.update_separators() + + @property + def toolbar_actions(self) -> list[ToolBarAction]: + """ + Returns a list of all actions currently in the toolbar. + + Returns: + list[ToolBarAction]: List of actions in the toolbar. + """ + actions = [] + for bundle in self.shown_bundles: + if bundle not in self.bundles: + continue + for action in self.bundles[bundle].bundle_actions.values(): + action_instance = action() + if action_instance is not None: + actions.append(action_instance) + return actions + + def refresh(self): + """Refreshes the toolbar by clearing and re-populating it.""" + self.clear() + self.show_bundles(self.shown_bundles) + + def connect_bundle(self, connection_name: str, connector: BundleConnection): + """ + Connects a bundle to a target widget or application. + + Args: + bundle_name (str): The name of the bundle to connect. + connector (BundleConnection): The connector instance that implements the connection logic. + """ + bundle_name = connector.bundle_name + if bundle_name not in self.bundles: + raise KeyError(f"Bundle with name '{bundle_name}' does not exist.") + connector.connect() + self.bundles[bundle_name].add_connection(connection_name, connector) + + def disconnect_bundle(self, bundle_name: str, connection_name: str | None = None): + """ + Disconnects a bundle connection. + + Args: + bundle_name (str): The name of the bundle to disconnect. + connection_name (str): The name of the connection to disconnect. If None, disconnects all connections for the bundle. + """ + if bundle_name not in self.bundles: + raise KeyError(f"Bundle with name '{bundle_name}' does not exist.") + bundle = self.bundles[bundle_name] + if connection_name is None: + # Disconnect all connections in the bundle + bundle.disconnect() + else: + bundle.remove_connection(name=connection_name) + + def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"): + """ + Sets the background color and other appearance settings. + + Args: + color (str): The background color of the toolbar. + """ + self.setIconSize(QSize(20, 20)) + self.setMovable(False) + self.setFloatable(False) + self.setContentsMargins(0, 0, 0, 0) + self.background_color = color + self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}") + + def set_orientation(self, orientation: Literal["horizontal", "vertical"]): + """Sets the orientation of the toolbar. + + Args: + orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar. + """ + if orientation == "horizontal": + self.setOrientation(Qt.Horizontal) + elif orientation == "vertical": + self.setOrientation(Qt.Vertical) + else: + raise ValueError("Orientation must be 'horizontal' or 'vertical'.") + + def update_material_icon_colors(self, new_color: str | tuple | QColor): + """ + Updates the color of all MaterialIconAction icons. + + Args: + new_color (str | tuple | QColor): The new color. + """ + for action in self.available_widgets.values(): + if isinstance(action, MaterialIconAction): + action.color = new_color + updated_icon = action.get_icon() + action.action.setIcon(updated_icon) + + def contextMenuEvent(self, event): + """ + Overrides the context menu event to show toolbar actions with checkboxes and icons. + + Args: + event (QContextMenuEvent): The context menu event. + """ + menu = QMenu(self) + theme = get_theme_name() + if theme == "dark": + menu.setStyleSheet( + """ + QMenu { + background-color: rgba(50, 50, 50, 0.9); + border: 1px solid rgba(255, 255, 255, 0.2); + } + QMenu::item:selected { + background-color: rgba(0, 0, 255, 0.2); + } + """ + ) + else: + # Light theme styling + menu.setStyleSheet( + """ + QMenu { + background-color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(0, 0, 0, 0.2); + } + QMenu::item:selected { + background-color: rgba(0, 0, 255, 0.2); + } + """ + ) + for ii, bundle in enumerate(self.shown_bundles): + self.handle_bundle_context_menu(menu, bundle) + if ii < len(self.shown_bundles) - 1: + menu.addSeparator() + menu.triggered.connect(self.handle_menu_triggered) + menu.exec_(event.globalPos()) + + def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str): + """ + Adds bundle actions to the context menu. + + Args: + menu (QMenu): The context menu. + bundle_id (str): The bundle identifier. + """ + bundle = self.bundles.get(bundle_id) + if not bundle: + return + for act_id in bundle.bundle_actions: + toolbar_action = self.components.get_action(act_id) + if not isinstance(toolbar_action, ToolBarAction) or not hasattr( + toolbar_action, "action" + ): + continue + qaction = toolbar_action.action + if not isinstance(qaction, QAction): + continue + self._add_qaction_to_menu(menu, qaction, toolbar_action, act_id) + + def _add_qaction_to_menu( + self, menu: QMenu, qaction: QAction, toolbar_action: ToolBarAction, act_id: str + ): + display_name = qaction.text() or toolbar_action.tooltip or act_id + menu_action = QAction(display_name, self) + menu_action.setCheckable(True) + menu_action.setChecked(qaction.isVisible()) + menu_action.setData(act_id) # Store the action_id + + # Set the icon if available + if qaction.icon() and not qaction.icon().isNull(): + menu_action.setIcon(qaction.icon()) + menu.addAction(menu_action) + + def handle_action_context_menu(self, menu: QMenu, action_id: str): + """ + Adds a single toolbar action to the context menu. + + Args: + menu (QMenu): The context menu to which the action is added. + action_id (str): Unique identifier for the action. + """ + toolbar_action = self.available_widgets.get(action_id) + if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"): + return + qaction = toolbar_action.action + if not isinstance(qaction, QAction): + return + display_name = qaction.text() or toolbar_action.tooltip or action_id + menu_action = QAction(display_name, self) + menu_action.setCheckable(True) + menu_action.setChecked(qaction.isVisible()) + menu_action.setIconVisibleInMenu(True) + menu_action.setData(action_id) # Store the action_id + + # Set the icon if available + if qaction.icon() and not qaction.icon().isNull(): + menu_action.setIcon(qaction.icon()) + + menu.addAction(menu_action) + + def handle_menu_triggered(self, action): + """ + Handles the triggered signal from the context menu. + + Args: + action: Action triggered. + """ + action_id = action.data() + if action_id: + self.toggle_action_visibility(action_id) + + def toggle_action_visibility(self, action_id: str, visible: bool | None = None): + """ + Toggles the visibility of a specific action. + + Args: + action_id (str): Unique identifier. + visible (bool): Whether the action should be visible. If None, toggles the current visibility. + """ + if not self.components.exists(action_id): + return + tool_action = self.components.get_action(action_id) + if hasattr(tool_action, "action") and tool_action.action is not None: + if visible is None: + visible = not tool_action.action.isVisible() + tool_action.action.setVisible(visible) + self.update_separators() + + def update_separators(self): + """ + Hide separators that are adjacent to another separator or have no non-separator actions between them. + """ + toolbar_actions = self.actions() + # First pass: set visibility based on surrounding non-separator actions. + for i, action in enumerate(toolbar_actions): + if not action.isSeparator(): + continue + prev_visible = None + for j in range(i - 1, -1, -1): + if toolbar_actions[j].isVisible(): + prev_visible = toolbar_actions[j] + break + next_visible = None + for j in range(i + 1, len(toolbar_actions)): + if toolbar_actions[j].isVisible(): + next_visible = toolbar_actions[j] + break + if (prev_visible is None or prev_visible.isSeparator()) and ( + next_visible is None or next_visible.isSeparator() + ): + action.setVisible(False) + else: + action.setVisible(True) + # Second pass: ensure no two visible separators are adjacent. + prev = None + for action in toolbar_actions: + if action.isVisible() and action.isSeparator(): + if prev and prev.isSeparator(): + action.setVisible(False) + else: + prev = action + else: + if action.isVisible(): + prev = action + + if not toolbar_actions: + return + + # Make sure the first visible action is not a separator + for i, action in enumerate(toolbar_actions): + if action.isVisible(): + if action.isSeparator(): + action.setVisible(False) + break + + # Make sure the last visible action is not a separator + for i, action in enumerate(reversed(toolbar_actions)): + if action.isVisible(): + if action.isSeparator(): + action.setVisible(False) + break + + def cleanup(self): + """ + Cleans up the toolbar by removing all actions and bundles. + """ + # First, disconnect all bundles + for bundle_name in list(self.bundles.keys()): + self.disconnect_bundle(bundle_name) + + # Clear all components + self.components.cleanup() + self.bundles.clear() + + +if __name__ == "__main__": # pragma: no cover + from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle + from bec_widgets.widgets.plots.toolbar_components.plot_export import plot_export_bundle + + class MainWindow(QMainWindow): # pragma: no cover + def __init__(self): + super().__init__() + self.setWindowTitle("Toolbar / ToolbarBundle Demo") + self.central_widget = QWidget() + self.setCentralWidget(self.central_widget) + self.test_label = QLabel(text="This is a test label.") + self.central_widget.layout = QVBoxLayout(self.central_widget) + self.central_widget.layout.addWidget(self.test_label) + + self.toolbar = ModularToolBar(parent=self) + self.addToolBar(self.toolbar) + self.toolbar.add_bundle(performance_bundle(self.toolbar.components)) + self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components)) + self.toolbar.connect_bundle( + "base", PerformanceConnection(self.toolbar.components, self) + ) + self.toolbar.show_bundles(["performance", "plot_export"]) + self.toolbar.get_bundle("performance").add_action("save") + self.toolbar.refresh() + + def enable_fps_monitor(self, enabled: bool): + """ + Example method to enable or disable FPS monitoring. + This method should be implemented in the target widget. + """ + if enabled: + self.test_label.setText("FPS Monitor Enabled") + else: + self.test_label.setText("FPS Monitor Disabled") + + app = QApplication(sys.argv) + set_theme("light") + main_window = MainWindow() + main_window.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/containers/dock/dock_area.py b/bec_widgets/widgets/containers/dock/dock_area.py index b0709d42..c9171345 100644 --- a/bec_widgets/widgets/containers/dock/dock_area.py +++ b/bec_widgets/widgets/containers/dock/dock_area.py @@ -15,12 +15,13 @@ from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.name_utils import pascal_to_snake -from bec_widgets.utils.toolbar import ( +from bec_widgets.utils.toolbars.actions import ( ExpandableMenuAction, MaterialIconAction, - ModularToolBar, - SeparatorAction, + WidgetAction, ) +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow @@ -104,151 +105,227 @@ class BECDockArea(BECWidget, QWidget): self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) self.dock_area = DockArea(parent=self) - self.toolbar = ModularToolBar( - parent=self, - actions={ - "menu_plots": ExpandableMenuAction( - label="Add Plot ", - actions={ - "waveform": MaterialIconAction( - icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True - ), - "scatter_waveform": MaterialIconAction( - icon_name=ScatterWaveform.ICON_NAME, - tooltip="Add Scatter Waveform", - filled=True, - ), - "multi_waveform": MaterialIconAction( - icon_name=MultiWaveform.ICON_NAME, - tooltip="Add Multi Waveform", - filled=True, - ), - "image": MaterialIconAction( - icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True - ), - "motor_map": MaterialIconAction( - icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True - ), - }, - ), - "separator_0": SeparatorAction(), - "menu_devices": ExpandableMenuAction( - label="Add Device Control ", - actions={ - "scan_control": MaterialIconAction( - icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True - ), - "positioner_box": MaterialIconAction( - icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True - ), - }, - ), - "separator_1": SeparatorAction(), - "menu_utils": ExpandableMenuAction( - label="Add Utils ", - actions={ - "queue": MaterialIconAction( - icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True - ), - "vs_code": MaterialIconAction( - icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True - ), - "status": MaterialIconAction( - icon_name=BECStatusBox.ICON_NAME, - tooltip="Add BEC Status Box", - filled=True, - ), - "progress_bar": MaterialIconAction( - icon_name=RingProgressBar.ICON_NAME, - tooltip="Add Circular ProgressBar", - filled=True, - ), - # FIXME temporarily disabled -> issue #644 - "log_panel": MaterialIconAction( - icon_name=LogPanel.ICON_NAME, - tooltip="Add LogPanel - Disabled", - filled=True, - ), - "sbb_monitor": MaterialIconAction( - icon_name="train", tooltip="Add SBB Monitor", filled=True - ), - }, - ), - "separator_2": SeparatorAction(), - "attach_all": MaterialIconAction( - icon_name="zoom_in_map", tooltip="Attach all floating docks" - ), - "save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"), - "restore_state": MaterialIconAction( - icon_name="frame_reload", tooltip="Restore Dock State" - ), - }, - target_widget=self, - ) + self.toolbar = ModularToolBar(parent=self) + self._setup_toolbar() self.layout.addWidget(self.toolbar) self.layout.addWidget(self.dock_area) - self.spacer = QWidget(parent=self) - self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar.addWidget(self.spacer) - self.toolbar.addWidget(self.dark_mode_button) + self._hook_toolbar() + self.toolbar.show_bundles( + ["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"] + ) def minimumSizeHint(self): return QSize(800, 600) + def _setup_toolbar(self): + + # Add plot menu + self.toolbar.components.add_safe( + "menu_plots", + ExpandableMenuAction( + label="Add Plot ", + actions={ + "waveform": MaterialIconAction( + icon_name=Waveform.ICON_NAME, + tooltip="Add Waveform", + filled=True, + parent=self, + ), + "scatter_waveform": MaterialIconAction( + icon_name=ScatterWaveform.ICON_NAME, + tooltip="Add Scatter Waveform", + filled=True, + parent=self, + ), + "multi_waveform": MaterialIconAction( + icon_name=MultiWaveform.ICON_NAME, + tooltip="Add Multi Waveform", + filled=True, + parent=self, + ), + "image": MaterialIconAction( + icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self + ), + "motor_map": MaterialIconAction( + icon_name=MotorMap.ICON_NAME, + tooltip="Add Motor Map", + filled=True, + parent=self, + ), + }, + ), + ) + + bundle = ToolbarBundle("menu_plots", self.toolbar.components) + bundle.add_action("menu_plots") + self.toolbar.add_bundle(bundle) + + # Add control menu + self.toolbar.components.add_safe( + "menu_devices", + ExpandableMenuAction( + label="Add Device Control ", + actions={ + "scan_control": MaterialIconAction( + icon_name=ScanControl.ICON_NAME, + tooltip="Add Scan Control", + filled=True, + parent=self, + ), + "positioner_box": MaterialIconAction( + icon_name=PositionerBox.ICON_NAME, + tooltip="Add Device Box", + filled=True, + parent=self, + ), + }, + ), + ) + bundle = ToolbarBundle("menu_devices", self.toolbar.components) + bundle.add_action("menu_devices") + self.toolbar.add_bundle(bundle) + + # Add utils menu + self.toolbar.components.add_safe( + "menu_utils", + ExpandableMenuAction( + label="Add Utils ", + actions={ + "queue": MaterialIconAction( + icon_name=BECQueue.ICON_NAME, + tooltip="Add Scan Queue", + filled=True, + parent=self, + ), + "vs_code": MaterialIconAction( + icon_name=VSCodeEditor.ICON_NAME, + tooltip="Add VS Code", + filled=True, + parent=self, + ), + "status": MaterialIconAction( + icon_name=BECStatusBox.ICON_NAME, + tooltip="Add BEC Status Box", + filled=True, + parent=self, + ), + "progress_bar": MaterialIconAction( + icon_name=RingProgressBar.ICON_NAME, + tooltip="Add Circular ProgressBar", + filled=True, + parent=self, + ), + # FIXME temporarily disabled -> issue #644 + "log_panel": MaterialIconAction( + icon_name=LogPanel.ICON_NAME, + tooltip="Add LogPanel - Disabled", + filled=True, + parent=self, + ), + "sbb_monitor": MaterialIconAction( + icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self + ), + }, + ), + ) + bundle = ToolbarBundle("menu_utils", self.toolbar.components) + bundle.add_action("menu_utils") + self.toolbar.add_bundle(bundle) + + ########## Dock Actions ########## + spacer = QWidget(parent=self) + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False)) + + self.toolbar.components.add_safe( + "dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False) + ) + + bundle = ToolbarBundle("dark_mode", self.toolbar.components) + bundle.add_action("spacer") + bundle.add_action("dark_mode") + self.toolbar.add_bundle(bundle) + + self.toolbar.components.add_safe( + "attach_all", + MaterialIconAction( + icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self + ), + ) + + self.toolbar.components.add_safe( + "save_state", + MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self), + ) + self.toolbar.components.add_safe( + "restore_state", + MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self), + ) + + bundle = ToolbarBundle("dock_actions", self.toolbar.components) + bundle.add_action("attach_all") + bundle.add_action("save_state") + bundle.add_action("restore_state") + self.toolbar.add_bundle(bundle) + def _hook_toolbar(self): - # Menu Plot - self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect( + menu_plots = self.toolbar.components.get_action("menu_plots") + menu_devices = self.toolbar.components.get_action("menu_devices") + menu_utils = self.toolbar.components.get_action("menu_utils") + + menu_plots.actions["waveform"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="Waveform") ) - self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect( + + menu_plots.actions["scatter_waveform"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform") ) - self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect( + menu_plots.actions["multi_waveform"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform") ) - self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect( + menu_plots.actions["image"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="Image") ) - self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect( + menu_plots.actions["motor_map"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="MotorMap") ) # Menu Devices - self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect( + menu_devices.actions["scan_control"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="ScanControl") ) - self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect( + menu_devices.actions["positioner_box"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="PositionerBox") ) # Menu Utils - self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect( + menu_utils.actions["queue"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="BECQueue") ) - self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect( + menu_utils.actions["status"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox") ) - self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect( + menu_utils.actions["vs_code"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor") ) - self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect( + menu_utils.actions["progress_bar"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar") ) # FIXME temporarily disabled -> issue #644 - self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False) - # self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect( - # lambda: self._create_widget_from_toolbar(widget_name="LogPanel") - # ) - self.toolbar.widgets["menu_utils"].widgets["sbb_monitor"].triggered.connect( + menu_utils.actions["log_panel"].action.setEnabled(False) + + menu_utils.actions["sbb_monitor"].action.triggered.connect( lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor") ) # Icons - self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all) - self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state) - self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state) + self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) + self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state) + self.toolbar.components.get_action("restore_state").action.triggered.connect( + self.restore_state + ) @SafeSlot() def _create_widget_from_toolbar(self, widget_name: str) -> None: diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index 2432a791..079102ee 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -7,11 +7,19 @@ import numpy as np from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from pydantic import BaseModel, Field, field_validator -from qtpy.QtWidgets import QWidget +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import QComboBox, QStyledItemDelegate, QWidget from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.colors import Colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.widgets.control.device_input.base_classes.device_input_base import ( + BECDeviceFilter, + ReadoutPriority, +) +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.plots.image.image_base import ImageBase from bec_widgets.widgets.plots.image.image_item import ImageItem @@ -139,9 +147,119 @@ class Image(ImageBase): super().__init__( parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs ) + self._init_toolbar_image() self.layer_removed.connect(self._on_layer_removed) self.scan_id = None + ################################## + ### Toolbar Initialization + ################################## + + def _init_toolbar_image(self): + """ + Initializes the toolbar for the image widget. + """ + self.device_combo_box = DeviceComboBox( + parent=self, + device_filter=BECDeviceFilter.DEVICE, + readout_priority_filter=[ReadoutPriority.ASYNC], + ) + self.device_combo_box.addItem("", None) + self.device_combo_box.setCurrentText("") + self.device_combo_box.setToolTip("Select Device") + self.device_combo_box.setFixedWidth(150) + self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box)) + + self.dim_combo_box = QComboBox(parent=self) + self.dim_combo_box.addItems(["auto", "1d", "2d"]) + self.dim_combo_box.setCurrentText("auto") + self.dim_combo_box.setToolTip("Monitor Dimension") + self.dim_combo_box.setFixedWidth(100) + self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box)) + + self.toolbar.components.add_safe( + "image_device_combo", WidgetAction(widget=self.device_combo_box, adjust_size=False) + ) + self.toolbar.components.add_safe( + "image_dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False) + ) + + bundle = ToolbarBundle("monitor_selection", self.toolbar.components) + bundle.add_action("image_device_combo") + bundle.add_action("image_dim_combo") + + self.toolbar.add_bundle(bundle) + self.device_combo_box.currentTextChanged.connect(self.connect_monitor) + self.dim_combo_box.currentTextChanged.connect(self.connect_monitor) + + crosshair_bundle = self.toolbar.get_bundle("image_crosshair") + crosshair_bundle.add_action("image_autorange") + crosshair_bundle.add_action("image_colorbar_switch") + + self.toolbar.show_bundles( + [ + "monitor_selection", + "plot_export", + "mouse_interaction", + "image_crosshair", + "image_processing", + "axis_popup", + ] + ) + + QTimer.singleShot(0, self._adjust_and_connect) + + def _adjust_and_connect(self): + """ + Adjust the size of the device combo box and populate it with preview signals. + Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing. + """ + self._populate_preview_signals() + self._reverse_device_items() + self.device_combo_box.setCurrentText("") # set again default to empty string + + def _populate_preview_signals(self) -> None: + """ + Populate the device combo box with preview-signal devices in the + format '_' and store the tuple(device, signal) in + the item's userData for later use. + """ + preview_signals = self.client.device_manager.get_bec_signals("PreviewSignal") + for device, signal, signal_config in preview_signals: + label = signal_config.get("obj_name", f"{device}_{signal}") + self.device_combo_box.addItem(label, (device, signal, signal_config)) + + def _reverse_device_items(self) -> None: + """ + Reverse the current order of items in the device combo box while + keeping their userData and restoring the previous selection. + """ + current_text = self.device_combo_box.currentText() + items = [ + (self.device_combo_box.itemText(i), self.device_combo_box.itemData(i)) + for i in range(self.device_combo_box.count()) + ] + self.device_combo_box.clear() + for text, data in reversed(items): + self.device_combo_box.addItem(text, data) + if current_text: + self.device_combo_box.setCurrentText(current_text) + + @SafeSlot() + def connect_monitor(self, *args, **kwargs): + """ + Connect the target widget to the selected monitor based on the current device and dimension. + + If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor. + """ + dim = self.dim_combo_box.currentText() + data = self.device_combo_box.currentData() + + if isinstance(data, tuple): + self.image(monitor=data, monitor_type="auto") + else: + self.image(monitor=self.device_combo_box.currentText(), monitor_type=dim) + ################################################################################ # Data Acquisition @@ -227,35 +345,21 @@ class Image(ImageBase): """ config = self.subscriptions["main"] if config.monitor is not None: - for combo in ( - self.selection_bundle.device_combo_box, - self.selection_bundle.dim_combo_box, - ): + for combo in (self.device_combo_box, self.dim_combo_box): combo.blockSignals(True) if isinstance(config.monitor, tuple): - self.selection_bundle.device_combo_box.setCurrentText( - f"{config.monitor[0]}_{config.monitor[1]}" - ) + self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}") else: - self.selection_bundle.device_combo_box.setCurrentText(config.monitor) - self.selection_bundle.dim_combo_box.setCurrentText(config.monitor_type) - for combo in ( - self.selection_bundle.device_combo_box, - self.selection_bundle.dim_combo_box, - ): + self.device_combo_box.setCurrentText(config.monitor) + self.dim_combo_box.setCurrentText(config.monitor_type) + for combo in (self.device_combo_box, self.dim_combo_box): combo.blockSignals(False) else: - for combo in ( - self.selection_bundle.device_combo_box, - self.selection_bundle.dim_combo_box, - ): + for combo in (self.device_combo_box, self.dim_combo_box): combo.blockSignals(True) - self.selection_bundle.device_combo_box.setCurrentText("") - self.selection_bundle.dim_combo_box.setCurrentText("auto") - for combo in ( - self.selection_bundle.device_combo_box, - self.selection_bundle.dim_combo_box, - ): + self.device_combo_box.setCurrentText("") + self.dim_combo_box.setCurrentText("auto") + for combo in (self.device_combo_box, self.dim_combo_box): combo.blockSignals(False) ################################################################################ @@ -554,8 +658,10 @@ class Image(ImageBase): self.subscriptions.clear() # Toolbar cleanup - self.toolbar.widgets["monitor"].widget.close() - self.toolbar.widgets["monitor"].widget.deleteLater() + self.device_combo_box.close() + self.device_combo_box.deleteLater() + self.dim_combo_box.close() + self.dim_combo_box.deleteLater() super().cleanup() @@ -570,10 +676,10 @@ if __name__ == "__main__": # pragma: no cover ml = QHBoxLayout(win) image_popup = Image(popups=True) - image_side_panel = Image(popups=False) + # image_side_panel = Image(popups=False) ml.addWidget(image_popup) - ml.addWidget(image_side_panel) + # ml.addWidget(image_side_panel) win.resize(1500, 800) win.show() diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index 2aa43d63..1c8cf51c 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -12,14 +12,19 @@ from qtpy.QtWidgets import QDialog, QVBoxLayout from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.side_panel import SidePanel -from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction +from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction from bec_widgets.widgets.plots.image.image_item import ImageItem from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree -from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import ( - MonitorSelectionToolbarBundle, +from bec_widgets.widgets.plots.image.toolbar_components.image_base_actions import ( + ImageColorbarConnection, + ImageProcessingConnection, + ImageRoiConnection, + image_autorange, + image_colorbar, + image_processing, + image_roi_bundle, ) -from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle from bec_widgets.widgets.plots.plot_base import PlotBase from bec_widgets.widgets.plots.roi.image_roi import ( BaseROI, @@ -256,6 +261,7 @@ class ImageBase(PlotBase): self.x_roi = None self.y_roi = None super().__init__(*args, **kwargs) + self.roi_controller = ROIController(colormap="viridis") # Headless controller keeps the canonical list. @@ -264,6 +270,7 @@ class ImageBase(PlotBase): self, plot_item=self.plot_item, on_add=self.layer_added, on_remove=self.layer_removed ) self.layer_manager.add("main") + self._init_image_base_toolbar() self.autorange = True self.autorange_mode = "mean" @@ -274,6 +281,16 @@ class ImageBase(PlotBase): # Refresh theme for ROI plots self._update_theme() + self.toolbar.show_bundles( + [ + "image_crosshair", + "mouse_interaction", + "image_autorange", + "image_colorbar", + "image_processing", + ] + ) + ################################################################################ # Widget Specific GUI interactions ################################################################################ @@ -318,135 +335,66 @@ class ImageBase(PlotBase): """ return list(self.layer_manager.layers.values()) - def _init_toolbar(self): + def _init_image_base_toolbar(self): try: - # add to the first position - self.selection_bundle = MonitorSelectionToolbarBundle( - bundle_id="selection", target_widget=self - ) - self.toolbar.add_bundle(self.selection_bundle, self) - super()._init_toolbar() - - # Image specific changes to PlotBase toolbar - self.toolbar.widgets["reset_legend"].action.setVisible(False) - - # ROI Bundle replacement with switchable crosshair - self.toolbar.remove_bundle("roi") - crosshair = MaterialIconAction( - icon_name="point_scan", tooltip="Show Crosshair", checkable=True, parent=self - ) - crosshair_roi = MaterialIconAction( - icon_name="my_location", - tooltip="Show Crosshair with ROI plots", - checkable=True, - parent=self, - ) - crosshair_roi.action.toggled.connect(self.toggle_roi_panels) - crosshair.action.toggled.connect(self.toggle_crosshair) - switch_crosshair = SwitchableToolBarAction( - actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi}, - initial_action="crosshair_simple", - tooltip="Crosshair", - checkable=True, - parent=self, - ) - self.toolbar.add_action( - action_id="switch_crosshair", action=switch_crosshair, target_widget=self + # ROI Actions + self.toolbar.add_bundle(image_roi_bundle(self.toolbar.components)) + self.toolbar.connect_bundle( + "image_base", ImageRoiConnection(self.toolbar.components, target_widget=self) ) - # Lock aspect ratio button - self.lock_aspect_ratio_action = MaterialIconAction( + # Lock Aspect Ratio Action + lock_aspect_ratio_action = MaterialIconAction( icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self ) - self.toolbar.add_action_to_bundle( - bundle_id="mouse_interaction", - action_id="lock_aspect_ratio", - action=self.lock_aspect_ratio_action, - target_widget=self, - ) - self.lock_aspect_ratio_action.action.toggled.connect( + self.toolbar.components.add_safe("lock_aspect_ratio", lock_aspect_ratio_action) + self.toolbar.get_bundle("mouse_interaction").add_action("lock_aspect_ratio") + lock_aspect_ratio_action.action.toggled.connect( lambda checked: self.setProperty("lock_aspect_ratio", checked) ) - self.lock_aspect_ratio_action.action.setChecked(True) + lock_aspect_ratio_action.action.setChecked(True) - self._init_autorange_action() - self._init_colorbar_action() - - # Processing Bundle - self.processing_bundle = ImageProcessingToolbarBundle( - bundle_id="processing", target_widget=self + # Autorange Action + self.toolbar.add_bundle(image_autorange(self.toolbar.components)) + action = self.toolbar.components.get_action("image_autorange") + action.actions["mean"].action.toggled.connect( + lambda checked: self.toggle_autorange(checked, mode="mean") + ) + action.actions["max"].action.toggled.connect( + lambda checked: self.toggle_autorange(checked, mode="max") + ) + + # Colorbar Actions + self.toolbar.add_bundle(image_colorbar(self.toolbar.components)) + + self.toolbar.connect_bundle( + "image_colorbar", + ImageColorbarConnection(self.toolbar.components, target_widget=self), + ) + + # Image Processing Actions + self.toolbar.add_bundle(image_processing(self.toolbar.components)) + self.toolbar.connect_bundle( + "image_processing", + ImageProcessingConnection(self.toolbar.components, target_widget=self), + ) + + # ROI Manager Action + self.toolbar.components.add_safe( + "roi_mgr", + MaterialIconAction( + icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self + ), + ) + self.toolbar.get_bundle("axis_popup").add_action("roi_mgr") + self.toolbar.components.get_action("roi_mgr").action.triggered.connect( + self.show_roi_manager_popup ) - self.toolbar.add_bundle(self.processing_bundle, target_widget=self) except Exception as e: logger.error(f"Error initializing toolbar: {e}") - def _init_autorange_action(self): - - self.autorange_mean_action = MaterialIconAction( - icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self - ) - self.autorange_max_action = MaterialIconAction( - icon_name="hdr_auto", - tooltip="Enable Auto Range (Max)", - checkable=True, - filled=True, - parent=self, - ) - - self.autorange_switch = SwitchableToolBarAction( - actions={ - "auto_range_mean": self.autorange_mean_action, - "auto_range_max": self.autorange_max_action, - }, - initial_action="auto_range_mean", - tooltip="Enable Auto Range", - checkable=True, - parent=self, - ) - - self.toolbar.add_action( - action_id="autorange_image", action=self.autorange_switch, target_widget=self - ) - - self.autorange_mean_action.action.toggled.connect( - lambda checked: self.toggle_autorange(checked, mode="mean") - ) - self.autorange_max_action.action.toggled.connect( - lambda checked: self.toggle_autorange(checked, mode="max") - ) - - def _init_colorbar_action(self): - self.full_colorbar_action = MaterialIconAction( - icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self - ) - self.simple_colorbar_action = MaterialIconAction( - icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self - ) - - self.colorbar_switch = SwitchableToolBarAction( - actions={ - "full_colorbar": self.full_colorbar_action, - "simple_colorbar": self.simple_colorbar_action, - }, - initial_action="full_colorbar", - tooltip="Enable Full Colorbar", - checkable=True, - parent=self, - ) - - self.toolbar.add_action( - action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self - ) - - self.simple_colorbar_action.action.toggled.connect( - lambda checked: self.enable_colorbar(checked, style="simple") - ) - self.full_colorbar_action.action.toggled.connect( - lambda checked: self.enable_colorbar(checked, style="full") - ) - ######################################## # ROI Gui Manager def add_side_menus(self): @@ -461,20 +409,8 @@ class ImageBase(PlotBase): title="ROI Manager", ) - def add_popups(self): - super().add_popups() # keep Axis Settings - - roi_action = MaterialIconAction( - icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self - ) - # self.popup_bundle.add_action("roi_mgr", roi_action) - self.toolbar.add_action_to_bundle( - bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self - ) - self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup) - def show_roi_manager_popup(self): - roi_action = self.toolbar.widgets["roi_mgr"].action + roi_action = self.toolbar.components.get_action("roi_mgr").action if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible(): self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self) self.roi_manager_dialog = QDialog(modal=False) @@ -494,7 +430,7 @@ class ImageBase(PlotBase): self.roi_manager_dialog.close() self.roi_manager_dialog.deleteLater() self.roi_manager_dialog = None - self.toolbar.widgets["roi_mgr"].action.setChecked(False) + self.toolbar.components.get_action("roi_mgr").action.setChecked(False) def enable_colorbar( self, @@ -518,12 +454,11 @@ class ImageBase(PlotBase): self.plot_widget.removeItem(self._color_bar) self._color_bar = None + def disable_autorange(): + print("Disabling autorange") + self.setProperty("autorange", False) + if style == "simple": - - def disable_autorange(): - print("Disabling autorange") - self.setProperty("autorange", False) - self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map) self._color_bar.setImageItem(self.layer_manager["main"].image) self._color_bar.sigLevelsChangeFinished.connect(disable_autorange) @@ -532,9 +467,7 @@ class ImageBase(PlotBase): self._color_bar = pg.HistogramLUTItem() self._color_bar.setImageItem(self.layer_manager["main"].image) self._color_bar.gradient.loadPreset(self.config.color_map) - self._color_bar.sigLevelsChanged.connect( - lambda: self.setProperty("autorange", False) - ) + self._color_bar.sigLevelsChanged.connect(disable_autorange) self.plot_widget.addItem(self._color_bar, row=0, col=1) self.config.color_bar = style @@ -827,6 +760,9 @@ class ImageBase(PlotBase): Args: value(tuple | list | QPointF): The range of values to set. """ + self._set_vrange(value, disable_autorange=True) + + def _set_vrange(self, value: tuple | list | QPointF, disable_autorange: bool = True): if isinstance(value, (tuple, list)): value = self._tuple_to_qpointf(value) @@ -835,7 +771,7 @@ class ImageBase(PlotBase): for layer in self.layer_manager: if not layer.sync.v_range: continue - layer.image.v_range = (vmin, vmax) + layer.image.set_v_range((vmin, vmax), disable_autorange=disable_autorange) # propagate to colorbar if exists if self._color_bar: @@ -845,7 +781,7 @@ class ImageBase(PlotBase): self._color_bar.setLevels(min=vmin, max=vmax) self._color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax) - self.autorange_switch.set_state_all(False) + # self.toolbar.components.get_action("image_autorange").set_state_all(False) @property def v_min(self) -> float: @@ -919,14 +855,27 @@ class ImageBase(PlotBase): Args: enabled(bool): Whether to enable autorange. """ + self._set_autorange(enabled) + + def _set_autorange(self, enabled: bool, sync: bool = True): + """ + Set the autorange for all layers. + + Args: + enabled(bool): Whether to enable autorange. + sync(bool): Whether to synchronize the autorange state across all layers. + """ + print(f"Setting autorange to {enabled}") for layer in self.layer_manager: if not layer.sync.autorange: continue layer.image.autorange = enabled if enabled and layer.image.raw_data is not None: layer.image.apply_autorange() + # if sync: self._sync_colorbar_levels() self._sync_autorange_switch() + print(f"Autorange set to {enabled}") @SafeProperty(str) def autorange_mode(self) -> str: @@ -948,6 +897,7 @@ class ImageBase(PlotBase): Args: mode(str): The autorange mode. Options are "max" or "mean". """ + print(f"Setting autorange mode to {mode}") # for qt Designer if mode not in ["max", "mean"]: return @@ -969,7 +919,7 @@ class ImageBase(PlotBase): """ if not self.layer_manager: return - + print(f"Toggling autorange to {enabled} with mode {mode}") for layer in self.layer_manager: if layer.sync.autorange: layer.image.autorange = enabled @@ -981,19 +931,16 @@ class ImageBase(PlotBase): # We only need to apply autorange if we enabled it layer.image.apply_autorange() - if enabled: - self._sync_colorbar_levels() + self._sync_colorbar_levels() def _sync_autorange_switch(self): """ Synchronize the autorange switch with the current autorange state and mode if changed from outside. """ - self.autorange_switch.block_all_signals(True) - self.autorange_switch.set_default_action( - f"auto_range_{self.layer_manager['main'].image.autorange_mode}" - ) - self.autorange_switch.set_state_all(self.layer_manager["main"].image.autorange) - self.autorange_switch.block_all_signals(False) + action: SwitchableToolBarAction = self.toolbar.components.get_action("image_autorange") # type: ignore + with action.signal_blocker(): + action.set_default_action(f"{self.layer_manager['main'].image.autorange_mode}") + action.set_state_all(self.layer_manager["main"].image.autorange) def _sync_colorbar_levels(self): """Immediately propagate current levels to the active colorbar.""" @@ -1009,20 +956,22 @@ class ImageBase(PlotBase): total_vrange = (min(total_vrange[0], img.v_min), max(total_vrange[1], img.v_max)) self._color_bar.blockSignals(True) - self.v_range = total_vrange # type: ignore + self._set_vrange(total_vrange, disable_autorange=False) # type: ignore self._color_bar.blockSignals(False) def _sync_colorbar_actions(self): """ Synchronize the colorbar actions with the current colorbar state. """ - self.colorbar_switch.block_all_signals(True) - if self._color_bar is not None: - self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar") - self.colorbar_switch.set_state_all(True) - else: - self.colorbar_switch.set_state_all(False) - self.colorbar_switch.block_all_signals(False) + colorbar_switch: SwitchableToolBarAction = self.toolbar.components.get_action( + "image_colorbar_switch" + ) + with colorbar_switch.signal_blocker(): + if self._color_bar is not None: + colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar") + colorbar_switch.set_state_all(True) + else: + colorbar_switch.set_state_all(False) @staticmethod def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem): 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 1b3c0fdb..ace193e2 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 @@ -20,7 +20,9 @@ from qtpy.QtWidgets import ( from bec_widgets import BECWidget from bec_widgets.utils import BECDispatcher, ConnectionConfig -from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar +from bec_widgets.utils.toolbars.actions import WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.widgets.plots.roi.image_roi import ( BaseROI, CircularROI, @@ -121,20 +123,33 @@ class ROIPropertyTree(BECWidget, QWidget): # --------------------------------------------------------------------- UI def _init_toolbar(self): - tb = ModularToolBar(self, self, orientation="horizontal") + tb = self.toolbar = ModularToolBar(self, orientation="horizontal") self._draw_actions: dict[str, MaterialIconAction] = {} # --- ROI draw actions (toggleable) --- - self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self) - tb.add_action("Add Rect ROI", self.add_rect_action, self) - self._draw_actions["rect"] = self.add_rect_action - self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self) - tb.add_action("Add Circle ROI", self.add_circle_action, self) - self._draw_actions["circle"] = self.add_circle_action - # --- Ellipse ROI draw action --- - self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self) - tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self) - self._draw_actions["ellipse"] = self.add_ellipse_action + tb.components.add_safe( + "roi_rectangle", + MaterialIconAction("add_box", "Add Rect ROI", checkable=True, parent=self), + ) + tb.components.add_safe( + "roi_circle", + MaterialIconAction("add_circle", "Add Circle ROI", checkable=True, parent=self), + ) + tb.components.add_safe( + "roi_ellipse", + MaterialIconAction("vignette", "Add Ellipse ROI", checkable=True, parent=self), + ) + bundle = ToolbarBundle("roi_draw", tb.components) + bundle.add_action("roi_rectangle") + bundle.add_action("roi_circle") + bundle.add_action("roi_ellipse") + tb.add_bundle(bundle) + + self._draw_actions = { + "rect": tb.components.get_action("roi_rectangle"), + "circle": tb.components.get_action("roi_circle"), + "ellipse": tb.components.get_action("roi_ellipse"), + } for mode, act in self._draw_actions.items(): act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on)) @@ -142,7 +157,7 @@ class ROIPropertyTree(BECWidget, QWidget): self.expand_toggle = MaterialIconAction( "unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed ) - tb.add_action("Expand/Collapse", self.expand_toggle, self) + tb.components.add_safe("expand_toggle", self.expand_toggle) def _exp_toggled(on: bool): if on: @@ -163,7 +178,7 @@ class ROIPropertyTree(BECWidget, QWidget): 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) + tb.components.add_safe("lock_unlock_all", self.lock_all_action) def _lock_all(checked: bool): # checked -> everything locked (movable = False) @@ -178,12 +193,23 @@ class ROIPropertyTree(BECWidget, QWidget): # colormap widget self.cmap = BECColorMapWidget(cmap=self.controller.colormap) - tb.addWidget(QWidget()) # spacer - tb.addWidget(self.cmap) + + tb.components.add_safe("roi_tree_spacer", WidgetAction(widget=QWidget())) + tb.components.add_safe("roi_tree_cmap", WidgetAction(widget=self.cmap)) + self.cmap.colormap_changed_signal.connect(self.controller.set_colormap) self.layout.addWidget(tb) self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap)) + bundle = ToolbarBundle("roi_tools", tb.components) + bundle.add_action("expand_toggle") + bundle.add_action("lock_unlock_all") + bundle.add_action("roi_tree_spacer") + bundle.add_action("roi_tree_cmap") + tb.add_bundle(bundle) + + tb.show_bundles(["roi_draw", "roi_tools"]) + # ROI drawing state self._roi_draw_mode = None # 'rect' | 'circle' | 'ellipse' | None self._roi_start_pos = None # QPointF in image coords diff --git a/bec_widgets/widgets/plots/image/toolbar_bundles/image_selection.py b/bec_widgets/widgets/plots/image/toolbar_bundles/image_selection.py deleted file mode 100644 index f18e54b9..00000000 --- a/bec_widgets/widgets/plots/image/toolbar_bundles/image_selection.py +++ /dev/null @@ -1,107 +0,0 @@ -from bec_lib.device import ReadoutPriority -from qtpy.QtCore import Qt, QTimer -from qtpy.QtWidgets import QComboBox, QStyledItemDelegate - -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox - - -class NoCheckDelegate(QStyledItemDelegate): - """To reduce space in combo boxes by removing the checkmark.""" - - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - # Remove any check indicator - option.checkState = Qt.Unchecked - - -class MonitorSelectionToolbarBundle(ToolbarBundle): - """ - A bundle of actions for a toolbar that controls monitor selection on a plot. - """ - - def __init__(self, bundle_id="device_selection", target_widget=None, **kwargs): - super().__init__(bundle_id=bundle_id, actions=[], **kwargs) - self.target_widget = target_widget - - # 1) Device combo box - self.device_combo_box = DeviceComboBox( - parent=self.target_widget, - device_filter=BECDeviceFilter.DEVICE, - readout_priority_filter=[ReadoutPriority.ASYNC], - ) - self.device_combo_box.addItem("", None) - self.device_combo_box.setCurrentText("") - self.device_combo_box.setToolTip("Select Device") - self.device_combo_box.setFixedWidth(150) - self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box)) - - self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=False)) - - # 2) Dimension combo box - self.dim_combo_box = QComboBox(parent=self.target_widget) - self.dim_combo_box.addItems(["auto", "1d", "2d"]) - self.dim_combo_box.setCurrentText("auto") - self.dim_combo_box.setToolTip("Monitor Dimension") - self.dim_combo_box.setFixedWidth(100) - self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box)) - - self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False)) - - self.device_combo_box.currentTextChanged.connect(self.connect_monitor) - self.dim_combo_box.currentTextChanged.connect(self.connect_monitor) - - QTimer.singleShot(0, self._adjust_and_connect) - - def _adjust_and_connect(self): - """ - Adjust the size of the device combo box and populate it with preview signals. - Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing. - """ - self._populate_preview_signals() - self._reverse_device_items() - self.device_combo_box.setCurrentText("") # set again default to empty string - - def _populate_preview_signals(self) -> None: - """ - Populate the device combo box with preview‑signal devices in the - format '_' and store the tuple(device, signal) in - the item's userData for later use. - """ - preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal") - for device, signal, signal_config in preview_signals: - label = signal_config.get("obj_name", f"{device}_{signal}") - self.device_combo_box.addItem(label, (device, signal, signal_config)) - - def _reverse_device_items(self) -> None: - """ - Reverse the current order of items in the device combo box while - keeping their userData and restoring the previous selection. - """ - current_text = self.device_combo_box.currentText() - items = [ - (self.device_combo_box.itemText(i), self.device_combo_box.itemData(i)) - for i in range(self.device_combo_box.count()) - ] - self.device_combo_box.clear() - for text, data in reversed(items): - self.device_combo_box.addItem(text, data) - if current_text: - self.device_combo_box.setCurrentText(current_text) - - @SafeSlot() - def connect_monitor(self, *args, **kwargs): - """ - Connect the target widget to the selected monitor based on the current device and dimension. - - If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor. - """ - dim = self.dim_combo_box.currentText() - data = self.device_combo_box.currentData() - - if isinstance(data, tuple): - self.target_widget.image(monitor=data, monitor_type="auto") - else: - self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim) diff --git a/bec_widgets/widgets/plots/image/toolbar_bundles/processing.py b/bec_widgets/widgets/plots/image/toolbar_bundles/processing.py deleted file mode 100644 index e5b76098..00000000 --- a/bec_widgets/widgets/plots/image/toolbar_bundles/processing.py +++ /dev/null @@ -1,92 +0,0 @@ -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle - - -class ImageProcessingToolbarBundle(ToolbarBundle): - """ - A bundle of actions for a toolbar that controls processing of monitor. - """ - - def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs): - super().__init__(bundle_id=bundle_id, actions=[], **kwargs) - self.target_widget = target_widget - - self.fft = MaterialIconAction( - icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=self.target_widget - ) - self.log = MaterialIconAction( - icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=self.target_widget - ) - self.transpose = MaterialIconAction( - icon_name="transform", - tooltip="Transpose Image", - checkable=True, - parent=self.target_widget, - ) - self.right = MaterialIconAction( - icon_name="rotate_right", - tooltip="Rotate image clockwise by 90 deg", - parent=self.target_widget, - ) - self.left = MaterialIconAction( - icon_name="rotate_left", - tooltip="Rotate image counterclockwise by 90 deg", - parent=self.target_widget, - ) - self.reset = MaterialIconAction( - icon_name="reset_settings", tooltip="Reset Image Settings", parent=self.target_widget - ) - - self.add_action("fft", self.fft) - self.add_action("log", self.log) - self.add_action("transpose", self.transpose) - self.add_action("rotate_right", self.right) - self.add_action("rotate_left", self.left) - self.add_action("reset", self.reset) - - self.fft.action.triggered.connect(self.toggle_fft) - self.log.action.triggered.connect(self.toggle_log) - self.transpose.action.triggered.connect(self.toggle_transpose) - self.right.action.triggered.connect(self.rotate_right) - self.left.action.triggered.connect(self.rotate_left) - self.reset.action.triggered.connect(self.reset_settings) - - @SafeSlot() - def toggle_fft(self): - checked = self.fft.action.isChecked() - self.target_widget.fft = checked - - @SafeSlot() - def toggle_log(self): - checked = self.log.action.isChecked() - self.target_widget.log = checked - - @SafeSlot() - def toggle_transpose(self): - checked = self.transpose.action.isChecked() - self.target_widget.transpose = checked - - @SafeSlot() - def rotate_right(self): - if self.target_widget.num_rotation_90 is None: - return - rotation = (self.target_widget.num_rotation_90 - 1) % 4 - self.target_widget.num_rotation_90 = rotation - - @SafeSlot() - def rotate_left(self): - if self.target_widget.num_rotation_90 is None: - return - rotation = (self.target_widget.num_rotation_90 + 1) % 4 - self.target_widget.num_rotation_90 = rotation - - @SafeSlot() - def reset_settings(self): - self.target_widget.fft = False - self.target_widget.log = False - self.target_widget.transpose = False - self.target_widget.num_rotation_90 = 0 - - self.fft.action.setChecked(False) - self.log.action.setChecked(False) - self.transpose.action.setChecked(False) diff --git a/bec_widgets/widgets/plots/image/toolbar_bundles/__init__.py b/bec_widgets/widgets/plots/image/toolbar_components/__init__.py similarity index 100% rename from bec_widgets/widgets/plots/image/toolbar_bundles/__init__.py rename to bec_widgets/widgets/plots/image/toolbar_components/__init__.py diff --git a/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py b/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py new file mode 100644 index 00000000..7d357944 --- /dev/null +++ b/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection + + +def image_roi_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a toolbar bundle for ROI and crosshair interaction. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The ROI toolbar bundle. + """ + components.add_safe( + "image_crosshair", + MaterialIconAction( + icon_name="point_scan", + tooltip="Show Crosshair", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "image_crosshair_roi", + MaterialIconAction( + icon_name="my_location", + tooltip="Show Crosshair with ROI plots", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "image_switch_crosshair", + SwitchableToolBarAction( + actions={ + "crosshair": components.get_action_reference("image_crosshair")(), + "crosshair_roi": components.get_action_reference("image_crosshair_roi")(), + }, + initial_action="crosshair", + tooltip="Crosshair", + checkable=True, + parent=components.toolbar, + ), + ) + bundle = ToolbarBundle("image_crosshair", components) + bundle.add_action("image_switch_crosshair") + return bundle + + +class ImageRoiConnection(BundleConnection): + """ + Connection class for the ROI toolbar bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + self.bundle_name = "roi" + self.components = components + self.target_widget = target_widget + if not hasattr(self.target_widget, "toggle_roi_panels") or not hasattr( + self.target_widget, "toggle_crosshair" + ): + raise AttributeError( + "Target widget must implement 'toggle_roi_panels' and 'toggle_crosshair'." + ) + super().__init__() + self._connected = False + + def connect(self): + self._connected = True + # Connect the action to the target widget's method + self.components.get_action("image_crosshair").action.toggled.connect( + self.target_widget.toggle_crosshair + ) + self.components.get_action("image_crosshair_roi").action.triggered.connect( + self.target_widget.toggle_roi_panels + ) + + def disconnect(self): + if not self._connected: + return + # Disconnect the action from the target widget's method + self.components.get_action("image_crosshair").action.toggled.disconnect( + self.target_widget.toggle_crosshair + ) + self.components.get_action("image_crosshair_roi").action.triggered.disconnect( + self.target_widget.toggle_roi_panels + ) + + +def image_autorange(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a toolbar bundle for image autorange functionality. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The autorange toolbar bundle. + """ + components.add_safe( + "image_autorange_mean", + MaterialIconAction( + icon_name="hdr_auto", + tooltip="Enable Auto Range (Mean)", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "image_autorange_max", + MaterialIconAction( + icon_name="hdr_auto", + tooltip="Enable Auto Range (Max)", + checkable=True, + parent=components.toolbar, + filled=True, + ), + ) + components.add_safe( + "image_autorange", + SwitchableToolBarAction( + actions={ + "mean": components.get_action_reference("image_autorange_mean")(), + "max": components.get_action_reference("image_autorange_max")(), + }, + initial_action="mean", + tooltip="Autorange", + checkable=True, + parent=components.toolbar, + default_state_checked=True, + ), + ) + bundle = ToolbarBundle("image_autorange", components) + bundle.add_action("image_autorange") + return bundle + + +def image_colorbar(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a toolbar bundle for image colorbar functionality. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The colorbar toolbar bundle. + """ + components.add_safe( + "image_full_colorbar", + MaterialIconAction( + icon_name="edgesensor_low", + tooltip="Enable Full Colorbar", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "image_simple_colorbar", + MaterialIconAction( + icon_name="smartphone", + tooltip="Enable Simple Colorbar", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "image_colorbar_switch", + SwitchableToolBarAction( + actions={ + "full_colorbar": components.get_action_reference("image_full_colorbar")(), + "simple_colorbar": components.get_action_reference("image_simple_colorbar")(), + }, + initial_action="full_colorbar", + tooltip="Colorbar", + checkable=True, + parent=components.toolbar, + ), + ) + bundle = ToolbarBundle("image_colorbar", components) + bundle.add_action("image_colorbar_switch") + return bundle + + +class ImageColorbarConnection(BundleConnection): + """ + Connection class for the image colorbar toolbar bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + self.bundle_name = "image_colorbar" + self.components = components + self.target_widget = target_widget + if not hasattr(self.target_widget, "enable_colorbar"): + raise AttributeError("Target widget must implement 'enable_colorbar' method.") + super().__init__() + self._connected = False + + def _enable_full_colorbar(self, checked: bool): + """ + Enable or disable the full colorbar based on the checked state. + """ + self.target_widget.enable_colorbar(checked, style="full") + + def _enable_simple_colorbar(self, checked: bool): + """ + Enable or disable the simple colorbar based on the checked state. + """ + self.target_widget.enable_colorbar(checked, style="simple") + + def connect(self): + self._connected = True + # Connect the action to the target widget's method + self.components.get_action("image_full_colorbar").action.toggled.connect( + self._enable_full_colorbar + ) + self.components.get_action("image_simple_colorbar").action.toggled.connect( + self._enable_simple_colorbar + ) + + def disconnect(self): + if not self._connected: + return + # Disconnect the action from the target widget's method + self.components.get_action("image_full_colorbar").action.toggled.disconnect( + self._enable_full_colorbar + ) + self.components.get_action("image_simple_colorbar").action.toggled.disconnect( + self._enable_simple_colorbar + ) + + +def image_processing(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a toolbar bundle for image processing functionality. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The image processing toolbar bundle. + """ + components.add_safe( + "image_processing_fft", + MaterialIconAction( + icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=components.toolbar + ), + ) + components.add_safe( + "image_processing_log", + MaterialIconAction( + icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=components.toolbar + ), + ) + components.add_safe( + "image_processing_transpose", + MaterialIconAction( + icon_name="transform", + tooltip="Transpose Image", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "image_processing_rotate_right", + MaterialIconAction( + icon_name="rotate_right", + tooltip="Rotate image clockwise by 90 deg", + parent=components.toolbar, + ), + ) + components.add_safe( + "image_processing_rotate_left", + MaterialIconAction( + icon_name="rotate_left", + tooltip="Rotate image counterclockwise by 90 deg", + parent=components.toolbar, + ), + ) + components.add_safe( + "image_processing_reset", + MaterialIconAction( + icon_name="reset_settings", tooltip="Reset Image Settings", parent=components.toolbar + ), + ) + bundle = ToolbarBundle("image_processing", components) + bundle.add_action("image_processing_fft") + bundle.add_action("image_processing_log") + bundle.add_action("image_processing_transpose") + bundle.add_action("image_processing_rotate_right") + bundle.add_action("image_processing_rotate_left") + bundle.add_action("image_processing_reset") + return bundle + + +class ImageProcessingConnection(BundleConnection): + """ + Connection class for the image processing toolbar bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + self.bundle_name = "image_processing" + self.components = components + self.target_widget = target_widget + if ( + not hasattr(self.target_widget, "fft") + or not hasattr(self.target_widget, "log") + or not hasattr(self.target_widget, "transpose") + or not hasattr(self.target_widget, "num_rotation_90") + ): + raise AttributeError( + "Target widget must implement 'fft', 'log', 'transpose', and 'num_rotation_90' attributes." + ) + super().__init__() + self.fft = components.get_action("image_processing_fft") + self.log = components.get_action("image_processing_log") + self.transpose = components.get_action("image_processing_transpose") + self.right = components.get_action("image_processing_rotate_right") + self.left = components.get_action("image_processing_rotate_left") + self.reset = components.get_action("image_processing_reset") + self._connected = False + + @SafeSlot() + def toggle_fft(self): + checked = self.fft.action.isChecked() + self.target_widget.fft = checked + + @SafeSlot() + def toggle_log(self): + checked = self.log.action.isChecked() + self.target_widget.log = checked + + @SafeSlot() + def toggle_transpose(self): + checked = self.transpose.action.isChecked() + self.target_widget.transpose = checked + + @SafeSlot() + def rotate_right(self): + if self.target_widget.num_rotation_90 is None: + return + rotation = (self.target_widget.num_rotation_90 - 1) % 4 + self.target_widget.num_rotation_90 = rotation + + @SafeSlot() + def rotate_left(self): + if self.target_widget.num_rotation_90 is None: + return + rotation = (self.target_widget.num_rotation_90 + 1) % 4 + self.target_widget.num_rotation_90 = rotation + + @SafeSlot() + def reset_settings(self): + self.target_widget.fft = False + self.target_widget.log = False + self.target_widget.transpose = False + self.target_widget.num_rotation_90 = 0 + + self.fft.action.setChecked(False) + self.log.action.setChecked(False) + self.transpose.action.setChecked(False) + + def connect(self): + """ + Connect the actions to the target widget's methods. + """ + self._connected = True + self.fft.action.triggered.connect(self.toggle_fft) + self.log.action.triggered.connect(self.toggle_log) + self.transpose.action.triggered.connect(self.toggle_transpose) + self.right.action.triggered.connect(self.rotate_right) + self.left.action.triggered.connect(self.rotate_left) + self.reset.action.triggered.connect(self.reset_settings) + + def disconnect(self): + """ + Disconnect the actions from the target widget's methods. + """ + if not self._connected: + return + self.fft.action.triggered.disconnect(self.toggle_fft) + self.log.action.triggered.disconnect(self.toggle_log) + self.transpose.action.triggered.disconnect(self.toggle_transpose) + self.right.action.triggered.disconnect(self.rotate_right) + self.left.action.triggered.disconnect(self.rotate_left) + self.reset.action.triggered.disconnect(self.reset_settings) diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index 31f5da5f..19da515f 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -1,6 +1,5 @@ from __future__ import annotations -import numpy as np import pyqtgraph as pg from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints @@ -15,12 +14,12 @@ from bec_widgets.utils import Colors, ConnectionConfig from bec_widgets.utils.colors import set_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog -from bec_widgets.utils.toolbar import MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings -from bec_widgets.widgets.plots.motor_map.toolbar_bundles.motor_selection import ( - MotorSelectionToolbarBundle, +from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import ( + MotorSelectionAction, ) -from bec_widgets.widgets.plots.plot_base import PlotBase +from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode logger = bec_logger.logger @@ -182,33 +181,60 @@ class MotorMap(PlotBase): self.proxy_update_plot = pg.SignalProxy( self.update_signal, rateLimit=25, slot=self._update_plot ) + self._init_motor_map_toolbar() self._add_motor_map_settings() ################################################################################ # Widget Specific GUI interactions ################################################################################ - def _init_toolbar(self): + def _init_motor_map_toolbar(self): """ Initialize the toolbar for the motor map widget. """ - self.motor_selection_bundle = MotorSelectionToolbarBundle( - bundle_id="motor_selection", target_widget=self - ) - self.toolbar.add_bundle(self.motor_selection_bundle, target_widget=self) - super()._init_toolbar() - self.toolbar.widgets["reset_legend"].action.setVisible(False) + motor_selection = MotorSelectionAction(parent=self) + self.toolbar.add_action("motor_selection", motor_selection) - self.reset_legend_action = MaterialIconAction( - icon_name="history", tooltip="Reset the position of legend." + motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed) + motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed) + + self.toolbar.components.get_action("reset_legend").action.setVisible(False) + + reset_legend = MaterialIconAction( + icon_name="history", + tooltip="Reset the position of legend.", + checkable=False, + parent=self, ) - self.toolbar.add_action_to_bundle( - bundle_id="roi", - action_id="motor_map_history", - action=self.reset_legend_action, - target_widget=self, + self.toolbar.components.add_safe("reset_motor_map_legend", reset_legend) + self.toolbar.get_bundle("roi").add_action("reset_motor_map_legend") + reset_legend.action.triggered.connect(self.reset_history) + + settings_brightness = MaterialIconAction( + icon_name="settings_brightness", + tooltip="Show Motor Map Settings", + checkable=True, + parent=self, ) - self.reset_legend_action.action.triggered.connect(self.reset_history) + self.toolbar.components.add_safe("motor_map_settings", settings_brightness) + self.toolbar.get_bundle("axis_popup").add_action("motor_map_settings") + + settings_brightness.action.triggered.connect(self.show_motor_map_settings) + + bundles = ["motor_selection", "plot_export", "mouse_interaction", "roi"] + if self.ui_mode == UIMode.POPUP: + bundles.append("axis_popup") + self.toolbar.show_bundles(bundles) + + @SafeSlot() + def on_motor_selection_changed(self, _): + action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection") + motor_x = action.motor_x.currentText() + motor_y = action.motor_y.currentText() + + if motor_x != "" and motor_y != "": + if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name: + self.map(motor_x, motor_y) def _add_motor_map_settings(self): """Add the motor map settings to the side panel.""" @@ -221,32 +247,11 @@ class MotorMap(PlotBase): title="Motor Map Settings", ) - def add_popups(self): - """ - Add popups to the ScatterWaveform widget. - """ - super().add_popups() - scatter_curve_setting_action = MaterialIconAction( - icon_name="settings_brightness", - tooltip="Show Motor Map Settings", - checkable=True, - parent=self, - ) - self.toolbar.add_action_to_bundle( - bundle_id="popup_bundle", - action_id="motor_map_settings", - action=scatter_curve_setting_action, - target_widget=self, - ) - self.toolbar.widgets["motor_map_settings"].action.triggered.connect( - self.show_motor_map_settings - ) - def show_motor_map_settings(self): """ Show the DAP summary popup. """ - action = self.toolbar.widgets["motor_map_settings"].action + action = self.toolbar.components.get_action("motor_map_settings").action if self.motor_map_settings is None or not self.motor_map_settings.isVisible(): motor_map_settings = MotorMapSettings(parent=self, target_widget=self, popup=True) self.motor_map_settings = SettingsDialog( @@ -272,7 +277,7 @@ class MotorMap(PlotBase): """ self.motor_map_settings.deleteLater() self.motor_map_settings = None - self.toolbar.widgets["motor_map_settings"].action.setChecked(False) + self.toolbar.components.get_action("motor_map_settings").action.setChecked(False) ################################################################################ # Widget Specific Properties @@ -766,20 +771,21 @@ class MotorMap(PlotBase): """ Sync the motor map selection toolbar with the current motor map. """ - if self.motor_selection_bundle is not None: - motor_x = self.motor_selection_bundle.motor_x.currentText() - motor_y = self.motor_selection_bundle.motor_y.currentText() + motor_selection = self.toolbar.components.get_action("motor_selection") - if motor_x != self.config.x_motor.name: - self.motor_selection_bundle.motor_x.blockSignals(True) - self.motor_selection_bundle.motor_x.set_device(self.config.x_motor.name) - self.motor_selection_bundle.motor_x.check_validity(self.config.x_motor.name) - self.motor_selection_bundle.motor_x.blockSignals(False) - if motor_y != self.config.y_motor.name: - self.motor_selection_bundle.motor_y.blockSignals(True) - self.motor_selection_bundle.motor_y.set_device(self.config.y_motor.name) - self.motor_selection_bundle.motor_y.check_validity(self.config.y_motor.name) - self.motor_selection_bundle.motor_y.blockSignals(False) + motor_x = motor_selection.motor_x.currentText() + motor_y = motor_selection.motor_y.currentText() + + if motor_x != self.config.x_motor.name: + motor_selection.motor_x.blockSignals(True) + motor_selection.motor_x.set_device(self.config.x_motor.name) + motor_selection.motor_x.check_validity(self.config.x_motor.name) + motor_selection.motor_x.blockSignals(False) + if motor_y != self.config.y_motor.name: + motor_selection.motor_y.blockSignals(True) + motor_selection.motor_y.set_device(self.config.y_motor.name) + motor_selection.motor_y.check_validity(self.config.y_motor.name) + motor_selection.motor_y.blockSignals(False) ################################################################################ # Export Methods @@ -795,10 +801,6 @@ class MotorMap(PlotBase): data = {"x": self._buffer["x"], "y": self._buffer["y"]} return data - def cleanup(self): - self.motor_selection_bundle.cleanup() - super().cleanup() - class DemoApp(QMainWindow): # pragma: no cover def __init__(self): diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_bundles/motor_selection.py b/bec_widgets/widgets/plots/motor_map/toolbar_bundles/motor_selection.py deleted file mode 100644 index da9f956c..00000000 --- a/bec_widgets/widgets/plots/motor_map/toolbar_bundles/motor_selection.py +++ /dev/null @@ -1,70 +0,0 @@ -from bec_lib.device import ReadoutPriority -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QStyledItemDelegate - -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox - - -class NoCheckDelegate(QStyledItemDelegate): - """To reduce space in combo boxes by removing the checkmark.""" - - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - # Remove any check indicator - option.checkState = Qt.Unchecked - - -class MotorSelectionToolbarBundle(ToolbarBundle): - """ - A bundle of actions for a toolbar that selects motors. - """ - - def __init__(self, bundle_id="motor_selection", target_widget=None, **kwargs): - super().__init__(bundle_id=bundle_id, actions=[], **kwargs) - self.target_widget = target_widget - - # Motor X - self.motor_x = DeviceComboBox( - parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER] - ) - self.motor_x.addItem("", None) - self.motor_x.setCurrentText("") - self.motor_x.setToolTip("Select Motor X") - self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x)) - - # Motor X - self.motor_y = DeviceComboBox( - parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER] - ) - self.motor_y.addItem("", None) - self.motor_y.setCurrentText("") - self.motor_y.setToolTip("Select Motor Y") - self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y)) - - self.add_action("motor_x", WidgetAction(widget=self.motor_x, adjust_size=False)) - self.add_action("motor_y", WidgetAction(widget=self.motor_y, adjust_size=False)) - - # Connect slots, a device will be connected upon change of any combobox - self.motor_x.currentTextChanged.connect(lambda: self.connect_motors()) - self.motor_y.currentTextChanged.connect(lambda: self.connect_motors()) - - @SafeSlot() - def connect_motors(self): - motor_x = self.motor_x.currentText() - motor_y = self.motor_y.currentText() - - if motor_x != "" and motor_y != "": - if ( - motor_x != self.target_widget.config.x_motor.name - or motor_y != self.target_widget.config.y_motor.name - ): - self.target_widget.map(motor_x, motor_y) - - def cleanup(self): - self.motor_x.close() - self.motor_x.deleteLater() - self.motor_y.close() - self.motor_y.deleteLater() diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_bundles/__init__.py b/bec_widgets/widgets/plots/motor_map/toolbar_components/__init__.py similarity index 100% rename from bec_widgets/widgets/plots/motor_map/toolbar_bundles/__init__.py rename to bec_widgets/widgets/plots/motor_map/toolbar_components/__init__.py diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py new file mode 100644 index 00000000..a37c3f21 --- /dev/null +++ b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py @@ -0,0 +1,51 @@ +from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget + +from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction +from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox + + +class MotorSelectionAction(ToolBarAction): + def __init__(self, parent=None): + super().__init__(icon_path=None, tooltip=None, checkable=False) + self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + self.motor_x.addItem("", None) + self.motor_x.setCurrentText("") + self.motor_x.setToolTip("Select Motor X") + self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x)) + self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + self.motor_y.addItem("", None) + self.motor_y.setCurrentText("") + self.motor_y.setToolTip("Select Motor Y") + self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y)) + + self.container = QWidget(parent) + layout = QHBoxLayout(self.container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.motor_x) + layout.addWidget(self.motor_y) + self.container.setLayout(layout) + self.action = self.container + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + """ + Adds the widget to the toolbar. + + Args: + toolbar (QToolBar): The toolbar to add the widget to. + target (QWidget): The target widget for the action. + """ + + toolbar.addWidget(self.container) + + def cleanup(self): + """ + Cleans up the action, if necessary. + """ + self.motor_x.close() + self.motor_x.deleteLater() + self.motor_y.close() + self.motor_y.deleteLater() + self.container.close() + self.container.deleteLater() diff --git a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py index 75bad154..9c9b80be 100644 --- a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py +++ b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import deque +from typing import TYPE_CHECKING, cast import pyqtgraph as pg from bec_lib.endpoints import MessageEndpoints @@ -12,13 +13,15 @@ from qtpy.QtWidgets import QWidget from bec_widgets.utils import Colors, ConnectionConfig from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.side_panel import SidePanel +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.plots.multi_waveform.settings.control_panel import ( MultiWaveformControlPanel, ) -from bec_widgets.widgets.plots.multi_waveform.toolbar_bundles.monitor_selection import ( - MultiWaveformSelectionToolbarBundle, +from bec_widgets.widgets.plots.multi_waveform.toolbar_components.monitor_selection import ( + monitor_selection_bundle, ) from bec_widgets.widgets.plots.plot_base import PlotBase +from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget logger = bec_logger.logger @@ -141,33 +144,54 @@ class MultiWaveform(PlotBase): self.visible_curves = [] self.number_of_visible_curves = 0 - self._init_control_panel() + self._init_multiwaveform_toolbar() ################################################################################ # Widget Specific GUI interactions ################################################################################ - def _init_toolbar(self): - self.monitor_selection_bundle = MultiWaveformSelectionToolbarBundle( - bundle_id="motor_selection", target_widget=self + def _init_multiwaveform_toolbar(self): + self.toolbar.add_bundle( + monitor_selection_bundle(self.toolbar.components, target_widget=self) ) - self.toolbar.add_bundle(self.monitor_selection_bundle, target_widget=self) - super()._init_toolbar() - self.toolbar.widgets["reset_legend"].action.setVisible(False) + self.toolbar.toggle_action_visibility("reset_legend", visible=False) + + combobox = self.toolbar.components.get_action("monitor_selection").widget + combobox.currentTextChanged.connect(self.connect_monitor) + + cmap = self.toolbar.components.get_action("color_map").widget + cmap.colormap_changed_signal.connect(self.change_colormap) + + bundles = self.toolbar.shown_bundles + bundles.insert(0, "monitor_selection") + self.toolbar.show_bundles(bundles) + + self._init_control_panel() def _init_control_panel(self): - self.control_panel = SidePanel(self, orientation="top", panel_max_width=90) - self.layout_manager.add_widget_relative( - self.control_panel, self.round_plot_widget, "bottom" - ) + control_panel = SidePanel(self, orientation="top", panel_max_width=90) + self.layout_manager.add_widget_relative(control_panel, self.round_plot_widget, "bottom") self.controls = MultiWaveformControlPanel(parent=self, target_widget=self) - self.control_panel.add_menu( + control_panel.add_menu( action_id="control", icon_name="tune", tooltip="Show Control panel", widget=self.controls, title=None, ) - self.control_panel.toolbar.widgets["control"].action.trigger() + control_panel.toolbar.components.get_action("control").action.trigger() + + @SafeSlot() + def connect_monitor(self, _): + combobox = self.toolbar.components.get_action("monitor_selection").widget + monitor = combobox.currentText() + + if monitor != "": + if monitor != self.config.monitor: + self.config.monitor = monitor + + @SafeSlot(str) + def change_colormap(self, colormap: str): + self.color_palette = colormap ################################################################################ # Widget Specific Properties @@ -488,23 +512,30 @@ class MultiWaveform(PlotBase): """ Sync the motor map selection toolbar with the current motor map. """ - if self.monitor_selection_bundle is not None: - monitor = self.monitor_selection_bundle.monitor.currentText() - color_palette = self.monitor_selection_bundle.colormap_widget.colormap - if monitor != self.config.monitor: - self.monitor_selection_bundle.monitor.blockSignals(True) - self.monitor_selection_bundle.monitor.set_device(self.config.monitor) - self.monitor_selection_bundle.monitor.check_validity(self.config.monitor) - self.monitor_selection_bundle.monitor.blockSignals(False) + combobox_widget: DeviceComboBox = cast( + DeviceComboBox, self.toolbar.components.get_action("monitor_selection").widget + ) + cmap_widget: BECColorMapWidget = cast( + BECColorMapWidget, self.toolbar.components.get_action("color_map").widget + ) - if color_palette != self.config.color_palette: - self.monitor_selection_bundle.colormap_widget.blockSignals(True) - self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette - self.monitor_selection_bundle.colormap_widget.blockSignals(False) + monitor = combobox_widget.currentText() + color_palette = cmap_widget.colormap + + if monitor != self.config.monitor: + combobox_widget.setCurrentText(monitor) + combobox_widget.blockSignals(True) + combobox_widget.set_device(self.config.monitor) + combobox_widget.check_validity(self.config.monitor) + combobox_widget.blockSignals(False) + + if color_palette != self.config.color_palette: + cmap_widget.blockSignals(True) + cmap_widget.colormap = self.config.color_palette + cmap_widget.blockSignals(False) def cleanup(self): self._disconnect_monitor() self.clear_curves() - self.monitor_selection_bundle.cleanup() super().cleanup() diff --git a/bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/__init__.py b/bec_widgets/widgets/plots/multi_waveform/toolbar_components/__init__.py similarity index 100% rename from bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/__init__.py rename to bec_widgets/widgets/plots/multi_waveform/toolbar_components/__init__.py diff --git a/bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py b/bec_widgets/widgets/plots/multi_waveform/toolbar_components/monitor_selection.py similarity index 66% rename from bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py rename to bec_widgets/widgets/plots/multi_waveform/toolbar_components/monitor_selection.py index 86402ee9..024dd770 100644 --- a/bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py +++ b/bec_widgets/widgets/plots/multi_waveform/toolbar_components/monitor_selection.py @@ -1,9 +1,11 @@ from bec_lib.device import ReadoutPriority from qtpy.QtCore import Qt -from qtpy.QtWidgets import QStyledItemDelegate +from qtpy.QtWidgets import QStyledItemDelegate, QWidget from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction +from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarComponents +from bec_widgets.utils.toolbars.toolbar import ToolbarBundle from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget @@ -18,6 +20,37 @@ class NoCheckDelegate(QStyledItemDelegate): option.checkState = Qt.Unchecked +def monitor_selection_bundle( + components: ToolbarComponents, target_widget: QWidget +) -> ToolbarBundle: + """ + Creates a monitor selection toolbar bundle. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The monitor selection toolbar bundle. + """ + components.add_safe( + "monitor_selection", + DeviceComboBoxAction( + target_widget=target_widget, + device_filter=[BECDeviceFilter.DEVICE], + readout_priority_filter=ReadoutPriority.ASYNC, + add_empty_item=True, + no_check_delegate=True, + ), + ) + components.add_safe( + "color_map", WidgetAction(widget=BECColorMapWidget(cmap="plasma"), adjust_size=False) + ) + bundle = ToolbarBundle("monitor_selection", components) + bundle.add_action("monitor_selection") + bundle.add_action("color_map") + return bundle + + class MultiWaveformSelectionToolbarBundle(ToolbarBundle): """ A bundle of actions for a toolbar that selects motors. diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index aaad89cd..f77f0392 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -14,17 +14,25 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.fps_counter import FPSCounter from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem from bec_widgets.utils.round_frame import RoundedFrame -from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.side_panel import SidePanel -from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar, ToolbarBundle +from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.widget_state_manager import WidgetStateManager from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings -from bec_widgets.widgets.plots.toolbar_bundles.mouse_interactions import ( - MouseInteractionToolbarBundle, +from bec_widgets.widgets.plots.toolbar_components.axis_settings_popup import ( + AxisSettingsPopupConnection, + axis_popup_bundle, ) -from bec_widgets.widgets.plots.toolbar_bundles.plot_export import PlotExportBundle -from bec_widgets.widgets.plots.toolbar_bundles.roi_bundle import ROIBundle +from bec_widgets.widgets.plots.toolbar_components.mouse_interactions import ( + MouseInteractionConnection, + mouse_interaction_bundle, +) +from bec_widgets.widgets.plots.toolbar_components.plot_export import ( + PlotExportConnection, + plot_export_bundle, +) +from bec_widgets.widgets.plots.toolbar_components.roi import RoiConnection, roi_bundle logger = bec_logger.logger @@ -102,8 +110,6 @@ class PlotBase(BECWidget, QWidget): self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True)) self.plot_widget.addItem(self.plot_item) self.side_panel = SidePanel(self, orientation="left", panel_max_width=280) - self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal") - self._init_toolbar() # PlotItem Addons self.plot_item.addLegend() @@ -122,6 +128,9 @@ class PlotBase(BECWidget, QWidget): self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item) self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item) + self.toolbar = ModularToolBar(parent=self, orientation="horizontal") + self._init_toolbar() + self._init_ui() self._connect_to_theme_change() @@ -146,36 +155,33 @@ class PlotBase(BECWidget, QWidget): self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed) def _init_toolbar(self): - self.popup_bundle = None - self.performance_bundle = ToolbarBundle("performance") - self.plot_export_bundle = PlotExportBundle("plot_export", target_widget=self) - self.mouse_bundle = MouseInteractionToolbarBundle("mouse_interaction", target_widget=self) - # self.state_export_bundle = SaveStateBundle("state_export", target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user - self.roi_bundle = ROIBundle("roi", target_widget=self) + self.toolbar.add_bundle(performance_bundle(self.toolbar.components)) + self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components)) + self.toolbar.add_bundle(mouse_interaction_bundle(self.toolbar.components)) + self.toolbar.add_bundle(roi_bundle(self.toolbar.components)) + self.toolbar.add_bundle(axis_popup_bundle(self.toolbar.components)) - # Add elements to toolbar - self.toolbar.add_bundle(self.plot_export_bundle, target_widget=self) - # self.toolbar.add_bundle(self.state_export_bundle, target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user - self.toolbar.add_bundle(self.mouse_bundle, target_widget=self) - self.toolbar.add_bundle(self.roi_bundle, target_widget=self) - - self.performance_bundle.add_action( - "fps_monitor", - MaterialIconAction( - icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=self - ), + self.toolbar.connect_bundle( + "plot_base", PlotExportConnection(self.toolbar.components, self) ) - self.toolbar.add_bundle(self.performance_bundle, target_widget=self) - - self.toolbar.widgets["fps_monitor"].action.toggled.connect( - lambda checked: setattr(self, "enable_fps_monitor", checked) + self.toolbar.connect_bundle( + "plot_base", PerformanceConnection(self.toolbar.components, self) + ) + self.toolbar.connect_bundle( + "plot_base", MouseInteractionConnection(self.toolbar.components, self) + ) + self.toolbar.connect_bundle("plot_base", RoiConnection(self.toolbar.components, self)) + self.toolbar.connect_bundle( + "plot_base", AxisSettingsPopupConnection(self.toolbar.components, self) ) # hide some options by default self.toolbar.toggle_action_visibility("fps_monitor", False) # Get default viewbox state - self.mouse_bundle.get_viewbox_mode() + self.toolbar.show_bundles( + ["plot_export", "mouse_interaction", "roi", "performance", "axis_popup"] + ) def add_side_menus(self): """Adds multiple menus to the side panel.""" @@ -192,45 +198,6 @@ class PlotBase(BECWidget, QWidget): except ValueError: return - def add_popups(self): - """ - Add popups to the toolbar. - """ - self.popup_bundle = ToolbarBundle("popup_bundle") - settings = MaterialIconAction( - icon_name="settings", tooltip="Show Axis Settings", checkable=True, parent=self - ) - self.popup_bundle.add_action("axis", settings) - self.toolbar.add_bundle(self.popup_bundle, target_widget=self) - self.toolbar.widgets["axis"].action.triggered.connect(self.show_axis_settings_popup) - - def show_axis_settings_popup(self): - """ - Show the axis settings dialog. - """ - settings_action = self.toolbar.widgets["axis"].action - if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible(): - axis_setting = AxisSettings(parent=self, target_widget=self, popup=True) - self.axis_settings_dialog = SettingsDialog( - self, settings_widget=axis_setting, window_title="Axis Settings", modal=False - ) - # When the dialog is closed, update the toolbar icon and clear the reference - self.axis_settings_dialog.finished.connect(self._axis_settings_closed) - self.axis_settings_dialog.show() - settings_action.setChecked(True) - else: - # If already open, bring it to the front - self.axis_settings_dialog.raise_() - self.axis_settings_dialog.activateWindow() - settings_action.setChecked(True) # keep it toggled - - def _axis_settings_closed(self): - """ - Slot for when the axis settings dialog is closed. - """ - self.axis_settings_dialog = None - self.toolbar.widgets["axis"].action.setChecked(False) - def reset_legend(self): """In the case that the legend is not visible, reset it to be visible to top left corner""" self.plot_item.legend.autoAnchor(50) @@ -257,22 +224,23 @@ class PlotBase(BECWidget, QWidget): raise ValueError("ui_mode must be an instance of UIMode") self._ui_mode = mode - # First, clear both UI elements: - if self.popup_bundle is not None: - for action_id in self.toolbar.bundles["popup_bundle"]: - self.toolbar.widgets[action_id].action.setVisible(False) - if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible(): - self.axis_settings_dialog.close() - self.side_panel.hide() - # Now, apply the new mode: if mode == UIMode.POPUP: - if self.popup_bundle is None: - self.add_popups() - else: - for action_id in self.toolbar.bundles["popup_bundle"]: - self.toolbar.widgets[action_id].action.setVisible(True) + shown_bundles = self.toolbar.shown_bundles + if "axis_popup" not in shown_bundles: + shown_bundles.append("axis_popup") + self.toolbar.show_bundles(shown_bundles) + self.side_panel.hide() + elif mode == UIMode.SIDE: + shown_bundles = self.toolbar.shown_bundles + if "axis_popup" in shown_bundles: + shown_bundles.remove("axis_popup") + self.toolbar.show_bundles(shown_bundles) + pb_connection = self.toolbar.bundles["axis_popup"].get_connection("plot_base") + if pb_connection.axis_settings_dialog is not None: + pb_connection.axis_settings_dialog.close() + pb_connection.axis_settings_dialog = None self.add_side_menus() self.side_panel.show() @@ -1049,6 +1017,7 @@ class PlotBase(BECWidget, QWidget): self.axis_settings_dialog = None self.cleanup_pyqtgraph() self.round_plot_widget.close() + self.toolbar.cleanup() super().cleanup() def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None): @@ -1087,8 +1056,12 @@ if __name__ == "__main__": # pragma: no cover: from qtpy.QtWidgets import QApplication + from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow + app = QApplication(sys.argv) - window = PlotBase() - window.show() + launch_window = BECMainWindow() + pb = PlotBase(popups=False) + launch_window.setCentralWidget(pb) + launch_window.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 0bac6832..191f511c 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -13,7 +13,7 @@ from bec_widgets.utils import Colors, ConnectionConfig from bec_widgets.utils.colors import set_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog -from bec_widgets.utils.toolbar import MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import ( ScatterCurve, @@ -131,8 +131,8 @@ class ScatterWaveform(PlotBase): self.proxy_update_sync = pg.SignalProxy( self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves ) - if self.ui_mode == UIMode.SIDE: - self._init_scatter_curve_settings() + + self._init_scatter_curve_settings() self.update_with_scan_history(-1) ################################################################################ @@ -143,44 +143,40 @@ class ScatterWaveform(PlotBase): """ Initialize the scatter curve settings menu. """ + if self.ui_mode == UIMode.SIDE: + self.scatter_curve_settings = ScatterCurveSettings( + parent=self, target_widget=self, popup=False + ) + self.side_panel.add_menu( + action_id="scatter_curve", + icon_name="scatter_plot", + tooltip="Show Scatter Curve Settings", + widget=self.scatter_curve_settings, + title="Scatter Curve Settings", + ) + else: + scatter_curve_action = MaterialIconAction( + icon_name="scatter_plot", + tooltip="Show Scatter Curve Settings", + checkable=True, + parent=self, + ) + self.toolbar.components.add_safe("scatter_waveform_settings", scatter_curve_action) + self.toolbar.get_bundle("axis_popup").add_action("scatter_waveform_settings") + scatter_curve_action.action.triggered.connect(self.show_scatter_curve_settings) - self.scatter_curve_settings = ScatterCurveSettings( - parent=self, target_widget=self, popup=False - ) - self.side_panel.add_menu( - action_id="scatter_curve", - icon_name="scatter_plot", - tooltip="Show Scatter Curve Settings", - widget=self.scatter_curve_settings, - title="Scatter Curve Settings", - ) - - def add_popups(self): - """ - Add popups to the ScatterWaveform widget. - """ - super().add_popups() - scatter_curve_setting_action = MaterialIconAction( - icon_name="scatter_plot", - tooltip="Show Scatter Curve Settings", - checkable=True, - parent=self, - ) - self.toolbar.add_action_to_bundle( - bundle_id="popup_bundle", - action_id="scatter_waveform_settings", - action=scatter_curve_setting_action, - target_widget=self, - ) - self.toolbar.widgets["scatter_waveform_settings"].action.triggered.connect( - self.show_scatter_curve_settings - ) + shown_bundles = self.toolbar.shown_bundles + if "performance" in shown_bundles: + shown_bundles.remove("performance") + self.toolbar.show_bundles(shown_bundles) def show_scatter_curve_settings(self): """ Show the scatter curve settings dialog. """ - scatter_settings_action = self.toolbar.widgets["scatter_waveform_settings"].action + scatter_settings_action = self.toolbar.components.get_action( + "scatter_waveform_settings" + ).action if self.scatter_dialog is None or not self.scatter_dialog.isVisible(): scatter_settings = ScatterCurveSettings(parent=self, target_widget=self, popup=True) self.scatter_dialog = SettingsDialog( @@ -205,7 +201,7 @@ class ScatterWaveform(PlotBase): Slot for when the scatter curve settings dialog is closed. """ self.scatter_dialog = None - self.toolbar.widgets["scatter_waveform_settings"].action.setChecked(False) + self.toolbar.components.get_action("scatter_waveform_settings").action.setChecked(False) ################################################################################ # Widget Specific Properties diff --git a/bec_widgets/widgets/plots/toolbar_bundles/mouse_interactions.py b/bec_widgets/widgets/plots/toolbar_bundles/mouse_interactions.py deleted file mode 100644 index fba15470..00000000 --- a/bec_widgets/widgets/plots/toolbar_bundles/mouse_interactions.py +++ /dev/null @@ -1,108 +0,0 @@ -import pyqtgraph as pg -from qtpy.QtCore import QTimer - -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle - - -class MouseInteractionToolbarBundle(ToolbarBundle): - """ - A bundle of actions that are hooked in this constructor itself, - so that you can immediately connect the signals and toggle states. - - This bundle is for a toolbar that controls mouse interactions on a plot. - """ - - def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs): - super().__init__(bundle_id=bundle_id, actions=[], **kwargs) - self.target_widget = target_widget - self.mouse_mode = None - - # Create each MaterialIconAction with a parent - # so the signals can fire even if the toolbar isn't added yet. - drag = MaterialIconAction( - icon_name="drag_pan", - tooltip="Drag Mouse Mode", - checkable=True, - parent=self.target_widget, # or any valid parent - ) - rect = MaterialIconAction( - icon_name="frame_inspect", - tooltip="Rectangle Zoom Mode", - checkable=True, - parent=self.target_widget, - ) - auto = MaterialIconAction( - icon_name="open_in_full", - tooltip="Autorange Plot", - checkable=False, - parent=self.target_widget, - ) - - self.switch_mouse_action = SwitchableToolBarAction( - actions={"drag_mode": drag, "rectangle_mode": rect}, - initial_action="drag_mode", - tooltip="Mouse Modes", - checkable=True, - parent=self.target_widget, - ) - - # Add them to the bundle - self.add_action("switch_mouse", self.switch_mouse_action) - self.add_action("auto_range", auto) - - # Immediately connect signals - drag.action.toggled.connect(self.enable_mouse_pan_mode) - rect.action.toggled.connect(self.enable_mouse_rectangle_mode) - auto.action.triggered.connect(self.autorange_plot) - - def get_viewbox_mode(self): - """ - Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action. - """ - - if self.target_widget: - viewbox = self.target_widget.plot_item.getViewBox() - if viewbox.getState()["mouseMode"] == 3: - self.switch_mouse_action.set_default_action("drag_mode") - self.switch_mouse_action.main_button.setChecked(True) - self.mouse_mode = "PanMode" - elif viewbox.getState()["mouseMode"] == 1: - self.switch_mouse_action.set_default_action("rectangle_mode") - self.switch_mouse_action.main_button.setChecked(True) - self.mouse_mode = "RectMode" - - @SafeSlot(bool) - def enable_mouse_rectangle_mode(self, checked: bool): - """ - Enable the rectangle zoom mode on the plot widget. - """ - if self.mouse_mode == "RectMode": - self.switch_mouse_action.main_button.setChecked(True) - return - self.actions["switch_mouse"].actions["drag_mode"].action.setChecked(not checked) - if self.target_widget and checked: - self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode) - self.mouse_mode = "RectMode" - - @SafeSlot(bool) - def enable_mouse_pan_mode(self, checked: bool): - """ - Enable the pan mode on the plot widget. - """ - if self.mouse_mode == "PanMode": - self.switch_mouse_action.main_button.setChecked(True) - return - self.actions["switch_mouse"].actions["rectangle_mode"].action.setChecked(not checked) - if self.target_widget and checked: - self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode) - self.mouse_mode = "PanMode" - - @SafeSlot() - def autorange_plot(self): - """ - Enable autorange on the plot widget. - """ - if self.target_widget: - self.target_widget.auto_range_x = True - self.target_widget.auto_range_y = True diff --git a/bec_widgets/widgets/plots/toolbar_bundles/plot_export.py b/bec_widgets/widgets/plots/toolbar_bundles/plot_export.py deleted file mode 100644 index 32d3cb42..00000000 --- a/bec_widgets/widgets/plots/toolbar_bundles/plot_export.py +++ /dev/null @@ -1,81 +0,0 @@ -import traceback - -from pyqtgraph.exporters import MatplotlibExporter - -from bec_widgets.utils.error_popups import SafeSlot, WarningPopupUtility -from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle - - -class PlotExportBundle(ToolbarBundle): - """ - A bundle of actions that are hooked in this constructor itself, - so that you can immediately connect the signals and toggle states. - - This bundle is for a toolbar that controls exporting a plot. - """ - - def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs): - super().__init__(bundle_id=bundle_id, actions=[], **kwargs) - self.target_widget = target_widget - - # Create each MaterialIconAction with a parent - # so the signals can fire even if the toolbar isn't added yet. - save = MaterialIconAction( - icon_name="save", tooltip="Open Export Dialog", parent=self.target_widget - ) - matplotlib = MaterialIconAction( - icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=self.target_widget - ) - - switch_export_action = SwitchableToolBarAction( - actions={"save": save, "matplotlib": matplotlib}, - initial_action="save", - tooltip="Switchable Action", - checkable=False, - parent=self, - ) - - # Add them to the bundle - self.add_action("export_switch", switch_export_action) - - # Immediately connect signals - save.action.triggered.connect(self.export_dialog) - matplotlib.action.triggered.connect(self.matplotlib_dialog) - - @SafeSlot() - def export_dialog(self): - """ - Open the export dialog for the plot widget. - """ - if self.target_widget: - scene = self.target_widget.plot_item.scene() - scene.contextMenuItem = self.target_widget.plot_item - scene.showExportDialog() - - @SafeSlot() - def matplotlib_dialog(self): - """ - Export the plot widget to Matplotlib. - """ - if self.target_widget: - try: - import matplotlib as mpl - - MatplotlibExporter(self.target_widget.plot_item).export() - except ModuleNotFoundError: - warning_util = WarningPopupUtility() - warning_util.show_warning( - title="Matplotlib not installed", - message="Matplotlib is required for this feature.", - detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.", - ) - return - except TypeError: - warning_util = WarningPopupUtility() - error_msg = traceback.format_exc() - warning_util.show_warning( - title="Matplotlib TypeError", - message="Matplotlib exporter could not resolve the plot item.", - detailed_text=error_msg, - ) - return diff --git a/bec_widgets/widgets/plots/toolbar_bundles/roi_bundle.py b/bec_widgets/widgets/plots/toolbar_bundles/roi_bundle.py deleted file mode 100644 index ac73dee6..00000000 --- a/bec_widgets/widgets/plots/toolbar_bundles/roi_bundle.py +++ /dev/null @@ -1,31 +0,0 @@ -from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle - - -class ROIBundle(ToolbarBundle): - """ - A bundle of actions that are hooked in this constructor itself, - so that you can immediately connect the signals and toggle states. - - This bundle is for a toolbar that controls crosshair and ROI interaction. - """ - - def __init__(self, bundle_id="roi", target_widget=None, **kwargs): - super().__init__(bundle_id=bundle_id, actions=[], **kwargs) - self.target_widget = target_widget - - # Create each MaterialIconAction with a parent - # so the signals can fire even if the toolbar isn't added yet. - crosshair = MaterialIconAction( - icon_name="point_scan", tooltip="Show Crosshair", checkable=True - ) - reset_legend = MaterialIconAction( - icon_name="restart_alt", tooltip="Reset the position of legend.", checkable=False - ) - - # Add them to the bundle - self.add_action("crosshair", crosshair) - self.add_action("reset_legend", reset_legend) - - # Immediately connect signals - crosshair.action.toggled.connect(self.target_widget.toggle_crosshair) - reset_legend.action.triggered.connect(self.target_widget.reset_legend) diff --git a/bec_widgets/widgets/plots/toolbar_bundles/save_state.py b/bec_widgets/widgets/plots/toolbar_bundles/save_state.py deleted file mode 100644 index c0ce3dbe..00000000 --- a/bec_widgets/widgets/plots/toolbar_bundles/save_state.py +++ /dev/null @@ -1,48 +0,0 @@ -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle - - -class SaveStateBundle(ToolbarBundle): - """ - A bundle of actions that are hooked in this constructor itself, - so that you can immediately connect the signals and toggle states. - - This bundle is for a toolbar that controls saving the state of the widget. - """ - - def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs): - super().__init__(bundle_id=bundle_id, actions=[], **kwargs) - self.target_widget = target_widget - - # Create each MaterialIconAction with a parent - # so the signals can fire even if the toolbar isn't added yet. - save_state = MaterialIconAction( - icon_name="download", tooltip="Save Widget State", parent=self.target_widget - ) - load_state = MaterialIconAction( - icon_name="upload", tooltip="Load Widget State", parent=self.target_widget - ) - - # Add them to the bundle - self.add_action("save", save_state) - self.add_action("matplotlib", load_state) - - # Immediately connect signals - save_state.action.triggered.connect(self.save_state_dialog) - load_state.action.triggered.connect(self.load_state_dialog) - - @SafeSlot() - def save_state_dialog(self): - """ - Open the export dialog to save a state of the widget. - """ - if self.target_widget: - self.target_widget.state_manager.save_state() - - @SafeSlot() - def load_state_dialog(self): - """ - Load a saved state of the widget. - """ - if self.target_widget: - self.target_widget.state_manager.load_state() diff --git a/bec_widgets/widgets/plots/toolbar_bundles/__init__.py b/bec_widgets/widgets/plots/toolbar_components/__init__.py similarity index 100% rename from bec_widgets/widgets/plots/toolbar_bundles/__init__.py rename to bec_widgets/widgets/plots/toolbar_components/__init__.py diff --git a/bec_widgets/widgets/plots/toolbar_components/axis_settings_popup.py b/bec_widgets/widgets/plots/toolbar_components/axis_settings_popup.py new file mode 100644 index 00000000..b17af1c2 --- /dev/null +++ b/bec_widgets/widgets/plots/toolbar_components/axis_settings_popup.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from bec_widgets.utils.settings_dialog import SettingsDialog +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.connections import BundleConnection +from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.utils.toolbars.toolbar import ToolbarComponents + + +def axis_popup_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates an axis popup toolbar bundle. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The axis popup toolbar bundle. + """ + components.add_safe( + "axis_settings_popup", + MaterialIconAction( + icon_name="settings", + tooltip="Show Axis Settings", + checkable=True, + parent=components.toolbar, + ), + ) + bundle = ToolbarBundle("axis_popup", components) + bundle.add_action("axis_settings_popup") + return bundle + + +class AxisSettingsPopupConnection(BundleConnection): + + def __init__(self, components: ToolbarComponents, target_widget=None): + self.bundle_name = "axis_popup" + self.components = components + self.target_widget = target_widget + self.axis_settings_dialog = None + self._connected = False + super().__init__() + + def connect(self): + self._connected = True + # Connect the action to the target widget's method + self.components.get_action_reference("axis_settings_popup")().action.triggered.connect( + self.show_axis_settings_popup + ) + + def disconnect(self): + if not self._connected: + return + # Disconnect the action from the target widget's method + self.components.get_action_reference("axis_settings_popup")().action.triggered.disconnect( + self.show_axis_settings_popup + ) + + def show_axis_settings_popup(self): + """ + Show the axis settings dialog. + """ + settings_action = self.components.get_action_reference("axis_settings_popup")().action + if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible(): + axis_setting = AxisSettings( + parent=self.target_widget, target_widget=self.target_widget, popup=True + ) + self.axis_settings_dialog = SettingsDialog( + self.target_widget, + settings_widget=axis_setting, + window_title="Axis Settings", + modal=False, + ) + # When the dialog is closed, update the toolbar icon and clear the reference + self.axis_settings_dialog.finished.connect(self._axis_settings_closed) + self.axis_settings_dialog.show() + settings_action.setChecked(True) + else: + # If already open, bring it to the front + self.axis_settings_dialog.raise_() + self.axis_settings_dialog.activateWindow() + settings_action.setChecked(True) # keep it toggled + + def _axis_settings_closed(self): + """ + Slot for when the axis settings dialog is closed. + """ + self.axis_settings_dialog = None + self.components.get_action_reference("axis_settings_popup")().action.setChecked(False) diff --git a/bec_widgets/widgets/plots/toolbar_components/mouse_interactions.py b/bec_widgets/widgets/plots/toolbar_components/mouse_interactions.py new file mode 100644 index 00000000..61a7425d --- /dev/null +++ b/bec_widgets/widgets/plots/toolbar_components/mouse_interactions.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pyqtgraph as pg + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.connections import BundleConnection + +if TYPE_CHECKING: + from bec_widgets.utils.toolbars.toolbar import ToolbarComponents + + +def mouse_interaction_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a mouse interaction toolbar bundle. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The mouse interaction toolbar bundle. + """ + components.add_safe( + "mouse_drag", + MaterialIconAction( + icon_name="drag_pan", + tooltip="Drag Mouse Mode", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "mouse_rect", + MaterialIconAction( + icon_name="frame_inspect", + tooltip="Rectangle Zoom Mode", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "auto_range", + MaterialIconAction( + icon_name="open_in_full", + tooltip="Autorange Plot", + checkable=False, + parent=components.toolbar, + ), + ) + components.add_safe( + "switch_mouse_mode", + SwitchableToolBarAction( + actions={ + "drag_mode": components.get_action_reference("mouse_drag")(), + "rectangle_mode": components.get_action_reference("mouse_rect")(), + }, + initial_action="drag_mode", + tooltip="Mouse Modes", + checkable=True, + parent=components.toolbar, + default_state_checked=True, + ), + ) + bundle = ToolbarBundle("mouse_interaction", components) + bundle.add_action("switch_mouse_mode") + bundle.add_action("auto_range") + return bundle + + +class MouseInteractionConnection(BundleConnection): + """ + Connection class for mouse interaction toolbar bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + self.bundle_name = "mouse_interaction" + self.components = components + self.target_widget = target_widget + self.mouse_mode = None + if ( + not hasattr(self.target_widget, "plot_item") + or not hasattr(self.target_widget, "auto_range_x") + or not hasattr(self.target_widget, "auto_range_y") + ): + raise AttributeError( + "Target widget must implement required methods for mouse interactions." + ) + super().__init__() + self._connected = False # Track if the connection has been made + + def connect(self): + self._connected = True + drag = self.components.get_action_reference("mouse_drag")() + rect = self.components.get_action_reference("mouse_rect")() + auto = self.components.get_action_reference("auto_range")() + + drag.action.toggled.connect(self.enable_mouse_pan_mode) + rect.action.toggled.connect(self.enable_mouse_rectangle_mode) + auto.action.triggered.connect(self.autorange_plot) + + def disconnect(self): + if not self._connected: + return + # Disconnect the action from the target widget's method + drag = self.components.get_action_reference("mouse_drag")() + rect = self.components.get_action_reference("mouse_rect")() + auto = self.components.get_action_reference("auto_range")() + drag.action.toggled.disconnect(self.enable_mouse_pan_mode) + rect.action.toggled.disconnect(self.enable_mouse_rectangle_mode) + auto.action.triggered.disconnect(self.autorange_plot) + + def get_viewbox_mode(self): + """ + Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action. + """ + + if self.target_widget: + viewbox = self.target_widget.plot_item.getViewBox() + switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")() + if viewbox.getState()["mouseMode"] == 3: + switch_mouse_action.set_default_action("drag_mode") + switch_mouse_action.main_button.setChecked(True) + self.mouse_mode = "PanMode" + elif viewbox.getState()["mouseMode"] == 1: + switch_mouse_action.set_default_action("rectangle_mode") + switch_mouse_action.main_button.setChecked(True) + self.mouse_mode = "RectMode" + + @SafeSlot(bool) + def enable_mouse_rectangle_mode(self, checked: bool): + """ + Enable the rectangle zoom mode on the plot widget. + """ + switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")() + if self.mouse_mode == "RectMode": + switch_mouse_action.main_button.setChecked(True) + return + drag_mode = self.components.get_action_reference("mouse_drag")() + drag_mode.action.setChecked(not checked) + if self.target_widget and checked: + self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode) + self.mouse_mode = "RectMode" + + @SafeSlot(bool) + def enable_mouse_pan_mode(self, checked: bool): + """ + Enable the pan mode on the plot widget. + """ + if self.mouse_mode == "PanMode": + switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")() + switch_mouse_action.main_button.setChecked(True) + return + rect_mode = self.components.get_action_reference("mouse_rect")() + rect_mode.action.setChecked(not checked) + if self.target_widget and checked: + self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode) + self.mouse_mode = "PanMode" + + @SafeSlot() + def autorange_plot(self): + """ + Enable autorange on the plot widget. + """ + if self.target_widget: + self.target_widget.auto_range_x = True + self.target_widget.auto_range_y = True diff --git a/bec_widgets/widgets/plots/toolbar_components/plot_export.py b/bec_widgets/widgets/plots/toolbar_components/plot_export.py new file mode 100644 index 00000000..92c43a9a --- /dev/null +++ b/bec_widgets/widgets/plots/toolbar_components/plot_export.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import traceback + +from bec_widgets.utils.error_popups import SafeSlot, WarningPopupUtility +from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection + + +def plot_export_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a plot export toolbar bundle. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The plot export toolbar bundle. + """ + components.add_safe( + "save", + MaterialIconAction( + icon_name="save", tooltip="Open Export Dialog", parent=components.toolbar + ), + ) + components.add_safe( + "matplotlib", + MaterialIconAction( + icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=components.toolbar + ), + ) + components.add_safe( + "export_switch", + SwitchableToolBarAction( + actions={ + "save": components.get_action_reference("save")(), + "matplotlib": components.get_action_reference("matplotlib")(), + }, + initial_action="save", + tooltip="Export Plot", + checkable=False, + parent=components.toolbar, + ), + ) + bundle = ToolbarBundle("plot_export", components) + bundle.add_action("export_switch") + return bundle + + +def plot_export_connection(components: ToolbarComponents, target_widget=None): + """ + Connects the plot export actions to the target widget. + Args: + components (ToolbarComponents): The components to be connected. + target_widget: The widget to which the actions will be connected. + """ + + +class PlotExportConnection(BundleConnection): + def __init__(self, components: ToolbarComponents, target_widget): + super().__init__() + self.bundle_name = "plot_export" + self.components = components + self.target_widget = target_widget + self._connected = False # Track if the connection has been made + + def connect(self): + self._connected = True + # Connect the actions to the target widget + self.components.get_action_reference("save")().action.triggered.connect(self.export_dialog) + self.components.get_action_reference("matplotlib")().action.triggered.connect( + self.matplotlib_dialog + ) + + def disconnect(self): + if not self._connected: + return + # Disconnect the actions from the target widget + self.components.get_action_reference("save")().action.triggered.disconnect( + self.export_dialog + ) + self.components.get_action_reference("matplotlib")().action.triggered.disconnect( + self.matplotlib_dialog + ) + + @SafeSlot() + def export_dialog(self): + """ + Open the export dialog for the plot widget. + """ + if self.target_widget: + scene = self.target_widget.plot_item.scene() + scene.contextMenuItem = self.target_widget.plot_item + scene.showExportDialog() + + @SafeSlot() + def matplotlib_dialog(self): + """ + Export the plot widget to Matplotlib. + """ + if self.target_widget: + try: + import matplotlib as mpl + + MatplotlibExporter(self.target_widget.plot_item).export() + except ModuleNotFoundError: + warning_util = WarningPopupUtility() + warning_util.show_warning( + title="Matplotlib not installed", + message="Matplotlib is required for this feature.", + detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.", + ) + return + except TypeError: + warning_util = WarningPopupUtility() + error_msg = traceback.format_exc() + warning_util.show_warning( + title="Matplotlib TypeError", + message="Matplotlib exporter could not resolve the plot item.", + detailed_text=error_msg, + ) + return diff --git a/bec_widgets/widgets/plots/toolbar_components/roi.py b/bec_widgets/widgets/plots/toolbar_components/roi.py new file mode 100644 index 00000000..e4f0ffdf --- /dev/null +++ b/bec_widgets/widgets/plots/toolbar_components/roi.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection + + +def roi_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a toolbar bundle for ROI and crosshair interaction. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The ROI toolbar bundle. + """ + components.add_safe( + "crosshair", + MaterialIconAction( + icon_name="point_scan", + tooltip="Show Crosshair", + checkable=True, + parent=components.toolbar, + ), + ) + components.add_safe( + "reset_legend", + MaterialIconAction( + icon_name="restart_alt", + tooltip="Reset the position of legend.", + checkable=False, + parent=components.toolbar, + ), + ) + bundle = ToolbarBundle("roi", components) + bundle.add_action("crosshair") + bundle.add_action("reset_legend") + return bundle + + +class RoiConnection(BundleConnection): + """ + Connection class for the ROI toolbar bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + self.bundle_name = "roi" + self.components = components + self.target_widget = target_widget + if not hasattr(self.target_widget, "toggle_crosshair") or not hasattr( + self.target_widget, "reset_legend" + ): + raise AttributeError( + "Target widget must implement 'toggle_crosshair' and 'reset_legend'." + ) + super().__init__() + self._connected = False + + def connect(self): + self._connected = True + # Connect the action to the target widget's method + self.components.get_action_reference("crosshair")().action.toggled.connect( + self.target_widget.toggle_crosshair + ) + self.components.get_action_reference("reset_legend")().action.triggered.connect( + self.target_widget.reset_legend + ) + + def disconnect(self): + if not self._connected: + return + # Disconnect the action from the target widget's method + self.components.get_action_reference("crosshair")().action.toggled.disconnect( + self.target_widget.toggle_crosshair + ) + self.components.get_action_reference("reset_legend")().action.triggered.disconnect( + self.target_widget.reset_legend + ) diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py index 61066d80..c30237e7 100644 --- a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +++ b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py @@ -25,7 +25,9 @@ from bec_widgets import SafeSlot from bec_widgets.utils import ConnectionConfig, EntryValidator from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import Colors -from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar +from bec_widgets.utils.toolbars.actions import WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox @@ -379,43 +381,67 @@ class CurveTree(BECWidget, QWidget): def _init_toolbar(self): """Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize.""" - self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal") - add = MaterialIconAction( - icon_name="add", tooltip="Add new curve", checkable=False, parent=self + self.toolbar = ModularToolBar(parent=self, orientation="horizontal") + self.toolbar.components.add_safe( + "add", + MaterialIconAction( + icon_name="add", tooltip="Add new curve", checkable=False, parent=self + ), ) - expand = MaterialIconAction( - icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self + self.toolbar.components.add_safe( + "expand", + MaterialIconAction( + icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self + ), ) - collapse = MaterialIconAction( - icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self + self.toolbar.components.add_safe( + "collapse", + MaterialIconAction( + icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self + ), ) - - self.toolbar.add_action("add", add, self) - self.toolbar.add_action("expand_all", expand, self) - self.toolbar.add_action("collapse_all", collapse, self) + bundle = ToolbarBundle("curve_tree", self.toolbar.components) + bundle.add_action("add") + bundle.add_action("expand") + bundle.add_action("collapse") + self.toolbar.add_bundle(bundle) # Add colormap widget (not updating waveform's color_palette until Send is pressed) - self.spacer = QWidget() - self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar.addWidget(self.spacer) + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False)) + bundle.add_action("spacer") # Renormalize colors button - renorm_action = MaterialIconAction( - icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self + self.toolbar.components.add_safe( + "renormalize_colors", + MaterialIconAction( + icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self + ), ) - self.toolbar.add_action("renormalize_colors", renorm_action, self) + bundle.add_action("renormalize_colors") + renorm_action = self.toolbar.components.get_action("renormalize_colors") renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors()) self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "plasma") - self.toolbar.addWidget(self.colormap_widget) + self.toolbar.components.add_safe( + "colormap_widget", WidgetAction(widget=self.colormap_widget) + ) + bundle.add_action("colormap_widget") self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed) + add = self.toolbar.components.get_action("add") + expand = self.toolbar.components.get_action("expand") + collapse = self.toolbar.components.get_action("collapse") add.action.triggered.connect(lambda checked: self.add_new_curve()) expand.action.triggered.connect(lambda checked: self.expand_all_daps()) collapse.action.triggered.connect(lambda checked: self.collapse_all_daps()) self.layout.addWidget(self.toolbar) + self.toolbar.show_bundles(["curve_tree"]) + def _init_tree(self): """Initialize the QTreeWidget with 7 columns and compact widths.""" self.tree = QTreeWidget() diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index bf4bb711..8dc1e35a 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Any, Literal +from typing import Literal import lmfit import numpy as np @@ -29,7 +29,7 @@ from bec_widgets.utils.colors import Colors, set_theme from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog -from bec_widgets.utils.toolbar import MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog from bec_widgets.widgets.plots.plot_base import PlotBase from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal @@ -182,6 +182,7 @@ class Waveform(PlotBase): self._init_roi_manager() self.dap_summary = None self.dap_summary_dialog = None + self._add_fit_parameters_popup() self._enable_roi_toolbar_action(False) # default state where are no dap curves self._init_curve_dialog() self.curve_settings_dialog = None @@ -214,6 +215,8 @@ class Waveform(PlotBase): # To fix the ViewAll action with clipToView activated self._connect_viewbox_menu_actions() + self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"]) + def _connect_viewbox_menu_actions(self): """Connect the viewbox menu action ViewAll to the custom reset_view method.""" menu = self.plot_item.vb.menu @@ -247,21 +250,21 @@ class Waveform(PlotBase): super().add_side_menus() self._add_dap_summary_side_menu() - def add_popups(self): + def _add_fit_parameters_popup(self): """ Add popups to the Waveform widget. """ - super().add_popups() - LMFitDialog_action = MaterialIconAction( - icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self + self.toolbar.components.add_safe( + "fit_params", + MaterialIconAction( + icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self + ), ) - self.toolbar.add_action_to_bundle( - bundle_id="popup_bundle", - action_id="fit_params", - action=LMFitDialog_action, - target_widget=self, + self.toolbar.get_bundle("axis_popup").add_action("fit_params") + + self.toolbar.components.get_action("fit_params").action.triggered.connect( + self.show_dap_summary_popup ) - self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_dap_summary_popup) @SafeSlot() def _reset_view(self): @@ -290,14 +293,17 @@ class Waveform(PlotBase): Initialize the ROI manager for the Waveform widget. """ # Add toolbar icon - roi = MaterialIconAction( - icon_name="align_justify_space_between", - tooltip="Add ROI region for DAP", - checkable=True, - ) - self.toolbar.add_action_to_bundle( - bundle_id="roi", action_id="roi_linear", action=roi, target_widget=self + self.toolbar.components.add_safe( + "roi_linear", + MaterialIconAction( + icon_name="align_justify_space_between", + tooltip="Add ROI region for DAP", + checkable=True, + parent=self, + ), ) + self.toolbar.get_bundle("roi").add_action("roi_linear") + self._roi_manager = WaveformROIManager(self.plot_item, parent=self) # Connect manager signals -> forward them via Waveform's own signals @@ -307,23 +313,30 @@ class Waveform(PlotBase): # Example: connect ROI changed to re-request DAP self.roi_changed.connect(self._on_roi_changed_for_dap) self._roi_manager.roi_active.connect(self.request_dap_update) - self.toolbar.widgets["roi_linear"].action.toggled.connect(self._roi_manager.toggle_roi) + self.toolbar.components.get_action("roi_linear").action.toggled.connect( + self._roi_manager.toggle_roi + ) def _init_curve_dialog(self): """ Initializes the Curve dialog within the toolbar. """ - curve_settings = MaterialIconAction( - icon_name="timeline", tooltip="Show Curve dialog.", checkable=True + self.toolbar.components.add_safe( + "curve", + MaterialIconAction( + icon_name="timeline", tooltip="Show Curve dialog.", checkable=True, parent=self + ), + ) + self.toolbar.get_bundle("axis_popup").add_action("curve") + self.toolbar.components.get_action("curve").action.triggered.connect( + self.show_curve_settings_popup ) - self.toolbar.add_action("curve", curve_settings, target_widget=self) - self.toolbar.widgets["curve"].action.triggered.connect(self.show_curve_settings_popup) def show_curve_settings_popup(self): """ Displays the curve settings popup to allow users to modify curve-related configurations. """ - curve_action = self.toolbar.widgets["curve"].action + curve_action = self.toolbar.components.get_action("curve").action if self.curve_settings_dialog is None or not self.curve_settings_dialog.isVisible(): curve_setting = CurveSetting(parent=self, target_widget=self) @@ -347,7 +360,7 @@ class Waveform(PlotBase): self.curve_settings_dialog.close() self.curve_settings_dialog.deleteLater() self.curve_settings_dialog = None - self.toolbar.widgets["curve"].action.setChecked(False) + self.toolbar.components.get_action("curve").action.setChecked(False) @property def roi_region(self) -> tuple[float, float] | None: @@ -394,9 +407,9 @@ class Waveform(PlotBase): Args: enable(bool): Enable or disable the ROI toolbar action. """ - self.toolbar.widgets["roi_linear"].action.setEnabled(enable) + self.toolbar.components.get_action("roi_linear").action.setEnabled(enable) if enable is False: - self.toolbar.widgets["roi_linear"].action.setChecked(False) + self.toolbar.components.get_action("roi_linear").action.setChecked(False) self._roi_manager.toggle_roi(False) ################################################################################ @@ -420,7 +433,7 @@ class Waveform(PlotBase): """ Show the DAP summary popup. """ - fit_action = self.toolbar.widgets["fit_params"].action + fit_action = self.toolbar.components.get_action("fit_params").action if self.dap_summary_dialog is None or not self.dap_summary_dialog.isVisible(): self.dap_summary = LMFitDialog(parent=self) self.dap_summary_dialog = QDialog(modal=False) @@ -446,7 +459,7 @@ class Waveform(PlotBase): self.dap_summary.deleteLater() self.dap_summary_dialog.deleteLater() self.dap_summary_dialog = None - self.toolbar.widgets["fit_params"].action.setChecked(False) + self.toolbar.components.get_action("fit_params").action.setChecked(False) def _get_dap_from_target_widget(self) -> None: """Get the DAP data from the target widget and update the DAP dialog manually on creation.""" diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 5b2a8fff..1530afda 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -9,7 +9,9 @@ from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem, from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.compact_popup import CompactPopupWidget -from bec_widgets.utils.toolbar import ModularToolBar, SeparatorAction, WidgetAction +from bec_widgets.utils.toolbars.actions import WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton @@ -76,17 +78,26 @@ class BECQueue(BECWidget, CompactPopupWidget): """ widget_label = QLabel(text="Live Queue", parent=self) widget_label.setStyleSheet("font-weight: bold;") - self.toolbar = ModularToolBar( - parent=self, - actions={ - "widget_label": WidgetAction(widget=widget_label), - "separator_1": SeparatorAction(), - "resume": WidgetAction(widget=ResumeButton(parent=self, toolbar=False)), - "stop": WidgetAction(widget=StopButton(parent=self, toolbar=False)), - "reset": WidgetAction(widget=ResetButton(parent=self, toolbar=False)), - }, - target_widget=self, + self.toolbar = ModularToolBar(parent=self) + self.toolbar.components.add_safe("widget_label", WidgetAction(widget=widget_label)) + bundle = ToolbarBundle("queue_label", self.toolbar.components) + bundle.add_action("widget_label") + self.toolbar.add_bundle(bundle) + + self.toolbar.add_action( + "resume", WidgetAction(widget=ResumeButton(parent=self, toolbar=True)) ) + self.toolbar.add_action("stop", WidgetAction(widget=StopButton(parent=self, toolbar=True))) + self.toolbar.add_action( + "reset", WidgetAction(widget=ResetButton(parent=self, toolbar=True)) + ) + + control_bundle = ToolbarBundle("control", self.toolbar.components) + control_bundle.add_action("resume") + control_bundle.add_action("stop") + control_bundle.add_action("reset") + self.toolbar.add_bundle(control_bundle) + self.toolbar.show_bundles(["queue_label", "control"]) self.addWidget(self.toolbar) diff --git a/tests/unit_tests/test_bec_dock.py b/tests/unit_tests/test_bec_dock.py index 53405059..8aacd199 100644 --- a/tests/unit_tests/test_bec_dock.py +++ b/tests/unit_tests/test_bec_dock.py @@ -97,13 +97,15 @@ def test_new_dock_raises_for_invalid_name(bec_dock_area): # Toolbar Actions ################################### def test_toolbar_add_plot_waveform(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_plots"].widgets["waveform"].trigger() + bec_dock_area.toolbar.components.get_action("menu_plots").actions["waveform"].action.trigger() assert "waveform_0" in bec_dock_area.panels assert bec_dock_area.panels["waveform_0"].widgets[0].config.widget_class == "Waveform" def test_toolbar_add_plot_scatter_waveform(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].trigger() + bec_dock_area.toolbar.components.get_action("menu_plots").actions[ + "scatter_waveform" + ].action.trigger() assert "scatter_waveform_0" in bec_dock_area.panels assert ( bec_dock_area.panels["scatter_waveform_0"].widgets[0].config.widget_class @@ -112,19 +114,22 @@ def test_toolbar_add_plot_scatter_waveform(bec_dock_area): def test_toolbar_add_plot_image(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_plots"].widgets["image"].trigger() + bec_dock_area.toolbar.components.get_action("menu_plots").actions["image"].action.trigger() assert "image_0" in bec_dock_area.panels assert bec_dock_area.panels["image_0"].widgets[0].config.widget_class == "Image" def test_toolbar_add_plot_motor_map(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_plots"].widgets["motor_map"].trigger() + bec_dock_area.toolbar.components.get_action("menu_plots").actions["motor_map"].action.trigger() assert "motor_map_0" in bec_dock_area.panels assert bec_dock_area.panels["motor_map_0"].widgets[0].config.widget_class == "MotorMap" def test_toolbar_add_multi_waveform(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_plots"].widgets["multi_waveform"].trigger() + bec_dock_area.toolbar.components.get_action("menu_plots").actions[ + "multi_waveform" + ].action.trigger() + # Check if the MultiWaveform panel is created assert "multi_waveform_0" in bec_dock_area.panels assert ( bec_dock_area.panels["multi_waveform_0"].widgets[0].config.widget_class == "MultiWaveform" @@ -132,7 +137,9 @@ def test_toolbar_add_multi_waveform(bec_dock_area): def test_toolbar_add_device_positioner_box(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_devices"].widgets["positioner_box"].trigger() + bec_dock_area.toolbar.components.get_action("menu_devices").actions[ + "positioner_box" + ].action.trigger() assert "positioner_box_0" in bec_dock_area.panels assert ( bec_dock_area.panels["positioner_box_0"].widgets[0].config.widget_class == "PositionerBox" @@ -143,19 +150,21 @@ def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full): bec_dock_area.client.connector.set_and_publish( MessageEndpoints.scan_queue_status(), bec_queue_msg_full ) - bec_dock_area.toolbar.widgets["menu_utils"].widgets["queue"].trigger() + bec_dock_area.toolbar.components.get_action("menu_utils").actions["queue"].action.trigger() assert "bec_queue_0" in bec_dock_area.panels assert bec_dock_area.panels["bec_queue_0"].widgets[0].config.widget_class == "BECQueue" def test_toolbar_add_utils_status(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_utils"].widgets["status"].trigger() + bec_dock_area.toolbar.components.get_action("menu_utils").actions["status"].action.trigger() assert "bec_status_box_0" in bec_dock_area.panels assert bec_dock_area.panels["bec_status_box_0"].widgets[0].config.widget_class == "BECStatusBox" def test_toolbar_add_utils_progress_bar(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_utils"].widgets["progress_bar"].trigger() + bec_dock_area.toolbar.components.get_action("menu_utils").actions[ + "progress_bar" + ].action.trigger() assert "ring_progress_bar_0" in bec_dock_area.panels assert ( bec_dock_area.panels["ring_progress_bar_0"].widgets[0].config.widget_class diff --git a/tests/unit_tests/test_curve_settings.py b/tests/unit_tests/test_curve_settings.py index 6164f868..d943a750 100644 --- a/tests/unit_tests/test_curve_settings.py +++ b/tests/unit_tests/test_curve_settings.py @@ -157,10 +157,10 @@ def test_curve_tree_init(curve_tree_fixture): assert curve_tree.color_palette == "plasma" assert curve_tree.tree.columnCount() == 7 - assert "add" in curve_tree.toolbar.widgets - assert "expand_all" in curve_tree.toolbar.widgets - assert "collapse_all" in curve_tree.toolbar.widgets - assert "renormalize_colors" in curve_tree.toolbar.widgets + assert curve_tree.toolbar.components.exists("add") + assert curve_tree.toolbar.components.exists("expand") + assert curve_tree.toolbar.components.exists("collapse") + assert curve_tree.toolbar.components.exists("renormalize_colors") def test_add_new_curve(curve_tree_fixture): diff --git a/tests/unit_tests/test_image_roi_tree.py b/tests/unit_tests/test_image_roi_tree.py index aa79def3..c0dc38ea 100644 --- a/tests/unit_tests/test_image_roi_tree.py +++ b/tests/unit_tests/test_image_roi_tree.py @@ -39,9 +39,11 @@ def test_initialization(roi_tree, image_widget): assert len(roi_tree.tree.findItems("", Qt.MatchContains)) == 0 # Empty tree initially # Check toolbar actions - assert hasattr(roi_tree, "add_rect_action") - assert hasattr(roi_tree, "add_circle_action") - assert hasattr(roi_tree, "expand_toggle") + assert roi_tree.toolbar.components.get_action("roi_rectangle") + assert roi_tree.toolbar.components.get_action("roi_circle") + assert roi_tree.toolbar.components.get_action("roi_ellipse") + assert roi_tree.toolbar.components.get_action("expand_toggle") + assert roi_tree.toolbar.components.get_action("lock_unlock_all") # Check tree view setup assert roi_tree.tree.columnCount() == 3 @@ -216,23 +218,25 @@ def test_draw_mode_toggle(roi_tree, qtbot): assert roi_tree._roi_draw_mode is None # Toggle rect mode on - roi_tree.add_rect_action.action.toggle() + rect_action = roi_tree.toolbar.components.get_action("roi_rectangle").action + circle_action = roi_tree.toolbar.components.get_action("roi_circle").action + rect_action.toggle() assert roi_tree._roi_draw_mode == "rect" - assert roi_tree.add_rect_action.action.isChecked() - assert not roi_tree.add_circle_action.action.isChecked() + assert rect_action.isChecked() + assert not circle_action.isChecked() # Toggle circle mode on (should turn off rect mode) - roi_tree.add_circle_action.action.toggle() + circle_action.toggle() qtbot.wait(200) assert roi_tree._roi_draw_mode == "circle" - assert not roi_tree.add_rect_action.action.isChecked() - assert roi_tree.add_circle_action.action.isChecked() + assert not rect_action.isChecked() + assert circle_action.isChecked() # Toggle circle mode off - roi_tree.add_circle_action.action.toggle() + circle_action.toggle() assert roi_tree._roi_draw_mode is None - assert not roi_tree.add_rect_action.action.isChecked() - assert not roi_tree.add_circle_action.action.isChecked() + assert not circle_action.isChecked() + assert not rect_action.isChecked() def test_add_roi_from_toolbar(qtbot, mocked_client): @@ -250,7 +254,7 @@ def test_add_roi_from_toolbar(qtbot, mocked_client): # Test rectangle ROI creation # 1. Activate rectangle drawing mode - roi_tree.add_rect_action.action.setChecked(True) + roi_tree.toolbar.components.get_action("roi_rectangle").action.setChecked(True) assert roi_tree._roi_draw_mode == "rect" # Get plot widget and view @@ -294,8 +298,8 @@ def test_add_roi_from_toolbar(qtbot, mocked_client): # Test circle ROI creation # Reset ROI draw mode - roi_tree.add_rect_action.action.setChecked(False) - roi_tree.add_circle_action.action.setChecked(True) + roi_tree.toolbar.components.get_action("roi_rectangle").action.setChecked(False) + roi_tree.toolbar.components.get_action("roi_circle").action.setChecked(True) assert roi_tree._roi_draw_mode == "circle" # Define new positions for circle ROI diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index e66d45a4..78e34705 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -242,10 +242,11 @@ def test_image_data_update_1d(qtbot, mocked_client): def test_toolbar_actions_presence(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - assert "autorange_image" in bec_image_view.toolbar.widgets - assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"] - assert "processing" in bec_image_view.toolbar.bundles - assert "selection" in bec_image_view.toolbar.bundles + assert bec_image_view.toolbar.components.exists("image_autorange") + assert bec_image_view.toolbar.components.exists("lock_aspect_ratio") + assert bec_image_view.toolbar.components.exists("image_processing_fft") + assert bec_image_view.toolbar.components.exists("image_device_combo") + assert bec_image_view.toolbar.components.exists("image_dim_combo") def test_image_processing_fft_toggle(qtbot, mocked_client): @@ -304,8 +305,8 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type): def test_setup_image_from_toolbar(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.selection_bundle.device_combo_box.setCurrentText("eiger") - bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d") + bec_image_view.device_combo_box.setCurrentText("eiger") + bec_image_view.dim_combo_box.setCurrentText("2d") assert bec_image_view.monitor == "eiger" assert bec_image_view.subscriptions["main"].source == "device_monitor_2d" @@ -318,17 +319,17 @@ def test_image_actions_interactions(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) bec_image_view.autorange = False # Change the initial state to False - bec_image_view.autorange_mean_action.action.trigger() + bec_image_view.toolbar.components.get_action("image_autorange_mean").action.trigger() assert bec_image_view.autorange is True assert bec_image_view.main_image.autorange is True assert bec_image_view.autorange_mode == "mean" - bec_image_view.autorange_max_action.action.trigger() + bec_image_view.toolbar.components.get_action("image_autorange_max").action.trigger() assert bec_image_view.autorange is True assert bec_image_view.main_image.autorange is True assert bec_image_view.autorange_mode == "max" - bec_image_view.toolbar.widgets["lock_aspect_ratio"].action.trigger() + bec_image_view.toolbar.components.get_action("lock_aspect_ratio").action.trigger() assert bec_image_view.lock_aspect_ratio is False assert bool(bec_image_view.plot_item.getViewBox().state["aspectLocked"]) is False @@ -336,7 +337,7 @@ def test_image_actions_interactions(qtbot, mocked_client): def test_image_toggle_action_fft(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.processing_bundle.fft.action.trigger() + bec_image_view.toolbar.components.get_action("image_processing_fft").action.trigger() assert bec_image_view.fft is True assert bec_image_view.main_image.fft is True @@ -346,7 +347,7 @@ def test_image_toggle_action_fft(qtbot, mocked_client): def test_image_toggle_action_log(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.processing_bundle.log.action.trigger() + bec_image_view.toolbar.components.get_action("image_processing_log").action.trigger() assert bec_image_view.log is True assert bec_image_view.main_image.log is True @@ -356,7 +357,7 @@ def test_image_toggle_action_log(qtbot, mocked_client): def test_image_toggle_action_transpose(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.processing_bundle.transpose.action.trigger() + bec_image_view.toolbar.components.get_action("image_processing_transpose").action.trigger() assert bec_image_view.transpose is True assert bec_image_view.main_image.transpose is True @@ -366,7 +367,7 @@ def test_image_toggle_action_transpose(qtbot, mocked_client): def test_image_toggle_action_rotate_right(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.processing_bundle.right.action.trigger() + bec_image_view.toolbar.components.get_action("image_processing_rotate_right").action.trigger() assert bec_image_view.num_rotation_90 == 3 assert bec_image_view.main_image.num_rotation_90 == 3 @@ -376,7 +377,7 @@ def test_image_toggle_action_rotate_right(qtbot, mocked_client): def test_image_toggle_action_rotate_left(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.processing_bundle.left.action.trigger() + bec_image_view.toolbar.components.get_action("image_processing_rotate_left").action.trigger() assert bec_image_view.num_rotation_90 == 1 assert bec_image_view.main_image.num_rotation_90 == 1 @@ -392,7 +393,7 @@ def test_image_toggle_action_reset(qtbot, mocked_client): bec_image_view.transpose = True bec_image_view.num_rotation_90 = 2 - bec_image_view.processing_bundle.reset.action.trigger() + bec_image_view.toolbar.components.get_action("image_processing_reset").action.trigger() assert bec_image_view.num_rotation_90 == 0 assert bec_image_view.main_image.num_rotation_90 == 0 @@ -473,8 +474,8 @@ def test_show_roi_manager_popup(qtbot, mocked_client): view = create_widget(qtbot, Image, client=mocked_client, popups=True) # ROI-manager toggle is exposed via the toolbar. - assert "roi_mgr" in view.toolbar.widgets - roi_action = view.toolbar.widgets["roi_mgr"].action + assert view.toolbar.components.exists("roi_mgr") + roi_action = view.toolbar.components.get_action("roi_mgr").action assert roi_action.isChecked() is False, "Should start unchecked" # Open the popup. @@ -497,10 +498,10 @@ def test_show_roi_manager_popup(qtbot, mocked_client): def test_crosshair_roi_panels_visibility(qtbot, mocked_client): """ - Verify that enabling the ROI‑crosshair shows ROI panels and disabling hides them. + Verify that enabling the ROI-crosshair shows ROI panels and disabling hides them. """ bec_image_view = create_widget(qtbot, Image, client=mocked_client) - switch = bec_image_view.toolbar.widgets["switch_crosshair"] + switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair") # Initially panels should be hidden assert bec_image_view.side_panel_x.panel_height == 0 @@ -548,7 +549,7 @@ def test_roi_plot_data_from_image(qtbot, mocked_client): bec_image_view.on_image_update_2d({"data": test_data}, {}) # Activate ROI crosshair - switch = bec_image_view.toolbar.widgets["switch_crosshair"] + switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair") switch.actions["crosshair_roi"].action.trigger() qtbot.wait(50) @@ -579,11 +580,10 @@ def test_roi_plot_data_from_image(qtbot, mocked_client): def test_monitor_selection_reverse_device_items(qtbot, mocked_client): """ Verify that _reverse_device_items correctly reverses the order of items in the - device combo‑box while preserving the current selection. + device combobox while preserving the current selection. """ view = create_widget(qtbot, Image, client=mocked_client) - bundle = view.selection_bundle - combo = bundle.device_combo_box + combo = view.device_combo_box # Replace existing items with a deterministic list combo.clear() @@ -593,7 +593,7 @@ def test_monitor_selection_reverse_device_items(qtbot, mocked_client): combo.setCurrentText("samy") # Reverse the items - bundle._reverse_device_items() + view._reverse_device_items() # Order should be reversed and selection preserved assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"] @@ -606,7 +606,6 @@ def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkey with the correct userData. """ view = create_widget(qtbot, Image, client=mocked_client) - bundle = view.selection_bundle # Provide a deterministic fake device_manager with get_bec_signals class _FakeDM: @@ -618,27 +617,26 @@ def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkey monkeypatch.setattr(view.client, "device_manager", _FakeDM()) - initial_count = bundle.device_combo_box.count() + initial_count = view.device_combo_box.count() - bundle._populate_preview_signals() + view._populate_preview_signals() # Two new entries should have been added - assert bundle.device_combo_box.count() == initial_count + 2 + assert view.device_combo_box.count() == initial_count + 2 # The first newly added item should carry tuple userData describing the device/signal - data = bundle.device_combo_box.itemData(initial_count) + data = view.device_combo_box.itemData(initial_count) assert isinstance(data, tuple) and data[0] == "eiger" def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch): """ - Verify that _adjust_and_connect performs the full set‑up: - ‑ fills the combo‑box with preview signals, - ‑ reverses their order, - ‑ and resets the currentText to an empty string. + Verify that _adjust_and_connect performs the full set-up: + - fills the combobox with preview signals, + - reverses their order, + - and resets the currentText to an empty string. """ view = create_widget(qtbot, Image, client=mocked_client) - bundle = view.selection_bundle # Deterministic fake device_manager class _FakeDM: @@ -647,14 +645,14 @@ def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch) monkeypatch.setattr(view.client, "device_manager", _FakeDM()) - combo = bundle.device_combo_box + combo = view.device_combo_box # Start from a clean state combo.clear() combo.addItem("", None) combo.setCurrentText("") # Execute the method under test - bundle._adjust_and_connect() + view._adjust_and_connect() # Expect exactly two items: preview label followed by the empty default assert combo.count() == 2 diff --git a/tests/unit_tests/test_modular_toolbar.py b/tests/unit_tests/test_modular_toolbar.py index d6d7a380..e9238f7c 100644 --- a/tests/unit_tests/test_modular_toolbar.py +++ b/tests/unit_tests/test_modular_toolbar.py @@ -5,19 +5,18 @@ from qtpy.QtCore import QPoint, Qt from qtpy.QtGui import QContextMenuEvent from qtpy.QtWidgets import QComboBox, QLabel, QMenu, QStyle, QToolButton, QWidget -from bec_widgets.utils.toolbar import ( +from bec_widgets.utils.toolbars.actions import ( DeviceSelectionAction, ExpandableMenuAction, - IconAction, LongPressToolButton, MaterialIconAction, - ModularToolBar, QtIconAction, SeparatorAction, SwitchableToolBarAction, - ToolbarBundle, WidgetAction, ) +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar @pytest.fixture @@ -34,14 +33,12 @@ def toolbar_fixture(qtbot, request, dummy_widget): """Parametrized fixture to create a ModularToolBar with different orientations.""" orientation: Literal["horizontal", "vertical"] = request.param toolbar = ModularToolBar( - target_widget=dummy_widget, orientation=orientation, background_color="rgba(255, 255, 255, 255)", # White background for testing ) qtbot.addWidget(toolbar) qtbot.waitExposed(toolbar) yield toolbar - toolbar.close() @pytest.fixture @@ -50,12 +47,6 @@ def separator_action(): return SeparatorAction() -@pytest.fixture -def icon_action(): - """Fixture to create an IconAction.""" - return IconAction(icon_path="assets/BEC-Icon.png", tooltip="Test Icon Action", checkable=True) - - @pytest.fixture def material_icon_action(): """Fixture to create a MaterialIconAction.""" @@ -64,6 +55,14 @@ def material_icon_action(): ) +@pytest.fixture +def material_icon_action_2(): + """Fixture to create another MaterialIconAction.""" + return MaterialIconAction( + icon_name="home", tooltip="Test Material Icon Action 2", checkable=False + ) + + @pytest.fixture def qt_icon_action(): """Fixture to create a QtIconAction.""" @@ -121,7 +120,7 @@ def test_initialization(toolbar_fixture): else: pytest.fail("Toolbar orientation is neither horizontal nor vertical.") assert toolbar.background_color == "rgba(255, 255, 255, 255)" - assert toolbar.widgets == {} + assert len(toolbar.components._components) == 1 # only the separator assert not toolbar.isMovable() assert not toolbar.isFloatable() @@ -152,80 +151,60 @@ def test_set_orientation(toolbar_fixture, qtbot, dummy_widget): assert toolbar.orientation() == Qt.Vertical -def test_add_action( - toolbar_fixture, - icon_action, - separator_action, - material_icon_action, - qt_icon_action, - dummy_widget, -): - """Test adding different types of actions to the toolbar.""" +def test_add_action(toolbar_fixture, material_icon_action, qt_icon_action): + """Test adding different types of actions to the toolbar components.""" toolbar = toolbar_fixture - # Add IconAction - toolbar.add_action("icon_action", icon_action, dummy_widget) - assert "icon_action" in toolbar.widgets - assert toolbar.widgets["icon_action"] == icon_action - assert icon_action.action in toolbar.actions() - - # Add SeparatorAction - toolbar.add_action("separator_action", separator_action, dummy_widget) - assert "separator_action" in toolbar.widgets - assert toolbar.widgets["separator_action"] == separator_action - # Add MaterialIconAction - toolbar.add_action("material_icon_action", material_icon_action, dummy_widget) - assert "material_icon_action" in toolbar.widgets - assert toolbar.widgets["material_icon_action"] == material_icon_action - assert material_icon_action.action in toolbar.actions() + toolbar.add_action("material_icon_action", material_icon_action) + assert toolbar.components.exists("material_icon_action") + assert toolbar.components.get_action("material_icon_action") == material_icon_action # Add QtIconAction - toolbar.add_action("qt_icon_action", qt_icon_action, dummy_widget) - assert "qt_icon_action" in toolbar.widgets - assert toolbar.widgets["qt_icon_action"] == qt_icon_action - assert qt_icon_action.action in toolbar.actions() + toolbar.add_action("qt_icon_action", qt_icon_action) + assert toolbar.components.exists("qt_icon_action") + assert toolbar.components.get_action("qt_icon_action") == qt_icon_action -def test_hide_show_action(toolbar_fixture, icon_action, qtbot, dummy_widget): +def test_hide_show_action(toolbar_fixture, qt_icon_action, qtbot): """Test hiding and showing actions on the toolbar.""" toolbar = toolbar_fixture # Add an action - toolbar.add_action("icon_action", icon_action, dummy_widget) - assert icon_action.action.isVisible() + toolbar.add_action("icon_action", qt_icon_action) + assert qt_icon_action.action.isVisible() # Hide the action toolbar.hide_action("icon_action") qtbot.wait(100) - assert not icon_action.action.isVisible() + assert not qt_icon_action.action.isVisible() # Show the action toolbar.show_action("icon_action") qtbot.wait(100) - assert icon_action.action.isVisible() + assert qt_icon_action.action.isVisible() -def test_add_duplicate_action(toolbar_fixture, icon_action, dummy_widget): +def test_add_duplicate_action(toolbar_fixture, qt_icon_action): """Test that adding an action with a duplicate action_id raises a ValueError.""" toolbar = toolbar_fixture # Add an action - toolbar.add_action("icon_action", icon_action, dummy_widget) - assert "icon_action" in toolbar.widgets + toolbar.add_action("qt_icon_action", qt_icon_action) + assert toolbar.components.exists("qt_icon_action") # Attempt to add another action with the same ID with pytest.raises(ValueError) as excinfo: - toolbar.add_action("icon_action", icon_action, dummy_widget) - assert "Action with ID 'icon_action' already exists." in str(excinfo.value) + toolbar.add_action("qt_icon_action", qt_icon_action) + assert "Bundle with name 'qt_icon_action' already exists." in str(excinfo.value) -def test_update_material_icon_colors(toolbar_fixture, material_icon_action, dummy_widget): +def test_update_material_icon_colors(toolbar_fixture, material_icon_action): """Test updating the color of MaterialIconAction icons.""" toolbar = toolbar_fixture # Add MaterialIconAction - toolbar.add_action("material_icon_action", material_icon_action, dummy_widget) + toolbar.add_action("material_icon_action", material_icon_action) assert material_icon_action.action is not None # Initial icon @@ -242,11 +221,12 @@ def test_update_material_icon_colors(toolbar_fixture, material_icon_action, dumm assert initial_icon != updated_icon -def test_device_selection_action(toolbar_fixture, device_selection_action, dummy_widget): +def test_device_selection_action(toolbar_fixture, device_selection_action): """Test adding a DeviceSelectionAction to the toolbar.""" toolbar = toolbar_fixture - toolbar.add_action("device_selection", device_selection_action, dummy_widget) - assert "device_selection" in toolbar.widgets + toolbar.add_action("device_selection", device_selection_action) + assert toolbar.components.exists("device_selection") + toolbar.show_bundles(["device_selection"]) # DeviceSelectionAction adds a QWidget, so it should be present in the toolbar's widgets # Check if the widget is added widget = device_selection_action.device_combobox.parentWidget() @@ -256,11 +236,12 @@ def test_device_selection_action(toolbar_fixture, device_selection_action, dummy assert label.text() == "Select Device:" -def test_widget_action(toolbar_fixture, widget_action, dummy_widget): +def test_widget_action(toolbar_fixture, widget_action): """Test adding a WidgetAction to the toolbar.""" toolbar = toolbar_fixture - toolbar.add_action("widget_action", widget_action, dummy_widget) - assert "widget_action" in toolbar.widgets + toolbar.add_action("widget_action", widget_action) + assert toolbar.components.exists("widget_action") + toolbar.show_bundles(["widget_action"]) # WidgetAction adds a QWidget to the toolbar container = widget_action.widget.parentWidget() assert container in toolbar.findChildren(QWidget) @@ -269,11 +250,12 @@ def test_widget_action(toolbar_fixture, widget_action, dummy_widget): assert label.text() == "Sample Label:" -def test_expandable_menu_action(toolbar_fixture, expandable_menu_action, dummy_widget): +def test_expandable_menu_action(toolbar_fixture, expandable_menu_action): """Test adding an ExpandableMenuAction to the toolbar.""" toolbar = toolbar_fixture - toolbar.add_action("expandable_menu", expandable_menu_action, dummy_widget) - assert "expandable_menu" in toolbar.widgets + toolbar.add_action("expandable_menu", expandable_menu_action) + assert toolbar.components.exists("expandable_menu") + toolbar.show_bundles(["expandable_menu"]) # ExpandableMenuAction adds a QToolButton with a QMenu # Find the QToolButton tool_buttons = toolbar.findChildren(QToolButton) @@ -300,44 +282,47 @@ def test_update_material_icon_colors_no_material_actions(toolbar_fixture, dummy_ def test_hide_action_nonexistent(toolbar_fixture): - """Test hiding an action that does not exist raises a ValueError.""" + """Test hiding an action that does not exist raises a KeyError.""" toolbar = toolbar_fixture - with pytest.raises(ValueError) as excinfo: + with pytest.raises(KeyError) as excinfo: toolbar.hide_action("nonexistent_action") - assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value) + excinfo.match("Component with name 'nonexistent_action' does not exist.") def test_show_action_nonexistent(toolbar_fixture): - """Test showing an action that does not exist raises a ValueError.""" + """Test showing an action that does not exist raises a KeyError.""" toolbar = toolbar_fixture - with pytest.raises(ValueError) as excinfo: + with pytest.raises(KeyError) as excinfo: toolbar.show_action("nonexistent_action") - assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value) + excinfo.match("Component with name 'nonexistent_action' does not exist.") -def test_add_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action): +def test_add_bundle(toolbar_fixture, material_icon_action): """Test adding a bundle of actions to the toolbar.""" toolbar = toolbar_fixture - bundle = ToolbarBundle( - bundle_id="test_bundle", - actions=[ - ("icon_action_in_bundle", icon_action), - ("material_icon_in_bundle", material_icon_action), - ], - ) - toolbar.add_bundle(bundle, dummy_widget) - assert "test_bundle" in toolbar.bundles - assert "icon_action_in_bundle" in toolbar.widgets - assert "material_icon_in_bundle" in toolbar.widgets - assert icon_action.action in toolbar.actions() + toolbar.add_action("material_icon_in_bundle", material_icon_action) + bundle = ToolbarBundle("test_bundle", toolbar.components) + bundle.add_action("material_icon_in_bundle") + + toolbar.add_bundle(bundle) + + assert toolbar.get_bundle("test_bundle") + assert toolbar.components.exists("material_icon_in_bundle") + + toolbar.show_bundles(["test_bundle"]) + assert material_icon_action.action in toolbar.actions() -def test_invalid_orientation(dummy_widget): +def test_invalid_orientation(): """Test that an invalid orientation raises a ValueError.""" - toolbar = ModularToolBar(target_widget=dummy_widget, orientation="horizontal") - with pytest.raises(ValueError): - toolbar.set_orientation("diagonal") + try: + toolbar = ModularToolBar(orientation="horizontal") + with pytest.raises(ValueError): + toolbar.set_orientation("diagonal") + finally: + toolbar.close() + toolbar.deleteLater() def test_widget_action_calculate_minimum_width(qtbot): @@ -353,24 +338,26 @@ def test_widget_action_calculate_minimum_width(qtbot): def test_add_action_to_bundle(toolbar_fixture, dummy_widget, material_icon_action): # Create an initial bundle with one action - bundle = ToolbarBundle( - bundle_id="test_bundle", actions=[("initial_action", material_icon_action)] - ) - toolbar_fixture.add_bundle(bundle, dummy_widget) + toolbar_fixture.add_action("initial_action", material_icon_action) + bundle = ToolbarBundle("test_bundle", toolbar_fixture.components) + bundle.add_action("initial_action") + toolbar_fixture.add_bundle(bundle) # Create a new action to add to the existing bundle new_action = MaterialIconAction( icon_name="counter_1", tooltip="New Action", checkable=True, parent=dummy_widget ) - toolbar_fixture.add_action_to_bundle("test_bundle", "new_action", new_action, dummy_widget) + toolbar_fixture.components.add_safe("new_action", new_action) + toolbar_fixture.get_bundle("test_bundle").add_action("new_action") + + toolbar_fixture.show_bundles(["test_bundle"]) # Verify the new action is registered in the toolbar's widgets - assert "new_action" in toolbar_fixture.widgets - assert toolbar_fixture.widgets["new_action"] == new_action + assert toolbar_fixture.components.exists("new_action") + assert toolbar_fixture.components.get_action("new_action") == new_action # Verify the new action is included in the bundle tracking - assert "new_action" in toolbar_fixture.bundles["test_bundle"] - assert toolbar_fixture.bundles["test_bundle"][-1] == "new_action" + assert toolbar_fixture.bundles["test_bundle"].bundle_actions["new_action"]() == new_action # Verify the new action's QAction is present in the toolbar's action list actions_list = toolbar_fixture.actions() @@ -384,7 +371,7 @@ def test_add_action_to_bundle(toolbar_fixture, dummy_widget, material_icon_actio def test_context_menu_contains_added_actions( - toolbar_fixture, icon_action, material_icon_action, dummy_widget, monkeypatch + toolbar_fixture, material_icon_action, material_icon_action_2, monkeypatch ): """ Test that the toolbar's context menu lists all added toolbar actions. @@ -392,9 +379,13 @@ def test_context_menu_contains_added_actions( toolbar = toolbar_fixture # Add two different actions - toolbar.add_action("icon_action", icon_action, dummy_widget) - toolbar.add_action("material_icon_action", material_icon_action, dummy_widget) + toolbar.components.add_safe("material_icon_action", material_icon_action) + toolbar.components.add_safe("material_icon_action_2", material_icon_action_2) + bundle = toolbar.new_bundle("test_bundle") + bundle.add_action("material_icon_action") + bundle.add_action("material_icon_action_2") + toolbar.show_bundles(["test_bundle"]) # Mock the QMenu.exec_ method to prevent the context menu from being displayed and block CI pipeline monkeypatch.setattr(QMenu, "exec_", lambda self, pos=None: None) event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10)) @@ -404,23 +395,26 @@ def test_context_menu_contains_added_actions( assert len(menus) > 0 menu = menus[-1] menu_action_texts = [action.text() for action in menu.actions()] - assert any(icon_action.tooltip in text or "icon_action" in text for text in menu_action_texts) - assert any( - material_icon_action.tooltip in text or "material_icon_action" in text - for text in menu_action_texts - ) + tooltips = [ + action.action.tooltip + for action in toolbar.components._components.values() + if not isinstance(action.action, SeparatorAction) + ] + menu_actions_tooltips = [ + action.toolTip() for action in menu.actions() if action.toolTip() != "" + ] + assert menu_action_texts == tooltips -def test_context_menu_toggle_action_visibility( - toolbar_fixture, icon_action, dummy_widget, monkeypatch -): +def test_context_menu_toggle_action_visibility(toolbar_fixture, material_icon_action, monkeypatch): """ Test that toggling action visibility works correctly through the toolbar's context menu. """ toolbar = toolbar_fixture # Add an action - toolbar.add_action("icon_action", icon_action, dummy_widget) - assert icon_action.action.isVisible() + toolbar.add_action("material_icon_action", material_icon_action) + toolbar.show_bundles(["material_icon_action"]) + assert material_icon_action.action.isVisible() # Manually trigger the context menu event monkeypatch.setattr(QMenu, "exec_", lambda self, pos=None: None) @@ -433,7 +427,7 @@ def test_context_menu_toggle_action_visibility( menu = menus[-1] # Locate the QAction in the menu - matching_actions = [m for m in menu.actions() if m.text() == icon_action.tooltip] + matching_actions = [m for m in menu.actions() if m.text() == material_icon_action.tooltip] assert len(matching_actions) == 1 action_in_menu = matching_actions[0] @@ -441,23 +435,24 @@ def test_context_menu_toggle_action_visibility( action_in_menu.setChecked(False) menu.triggered.emit(action_in_menu) # The action on the toolbar should now be hidden - assert not icon_action.action.isVisible() + assert not material_icon_action.action.isVisible() # Toggle it on (check) action_in_menu.setChecked(True) menu.triggered.emit(action_in_menu) # The action on the toolbar should be visible again - assert icon_action.action.isVisible() + assert material_icon_action.action.isVisible() -def test_switchable_toolbar_action_add(toolbar_fixture, dummy_widget, switchable_toolbar_action): +def test_switchable_toolbar_action_add(toolbar_fixture, switchable_toolbar_action): """Test that a switchable toolbar action can be added to the toolbar correctly.""" toolbar = toolbar_fixture - toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget) + toolbar.add_action("switch_action", switchable_toolbar_action) + toolbar.show_bundles(["switch_action"]) # Verify the action was added correctly - assert "switch_action" in toolbar.widgets - assert toolbar.widgets["switch_action"] == switchable_toolbar_action + assert toolbar.components.exists("switch_action") + assert toolbar.components.get_action("switch_action") == switchable_toolbar_action # Verify the button is present and is the correct type button = switchable_toolbar_action.main_button @@ -468,11 +463,10 @@ def test_switchable_toolbar_action_add(toolbar_fixture, dummy_widget, switchable assert button.toolTip() == "Action 1" -def test_switchable_toolbar_action_switching( - toolbar_fixture, dummy_widget, switchable_toolbar_action, qtbot -): +def test_switchable_toolbar_action_switching(toolbar_fixture, switchable_toolbar_action, qtbot): toolbar = toolbar_fixture - toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget) + toolbar.add_action("switch_action", switchable_toolbar_action) + toolbar.show_bundles(["switch_action"]) # Verify initial state is set to action1 assert switchable_toolbar_action.current_key == "action1" assert switchable_toolbar_action.main_button.toolTip() == "Action 1" @@ -494,9 +488,10 @@ def test_switchable_toolbar_action_switching( assert switchable_toolbar_action.main_button.toolTip() == "Action 2" -def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_action, qtbot): +def test_long_pressbutton(toolbar_fixture, switchable_toolbar_action, qtbot): toolbar = toolbar_fixture - toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget) + toolbar.add_action("switch_action", switchable_toolbar_action) + toolbar.show_bundles(["switch_action"]) # Verify the button is a LongPressToolButton button = switchable_toolbar_action.main_button @@ -521,92 +516,73 @@ def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_acti # Additional tests for action/bundle removal -def test_remove_standalone_action(toolbar_fixture, icon_action, dummy_widget): +def test_remove_standalone_action(toolbar_fixture, material_icon_action): """ Ensure that a standalone action is fully removed and no longer accessible. """ toolbar = toolbar_fixture # Add the action and check it is present - toolbar.add_action("icon_action", icon_action, dummy_widget) - assert "icon_action" in toolbar.widgets - assert icon_action.action in toolbar.actions() + toolbar.add_action("icon_action", material_icon_action) + + assert toolbar.components.exists("icon_action") + + toolbar.show_bundles(["icon_action"]) + assert material_icon_action.action in toolbar.actions() # Now remove it - toolbar.remove_action("icon_action") + toolbar.components.remove_action("icon_action") # Action bookkeeping - assert "icon_action" not in toolbar.widgets + assert not toolbar.components.exists("icon_action") # QAction list - assert icon_action.action not in toolbar.actions() + assert material_icon_action.action not in toolbar.actions() # Attempting to hide / show it should raise - with pytest.raises(ValueError): + with pytest.raises(KeyError): toolbar.hide_action("icon_action") - with pytest.raises(ValueError): + with pytest.raises(KeyError): toolbar.show_action("icon_action") -def test_remove_action_from_bundle( - toolbar_fixture, dummy_widget, icon_action, material_icon_action -): +def test_remove_action_from_bundle(toolbar_fixture, material_icon_action, material_icon_action_2): """ - Remove a single action that is part of a bundle and verify clean‑up. + Remove a single action that is part of a bundle. This should not remove the action + from the toolbar's components, but only from the bundle tracking. """ toolbar = toolbar_fixture - bundle = ToolbarBundle( - bundle_id="test_bundle", - actions=[("icon_action", icon_action), ("material_action", material_icon_action)], - ) - toolbar.add_bundle(bundle, dummy_widget) + bundle = toolbar.new_bundle("test_bundle") + # Add two actions to the bundle + toolbar.components.add_safe("material_action", material_icon_action) + toolbar.components.add_safe("material_action_2", material_icon_action_2) + bundle.add_action("material_action") + bundle.add_action("material_action_2") + + toolbar.show_bundles(["test_bundle"]) # Initial assertions assert "test_bundle" in toolbar.bundles - assert "icon_action" in toolbar.widgets - assert "material_action" in toolbar.widgets + assert toolbar.components.exists("material_action") + assert toolbar.components.exists("material_action_2") # Remove one action from the bundle - toolbar.remove_action("icon_action") + toolbar.get_bundle("test_bundle").remove_action("material_action") - # icon_action should be fully gone - assert "icon_action" not in toolbar.widgets - assert icon_action.action not in toolbar.actions() - # Bundle tracking should be updated - assert "icon_action" not in toolbar.bundles["test_bundle"] - # The other action must still exist - assert "material_action" in toolbar.widgets - assert material_icon_action.action in toolbar.actions() + # The bundle should still exist + assert "test_bundle" in toolbar.bundles + # The removed action should still exist in the components + assert toolbar.components.exists("material_action") + + # The removed action should not be in the bundle anymore + assert "material_action" not in toolbar.bundles["test_bundle"].bundle_actions -def test_remove_last_action_from_bundle_removes_bundle(toolbar_fixture, dummy_widget, icon_action): - """ - Removing the final action from a bundle should delete the bundle entry itself. - """ +def test_remove_entire_bundle(toolbar_fixture, material_icon_action, material_icon_action_2): toolbar = toolbar_fixture - bundle = ToolbarBundle(bundle_id="single_action_bundle", actions=[("only_action", icon_action)]) - toolbar.add_bundle(bundle, dummy_widget) - - # Sanity check - assert "single_action_bundle" in toolbar.bundles - assert "only_action" in toolbar.widgets - - # Remove the sole action - toolbar.remove_action("only_action") - - # Bundle should be gone - assert "single_action_bundle" not in toolbar.bundles - # QAction removed - assert icon_action.action not in toolbar.actions() - - -def test_remove_entire_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action): - """ - Ensure that removing a bundle deletes all its actions and separators. - """ - toolbar = toolbar_fixture - bundle = ToolbarBundle( - bundle_id="to_remove", - actions=[("icon_action", icon_action), ("material_action", material_icon_action)], - ) - toolbar.add_bundle(bundle, dummy_widget) + toolbar.components.add_safe("material_action", material_icon_action) + toolbar.components.add_safe("material_action_2", material_icon_action_2) + # Create a bundle with two actions + bundle = toolbar.new_bundle("to_remove") + bundle.add_action("material_action") + bundle.add_action("material_action_2") # Confirm bundle presence assert "to_remove" in toolbar.bundles @@ -616,58 +592,23 @@ def test_remove_entire_bundle(toolbar_fixture, dummy_widget, icon_action, materi # Bundle mapping gone assert "to_remove" not in toolbar.bundles - # All actions gone - for aid, act in [("icon_action", icon_action), ("material_action", material_icon_action)]: - assert aid not in toolbar.widgets - assert act.action not in toolbar.actions() - - -def test_trigger_removed_action_raises(toolbar_fixture, icon_action, dummy_widget, qtbot): - """ - Add an action, connect a mock slot, then remove the action and verify that - attempting to trigger it afterwards raises RuntimeError (since the underlying - QAction has been deleted). - """ - toolbar = toolbar_fixture - - # Add the action and connect a mock slot - toolbar.add_action("icon_action", icon_action, dummy_widget) - called = [] - - def mock_slot(): - called.append(True) - - icon_action.action.triggered.connect(mock_slot) - - # Trigger once to confirm connection works - icon_action.action.trigger() - assert called == [True] - - # Now remove the action - toolbar.remove_action("icon_action") - # Allow deleteLater event to process - qtbot.wait(50) - - # The underlying C++ object should be deleted; triggering should raise - with pytest.raises(RuntimeError): - icon_action.action.trigger() def test_remove_nonexistent_action(toolbar_fixture): """ - Attempting to remove an action that does not exist should raise ValueError. + Attempting to remove an action that does not exist should raise KeyError. """ toolbar = toolbar_fixture - with pytest.raises(ValueError) as excinfo: - toolbar.remove_action("nonexistent_action") + with pytest.raises(KeyError) as excinfo: + toolbar.components.remove_action("nonexistent_action") assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value) def test_remove_nonexistent_bundle(toolbar_fixture): """ - Attempting to remove a bundle that does not exist should raise ValueError. + Attempting to remove a bundle that does not exist should raise KeyError. """ toolbar = toolbar_fixture - with pytest.raises(ValueError) as excinfo: + with pytest.raises(KeyError) as excinfo: toolbar.remove_bundle("nonexistent_bundle") - assert "Bundle 'nonexistent_bundle' does not exist." in str(excinfo.value) + excinfo.match("Bundle with name 'nonexistent_bundle' does not exist.") diff --git a/tests/unit_tests/test_motor_map_next_gen.py b/tests/unit_tests/test_motor_map_next_gen.py index 8b6abb10..277b3be1 100644 --- a/tests/unit_tests/test_motor_map_next_gen.py +++ b/tests/unit_tests/test_motor_map_next_gen.py @@ -272,16 +272,15 @@ def test_motor_map_toolbar_selection(qtbot, mocked_client): mm = create_widget(qtbot, MotorMap, client=mocked_client) # Verify toolbar bundle was created during initialization - assert hasattr(mm, "motor_selection_bundle") - assert mm.motor_selection_bundle is not None + motor_selection = mm.toolbar.components.get_action("motor_selection") - mm.motor_selection_bundle.motor_x.setCurrentText("samx") - mm.motor_selection_bundle.motor_y.setCurrentText("samy") + motor_selection.motor_x.setCurrentText("samx") + motor_selection.motor_y.setCurrentText("samy") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samy" - mm.motor_selection_bundle.motor_y.setCurrentText("samz") + motor_selection.motor_y.setCurrentText("samz") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samz" @@ -291,9 +290,9 @@ def test_motor_map_settings_dialog(qtbot, mocked_client): """Test the settings dialog for the motor map.""" mm = create_widget(qtbot, MotorMap, client=mocked_client, popups=True) - assert "popup_bundle" in mm.toolbar.bundles - for action_id in mm.toolbar.bundles["popup_bundle"]: - assert mm.toolbar.widgets[action_id].action.isVisible() is True + assert "axis_popup" in mm.toolbar.bundles + for action_ref in mm.toolbar.bundles["axis_popup"].bundle_actions.values(): + assert action_ref().action.isVisible() # set properties to be fetched by dialog mm.map(x_name="samx", y_name="samy") diff --git a/tests/unit_tests/test_multi_waveform_next_gen.py b/tests/unit_tests/test_multi_waveform_next_gen.py index 9ce768c9..0b7ae96f 100644 --- a/tests/unit_tests/test_multi_waveform_next_gen.py +++ b/tests/unit_tests/test_multi_waveform_next_gen.py @@ -244,15 +244,14 @@ def test_selection_toolbar_updates_widget(qtbot, mocked_client): updates the widget properties. """ mw = create_widget(qtbot, MultiWaveform, client=mocked_client) - toolbar = mw.monitor_selection_bundle - monitor_combo = toolbar.monitor - colormap_widget = toolbar.colormap_widget + monitor_selection_action = mw.toolbar.components.get_action("monitor_selection") + cmap_action = mw.toolbar.components.get_action("color_map") - monitor_combo.addItem("waveform1d") - monitor_combo.setCurrentText("waveform1d") + monitor_selection_action.combobox.addItem("waveform1d") + monitor_selection_action.combobox.setCurrentText("waveform1d") assert mw.monitor == "waveform1d" - colormap_widget.colormap = "viridis" + cmap_action.widget.colormap = "viridis" assert mw.color_palette == "viridis" @@ -290,11 +289,10 @@ def test_control_panel_opacity_slider_spinbox(qtbot, mocked_client): def test_control_panel_highlight_slider_spinbox(qtbot, mocked_client): """ Test that the slider and spinbox for curve highlighting update - the widget’s highlighted_index property, and are disabled if + the widget's highlighted_index property, and are disabled if highlight_last_curve is True. """ mw = create_widget(qtbot, MultiWaveform, client=mocked_client) - slider_index = mw.controls.ui.highlighted_index spinbox_index = mw.controls.ui.spinbox_index checkbox_highlight_last = mw.controls.ui.highlight_last_curve diff --git a/tests/unit_tests/test_plot_base_next_gen.py b/tests/unit_tests/test_plot_base_next_gen.py index 89dfa000..79d52650 100644 --- a/tests/unit_tests/test_plot_base_next_gen.py +++ b/tests/unit_tests/test_plot_base_next_gen.py @@ -265,54 +265,56 @@ def test_ui_mode_popup(qtbot, mocked_client): pb = create_widget(qtbot, PlotBase, client=mocked_client) pb.ui_mode = UIMode.POPUP # The popup bundle should be created and its actions made visible. - assert "popup_bundle" in pb.toolbar.bundles - for action_id in pb.toolbar.bundles["popup_bundle"]: - assert pb.toolbar.widgets[action_id].action.isVisible() is True + assert "axis_popup" in pb.toolbar.bundles + for action_ref in pb.toolbar.bundles["axis_popup"].bundle_actions.values(): + assert action_ref().action.isVisible() is True # The side panel should be hidden. assert not pb.side_panel.isVisible() -def test_ui_mode_side(qtbot, mocked_client): - """ - Test that setting ui_mode to SIDE shows the side panel and ensures any popup actions - are hidden. - """ - pb = create_widget(qtbot, PlotBase, client=mocked_client) - pb.ui_mode = UIMode.SIDE - # If a popup bundle exists, its actions should be hidden. - if "popup_bundle" in pb.toolbar.bundles: - for action_id in pb.toolbar.bundles["popup_bundle"]: - assert pb.toolbar.widgets[action_id].action.isVisible() is False +# Side panels are not properly implemented yet. Once the logic is fixed, we can re-enable this test. +# See issue #742 +# def test_ui_mode_side(qtbot, mocked_client): +# """ +# Test that setting ui_mode to SIDE shows the side panel and ensures any popup actions +# are hidden. +# """ +# pb = create_widget(qtbot, PlotBase, client=mocked_client) +# pb.ui_mode = UIMode.SIDE +# # If a popup bundle exists, its actions should be hidden. +# if "axis_popup" in pb.toolbar.bundles: +# for action_ref in pb.toolbar.bundles["axis_popup"].bundle_actions.values(): +# assert action_ref().action.isVisible() is False -def test_enable_popups_property(qtbot, mocked_client): - """ - Test the enable_popups property: when enabled, ui_mode should be POPUP, - and when disabled, ui_mode should change to NONE. - """ - pb = create_widget(qtbot, PlotBase, client=mocked_client) - pb.enable_popups = True - assert pb.ui_mode == UIMode.POPUP - # The popup bundle actions should be visible. - assert "popup_bundle" in pb.toolbar.bundles - for action_id in pb.toolbar.bundles["popup_bundle"]: - assert pb.toolbar.widgets[action_id].action.isVisible() is True +# def test_enable_popups_property(qtbot, mocked_client): +# """ +# Test the enable_popups property: when enabled, ui_mode should be POPUP, +# and when disabled, ui_mode should change to NONE. +# """ +# pb = create_widget(qtbot, PlotBase, client=mocked_client) +# pb.enable_popups = True +# assert pb.ui_mode == UIMode.POPUP +# # The popup bundle actions should be visible. +# assert "popup_bundle" in pb.toolbar.bundles +# for action_id in pb.toolbar.bundles["popup_bundle"]: +# assert pb.toolbar.widgets[action_id].action.isVisible() is True - pb.enable_popups = False - assert pb.ui_mode == UIMode.NONE +# pb.enable_popups = False +# assert pb.ui_mode == UIMode.NONE -def test_enable_side_panel_property(qtbot, mocked_client): - """ - Test the enable_side_panel property: when enabled, ui_mode should be SIDE, - and when disabled, ui_mode should change to NONE. - """ - pb = create_widget(qtbot, PlotBase, client=mocked_client) - pb.enable_side_panel = True - assert pb.ui_mode == UIMode.SIDE +# def test_enable_side_panel_property(qtbot, mocked_client): +# """ +# Test the enable_side_panel property: when enabled, ui_mode should be SIDE, +# and when disabled, ui_mode should change to NONE. +# """ +# pb = create_widget(qtbot, PlotBase, client=mocked_client) +# pb.enable_side_panel = True +# assert pb.ui_mode == UIMode.SIDE - pb.enable_side_panel = False - assert pb.ui_mode == UIMode.NONE +# pb.enable_side_panel = False +# assert pb.ui_mode == UIMode.NONE def test_switching_between_popup_and_side_panel_closes_dialog(qtbot, mocked_client): @@ -323,18 +325,19 @@ def test_switching_between_popup_and_side_panel_closes_dialog(qtbot, mocked_clie pb = create_widget(qtbot, PlotBase, client=mocked_client) pb.ui_mode = UIMode.POPUP # Open the axis settings popup. - pb.show_axis_settings_popup() + pb_connection = pb.toolbar.bundles["axis_popup"].get_connection("plot_base") + pb_connection.show_axis_settings_popup() qtbot.wait(100) # The dialog should now exist and be visible. - assert pb.axis_settings_dialog is not None - assert pb.axis_settings_dialog.isVisible() is True + assert pb_connection.axis_settings_dialog is not None + assert pb_connection.axis_settings_dialog.isVisible() is True # Switch to side panel mode. pb.ui_mode = UIMode.SIDE qtbot.wait(100) # The axis settings dialog should be closed (and reference cleared). - qtbot.waitUntil(lambda: pb.axis_settings_dialog is None, timeout=5000) + qtbot.waitUntil(lambda: pb_connection.axis_settings_dialog is None, timeout=5000) def test_enable_fps_monitor_property(qtbot, mocked_client): diff --git a/tests/unit_tests/test_side_menu.py b/tests/unit_tests/test_side_menu.py index 3ada6cd1..964b205e 100644 --- a/tests/unit_tests/test_side_menu.py +++ b/tests/unit_tests/test_side_menu.py @@ -136,7 +136,7 @@ def test_add_menu(side_panel_fixture, menu_widget, qtbot): assert panel.stack_widget.count() == initial_count + 1 # Verify the action is added to the toolbar - action = panel.toolbar.widgets.get("test_action") + action = panel.toolbar.components.get_action("test_action") assert action is not None assert action.tooltip == "Test Tooltip" assert action.action in panel.toolbar.actions() @@ -155,7 +155,7 @@ def test_toggle_action_show_panel(side_panel_fixture, menu_widget, qtbot): ) qtbot.wait(100) - action = panel.toolbar.widgets.get("toggle_action") + action = panel.toolbar.components.get_action("toggle_action") assert action is not None # Initially, panel should be hidden @@ -199,8 +199,8 @@ def test_switch_actions(side_panel_fixture, menu_widget, qtbot): ) qtbot.wait(100) - action1 = panel.toolbar.widgets.get("action1") - action2 = panel.toolbar.widgets.get("action2") + action1 = panel.toolbar.components.get_action("action1") + action2 = panel.toolbar.components.get_action("action2") assert action1 is not None assert action2 is not None @@ -241,7 +241,7 @@ def test_multiple_add_menu(side_panel_fixture, menu_widget, qtbot): ) qtbot.wait(100) assert panel.stack_widget.count() == initial_count + i + 1 - action = panel.toolbar.widgets.get(f"action{i}") + action = panel.toolbar.components.get_action(f"action{i}") assert action is not None assert action.tooltip == f"Tooltip{i}" assert action.action in panel.toolbar.actions() @@ -360,7 +360,7 @@ def test_add_multiple_menus(side_panel_fixture, menu_widget, qtbot): ) qtbot.wait(100) assert panel.stack_widget.count() == initial_count + i + 1 - action = panel.toolbar.widgets.get(f"action{i}") + action = panel.toolbar.components.get_action(f"action{i}") assert action is not None assert action.tooltip == f"Tooltip{i}" assert action.action in panel.toolbar.actions() diff --git a/tests/unit_tests/test_waveform_next_gen.py b/tests/unit_tests/test_waveform_next_gen.py index 40f658dc..4a1da243 100644 --- a/tests/unit_tests/test_waveform_next_gen.py +++ b/tests/unit_tests/test_waveform_next_gen.py @@ -797,7 +797,7 @@ def test_show_curve_settings_popup(qtbot, mocked_client): """ wf = create_widget(qtbot, Waveform, client=mocked_client) - curve_action = wf.toolbar.widgets["curve"].action + curve_action = wf.toolbar.components.get_action("curve").action assert not curve_action.isChecked(), "Should start unchecked" wf.show_curve_settings_popup() @@ -807,8 +807,9 @@ def test_show_curve_settings_popup(qtbot, mocked_client): assert curve_action.isChecked() # add a new row to the curve tree - wf.curve_settings_dialog.widget.curve_manager.toolbar.widgets["add"].action.trigger() - wf.curve_settings_dialog.widget.curve_manager.toolbar.widgets["add"].action.trigger() + add_action = wf.curve_settings_dialog.widget.curve_manager.toolbar.components.get_action("add") + add_action.action.trigger() + add_action.action.trigger() qtbot.wait(100) # Check that the new row is added assert wf.curve_settings_dialog.widget.curve_manager.tree.model().rowCount() == 2 @@ -824,9 +825,9 @@ def test_show_dap_summary_popup(qtbot, mocked_client): """ wf = create_widget(qtbot, Waveform, client=mocked_client, popups=True) - assert "fit_params" in wf.toolbar.widgets + assert wf.toolbar.components.exists("fit_params") - fit_action = wf.toolbar.widgets["fit_params"].action + fit_action = wf.toolbar.components.get_action("fit_params").action assert fit_action.isChecked() is False wf.show_dap_summary_popup()