0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 11:41:49 +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 from __future__ import annotations
import os import os
import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from typing import Literal from typing import List, Literal, Tuple
from bec_qthemes._icon.material_icons import material_icon from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor, QIcon from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication,
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMainWindow,
QMenu, QMenu,
QSizePolicy, QSizePolicy,
QToolBar, QToolBar,
@ -31,7 +34,7 @@ class ToolBarAction(ABC):
Args: Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None. 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. checkable (bool, optional): Whether the action is checkable. Defaults to False.
""" """
@ -81,15 +84,17 @@ class IconAction(ToolBarAction):
toolbar.addAction(self.action) toolbar.addAction(self.action)
class MaterialIconAction: class MaterialIconAction(ToolBarAction):
""" """
Action with a Material icon for the toolbar. Action with a Material icon for the toolbar.
Args: Args:
icon_path (str, optional): The name of the Material icon. Defaults to None. icon_name (str, optional): The name of the Material icon. 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. checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. 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__( def __init__(
@ -100,10 +105,8 @@ class MaterialIconAction:
filled: bool = False, filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, 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.icon_name = icon_name
self.tooltip = tooltip
self.checkable = checkable
self.action = None
self.filled = filled self.filled = filled
self.color = color self.color = color
@ -114,7 +117,6 @@ class MaterialIconAction:
toolbar.addAction(self.action) toolbar.addAction(self.action)
def get_icon(self): def get_icon(self):
icon = material_icon( icon = material_icon(
self.icon_name, self.icon_name,
size=(20, 20), size=(20, 20),
@ -132,7 +134,6 @@ class DeviceSelectionAction(ToolBarAction):
Args: Args:
label (str): The label for the combobox. label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device. device_combobox (DeviceComboBox): The combobox for selecting the device.
""" """
def __init__(self, label: str, device_combobox): def __init__(self, label: str, device_combobox):
@ -160,7 +161,6 @@ class WidgetAction(ToolBarAction):
Args: Args:
label (str|None): The label for the widget. label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar. widget (QWidget): The widget to be added to the toolbar.
""" """
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None): 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. label (str): The label for the menu.
actions (dict): A dictionary of actions to populate 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. icon_path (str, optional): The path to the icon file. Defaults to None.
""" """
def __init__(self, label: str, actions: dict, icon_path: str = None): def __init__(self, label: str, actions: dict, icon_path: str = None):
@ -259,6 +258,20 @@ class ExpandableMenuAction(ToolBarAction):
toolbar.addWidget(button) 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): class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization. """Modular toolbar with optional automatic initialization.
@ -287,10 +300,14 @@ class ModularToolBar(QToolBar):
# Set the initial orientation # Set the initial orientation
self.set_orientation(orientation) self.set_orientation(orientation)
# Initialize bundles
self.bundles = {}
self.toolbar_items = []
if actions is not None and target_widget is not None: if actions is not None and target_widget is not None:
self.populate_toolbar(actions, target_widget) 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. """Populates the toolbar with a set of actions.
Args: Args:
@ -298,9 +315,12 @@ class ModularToolBar(QToolBar):
target_widget (QWidget): The widget that the actions will target. target_widget (QWidget): The widget that the actions will target.
""" """
self.clear() self.clear()
self.toolbar_items.clear() # Reset the order tracking
for action_id, action in actions.items(): for action_id, action in actions.items():
action.add_to_toolbar(self, target_widget) action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action 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)"): 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): 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: Args:
action_id (str): Unique identifier for the action. 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.") raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget) action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action 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): def hide_action(self, action_id: str):
""" """
@ -369,6 +391,7 @@ class ModularToolBar(QToolBar):
action = self.widgets[action_id] action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction): if hasattr(action, "action") and isinstance(action.action, QAction):
action.action.setVisible(False) action.action.setVisible(False)
self.update_separators() # Update separators after hiding the action
def show_action(self, action_id: str): def show_action(self, action_id: str):
""" """
@ -382,3 +405,195 @@ class ModularToolBar(QToolBar):
action = self.widgets[action_id] action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction): if hasattr(action, "action") and isinstance(action.action, QAction):
action.action.setVisible(True) 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 qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget
from bec_widgets.qt_utils.toolbar import ( from bec_widgets.qt_utils.toolbar import (
Bundle,
DeviceSelectionAction, DeviceSelectionAction,
ExpandableMenuAction, ExpandableMenuAction,
IconAction, IconAction,
@ -277,3 +278,36 @@ def test_show_action_nonexistent(toolbar_fixture):
with pytest.raises(ValueError) as excinfo: with pytest.raises(ValueError) as excinfo:
toolbar.show_action("nonexistent_action") toolbar.show_action("nonexistent_action")
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value) 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