0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

feat(modular_toolbar): context menu and action bundles

This commit is contained in:
2024-12-14 13:49:04 +01:00
parent 111dcef35a
commit 001e6fc807
2 changed files with 263 additions and 14 deletions

View File

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

View File

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