diff --git a/bec_widgets/qt_utils/toolbar.py b/bec_widgets/qt_utils/toolbar.py index 82cb074a..c5c23df6 100644 --- a/bec_widgets/qt_utils/toolbar.py +++ b/bec_widgets/qt_utils/toolbar.py @@ -2,17 +2,20 @@ from __future__ import annotations import os +import sys from abc import ABC, abstractmethod from collections import defaultdict -from typing import Literal +from typing import List, Literal, Tuple from bec_qthemes._icon.material_icons import material_icon from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QAction, QColor, QIcon from qtpy.QtWidgets import ( + QApplication, QComboBox, QHBoxLayout, QLabel, + QMainWindow, QMenu, QSizePolicy, QToolBar, @@ -31,7 +34,7 @@ class ToolBarAction(ABC): Args: icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None. - tooltip (bool, optional): The tooltip for the action. 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. """ @@ -81,15 +84,17 @@ class IconAction(ToolBarAction): toolbar.addAction(self.action) -class MaterialIconAction: +class MaterialIconAction(ToolBarAction): """ Action with a Material icon for the toolbar. Args: - icon_path (str, optional): The name of the Material icon. Defaults to None. - tooltip (bool, optional): The tooltip for the action. Defaults to None. + 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. """ def __init__( @@ -100,10 +105,8 @@ class MaterialIconAction: filled: bool = False, color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, ): + super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) self.icon_name = icon_name - self.tooltip = tooltip - self.checkable = checkable - self.action = None self.filled = filled self.color = color @@ -114,7 +117,6 @@ class MaterialIconAction: toolbar.addAction(self.action) def get_icon(self): - icon = material_icon( self.icon_name, size=(20, 20), @@ -132,7 +134,6 @@ class DeviceSelectionAction(ToolBarAction): Args: label (str): The label for the combobox. device_combobox (DeviceComboBox): The combobox for selecting the device. - """ def __init__(self, label: str, device_combobox): @@ -160,7 +161,6 @@ class WidgetAction(ToolBarAction): 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, parent=None): @@ -219,7 +219,6 @@ class ExpandableMenuAction(ToolBarAction): 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): @@ -259,6 +258,20 @@ class ExpandableMenuAction(ToolBarAction): toolbar.addWidget(button) +class Bundle: + """ + Represents a bundle of toolbar actions. + + Args: + bundle_id (str): Unique identifier for the bundle. + actions (List[Tuple[str, ToolBarAction]]): List of tuples containing action IDs and their corresponding ToolBarAction instances. + """ + + def __init__(self, bundle_id: str, actions: List[Tuple[str, ToolBarAction]]): + self.bundle_id = bundle_id + self.actions = actions # List of tuples (action_id, ToolBarAction) + + class ModularToolBar(QToolBar): """Modular toolbar with optional automatic initialization. @@ -287,10 +300,14 @@ class ModularToolBar(QToolBar): # 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): + def populate_toolbar(self, actions: dict, target_widget: QWidget): """Populates the toolbar with a set of actions. Args: @@ -298,9 +315,12 @@ class ModularToolBar(QToolBar): 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)"): """ @@ -345,7 +365,7 @@ class ModularToolBar(QToolBar): def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget): """ - Adds a new action to the toolbar dynamically. + Adds a new standalone action to the toolbar dynamically. Args: action_id (str): Unique identifier for the action. @@ -356,6 +376,8 @@ class ModularToolBar(QToolBar): 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() # Update separators after adding the action def hide_action(self, action_id: str): """ @@ -369,6 +391,7 @@ class ModularToolBar(QToolBar): action = self.widgets[action_id] if hasattr(action, "action") and isinstance(action.action, QAction): action.action.setVisible(False) + self.update_separators() # Update separators after hiding the action def show_action(self, action_id: str): """ @@ -382,3 +405,195 @@ class ModularToolBar(QToolBar): action = self.widgets[action_id] if hasattr(action, "action") and isinstance(action.action, QAction): action.action.setVisible(True) + self.update_separators() # Update separators after showing the action + + def add_bundle(self, bundle: Bundle, target_widget: QWidget): + """ + Adds a bundle of actions to the toolbar, separated by a separator. + + Args: + bundle (Bundle): The bundle to add. + target_widget (QWidget): The target widget for the actions. + """ + if bundle.bundle_id in self.bundles: + raise ValueError(f"Bundle with ID '{bundle.bundle_id}' already exists.") + + # Add a separator before the bundle + separator = SeparatorAction() + separator.add_to_toolbar(self, target_widget) + self.toolbar_items.append(("separator", None)) + + # Add each action in the bundle + for action_id, action in bundle.actions: + action.add_to_toolbar(self, target_widget) + self.widgets[action_id] = action + + # Register the bundle + self.bundles[bundle.bundle_id] = [action_id for action_id, _ in bundle.actions] + self.toolbar_items.append(("bundle", bundle.bundle_id)) + + self.update_separators() # Update separators after adding the bundle + + def contextMenuEvent(self, event): + """ + Overrides the context menu event to show a list of toolbar actions with checkboxes and icons, including separators. + + Args: + event(QContextMenuEvent): The context menu event. + """ + menu = QMenu(self) + + # Iterate through the toolbar items in order + for item_type, identifier in self.toolbar_items: + if item_type == "separator": + menu.addSeparator() + elif item_type == "bundle": + # Get actions in the bundle + action_ids = self.bundles.get(identifier, []) + for action_id in action_ids: + toolbar_action = self.widgets.get(action_id) + if isinstance(toolbar_action, ToolBarAction) and hasattr( + toolbar_action, "action" + ): + qaction = toolbar_action.action + if isinstance(qaction, QAction): + 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) + elif item_type == "action": + # Standalone action + toolbar_action = self.widgets.get(identifier) + if isinstance(toolbar_action, ToolBarAction) and hasattr(toolbar_action, "action"): + qaction = toolbar_action.action + if isinstance(qaction, QAction): + display_name = qaction.text() or toolbar_action.tooltip or identifier + menu_action = QAction(display_name, self) + menu_action.setCheckable(True) + menu_action.setChecked(qaction.isVisible()) + menu_action.setData(identifier) # 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) + + # Connect the triggered signal after all actions are added + menu.triggered.connect(self.handle_menu_triggered) + menu.exec_(event.globalPos()) + + def handle_menu_triggered(self, action): + """Handles the toggling of toolbar actions from the context menu.""" + 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 on the toolbar. + + Args: + action_id(str): Unique identifier for the action to toggle. + visible(bool): Whether the action should be visible. + """ + try: + action = self.widgets[action_id] + if hasattr(action, "action") and isinstance(action.action, QAction): + action.action.setVisible(visible) + self.update_separators() # Update separators after toggling visibility + except KeyError: + pass + + def update_separators(self): + """ + Hide separators that are adjacent to another separator or have no actions next to them. + """ + toolbar_actions = self.actions() + + for i, action in enumerate(toolbar_actions): + if action.isSeparator(): + # Find the previous visible action + prev_visible = None + for j in range(i - 1, -1, -1): + if toolbar_actions[j].isVisible(): + prev_visible = toolbar_actions[j] + break + + # Find the next visible action + next_visible = None + for j in range(i + 1, len(toolbar_actions)): + if toolbar_actions[j].isVisible(): + next_visible = toolbar_actions[j] + break + + # Determine if the separator should be hidden + # Hide if both previous and next visible actions are separators or non-existent + 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) + + +class MainWindow(QMainWindow): # pragma: no cover + def __init__(self): + super().__init__() + self.central_widget = QWidget() + self.setCentralWidget(self.central_widget) + + # Initialize the ModularToolBar + self.toolbar = ModularToolBar(parent=self, target_widget=self) + self.addToolBar(self.toolbar) + + # Define individual MaterialIconActions for the first bundle + home_action = MaterialIconAction(icon_name="home", tooltip="Home", checkable=True) + settings_action = MaterialIconAction( + icon_name="settings", tooltip="Settings", checkable=True + ) + profile_action = MaterialIconAction(icon_name="person", tooltip="Profile", checkable=True) + + # Create the first Bundle with these actions + main_actions_bundle = Bundle( + bundle_id="main_actions", + actions=[ + ("home_action", home_action), + ("settings_action", settings_action), + ("profile_action", profile_action), + ], + ) + + # Add the first bundle to the toolbar + self.toolbar.add_bundle(main_actions_bundle, target_widget=self) + + # Define individual MaterialIconActions for the second bundle + search_action = MaterialIconAction(icon_name="search", tooltip="Search", checkable=True) + help_action = MaterialIconAction(icon_name="help", tooltip="Help", checkable=True) + + # Create the second Bundle with these actions + secondary_actions_bundle = Bundle( + bundle_id="secondary_actions", + actions=[("search_action", search_action), ("help_action", help_action)], + ) + + # Add the second bundle to the toolbar + self.toolbar.add_bundle(secondary_actions_bundle, target_widget=self) + + # Define a standalone action + info_action = MaterialIconAction(icon_name="info", tooltip="Info", checkable=True) + self.toolbar.add_action("info_action", info_action, target_widget=self) + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + main_window = MainWindow() + main_window.show() + sys.exit(app.exec_()) diff --git a/tests/unit_tests/test_modular_toolbar.py b/tests/unit_tests/test_modular_toolbar.py index 5d9b01c2..45e6b643 100644 --- a/tests/unit_tests/test_modular_toolbar.py +++ b/tests/unit_tests/test_modular_toolbar.py @@ -5,6 +5,7 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget from bec_widgets.qt_utils.toolbar import ( + Bundle, DeviceSelectionAction, ExpandableMenuAction, IconAction, @@ -277,3 +278,36 @@ def test_show_action_nonexistent(toolbar_fixture): with pytest.raises(ValueError) as excinfo: toolbar.show_action("nonexistent_action") assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value) + + +def test_add_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action): + toolbar = toolbar_fixture + bundle = Bundle( + 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() + assert material_icon_action.action in toolbar.actions() + + +def test_invalid_orientation(dummy_widget): + toolbar = ModularToolBar(target_widget=dummy_widget, orientation="horizontal") + with pytest.raises(ValueError): + toolbar.set_orientation("diagonal") + + +def test_widgetaction_calculate_minimum_width(qtbot): + combo = QComboBox() + combo.addItems(["Short", "Longer Item", "The Longest Item In Combo"]) + widget_action = WidgetAction(label="Test", widget=combo) + width = widget_action.calculate_minimum_width(combo) + assert width > 0 + # Width should be large enough to accommodate the longest item plus additional space + assert width > 100