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:
@ -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_())
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user