0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 11:41:49 +02:00

fix(toolbar): adjusted to future plot base

This commit is contained in:
2025-01-10 16:21:25 +01:00
parent 001e6fc807
commit 508abfa8a5
2 changed files with 279 additions and 123 deletions

View File

@ -5,7 +5,7 @@ import os
import sys
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import List, Literal, Tuple
from typing import Dict, List, Literal, Tuple
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt
@ -95,6 +95,7 @@ class MaterialIconAction(ToolBarAction):
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__(
@ -104,27 +105,42 @@ class MaterialIconAction(ToolBarAction):
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
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = self.get_icon()
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
def get_icon(self):
icon = material_icon(
# Generate the icon
self.icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
return icon
# Immediately create an QAction with the given parent
self.action = QAction(self.icon, 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):
@ -258,18 +274,53 @@ class ExpandableMenuAction(ToolBarAction):
toolbar.addWidget(button)
class Bundle:
class ToolbarBundle:
"""
Represents a bundle of toolbar actions, keyed by action_id.
Allows direct dictionary-like access: self.actions["some_id"] -> ToolBarAction object.
"""
Represents a bundle of toolbar actions.
def __init__(self, bundle_id: str = None, actions=None):
"""
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.
actions: Either None or a list of (action_id, ToolBarAction) tuples.
"""
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)
self._actions: dict[str, ToolBarAction] = {}
# If you passed in a list of tuples, load them into the dictionary
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):
@ -407,29 +458,30 @@ class ModularToolBar(QToolBar):
action.action.setVisible(True)
self.update_separators() # Update separators after showing the action
def add_bundle(self, bundle: Bundle, target_widget: QWidget):
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
"""
Adds a bundle of actions to the toolbar, separated by a separator.
Args:
bundle (Bundle): The bundle to add.
bundle (ToolbarBundle): 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.")
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
# Add a separator before the bundle
separator = SeparatorAction()
separator.add_to_toolbar(self, target_widget)
# Add a separator before the bundle (but not to first one)
if self.toolbar_items:
sep = SeparatorAction()
sep.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
for action_id, action_obj in bundle.actions.items():
action_obj.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action_obj
# Register the bundle
self.bundles[bundle.bundle_id] = [action_id for action_id, _ in bundle.actions]
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
self.toolbar_items.append(("bundle", bundle.bundle_id))
self.update_separators() # Update separators after adding the bundle
@ -448,15 +500,58 @@ class ModularToolBar(QToolBar):
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(
self.handle_bundle_context_menu(menu, identifier)
elif item_type == "action":
self.handle_action_context_menu(menu, identifier)
# Connect the triggered signal after all actions are added
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds a set of bundle actions to the context menu.
Args:
menu (QMenu): The context menu to which the actions are added.
bundle_id (str): The identifier for the bundle.
"""
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 isinstance(qaction, QAction):
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)
@ -468,27 +563,6 @@ class ModularToolBar(QToolBar):
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."""
@ -504,13 +578,13 @@ class ModularToolBar(QToolBar):
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
if action_id not in self.widgets:
return
tool_action = self.widgets[action_id]
if hasattr(tool_action, "action") and isinstance(tool_action.action, QAction):
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
@ -519,7 +593,8 @@ class ModularToolBar(QToolBar):
toolbar_actions = self.actions()
for i, action in enumerate(toolbar_actions):
if action.isSeparator():
if not action.isSeparator():
continue
# Find the previous visible action
prev_visible = None
for j in range(i - 1, -1, -1):
@ -547,22 +622,26 @@ class ModularToolBar(QToolBar):
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)
# Initialize the ModularToolBar
# Create a modular toolbar
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
# Example: Add a single bundle
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=True, parent=self
)
profile_action = MaterialIconAction(icon_name="person", tooltip="Profile", checkable=True)
# Create the first Bundle with these actions
main_actions_bundle = Bundle(
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),
@ -570,26 +649,20 @@ class MainWindow(QMainWindow): # pragma: no cover
("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(
# Another bundle
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=True, parent=self
)
help_action = MaterialIconAction(
icon_name="help", tooltip="Help", checkable=True, parent=self
)
second_bundle = ToolbarBundle(
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)
self.toolbar.add_bundle(second_bundle, target_widget=self)
if __name__ == "__main__": # pragma: no cover

View File

@ -1,19 +1,21 @@
from typing import Literal
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget
from qtpy.QtCore import QPoint, Qt
from qtpy.QtGui import QContextMenuEvent
from qtpy.QtWidgets import QComboBox, QLabel, QMenu, QToolButton, QWidget
from bec_widgets.qt_utils.toolbar import (
Bundle,
DeviceSelectionAction,
ExpandableMenuAction,
IconAction,
MaterialIconAction,
ModularToolBar,
SeparatorAction,
ToolbarBundle,
WidgetAction,
)
from tests.unit_tests.conftest import create_widget
@pytest.fixture
@ -281,8 +283,9 @@ def test_show_action_nonexistent(toolbar_fixture):
def test_add_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action):
"""Test adding a bundle of actions to the toolbar."""
toolbar = toolbar_fixture
bundle = Bundle(
bundle = ToolbarBundle(
bundle_id="test_bundle",
actions=[
("icon_action_in_bundle", icon_action),
@ -298,12 +301,14 @@ def test_add_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_ac
def test_invalid_orientation(dummy_widget):
"""Test that an invalid orientation raises a ValueError."""
toolbar = ModularToolBar(target_widget=dummy_widget, orientation="horizontal")
with pytest.raises(ValueError):
toolbar.set_orientation("diagonal")
def test_widgetaction_calculate_minimum_width(qtbot):
def test_widget_action_calculate_minimum_width(qtbot):
"""Test calculate_minimum_width with various combo box items."""
combo = QComboBox()
combo.addItems(["Short", "Longer Item", "The Longest Item In Combo"])
widget_action = WidgetAction(label="Test", widget=combo)
@ -311,3 +316,81 @@ def test_widgetaction_calculate_minimum_width(qtbot):
assert width > 0
# Width should be large enough to accommodate the longest item plus additional space
assert width > 100
# FIXME test is stucking CI, works locally
# def test_context_menu_contains_added_actions(
# qtbot, icon_action, material_icon_action, dummy_widget
# ):
# """
# Test that the toolbar's context menu lists all added toolbar actions.
# """
# toolbar = create_widget(
# qtbot, widget=ModularToolBar, target_widget=dummy_widget, orientation="horizontal"
# )
#
# # Add two different actions
# toolbar.add_action("icon_action", icon_action, dummy_widget)
# toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
#
# # Manually trigger the context menu event
# event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
# toolbar.contextMenuEvent(event)
#
# # The QMenu is executed in contextMenuEvent, so we can fetch all possible actions
# # from the displayed menu by searching for QMenu in the immediate children of the toolbar.
# menus = toolbar.findChildren(QMenu)
# assert len(menus) > 0
# menu = menus[-1] # The most recently created menu
#
# menu_action_texts = [action.text() for action in menu.actions()]
# # Check if the menu contains entries for both added 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
# )
# menu.actions()[0].trigger() # Trigger the first action to close the menu
# toolbar.close()
# FIXME test is stucking CI, works locally
# def test_context_menu_toggle_action_visibility(qtbot, icon_action, dummy_widget):
# """
# Test that toggling action visibility works correctly through the toolbar's context menu.
# """
# toolbar = create_widget(
# qtbot, widget=ModularToolBar, target_widget=dummy_widget, orientation="horizontal"
# )
# # Add an action
# toolbar.add_action("icon_action", icon_action, dummy_widget)
# assert icon_action.action.isVisible()
#
# # Manually trigger the context menu event
# event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
# toolbar.contextMenuEvent(event)
#
# # Grab the menu that was created
# menus = toolbar.findChildren(QMenu)
# assert len(menus) > 0
# menu = menus[-1]
#
# # Locate the QAction in the menu
# matching_actions = [m for m in menu.actions() if m.text() == icon_action.tooltip]
# assert len(matching_actions) == 1
# action_in_menu = matching_actions[0]
#
# # Toggle it off (uncheck)
# 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()
#
# # 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()
#
# menu.actions()[0].trigger() # Trigger the first action to close the menu
# toolbar.close()