mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-05 00:12:49 +01:00
585 lines
22 KiB
Python
585 lines
22 KiB
Python
# pylint: disable=no-name-in-module
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from collections import defaultdict
|
|
from typing import DefaultDict, Literal
|
|
|
|
from bec_lib.logger import bec_logger
|
|
from qtpy.QtCore import QSize, Qt, QTimer
|
|
from qtpy.QtGui import QAction, QColor
|
|
from qtpy.QtWidgets import (
|
|
QApplication,
|
|
QComboBox,
|
|
QLabel,
|
|
QMainWindow,
|
|
QMenu,
|
|
QToolBar,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from bec_widgets.utils.colors import apply_theme, get_theme_name
|
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction, WidgetAction
|
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
|
from bec_widgets.utils.toolbars.connections import BundleConnection
|
|
|
|
logger = bec_logger.logger
|
|
|
|
# Ensure that icons are shown in menus (especially on macOS)
|
|
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
|
|
|
|
|
|
class ModularToolBar(QToolBar):
|
|
"""Modular toolbar with optional automatic initialization.
|
|
|
|
Args:
|
|
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
|
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
|
|
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
|
|
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
|
|
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
orientation: Literal["horizontal", "vertical"] = "horizontal",
|
|
background_color: str = "rgba(0, 0, 0, 0)",
|
|
):
|
|
super().__init__(parent=parent)
|
|
|
|
self.background_color = background_color
|
|
self.set_background_color(self.background_color)
|
|
|
|
# Set the initial orientation
|
|
self.set_orientation(orientation)
|
|
|
|
self.components = ToolbarComponents(self)
|
|
|
|
# Initialize bundles
|
|
self.bundles: dict[str, ToolbarBundle] = {}
|
|
self.shown_bundles: list[str] = []
|
|
|
|
#########################
|
|
# outdated items... remove
|
|
self.available_widgets: DefaultDict[str, ToolBarAction] = defaultdict()
|
|
########################
|
|
|
|
def new_bundle(self, name: str) -> ToolbarBundle:
|
|
"""
|
|
Creates a new bundle component.
|
|
|
|
Args:
|
|
name (str): Unique identifier for the bundle.
|
|
|
|
Returns:
|
|
ToolbarBundle: The new bundle component.
|
|
"""
|
|
if name in self.bundles:
|
|
raise ValueError(f"Bundle with name '{name}' already exists.")
|
|
bundle = ToolbarBundle(name=name, components=self.components)
|
|
self.bundles[name] = bundle
|
|
return bundle
|
|
|
|
def add_bundle(self, bundle: ToolbarBundle):
|
|
"""
|
|
Adds a bundle component to the toolbar.
|
|
|
|
Args:
|
|
bundle (ToolbarBundle): The bundle component to add.
|
|
"""
|
|
if bundle.name in self.bundles:
|
|
raise ValueError(f"Bundle with name '{bundle.name}' already exists.")
|
|
self.bundles[bundle.name] = bundle
|
|
if not bundle.bundle_actions:
|
|
logger.warning(f"Bundle '{bundle.name}' has no actions.")
|
|
|
|
def remove_bundle(self, name: str):
|
|
"""
|
|
Removes a bundle component by name.
|
|
|
|
Args:
|
|
name (str): The name of the bundle to remove.
|
|
"""
|
|
if name not in self.bundles:
|
|
raise KeyError(f"Bundle with name '{name}' does not exist.")
|
|
del self.bundles[name]
|
|
if name in self.shown_bundles:
|
|
self.shown_bundles.remove(name)
|
|
logger.info(f"Bundle '{name}' removed from the toolbar.")
|
|
|
|
def get_bundle(self, name: str) -> ToolbarBundle:
|
|
"""
|
|
Retrieves a bundle component by name.
|
|
|
|
Args:
|
|
name (str): The name of the bundle to retrieve.
|
|
|
|
Returns:
|
|
ToolbarBundle: The bundle component.
|
|
"""
|
|
if name not in self.bundles:
|
|
raise KeyError(
|
|
f"Bundle with name '{name}' does not exist. Available bundles: {list(self.bundles.keys())}"
|
|
)
|
|
return self.bundles[name]
|
|
|
|
def show_bundles(self, bundle_names: list[str]):
|
|
"""
|
|
Sets the bundles to be shown for the toolbar.
|
|
|
|
Args:
|
|
bundle_names (list[str]): A list of bundle names to show. If a bundle is not in this list, its actions will be hidden.
|
|
"""
|
|
self.clear()
|
|
for requested_bundle in bundle_names:
|
|
bundle = self.get_bundle(requested_bundle)
|
|
for bundle_action in bundle.bundle_actions.values():
|
|
action = bundle_action()
|
|
if action is None:
|
|
logger.warning(
|
|
f"Action for bundle '{requested_bundle}' has been deleted. Skipping."
|
|
)
|
|
continue
|
|
action.add_to_toolbar(self, self.parent())
|
|
separator = self.components.get_action_reference("separator")()
|
|
if separator is not None:
|
|
separator.add_to_toolbar(self, self.parent())
|
|
self.update_separators() # Ensure separators are updated after showing bundles
|
|
self.shown_bundles = bundle_names
|
|
|
|
def add_action(self, action_name: str, action: ToolBarAction):
|
|
"""
|
|
Adds a single action to the toolbar. It will create a new bundle
|
|
with the same name as the action.
|
|
|
|
Args:
|
|
action_name (str): Unique identifier for the action.
|
|
action (ToolBarAction): The action to add.
|
|
"""
|
|
self.components.add_safe(action_name, action)
|
|
bundle = ToolbarBundle(name=action_name, components=self.components)
|
|
bundle.add_action(action_name)
|
|
self.add_bundle(bundle)
|
|
|
|
def hide_action(self, action_name: str):
|
|
"""
|
|
Hides a specific action in the toolbar.
|
|
|
|
Args:
|
|
action_name (str): Unique identifier for the action to hide.
|
|
"""
|
|
action = self.components.get_action(action_name)
|
|
if hasattr(action, "action") and action.action is not None:
|
|
action.action.setVisible(False)
|
|
self.update_separators()
|
|
|
|
def show_action(self, action_name: str):
|
|
"""
|
|
Shows a specific action in the toolbar.
|
|
|
|
Args:
|
|
action_name (str): Unique identifier for the action to show.
|
|
"""
|
|
action = self.components.get_action(action_name)
|
|
if hasattr(action, "action") and action.action is not None:
|
|
action.action.setVisible(True)
|
|
self.update_separators()
|
|
|
|
@property
|
|
def toolbar_actions(self) -> list[ToolBarAction]:
|
|
"""
|
|
Returns a list of all actions currently in the toolbar.
|
|
|
|
Returns:
|
|
list[ToolBarAction]: List of actions in the toolbar.
|
|
"""
|
|
actions = []
|
|
for bundle in self.shown_bundles:
|
|
if bundle not in self.bundles:
|
|
continue
|
|
for action in self.bundles[bundle].bundle_actions.values():
|
|
action_instance = action()
|
|
if action_instance is not None:
|
|
actions.append(action_instance)
|
|
return actions
|
|
|
|
def refresh(self):
|
|
"""Refreshes the toolbar by clearing and re-populating it."""
|
|
self.clear()
|
|
self.show_bundles(self.shown_bundles)
|
|
|
|
def connect_bundle(self, connection_name: str, connector: BundleConnection):
|
|
"""
|
|
Connects a bundle to a target widget or application.
|
|
|
|
Args:
|
|
bundle_name (str): The name of the bundle to connect.
|
|
connector (BundleConnection): The connector instance that implements the connection logic.
|
|
"""
|
|
bundle_name = connector.bundle_name
|
|
if bundle_name not in self.bundles:
|
|
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
|
|
connector.connect()
|
|
self.bundles[bundle_name].add_connection(connection_name, connector)
|
|
|
|
def disconnect_bundle(self, bundle_name: str, connection_name: str | None = None):
|
|
"""
|
|
Disconnects a bundle connection.
|
|
|
|
Args:
|
|
bundle_name (str): The name of the bundle to disconnect.
|
|
connection_name (str): The name of the connection to disconnect. If None, disconnects all connections for the bundle.
|
|
"""
|
|
if bundle_name not in self.bundles:
|
|
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
|
|
bundle = self.bundles[bundle_name]
|
|
if connection_name is None:
|
|
# Disconnect all connections in the bundle
|
|
bundle.disconnect()
|
|
else:
|
|
bundle.remove_connection(name=connection_name)
|
|
|
|
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
|
|
"""
|
|
Sets the background color and other appearance settings.
|
|
|
|
Args:
|
|
color (str): The background color of the toolbar.
|
|
"""
|
|
self.setIconSize(QSize(20, 20))
|
|
self.setMovable(False)
|
|
self.setFloatable(False)
|
|
self.setContentsMargins(0, 0, 0, 0)
|
|
self.background_color = color
|
|
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
|
|
|
|
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
|
|
"""Sets the orientation of the toolbar.
|
|
|
|
Args:
|
|
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
|
|
"""
|
|
if orientation == "horizontal":
|
|
self.setOrientation(Qt.Horizontal)
|
|
elif orientation == "vertical":
|
|
self.setOrientation(Qt.Vertical)
|
|
else:
|
|
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
|
|
|
|
def update_material_icon_colors(self, new_color: str | tuple | QColor):
|
|
"""
|
|
Updates the color of all MaterialIconAction icons.
|
|
|
|
Args:
|
|
new_color (str | tuple | QColor): The new color.
|
|
"""
|
|
for action in self.available_widgets.values():
|
|
if isinstance(action, MaterialIconAction):
|
|
action.color = new_color
|
|
updated_icon = action.get_icon()
|
|
action.action.setIcon(updated_icon)
|
|
|
|
def contextMenuEvent(self, event):
|
|
"""
|
|
Overrides the context menu event to show toolbar actions with checkboxes and icons.
|
|
|
|
Args:
|
|
event (QContextMenuEvent): The context menu event.
|
|
"""
|
|
menu = QMenu(self)
|
|
theme = get_theme_name()
|
|
if theme == "dark":
|
|
menu.setStyleSheet(
|
|
"""
|
|
QMenu {
|
|
background-color: rgba(50, 50, 50, 0.9);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
}
|
|
QMenu::item:selected {
|
|
background-color: rgba(0, 0, 255, 0.2);
|
|
}
|
|
"""
|
|
)
|
|
else:
|
|
# Light theme styling
|
|
menu.setStyleSheet(
|
|
"""
|
|
QMenu {
|
|
background-color: rgba(255, 255, 255, 0.9);
|
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
}
|
|
QMenu::item:selected {
|
|
background-color: rgba(0, 0, 255, 0.2);
|
|
}
|
|
"""
|
|
)
|
|
for ii, bundle in enumerate(self.shown_bundles):
|
|
self.handle_bundle_context_menu(menu, bundle)
|
|
if ii < len(self.shown_bundles) - 1:
|
|
menu.addSeparator()
|
|
menu.triggered.connect(self.handle_menu_triggered)
|
|
menu.exec_(event.globalPos())
|
|
|
|
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
|
|
"""
|
|
Adds bundle actions to the context menu.
|
|
|
|
Args:
|
|
menu (QMenu): The context menu.
|
|
bundle_id (str): The bundle identifier.
|
|
"""
|
|
bundle = self.bundles.get(bundle_id)
|
|
if not bundle:
|
|
return
|
|
for act_id in bundle.bundle_actions:
|
|
toolbar_action = self.components.get_action(act_id)
|
|
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
|
|
toolbar_action, "action"
|
|
):
|
|
continue
|
|
qaction = toolbar_action.action
|
|
if not isinstance(qaction, QAction):
|
|
continue
|
|
self._add_qaction_to_menu(menu, qaction, toolbar_action, act_id)
|
|
|
|
def _add_qaction_to_menu(
|
|
self, menu: QMenu, qaction: QAction, toolbar_action: ToolBarAction, act_id: str
|
|
):
|
|
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.available_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)
|
|
menu_action.setChecked(qaction.isVisible())
|
|
menu_action.setIconVisibleInMenu(True)
|
|
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)
|
|
|
|
def handle_menu_triggered(self, action):
|
|
"""
|
|
Handles the triggered signal from the context menu.
|
|
|
|
Args:
|
|
action: Action triggered.
|
|
"""
|
|
action_id = action.data()
|
|
if action_id:
|
|
self.toggle_action_visibility(action_id)
|
|
|
|
def toggle_action_visibility(self, action_id: str, visible: bool | None = None):
|
|
"""
|
|
Toggles the visibility of a specific action.
|
|
|
|
Args:
|
|
action_id (str): Unique identifier.
|
|
visible (bool): Whether the action should be visible. If None, toggles the current visibility.
|
|
"""
|
|
if not self.components.exists(action_id):
|
|
return
|
|
tool_action = self.components.get_action(action_id)
|
|
if hasattr(tool_action, "action") and tool_action.action is not None:
|
|
if visible is None:
|
|
visible = not tool_action.action.isVisible()
|
|
tool_action.action.setVisible(visible)
|
|
self.update_separators()
|
|
|
|
def update_separators(self):
|
|
"""
|
|
Hide separators that are adjacent to another separator, splitters, or have no non-separator actions between them.
|
|
Splitters (ResizableSpacer) already provide visual separation, so we don't need separators next to them.
|
|
"""
|
|
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
|
|
|
|
toolbar_actions = self.actions()
|
|
|
|
# Helper function to check if a widget is a splitter
|
|
def is_splitter_widget(action):
|
|
widget = self.widgetForAction(action)
|
|
return widget is not None and isinstance(widget, ResizableSpacer)
|
|
|
|
# First pass: set visibility based on surrounding non-separator actions.
|
|
for i, action in enumerate(toolbar_actions):
|
|
if not action.isSeparator():
|
|
continue
|
|
prev_visible = None
|
|
for j in range(i - 1, -1, -1):
|
|
if toolbar_actions[j].isVisible():
|
|
prev_visible = toolbar_actions[j]
|
|
break
|
|
next_visible = None
|
|
for j in range(i + 1, len(toolbar_actions)):
|
|
if toolbar_actions[j].isVisible():
|
|
next_visible = toolbar_actions[j]
|
|
break
|
|
|
|
# Hide separator if adjacent to another separator, splitter, or at edges
|
|
if (
|
|
prev_visible is None
|
|
or prev_visible.isSeparator()
|
|
or is_splitter_widget(prev_visible)
|
|
) and (
|
|
next_visible is None
|
|
or next_visible.isSeparator()
|
|
or is_splitter_widget(next_visible)
|
|
):
|
|
action.setVisible(False)
|
|
else:
|
|
action.setVisible(True)
|
|
# Second pass: ensure no two visible separators are adjacent, and no separators next to splitters.
|
|
prev = None
|
|
for action in toolbar_actions:
|
|
if action.isVisible():
|
|
if action.isSeparator():
|
|
# Hide separator if previous visible item was a separator or splitter
|
|
if prev and (prev.isSeparator() or is_splitter_widget(prev)):
|
|
action.setVisible(False)
|
|
else:
|
|
prev = action
|
|
else:
|
|
prev = action
|
|
|
|
if not toolbar_actions:
|
|
return
|
|
|
|
# Make sure the first visible action is not a separator
|
|
for i, action in enumerate(toolbar_actions):
|
|
if action.isVisible():
|
|
if action.isSeparator():
|
|
action.setVisible(False)
|
|
break
|
|
|
|
# Make sure the last visible action is not a separator
|
|
for i, action in enumerate(reversed(toolbar_actions)):
|
|
if action.isVisible():
|
|
if action.isSeparator():
|
|
action.setVisible(False)
|
|
break
|
|
|
|
def cleanup(self):
|
|
"""
|
|
Cleans up the toolbar by removing all actions and bundles.
|
|
"""
|
|
# First, disconnect all bundles
|
|
for bundle_name in list(self.bundles.keys()):
|
|
self.disconnect_bundle(bundle_name)
|
|
|
|
# Clear all components
|
|
self.components.cleanup()
|
|
self.bundles.clear()
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
|
|
from bec_widgets.widgets.plots.toolbar_components.plot_export import plot_export_bundle
|
|
|
|
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)
|
|
self.test_label = QLabel(text="Drag the splitter (⋮) to resize!")
|
|
self.central_widget.layout = QVBoxLayout(self.central_widget)
|
|
self.central_widget.layout.addWidget(self.test_label)
|
|
|
|
self.toolbar = ModularToolBar(parent=self)
|
|
self.addToolBar(self.toolbar)
|
|
|
|
# Example: Bare combobox (no container). Give it a stable starting width
|
|
self.example_combo = QComboBox(parent=self)
|
|
self.example_combo.addItems(["device_1", "device_2", "device_3"])
|
|
|
|
self.toolbar.components.add_safe(
|
|
"example_combo", WidgetAction(widget=self.example_combo)
|
|
)
|
|
|
|
# Create a bundle with the combobox and a splitter
|
|
self.bundle_combo_splitter = ToolbarBundle("example_combo", self.toolbar.components)
|
|
self.bundle_combo_splitter.add_action("example_combo")
|
|
# Add splitter; target the bare widget
|
|
self.bundle_combo_splitter.add_splitter(
|
|
name="splitter_example", target_widget=self.example_combo, min_width=100
|
|
)
|
|
|
|
# Add other bundles
|
|
self.toolbar.add_bundle(self.bundle_combo_splitter)
|
|
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
|
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
|
self.toolbar.connect_bundle(
|
|
"base", PerformanceConnection(self.toolbar.components, self)
|
|
)
|
|
self.toolbar.components.add_safe(
|
|
"text",
|
|
MaterialIconAction(
|
|
"text_fields",
|
|
tooltip="Test Text Action",
|
|
checkable=True,
|
|
label_text="text",
|
|
text_position="under",
|
|
),
|
|
)
|
|
|
|
# Show bundles - notice how performance and plot_export appear compactly after splitter!
|
|
self.toolbar.show_bundles(["example_combo", "performance", "plot_export"])
|
|
self.toolbar.get_bundle("performance").add_action("save")
|
|
self.toolbar.get_bundle("performance").add_action("text")
|
|
self.toolbar.refresh()
|
|
|
|
# Timer to disable and enable text button each 2s
|
|
self.timer = QTimer()
|
|
self.timer.timeout.connect(self.toggle_text_action)
|
|
self.timer.start(2000)
|
|
|
|
def toggle_text_action(self):
|
|
text_action = self.toolbar.components.get_action("text")
|
|
if text_action.action.isEnabled():
|
|
text_action.action.setEnabled(False)
|
|
else:
|
|
text_action.action.setEnabled(True)
|
|
|
|
def enable_fps_monitor(self, enabled: bool):
|
|
"""
|
|
Example method to enable or disable FPS monitoring.
|
|
This method should be implemented in the target widget.
|
|
"""
|
|
if enabled:
|
|
self.test_label.setText("FPS Monitor Enabled")
|
|
else:
|
|
self.test_label.setText("FPS Monitor Disabled")
|
|
|
|
app = QApplication(sys.argv)
|
|
apply_theme("light")
|
|
main_window = MainWindow()
|
|
main_window.show()
|
|
sys.exit(app.exec_())
|