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

refactor(toolbar): split toolbar into components, bundles and connections

This commit is contained in:
2025-06-25 10:49:39 +02:00
committed by Jan Wyzula
parent f10140e0f3
commit db720e8fa4
48 changed files with 3415 additions and 2567 deletions

View File

@ -56,4 +56,4 @@ jobs:
- name: Run Pytest
run: |
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests

View File

@ -27,7 +27,7 @@ from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea

View File

@ -16,7 +16,8 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
class SidePanel(QWidget):
@ -61,7 +62,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
self.toolbar = ModularToolBar(parent=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@ -92,7 +93,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@ -288,8 +289,16 @@ class SidePanel(QWidget):
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
if action_id is not None and icon_name is not None and tooltip is not None:
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
action = MaterialIconAction(
icon_name=icon_name, tooltip=tooltip, checkable=True, parent=self
)
self.toolbar.components.add_safe(action_id, action)
bundle = ToolbarBundle(action_id, self.toolbar.components)
bundle.add_action(action_id)
self.toolbar.add_bundle(bundle)
shown_bundles = self.toolbar.shown_bundles
shown_bundles.append(action_id)
self.toolbar.show_bundles(shown_bundles)
def on_action_toggled(checked: bool):
if self.switching_actions:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,524 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Dict, Literal
from bec_lib.device import ReadoutPriority
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QMenu,
QSizePolicy,
QStyledItemDelegate,
QToolBar,
QToolButton,
QWidget,
)
import bec_widgets
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class NoCheckDelegate(QStyledItemDelegate):
"""To reduce space in combo boxes by removing the checkmark."""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
# Remove any check indicator
option.checkState = Qt.Unchecked
class LongPressToolButton(QToolButton):
def __init__(self, *args, long_press_threshold=500, **kwargs):
super().__init__(*args, **kwargs)
self.long_press_threshold = long_press_threshold
self._long_press_timer = QTimer(self)
self._long_press_timer.setSingleShot(True)
self._long_press_timer.timeout.connect(self.handleLongPress)
self._pressed = False
self._longPressed = False
def mousePressEvent(self, event):
self._pressed = True
self._longPressed = False
self._long_press_timer.start(self.long_press_threshold)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
self._pressed = False
if self._longPressed:
self._longPressed = False
self._long_press_timer.stop()
event.accept() # Prevent normal click action after a long press
return
self._long_press_timer.stop()
super().mouseReleaseEvent(event)
def handleLongPress(self):
if self._pressed:
self._longPressed = True
self.showMenu()
class ToolBarAction(ABC):
"""
Abstract base class for toolbar actions.
Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. 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.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
self.icon_path = (
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
)
self.tooltip = tooltip
self.checkable = checkable
self.action = None
@abstractmethod
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""Adds an action or widget to a toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action or widget to.
target (QWidget): The target widget for the action.
"""
def cleanup(self):
"""Cleans up the action, if necessary."""
pass
class SeparatorAction(ToolBarAction):
"""Separator action for the toolbar."""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
toolbar.addSeparator()
class QtIconAction(ToolBarAction):
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar, target):
toolbar.addAction(self.action)
def get_icon(self):
return self.icon
class MaterialIconAction(ToolBarAction):
"""
Action with a Material icon for the toolbar.
Args:
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.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
icon_name: str = None,
tooltip: str = None,
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
# Generate the icon using the material_icon helper
self.icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
if parent is None:
logger.warning(
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
)
self.action = QAction(icon=self.icon, text=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):
"""
Action for selecting a device in a combobox.
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str | None = None, device_combobox=None):
super().__init__()
self.label = label
self.device_combobox = device_combobox
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget(parent=target)
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label = QLabel(text=f"{self.label}", parent=target)
layout.addWidget(label)
if self.device_combobox is not None:
layout.addWidget(self.device_combobox)
toolbar.addWidget(widget)
def set_combobox_style(self, color: str):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class SwitchableToolBarAction(ToolBarAction):
"""
A split toolbar action that combines a main action and a drop-down menu for additional actions.
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
alternative action is selected, it becomes the new default and its callback is immediately executed.
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
Args:
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
checkable (bool, optional): Whether the action is checkable. Defaults to True.
parent (QWidget, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
actions: Dict[str, ToolBarAction],
initial_action: str = None,
tooltip: str = None,
checkable: bool = True,
default_state_checked: bool = False,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.actions = actions
self.current_key = initial_action if initial_action is not None else next(iter(actions))
self.parent = parent
self.checkable = checkable
self.default_state_checked = default_state_checked
self.main_button = None
self.menu_actions: Dict[str, QAction] = {}
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the split action to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action to.
target (QWidget): The target widget for the action.
"""
self.main_button = LongPressToolButton(toolbar)
self.main_button.setPopupMode(QToolButton.MenuButtonPopup)
self.main_button.setCheckable(self.checkable)
default_action = self.actions[self.current_key]
self.main_button.setIcon(default_action.get_icon())
self.main_button.setToolTip(default_action.tooltip)
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
for key, action_obj in self.actions.items():
menu_action = QAction(
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button
)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
menu.addAction(menu_action)
self.main_button.setMenu(menu)
if self.default_state_checked:
self.main_button.setChecked(True)
self.action = toolbar.addWidget(self.main_button)
def _trigger_current_action(self):
"""
Triggers the current action associated with the main button.
"""
action_obj = self.actions[self.current_key]
action_obj.action.trigger()
def set_default_action(self, key: str):
"""
Sets the default action for the split action.
Args:
key(str): The key of the action to set as default.
"""
if self.main_button is None:
return
self.current_key = key
new_action = self.actions[self.current_key]
self.main_button.setIcon(new_action.get_icon())
self.main_button.setToolTip(new_action.tooltip)
# Update check state of menu items
for k, menu_act in self.actions.items():
menu_act.action.setChecked(False)
new_action.action.trigger()
# Active action chosen from menu is always checked, uncheck through main button
if self.checkable:
new_action.action.setChecked(True)
self.main_button.setChecked(True)
def block_all_signals(self, block: bool = True):
"""
Blocks or unblocks all signals for the actions in the toolbar.
Args:
block (bool): Whether to block signals. Defaults to True.
"""
if self.main_button is not None:
self.main_button.blockSignals(block)
for action in self.actions.values():
action.action.blockSignals(block)
@contextmanager
def signal_blocker(self):
"""
Context manager to block signals for all actions in the toolbar.
"""
self.block_all_signals(True)
try:
yield
finally:
self.block_all_signals(False)
def set_state_all(self, state: bool):
"""
Uncheck all actions in the toolbar.
"""
for action in self.actions.values():
action.action.setChecked(state)
if self.main_button is None:
return
self.main_button.setChecked(state)
def get_icon(self) -> QIcon:
return self.actions[self.current_key].get_icon()
class WidgetAction(ToolBarAction):
"""
Action for adding any widget to the toolbar.
Please note that the injected widget must be life-cycled by the parent widget,
i.e., the widget must be properly cleaned up outside of this action. The WidgetAction
will not perform any cleanup on the widget itself, only on the container that holds it.
Args:
label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar.
adjust_size (bool): Whether to adjust the size of the widget based on its contents. Defaults to True.
"""
def __init__(
self,
label: str | None = None,
widget: QWidget = None,
adjust_size: bool = True,
parent=None,
):
super().__init__(icon_path=None, tooltip=label, checkable=False)
self.label = label
self.widget = widget
self.container = None
self.adjust_size = adjust_size
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the widget to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
self.container = QWidget(parent=target)
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label_widget = QLabel(text=f"{self.label}", parent=target)
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox) and self.adjust_size:
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.widget.setSizePolicy(size_policy)
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
layout.addWidget(self.widget)
toolbar.addWidget(self.container)
# Store the container as the action to allow toggling visibility.
self.action = self.container
def cleanup(self):
"""
Cleans up the action by closing and deleting the container widget.
This method will be called automatically when the toolbar is cleaned up.
"""
if self.container is not None:
self.container.close()
self.container.deleteLater()
return super().cleanup()
@staticmethod
def calculate_minimum_width(combo_box: QComboBox) -> int:
font_metrics = combo_box.fontMetrics()
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
return max_width + 60
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.
Args:
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):
super().__init__(icon_path, label)
self.actions = actions
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
if self.icon_path:
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)
button.setPopupMode(QToolButton.InstantPopup)
button.setStyleSheet(
"""
QToolButton {
font-size: 14px;
}
QMenu {
font-size: 14px;
}
"""
)
menu = QMenu(button)
for action_container in self.actions.values():
action: QAction = action_container.action
action.setIconVisibleInMenu(True)
if action_container.icon_path:
icon = QIcon()
icon.addFile(action_container.icon_path, size=QSize(20, 20))
action.setIcon(icon)
elif hasattr(action, "get_icon") and callable(action_container.get_icon):
sub_icon = action_container.get_icon()
if sub_icon and not sub_icon.isNull():
action.setIcon(sub_icon)
action.setCheckable(action_container.checkable)
menu.addAction(action)
button.setMenu(menu)
toolbar.addWidget(button)
class DeviceComboBoxAction(WidgetAction):
"""
Action for a device selection combobox in the toolbar.
Args:
label (str): The label for the combobox.
device_combobox (QComboBox): The combobox for selecting the device.
"""
def __init__(
self,
target_widget: QWidget,
device_filter: list[BECDeviceFilter] | None = None,
readout_priority_filter: (
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
) = None,
tooltip: str | None = None,
add_empty_item: bool = False,
no_check_delegate: bool = False,
):
self.combobox = DeviceComboBox(
parent=target_widget,
device_filter=device_filter,
readout_priority_filter=readout_priority_filter,
)
super().__init__(widget=self.combobox, adjust_size=False)
if add_empty_item:
self.combobox.addItem("", None)
self.combobox.setCurrentText("")
if tooltip is not None:
self.combobox.setToolTip(tooltip)
if no_check_delegate:
self.combobox.setItemDelegate(NoCheckDelegate(self.combobox))
def cleanup(self):
"""
Cleans up the action by closing and deleting the combobox widget.
This method will be called automatically when the toolbar is cleaned up.
"""
if self.combobox is not None:
self.combobox.close()
self.combobox.deleteLater()
return super().cleanup()

View File

@ -0,0 +1,244 @@
from __future__ import annotations
from collections import defaultdict
from typing import TYPE_CHECKING, DefaultDict
from weakref import ReferenceType
import louie
from bec_lib.logger import bec_logger
from pydantic import BaseModel
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
if TYPE_CHECKING:
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
logger = bec_logger.logger
class ActionInfo(BaseModel):
action: ToolBarAction
toolbar_bundle: ToolbarBundle | None = None
model_config = {"arbitrary_types_allowed": True}
class ToolbarComponents:
def __init__(self, toolbar: ModularToolBar):
"""
Initializes the toolbar components.
Args:
toolbar (ModularToolBar): The toolbar to which the components will be added.
"""
self.toolbar = toolbar
self._components: dict[str, ActionInfo] = {}
self.add("separator", SeparatorAction())
def add(self, name: str, component: ToolBarAction):
"""
Adds a component to the toolbar.
Args:
component (ToolBarAction): The component to add.
"""
if name in self._components:
raise ValueError(f"Component with name '{name}' already exists.")
self._components[name] = ActionInfo(action=component, toolbar_bundle=None)
def add_safe(self, name: str, component: ToolBarAction):
"""
Adds a component to the toolbar, ensuring it does not already exist.
Args:
name (str): The name of the component.
component (ToolBarAction): The component to add.
"""
if self.exists(name):
logger.info(f"Component with name '{name}' already exists. Skipping addition.")
return
self.add(name, component)
def exists(self, name: str) -> bool:
"""
Checks if a component exists in the toolbar.
Args:
name (str): The name of the component to check.
Returns:
bool: True if the component exists, False otherwise.
"""
return name in self._components
def get_action_reference(self, name: str) -> ReferenceType[ToolBarAction]:
"""
Retrieves a component by name.
Args:
name (str): The name of the component to retrieve.
"""
if not self.exists(name):
raise KeyError(f"Component with name '{name}' does not exist.")
return louie.saferef.safe_ref(self._components[name].action)
def get_action(self, name: str) -> ToolBarAction:
"""
Retrieves a component by name.
Args:
name (str): The name of the component to retrieve.
Returns:
ToolBarAction: The action associated with the given name.
"""
if not self.exists(name):
raise KeyError(
f"Component with name '{name}' does not exist. The following components are available: {list(self._components.keys())}"
)
return self._components[name].action
def set_bundle(self, name: str, bundle: ToolbarBundle):
"""
Sets the bundle for a component.
Args:
name (str): The name of the component.
bundle (ToolbarBundle): The bundle to set.
"""
if not self.exists(name):
raise KeyError(f"Component with name '{name}' does not exist.")
comp = self._components[name]
if comp.toolbar_bundle is not None:
logger.info(
f"Component '{name}' already has a bundle ({comp.toolbar_bundle.name}). Setting it to {bundle.name}."
)
comp.toolbar_bundle.bundle_actions.pop(name, None)
comp.toolbar_bundle = bundle
def remove_action(self, name: str):
"""
Removes a component from the toolbar.
Args:
name (str): The name of the component to remove.
"""
if not self.exists(name):
raise KeyError(f"Action with ID '{name}' does not exist.")
action_info = self._components.pop(name)
if action_info.toolbar_bundle:
action_info.toolbar_bundle.bundle_actions.pop(name, None)
self.toolbar.refresh()
action_info.toolbar_bundle = None
if hasattr(action_info.action, "cleanup"):
# Call cleanup if the action has a cleanup method
action_info.action.cleanup()
def cleanup(self):
"""
Cleans up the toolbar components by removing all actions and bundles.
"""
for action_info in self._components.values():
if hasattr(action_info.action, "cleanup"):
# Call cleanup if the action has a cleanup method
action_info.action.cleanup()
self._components.clear()
class ToolbarBundle:
def __init__(self, name: str, components: ToolbarComponents):
"""
Initializes a new bundle component.
Args:
bundle_name (str): Unique identifier for the bundle.
"""
self.name = name
self.components = components
self.bundle_actions: DefaultDict[str, ReferenceType[ToolBarAction]] = defaultdict()
self._connections: dict[str, BundleConnection] = {}
def add_action(self, name: str):
"""
Adds an action to the bundle.
Args:
name (str): Unique identifier for the action.
action (ToolBarAction): The action to add.
"""
if name in self.bundle_actions:
raise ValueError(f"Action with name '{name}' already exists in bundle '{self.name}'.")
if not self.components.exists(name):
raise ValueError(
f"Component with name '{name}' does not exist in the toolbar. Please add it first using the `ToolbarComponents.add` method."
)
self.bundle_actions[name] = self.components.get_action_reference(name)
self.components.set_bundle(name, self)
def remove_action(self, name: str):
"""
Removes an action from the bundle.
Args:
name (str): The name of the action to remove.
"""
if name not in self.bundle_actions:
raise KeyError(f"Action with name '{name}' does not exist in bundle '{self.name}'.")
del self.bundle_actions[name]
def add_separator(self):
"""
Adds a separator action to the bundle.
"""
self.add_action("separator")
def add_connection(self, name: str, connection):
"""
Adds a connection to the bundle.
Args:
name (str): Unique identifier for the connection.
connection: The connection to add.
"""
if name in self._connections:
raise ValueError(
f"Connection with name '{name}' already exists in bundle '{self.name}'."
)
self._connections[name] = connection
def remove_connection(self, name: str):
"""
Removes a connection from the bundle.
Args:
name (str): The name of the connection to remove.
"""
if name not in self._connections:
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
self._connections[name].disconnect()
del self._connections[name]
def get_connection(self, name: str):
"""
Retrieves a connection by name.
Args:
name (str): The name of the connection to retrieve.
Returns:
The connection associated with the given name.
"""
if name not in self._connections:
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
return self._connections[name]
def disconnect(self):
"""
Disconnects all connections in the bundle.
"""
for connection in self._connections.values():
connection.disconnect()
self._connections.clear()

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from abc import abstractmethod
from qtpy.QtCore import QObject
class BundleConnection(QObject):
bundle_name: str
@abstractmethod
def connect(self):
"""
Connects the bundle to the target widget or application.
This method should be implemented by subclasses to define how the bundle interacts with the target.
"""
@abstractmethod
def disconnect(self):
"""
Disconnects the bundle from the target widget or application.
This method should be implemented by subclasses to define how to clean up connections.
"""

View File

@ -0,0 +1,58 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.connections import BundleConnection
if TYPE_CHECKING:
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
def performance_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a performance toolbar bundle.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The performance toolbar bundle.
"""
components.add_safe(
"fps_monitor",
MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=components.toolbar
),
)
bundle = ToolbarBundle("performance", components)
bundle.add_action("fps_monitor")
return bundle
class PerformanceConnection(BundleConnection):
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "performance"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "enable_fps_monitor"):
raise AttributeError("Target widget must implement 'enable_fps_monitor'.")
super().__init__()
self._connected = False
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
)

View File

@ -0,0 +1,513 @@
# 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
from qtpy.QtGui import QAction, QColor
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_name, set_theme
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
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 or have no non-separator actions between them.
"""
toolbar_actions = self.actions()
# 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
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)
# Second pass: ensure no two visible separators are adjacent.
prev = None
for action in toolbar_actions:
if action.isVisible() and action.isSeparator():
if prev and prev.isSeparator():
action.setVisible(False)
else:
prev = action
else:
if action.isVisible():
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="This is a test label.")
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)
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.show_bundles(["performance", "plot_export"])
self.toolbar.get_bundle("performance").add_action("save")
self.toolbar.refresh()
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)
set_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@ -15,12 +15,13 @@ from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.toolbar import (
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
ModularToolBar,
SeparatorAction,
WidgetAction,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
@ -104,151 +105,227 @@ class BECDockArea(BECWidget, QWidget):
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.toolbar = ModularToolBar(
parent=self,
actions={
"menu_plots": ExpandableMenuAction(
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
icon_name=MultiWaveform.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
),
"image": MaterialIconAction(
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
),
"motor_map": MaterialIconAction(
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
),
},
),
"separator_0": SeparatorAction(),
"menu_devices": ExpandableMenuAction(
label="Add Device Control ",
actions={
"scan_control": MaterialIconAction(
icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True
),
"positioner_box": MaterialIconAction(
icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True
),
},
),
"separator_1": SeparatorAction(),
"menu_utils": ExpandableMenuAction(
label="Add Utils ",
actions={
"queue": MaterialIconAction(
icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True
),
"vs_code": MaterialIconAction(
icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True
),
"status": MaterialIconAction(
icon_name=BECStatusBox.ICON_NAME,
tooltip="Add BEC Status Box",
filled=True,
),
"progress_bar": MaterialIconAction(
icon_name=RingProgressBar.ICON_NAME,
tooltip="Add Circular ProgressBar",
filled=True,
),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel - Disabled",
filled=True,
),
"sbb_monitor": MaterialIconAction(
icon_name="train", tooltip="Add SBB Monitor", filled=True
),
},
),
"separator_2": SeparatorAction(),
"attach_all": MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks"
),
"save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"),
"restore_state": MaterialIconAction(
icon_name="frame_reload", tooltip="Restore Dock State"
),
},
target_widget=self,
)
self.toolbar = ModularToolBar(parent=self)
self._setup_toolbar()
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self.spacer = QWidget(parent=self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
self._hook_toolbar()
self.toolbar.show_bundles(
["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"]
)
def minimumSizeHint(self):
return QSize(800, 600)
def _setup_toolbar(self):
# Add plot menu
self.toolbar.components.add_safe(
"menu_plots",
ExpandableMenuAction(
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=Waveform.ICON_NAME,
tooltip="Add Waveform",
filled=True,
parent=self,
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
parent=self,
),
"multi_waveform": MaterialIconAction(
icon_name=MultiWaveform.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
parent=self,
),
"image": MaterialIconAction(
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self
),
"motor_map": MaterialIconAction(
icon_name=MotorMap.ICON_NAME,
tooltip="Add Motor Map",
filled=True,
parent=self,
),
},
),
)
bundle = ToolbarBundle("menu_plots", self.toolbar.components)
bundle.add_action("menu_plots")
self.toolbar.add_bundle(bundle)
# Add control menu
self.toolbar.components.add_safe(
"menu_devices",
ExpandableMenuAction(
label="Add Device Control ",
actions={
"scan_control": MaterialIconAction(
icon_name=ScanControl.ICON_NAME,
tooltip="Add Scan Control",
filled=True,
parent=self,
),
"positioner_box": MaterialIconAction(
icon_name=PositionerBox.ICON_NAME,
tooltip="Add Device Box",
filled=True,
parent=self,
),
},
),
)
bundle = ToolbarBundle("menu_devices", self.toolbar.components)
bundle.add_action("menu_devices")
self.toolbar.add_bundle(bundle)
# Add utils menu
self.toolbar.components.add_safe(
"menu_utils",
ExpandableMenuAction(
label="Add Utils ",
actions={
"queue": MaterialIconAction(
icon_name=BECQueue.ICON_NAME,
tooltip="Add Scan Queue",
filled=True,
parent=self,
),
"vs_code": MaterialIconAction(
icon_name=VSCodeEditor.ICON_NAME,
tooltip="Add VS Code",
filled=True,
parent=self,
),
"status": MaterialIconAction(
icon_name=BECStatusBox.ICON_NAME,
tooltip="Add BEC Status Box",
filled=True,
parent=self,
),
"progress_bar": MaterialIconAction(
icon_name=RingProgressBar.ICON_NAME,
tooltip="Add Circular ProgressBar",
filled=True,
parent=self,
),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel - Disabled",
filled=True,
parent=self,
),
"sbb_monitor": MaterialIconAction(
icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self
),
},
),
)
bundle = ToolbarBundle("menu_utils", self.toolbar.components)
bundle.add_action("menu_utils")
self.toolbar.add_bundle(bundle)
########## Dock Actions ##########
spacer = QWidget(parent=self)
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
self.toolbar.components.add_safe(
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False)
)
bundle = ToolbarBundle("dark_mode", self.toolbar.components)
bundle.add_action("spacer")
bundle.add_action("dark_mode")
self.toolbar.add_bundle(bundle)
self.toolbar.components.add_safe(
"attach_all",
MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
),
)
self.toolbar.components.add_safe(
"save_state",
MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self),
)
self.toolbar.components.add_safe(
"restore_state",
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
)
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
bundle.add_action("attach_all")
bundle.add_action("save_state")
bundle.add_action("restore_state")
self.toolbar.add_bundle(bundle)
def _hook_toolbar(self):
# Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
menu_plots = self.toolbar.components.get_action("menu_plots")
menu_devices = self.toolbar.components.get_action("menu_devices")
menu_utils = self.toolbar.components.get_action("menu_utils")
menu_plots.actions["waveform"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
)
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
menu_plots.actions["scatter_waveform"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
)
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
menu_plots.actions["multi_waveform"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
)
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
menu_plots.actions["image"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Image")
)
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
menu_plots.actions["motor_map"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
)
# Menu Devices
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
menu_devices.actions["scan_control"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
)
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
menu_devices.actions["positioner_box"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
)
# Menu Utils
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
menu_utils.actions["queue"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
)
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
menu_utils.actions["status"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
)
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
menu_utils.actions["vs_code"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
)
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
menu_utils.actions["progress_bar"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
# FIXME temporarily disabled -> issue #644
self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False)
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# )
self.toolbar.widgets["menu_utils"].widgets["sbb_monitor"].triggered.connect(
menu_utils.actions["log_panel"].action.setEnabled(False)
menu_utils.actions["sbb_monitor"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
)
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state)
self.toolbar.components.get_action("restore_state").action.triggered.connect(
self.restore_state
)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:

View File

@ -7,11 +7,19 @@ import numpy as np
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from pydantic import BaseModel, Field, field_validator
from qtpy.QtWidgets import QWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate, QWidget
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter,
ReadoutPriority,
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.plots.image.image_base import ImageBase
from bec_widgets.widgets.plots.image.image_item import ImageItem
@ -139,9 +147,119 @@ class Image(ImageBase):
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
self._init_toolbar_image()
self.layer_removed.connect(self._on_layer_removed)
self.scan_id = None
##################################
### Toolbar Initialization
##################################
def _init_toolbar_image(self):
"""
Initializes the toolbar for the image widget.
"""
self.device_combo_box = DeviceComboBox(
parent=self,
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
)
self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("")
self.device_combo_box.setToolTip("Select Device")
self.device_combo_box.setFixedWidth(150)
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
self.dim_combo_box = QComboBox(parent=self)
self.dim_combo_box.addItems(["auto", "1d", "2d"])
self.dim_combo_box.setCurrentText("auto")
self.dim_combo_box.setToolTip("Monitor Dimension")
self.dim_combo_box.setFixedWidth(100)
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
self.toolbar.components.add_safe(
"image_device_combo", WidgetAction(widget=self.device_combo_box, adjust_size=False)
)
self.toolbar.components.add_safe(
"image_dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False)
)
bundle = ToolbarBundle("monitor_selection", self.toolbar.components)
bundle.add_action("image_device_combo")
bundle.add_action("image_dim_combo")
self.toolbar.add_bundle(bundle)
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
crosshair_bundle = self.toolbar.get_bundle("image_crosshair")
crosshair_bundle.add_action("image_autorange")
crosshair_bundle.add_action("image_colorbar_switch")
self.toolbar.show_bundles(
[
"monitor_selection",
"plot_export",
"mouse_interaction",
"image_crosshair",
"image_processing",
"axis_popup",
]
)
QTimer.singleShot(0, self._adjust_and_connect)
def _adjust_and_connect(self):
"""
Adjust the size of the device combo box and populate it with preview signals.
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
"""
self._populate_preview_signals()
self._reverse_device_items()
self.device_combo_box.setCurrentText("") # set again default to empty string
def _populate_preview_signals(self) -> None:
"""
Populate the device combo box with preview-signal devices in the
format '<device>_<signal>' and store the tuple(device, signal) in
the item's userData for later use.
"""
preview_signals = self.client.device_manager.get_bec_signals("PreviewSignal")
for device, signal, signal_config in preview_signals:
label = signal_config.get("obj_name", f"{device}_{signal}")
self.device_combo_box.addItem(label, (device, signal, signal_config))
def _reverse_device_items(self) -> None:
"""
Reverse the current order of items in the device combo box while
keeping their userData and restoring the previous selection.
"""
current_text = self.device_combo_box.currentText()
items = [
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
for i in range(self.device_combo_box.count())
]
self.device_combo_box.clear()
for text, data in reversed(items):
self.device_combo_box.addItem(text, data)
if current_text:
self.device_combo_box.setCurrentText(current_text)
@SafeSlot()
def connect_monitor(self, *args, **kwargs):
"""
Connect the target widget to the selected monitor based on the current device and dimension.
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
"""
dim = self.dim_combo_box.currentText()
data = self.device_combo_box.currentData()
if isinstance(data, tuple):
self.image(monitor=data, monitor_type="auto")
else:
self.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
################################################################################
# Data Acquisition
@ -227,35 +345,21 @@ class Image(ImageBase):
"""
config = self.subscriptions["main"]
if config.monitor is not None:
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
for combo in (self.device_combo_box, self.dim_combo_box):
combo.blockSignals(True)
if isinstance(config.monitor, tuple):
self.selection_bundle.device_combo_box.setCurrentText(
f"{config.monitor[0]}_{config.monitor[1]}"
)
self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}")
else:
self.selection_bundle.device_combo_box.setCurrentText(config.monitor)
self.selection_bundle.dim_combo_box.setCurrentText(config.monitor_type)
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
self.device_combo_box.setCurrentText(config.monitor)
self.dim_combo_box.setCurrentText(config.monitor_type)
for combo in (self.device_combo_box, self.dim_combo_box):
combo.blockSignals(False)
else:
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
for combo in (self.device_combo_box, self.dim_combo_box):
combo.blockSignals(True)
self.selection_bundle.device_combo_box.setCurrentText("")
self.selection_bundle.dim_combo_box.setCurrentText("auto")
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
self.device_combo_box.setCurrentText("")
self.dim_combo_box.setCurrentText("auto")
for combo in (self.device_combo_box, self.dim_combo_box):
combo.blockSignals(False)
################################################################################
@ -554,8 +658,10 @@ class Image(ImageBase):
self.subscriptions.clear()
# Toolbar cleanup
self.toolbar.widgets["monitor"].widget.close()
self.toolbar.widgets["monitor"].widget.deleteLater()
self.device_combo_box.close()
self.device_combo_box.deleteLater()
self.dim_combo_box.close()
self.dim_combo_box.deleteLater()
super().cleanup()
@ -570,10 +676,10 @@ if __name__ == "__main__": # pragma: no cover
ml = QHBoxLayout(win)
image_popup = Image(popups=True)
image_side_panel = Image(popups=False)
# image_side_panel = Image(popups=False)
ml.addWidget(image_popup)
ml.addWidget(image_side_panel)
# ml.addWidget(image_side_panel)
win.resize(1500, 800)
win.show()

View File

@ -12,14 +12,19 @@ from qtpy.QtWidgets import QDialog, QVBoxLayout
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
MonitorSelectionToolbarBundle,
from bec_widgets.widgets.plots.image.toolbar_components.image_base_actions import (
ImageColorbarConnection,
ImageProcessingConnection,
ImageRoiConnection,
image_autorange,
image_colorbar,
image_processing,
image_roi_bundle,
)
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
@ -256,6 +261,7 @@ class ImageBase(PlotBase):
self.x_roi = None
self.y_roi = None
super().__init__(*args, **kwargs)
self.roi_controller = ROIController(colormap="viridis")
# Headless controller keeps the canonical list.
@ -264,6 +270,7 @@ class ImageBase(PlotBase):
self, plot_item=self.plot_item, on_add=self.layer_added, on_remove=self.layer_removed
)
self.layer_manager.add("main")
self._init_image_base_toolbar()
self.autorange = True
self.autorange_mode = "mean"
@ -274,6 +281,16 @@ class ImageBase(PlotBase):
# Refresh theme for ROI plots
self._update_theme()
self.toolbar.show_bundles(
[
"image_crosshair",
"mouse_interaction",
"image_autorange",
"image_colorbar",
"image_processing",
]
)
################################################################################
# Widget Specific GUI interactions
################################################################################
@ -318,135 +335,66 @@ class ImageBase(PlotBase):
"""
return list(self.layer_manager.layers.values())
def _init_toolbar(self):
def _init_image_base_toolbar(self):
try:
# add to the first position
self.selection_bundle = MonitorSelectionToolbarBundle(
bundle_id="selection", target_widget=self
)
self.toolbar.add_bundle(self.selection_bundle, self)
super()._init_toolbar()
# Image specific changes to PlotBase toolbar
self.toolbar.widgets["reset_legend"].action.setVisible(False)
# ROI Bundle replacement with switchable crosshair
self.toolbar.remove_bundle("roi")
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True, parent=self
)
crosshair_roi = MaterialIconAction(
icon_name="my_location",
tooltip="Show Crosshair with ROI plots",
checkable=True,
parent=self,
)
crosshair_roi.action.toggled.connect(self.toggle_roi_panels)
crosshair.action.toggled.connect(self.toggle_crosshair)
switch_crosshair = SwitchableToolBarAction(
actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi},
initial_action="crosshair_simple",
tooltip="Crosshair",
checkable=True,
parent=self,
)
self.toolbar.add_action(
action_id="switch_crosshair", action=switch_crosshair, target_widget=self
# ROI Actions
self.toolbar.add_bundle(image_roi_bundle(self.toolbar.components))
self.toolbar.connect_bundle(
"image_base", ImageRoiConnection(self.toolbar.components, target_widget=self)
)
# Lock aspect ratio button
self.lock_aspect_ratio_action = MaterialIconAction(
# Lock Aspect Ratio Action
lock_aspect_ratio_action = MaterialIconAction(
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
)
self.toolbar.add_action_to_bundle(
bundle_id="mouse_interaction",
action_id="lock_aspect_ratio",
action=self.lock_aspect_ratio_action,
target_widget=self,
)
self.lock_aspect_ratio_action.action.toggled.connect(
self.toolbar.components.add_safe("lock_aspect_ratio", lock_aspect_ratio_action)
self.toolbar.get_bundle("mouse_interaction").add_action("lock_aspect_ratio")
lock_aspect_ratio_action.action.toggled.connect(
lambda checked: self.setProperty("lock_aspect_ratio", checked)
)
self.lock_aspect_ratio_action.action.setChecked(True)
lock_aspect_ratio_action.action.setChecked(True)
self._init_autorange_action()
self._init_colorbar_action()
# Processing Bundle
self.processing_bundle = ImageProcessingToolbarBundle(
bundle_id="processing", target_widget=self
# Autorange Action
self.toolbar.add_bundle(image_autorange(self.toolbar.components))
action = self.toolbar.components.get_action("image_autorange")
action.actions["mean"].action.toggled.connect(
lambda checked: self.toggle_autorange(checked, mode="mean")
)
action.actions["max"].action.toggled.connect(
lambda checked: self.toggle_autorange(checked, mode="max")
)
# Colorbar Actions
self.toolbar.add_bundle(image_colorbar(self.toolbar.components))
self.toolbar.connect_bundle(
"image_colorbar",
ImageColorbarConnection(self.toolbar.components, target_widget=self),
)
# Image Processing Actions
self.toolbar.add_bundle(image_processing(self.toolbar.components))
self.toolbar.connect_bundle(
"image_processing",
ImageProcessingConnection(self.toolbar.components, target_widget=self),
)
# ROI Manager Action
self.toolbar.components.add_safe(
"roi_mgr",
MaterialIconAction(
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
),
)
self.toolbar.get_bundle("axis_popup").add_action("roi_mgr")
self.toolbar.components.get_action("roi_mgr").action.triggered.connect(
self.show_roi_manager_popup
)
self.toolbar.add_bundle(self.processing_bundle, target_widget=self)
except Exception as e:
logger.error(f"Error initializing toolbar: {e}")
def _init_autorange_action(self):
self.autorange_mean_action = MaterialIconAction(
icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self
)
self.autorange_max_action = MaterialIconAction(
icon_name="hdr_auto",
tooltip="Enable Auto Range (Max)",
checkable=True,
filled=True,
parent=self,
)
self.autorange_switch = SwitchableToolBarAction(
actions={
"auto_range_mean": self.autorange_mean_action,
"auto_range_max": self.autorange_max_action,
},
initial_action="auto_range_mean",
tooltip="Enable Auto Range",
checkable=True,
parent=self,
)
self.toolbar.add_action(
action_id="autorange_image", action=self.autorange_switch, target_widget=self
)
self.autorange_mean_action.action.toggled.connect(
lambda checked: self.toggle_autorange(checked, mode="mean")
)
self.autorange_max_action.action.toggled.connect(
lambda checked: self.toggle_autorange(checked, mode="max")
)
def _init_colorbar_action(self):
self.full_colorbar_action = MaterialIconAction(
icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self
)
self.simple_colorbar_action = MaterialIconAction(
icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self
)
self.colorbar_switch = SwitchableToolBarAction(
actions={
"full_colorbar": self.full_colorbar_action,
"simple_colorbar": self.simple_colorbar_action,
},
initial_action="full_colorbar",
tooltip="Enable Full Colorbar",
checkable=True,
parent=self,
)
self.toolbar.add_action(
action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self
)
self.simple_colorbar_action.action.toggled.connect(
lambda checked: self.enable_colorbar(checked, style="simple")
)
self.full_colorbar_action.action.toggled.connect(
lambda checked: self.enable_colorbar(checked, style="full")
)
########################################
# ROI Gui Manager
def add_side_menus(self):
@ -461,20 +409,8 @@ class ImageBase(PlotBase):
title="ROI Manager",
)
def add_popups(self):
super().add_popups() # keep Axis Settings
roi_action = MaterialIconAction(
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
)
# self.popup_bundle.add_action("roi_mgr", roi_action)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self
)
self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup)
def show_roi_manager_popup(self):
roi_action = self.toolbar.widgets["roi_mgr"].action
roi_action = self.toolbar.components.get_action("roi_mgr").action
if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
self.roi_manager_dialog = QDialog(modal=False)
@ -494,7 +430,7 @@ class ImageBase(PlotBase):
self.roi_manager_dialog.close()
self.roi_manager_dialog.deleteLater()
self.roi_manager_dialog = None
self.toolbar.widgets["roi_mgr"].action.setChecked(False)
self.toolbar.components.get_action("roi_mgr").action.setChecked(False)
def enable_colorbar(
self,
@ -518,12 +454,11 @@ class ImageBase(PlotBase):
self.plot_widget.removeItem(self._color_bar)
self._color_bar = None
def disable_autorange():
print("Disabling autorange")
self.setProperty("autorange", False)
if style == "simple":
def disable_autorange():
print("Disabling autorange")
self.setProperty("autorange", False)
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
self._color_bar.setImageItem(self.layer_manager["main"].image)
self._color_bar.sigLevelsChangeFinished.connect(disable_autorange)
@ -532,9 +467,7 @@ class ImageBase(PlotBase):
self._color_bar = pg.HistogramLUTItem()
self._color_bar.setImageItem(self.layer_manager["main"].image)
self._color_bar.gradient.loadPreset(self.config.color_map)
self._color_bar.sigLevelsChanged.connect(
lambda: self.setProperty("autorange", False)
)
self._color_bar.sigLevelsChanged.connect(disable_autorange)
self.plot_widget.addItem(self._color_bar, row=0, col=1)
self.config.color_bar = style
@ -827,6 +760,9 @@ class ImageBase(PlotBase):
Args:
value(tuple | list | QPointF): The range of values to set.
"""
self._set_vrange(value, disable_autorange=True)
def _set_vrange(self, value: tuple | list | QPointF, disable_autorange: bool = True):
if isinstance(value, (tuple, list)):
value = self._tuple_to_qpointf(value)
@ -835,7 +771,7 @@ class ImageBase(PlotBase):
for layer in self.layer_manager:
if not layer.sync.v_range:
continue
layer.image.v_range = (vmin, vmax)
layer.image.set_v_range((vmin, vmax), disable_autorange=disable_autorange)
# propagate to colorbar if exists
if self._color_bar:
@ -845,7 +781,7 @@ class ImageBase(PlotBase):
self._color_bar.setLevels(min=vmin, max=vmax)
self._color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
self.autorange_switch.set_state_all(False)
# self.toolbar.components.get_action("image_autorange").set_state_all(False)
@property
def v_min(self) -> float:
@ -919,14 +855,27 @@ class ImageBase(PlotBase):
Args:
enabled(bool): Whether to enable autorange.
"""
self._set_autorange(enabled)
def _set_autorange(self, enabled: bool, sync: bool = True):
"""
Set the autorange for all layers.
Args:
enabled(bool): Whether to enable autorange.
sync(bool): Whether to synchronize the autorange state across all layers.
"""
print(f"Setting autorange to {enabled}")
for layer in self.layer_manager:
if not layer.sync.autorange:
continue
layer.image.autorange = enabled
if enabled and layer.image.raw_data is not None:
layer.image.apply_autorange()
# if sync:
self._sync_colorbar_levels()
self._sync_autorange_switch()
print(f"Autorange set to {enabled}")
@SafeProperty(str)
def autorange_mode(self) -> str:
@ -948,6 +897,7 @@ class ImageBase(PlotBase):
Args:
mode(str): The autorange mode. Options are "max" or "mean".
"""
print(f"Setting autorange mode to {mode}")
# for qt Designer
if mode not in ["max", "mean"]:
return
@ -969,7 +919,7 @@ class ImageBase(PlotBase):
"""
if not self.layer_manager:
return
print(f"Toggling autorange to {enabled} with mode {mode}")
for layer in self.layer_manager:
if layer.sync.autorange:
layer.image.autorange = enabled
@ -981,19 +931,16 @@ class ImageBase(PlotBase):
# We only need to apply autorange if we enabled it
layer.image.apply_autorange()
if enabled:
self._sync_colorbar_levels()
self._sync_colorbar_levels()
def _sync_autorange_switch(self):
"""
Synchronize the autorange switch with the current autorange state and mode if changed from outside.
"""
self.autorange_switch.block_all_signals(True)
self.autorange_switch.set_default_action(
f"auto_range_{self.layer_manager['main'].image.autorange_mode}"
)
self.autorange_switch.set_state_all(self.layer_manager["main"].image.autorange)
self.autorange_switch.block_all_signals(False)
action: SwitchableToolBarAction = self.toolbar.components.get_action("image_autorange") # type: ignore
with action.signal_blocker():
action.set_default_action(f"{self.layer_manager['main'].image.autorange_mode}")
action.set_state_all(self.layer_manager["main"].image.autorange)
def _sync_colorbar_levels(self):
"""Immediately propagate current levels to the active colorbar."""
@ -1009,20 +956,22 @@ class ImageBase(PlotBase):
total_vrange = (min(total_vrange[0], img.v_min), max(total_vrange[1], img.v_max))
self._color_bar.blockSignals(True)
self.v_range = total_vrange # type: ignore
self._set_vrange(total_vrange, disable_autorange=False) # type: ignore
self._color_bar.blockSignals(False)
def _sync_colorbar_actions(self):
"""
Synchronize the colorbar actions with the current colorbar state.
"""
self.colorbar_switch.block_all_signals(True)
if self._color_bar is not None:
self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
self.colorbar_switch.set_state_all(True)
else:
self.colorbar_switch.set_state_all(False)
self.colorbar_switch.block_all_signals(False)
colorbar_switch: SwitchableToolBarAction = self.toolbar.components.get_action(
"image_colorbar_switch"
)
with colorbar_switch.signal_blocker():
if self._color_bar is not None:
colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
colorbar_switch.set_state_all(True)
else:
colorbar_switch.set_state_all(False)
@staticmethod
def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):

View File

@ -20,7 +20,9 @@ from qtpy.QtWidgets import (
from bec_widgets import BECWidget
from bec_widgets.utils import BECDispatcher, ConnectionConfig
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
CircularROI,
@ -121,20 +123,33 @@ class ROIPropertyTree(BECWidget, QWidget):
# --------------------------------------------------------------------- UI
def _init_toolbar(self):
tb = ModularToolBar(self, self, orientation="horizontal")
tb = self.toolbar = ModularToolBar(self, orientation="horizontal")
self._draw_actions: dict[str, MaterialIconAction] = {}
# --- ROI draw actions (toggleable) ---
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
tb.add_action("Add Rect ROI", self.add_rect_action, self)
self._draw_actions["rect"] = self.add_rect_action
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
tb.add_action("Add Circle ROI", self.add_circle_action, self)
self._draw_actions["circle"] = self.add_circle_action
# --- Ellipse ROI draw action ---
self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self)
tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self)
self._draw_actions["ellipse"] = self.add_ellipse_action
tb.components.add_safe(
"roi_rectangle",
MaterialIconAction("add_box", "Add Rect ROI", checkable=True, parent=self),
)
tb.components.add_safe(
"roi_circle",
MaterialIconAction("add_circle", "Add Circle ROI", checkable=True, parent=self),
)
tb.components.add_safe(
"roi_ellipse",
MaterialIconAction("vignette", "Add Ellipse ROI", checkable=True, parent=self),
)
bundle = ToolbarBundle("roi_draw", tb.components)
bundle.add_action("roi_rectangle")
bundle.add_action("roi_circle")
bundle.add_action("roi_ellipse")
tb.add_bundle(bundle)
self._draw_actions = {
"rect": tb.components.get_action("roi_rectangle"),
"circle": tb.components.get_action("roi_circle"),
"ellipse": tb.components.get_action("roi_ellipse"),
}
for mode, act in self._draw_actions.items():
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
@ -142,7 +157,7 @@ class ROIPropertyTree(BECWidget, QWidget):
self.expand_toggle = MaterialIconAction(
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
)
tb.add_action("Expand/Collapse", self.expand_toggle, self)
tb.components.add_safe("expand_toggle", self.expand_toggle)
def _exp_toggled(on: bool):
if on:
@ -163,7 +178,7 @@ class ROIPropertyTree(BECWidget, QWidget):
self.lock_all_action = MaterialIconAction(
"lock_open_right", "Lock/Unlock all ROIs", checkable=True, parent=self
)
tb.add_action("Lock/Unlock all ROIs", self.lock_all_action, self)
tb.components.add_safe("lock_unlock_all", self.lock_all_action)
def _lock_all(checked: bool):
# checked -> everything locked (movable = False)
@ -178,12 +193,23 @@ class ROIPropertyTree(BECWidget, QWidget):
# colormap widget
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
tb.addWidget(QWidget()) # spacer
tb.addWidget(self.cmap)
tb.components.add_safe("roi_tree_spacer", WidgetAction(widget=QWidget()))
tb.components.add_safe("roi_tree_cmap", WidgetAction(widget=self.cmap))
self.cmap.colormap_changed_signal.connect(self.controller.set_colormap)
self.layout.addWidget(tb)
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
bundle = ToolbarBundle("roi_tools", tb.components)
bundle.add_action("expand_toggle")
bundle.add_action("lock_unlock_all")
bundle.add_action("roi_tree_spacer")
bundle.add_action("roi_tree_cmap")
tb.add_bundle(bundle)
tb.show_bundles(["roi_draw", "roi_tools"])
# ROI drawing state
self._roi_draw_mode = None # 'rect' | 'circle' | 'ellipse' | None
self._roi_start_pos = None # QPointF in image coords

View File

@ -1,107 +0,0 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
class NoCheckDelegate(QStyledItemDelegate):
"""To reduce space in combo boxes by removing the checkmark."""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
# Remove any check indicator
option.checkState = Qt.Unchecked
class MonitorSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that controls monitor selection on a plot.
"""
def __init__(self, bundle_id="device_selection", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# 1) Device combo box
self.device_combo_box = DeviceComboBox(
parent=self.target_widget,
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
)
self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("")
self.device_combo_box.setToolTip("Select Device")
self.device_combo_box.setFixedWidth(150)
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=False))
# 2) Dimension combo box
self.dim_combo_box = QComboBox(parent=self.target_widget)
self.dim_combo_box.addItems(["auto", "1d", "2d"])
self.dim_combo_box.setCurrentText("auto")
self.dim_combo_box.setToolTip("Monitor Dimension")
self.dim_combo_box.setFixedWidth(100)
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
QTimer.singleShot(0, self._adjust_and_connect)
def _adjust_and_connect(self):
"""
Adjust the size of the device combo box and populate it with preview signals.
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
"""
self._populate_preview_signals()
self._reverse_device_items()
self.device_combo_box.setCurrentText("") # set again default to empty string
def _populate_preview_signals(self) -> None:
"""
Populate the device combo box with previewsignal devices in the
format '<device>_<signal>' and store the tuple(device, signal) in
the item's userData for later use.
"""
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
for device, signal, signal_config in preview_signals:
label = signal_config.get("obj_name", f"{device}_{signal}")
self.device_combo_box.addItem(label, (device, signal, signal_config))
def _reverse_device_items(self) -> None:
"""
Reverse the current order of items in the device combo box while
keeping their userData and restoring the previous selection.
"""
current_text = self.device_combo_box.currentText()
items = [
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
for i in range(self.device_combo_box.count())
]
self.device_combo_box.clear()
for text, data in reversed(items):
self.device_combo_box.addItem(text, data)
if current_text:
self.device_combo_box.setCurrentText(current_text)
@SafeSlot()
def connect_monitor(self, *args, **kwargs):
"""
Connect the target widget to the selected monitor based on the current device and dimension.
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
"""
dim = self.dim_combo_box.currentText()
data = self.device_combo_box.currentData()
if isinstance(data, tuple):
self.target_widget.image(monitor=data, monitor_type="auto")
else:
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)

View File

@ -1,92 +0,0 @@
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
class ImageProcessingToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that controls processing of monitor.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
self.fft = MaterialIconAction(
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=self.target_widget
)
self.log = MaterialIconAction(
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=self.target_widget
)
self.transpose = MaterialIconAction(
icon_name="transform",
tooltip="Transpose Image",
checkable=True,
parent=self.target_widget,
)
self.right = MaterialIconAction(
icon_name="rotate_right",
tooltip="Rotate image clockwise by 90 deg",
parent=self.target_widget,
)
self.left = MaterialIconAction(
icon_name="rotate_left",
tooltip="Rotate image counterclockwise by 90 deg",
parent=self.target_widget,
)
self.reset = MaterialIconAction(
icon_name="reset_settings", tooltip="Reset Image Settings", parent=self.target_widget
)
self.add_action("fft", self.fft)
self.add_action("log", self.log)
self.add_action("transpose", self.transpose)
self.add_action("rotate_right", self.right)
self.add_action("rotate_left", self.left)
self.add_action("reset", self.reset)
self.fft.action.triggered.connect(self.toggle_fft)
self.log.action.triggered.connect(self.toggle_log)
self.transpose.action.triggered.connect(self.toggle_transpose)
self.right.action.triggered.connect(self.rotate_right)
self.left.action.triggered.connect(self.rotate_left)
self.reset.action.triggered.connect(self.reset_settings)
@SafeSlot()
def toggle_fft(self):
checked = self.fft.action.isChecked()
self.target_widget.fft = checked
@SafeSlot()
def toggle_log(self):
checked = self.log.action.isChecked()
self.target_widget.log = checked
@SafeSlot()
def toggle_transpose(self):
checked = self.transpose.action.isChecked()
self.target_widget.transpose = checked
@SafeSlot()
def rotate_right(self):
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.num_rotation_90 - 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def rotate_left(self):
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.num_rotation_90 + 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def reset_settings(self):
self.target_widget.fft = False
self.target_widget.log = False
self.target_widget.transpose = False
self.target_widget.num_rotation_90 = 0
self.fft.action.setChecked(False)
self.log.action.setChecked(False)
self.transpose.action.setChecked(False)

View File

@ -0,0 +1,390 @@
from __future__ import annotations
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
def image_roi_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for ROI and crosshair interaction.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The ROI toolbar bundle.
"""
components.add_safe(
"image_crosshair",
MaterialIconAction(
icon_name="point_scan",
tooltip="Show Crosshair",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_crosshair_roi",
MaterialIconAction(
icon_name="my_location",
tooltip="Show Crosshair with ROI plots",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_switch_crosshair",
SwitchableToolBarAction(
actions={
"crosshair": components.get_action_reference("image_crosshair")(),
"crosshair_roi": components.get_action_reference("image_crosshair_roi")(),
},
initial_action="crosshair",
tooltip="Crosshair",
checkable=True,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("image_crosshair", components)
bundle.add_action("image_switch_crosshair")
return bundle
class ImageRoiConnection(BundleConnection):
"""
Connection class for the ROI toolbar bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "roi"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "toggle_roi_panels") or not hasattr(
self.target_widget, "toggle_crosshair"
):
raise AttributeError(
"Target widget must implement 'toggle_roi_panels' and 'toggle_crosshair'."
)
super().__init__()
self._connected = False
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action("image_crosshair").action.toggled.connect(
self.target_widget.toggle_crosshair
)
self.components.get_action("image_crosshair_roi").action.triggered.connect(
self.target_widget.toggle_roi_panels
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action("image_crosshair").action.toggled.disconnect(
self.target_widget.toggle_crosshair
)
self.components.get_action("image_crosshair_roi").action.triggered.disconnect(
self.target_widget.toggle_roi_panels
)
def image_autorange(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for image autorange functionality.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The autorange toolbar bundle.
"""
components.add_safe(
"image_autorange_mean",
MaterialIconAction(
icon_name="hdr_auto",
tooltip="Enable Auto Range (Mean)",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_autorange_max",
MaterialIconAction(
icon_name="hdr_auto",
tooltip="Enable Auto Range (Max)",
checkable=True,
parent=components.toolbar,
filled=True,
),
)
components.add_safe(
"image_autorange",
SwitchableToolBarAction(
actions={
"mean": components.get_action_reference("image_autorange_mean")(),
"max": components.get_action_reference("image_autorange_max")(),
},
initial_action="mean",
tooltip="Autorange",
checkable=True,
parent=components.toolbar,
default_state_checked=True,
),
)
bundle = ToolbarBundle("image_autorange", components)
bundle.add_action("image_autorange")
return bundle
def image_colorbar(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for image colorbar functionality.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The colorbar toolbar bundle.
"""
components.add_safe(
"image_full_colorbar",
MaterialIconAction(
icon_name="edgesensor_low",
tooltip="Enable Full Colorbar",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_simple_colorbar",
MaterialIconAction(
icon_name="smartphone",
tooltip="Enable Simple Colorbar",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_colorbar_switch",
SwitchableToolBarAction(
actions={
"full_colorbar": components.get_action_reference("image_full_colorbar")(),
"simple_colorbar": components.get_action_reference("image_simple_colorbar")(),
},
initial_action="full_colorbar",
tooltip="Colorbar",
checkable=True,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("image_colorbar", components)
bundle.add_action("image_colorbar_switch")
return bundle
class ImageColorbarConnection(BundleConnection):
"""
Connection class for the image colorbar toolbar bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "image_colorbar"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "enable_colorbar"):
raise AttributeError("Target widget must implement 'enable_colorbar' method.")
super().__init__()
self._connected = False
def _enable_full_colorbar(self, checked: bool):
"""
Enable or disable the full colorbar based on the checked state.
"""
self.target_widget.enable_colorbar(checked, style="full")
def _enable_simple_colorbar(self, checked: bool):
"""
Enable or disable the simple colorbar based on the checked state.
"""
self.target_widget.enable_colorbar(checked, style="simple")
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action("image_full_colorbar").action.toggled.connect(
self._enable_full_colorbar
)
self.components.get_action("image_simple_colorbar").action.toggled.connect(
self._enable_simple_colorbar
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action("image_full_colorbar").action.toggled.disconnect(
self._enable_full_colorbar
)
self.components.get_action("image_simple_colorbar").action.toggled.disconnect(
self._enable_simple_colorbar
)
def image_processing(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for image processing functionality.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The image processing toolbar bundle.
"""
components.add_safe(
"image_processing_fft",
MaterialIconAction(
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=components.toolbar
),
)
components.add_safe(
"image_processing_log",
MaterialIconAction(
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=components.toolbar
),
)
components.add_safe(
"image_processing_transpose",
MaterialIconAction(
icon_name="transform",
tooltip="Transpose Image",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_processing_rotate_right",
MaterialIconAction(
icon_name="rotate_right",
tooltip="Rotate image clockwise by 90 deg",
parent=components.toolbar,
),
)
components.add_safe(
"image_processing_rotate_left",
MaterialIconAction(
icon_name="rotate_left",
tooltip="Rotate image counterclockwise by 90 deg",
parent=components.toolbar,
),
)
components.add_safe(
"image_processing_reset",
MaterialIconAction(
icon_name="reset_settings", tooltip="Reset Image Settings", parent=components.toolbar
),
)
bundle = ToolbarBundle("image_processing", components)
bundle.add_action("image_processing_fft")
bundle.add_action("image_processing_log")
bundle.add_action("image_processing_transpose")
bundle.add_action("image_processing_rotate_right")
bundle.add_action("image_processing_rotate_left")
bundle.add_action("image_processing_reset")
return bundle
class ImageProcessingConnection(BundleConnection):
"""
Connection class for the image processing toolbar bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "image_processing"
self.components = components
self.target_widget = target_widget
if (
not hasattr(self.target_widget, "fft")
or not hasattr(self.target_widget, "log")
or not hasattr(self.target_widget, "transpose")
or not hasattr(self.target_widget, "num_rotation_90")
):
raise AttributeError(
"Target widget must implement 'fft', 'log', 'transpose', and 'num_rotation_90' attributes."
)
super().__init__()
self.fft = components.get_action("image_processing_fft")
self.log = components.get_action("image_processing_log")
self.transpose = components.get_action("image_processing_transpose")
self.right = components.get_action("image_processing_rotate_right")
self.left = components.get_action("image_processing_rotate_left")
self.reset = components.get_action("image_processing_reset")
self._connected = False
@SafeSlot()
def toggle_fft(self):
checked = self.fft.action.isChecked()
self.target_widget.fft = checked
@SafeSlot()
def toggle_log(self):
checked = self.log.action.isChecked()
self.target_widget.log = checked
@SafeSlot()
def toggle_transpose(self):
checked = self.transpose.action.isChecked()
self.target_widget.transpose = checked
@SafeSlot()
def rotate_right(self):
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.num_rotation_90 - 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def rotate_left(self):
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.num_rotation_90 + 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def reset_settings(self):
self.target_widget.fft = False
self.target_widget.log = False
self.target_widget.transpose = False
self.target_widget.num_rotation_90 = 0
self.fft.action.setChecked(False)
self.log.action.setChecked(False)
self.transpose.action.setChecked(False)
def connect(self):
"""
Connect the actions to the target widget's methods.
"""
self._connected = True
self.fft.action.triggered.connect(self.toggle_fft)
self.log.action.triggered.connect(self.toggle_log)
self.transpose.action.triggered.connect(self.toggle_transpose)
self.right.action.triggered.connect(self.rotate_right)
self.left.action.triggered.connect(self.rotate_left)
self.reset.action.triggered.connect(self.reset_settings)
def disconnect(self):
"""
Disconnect the actions from the target widget's methods.
"""
if not self._connected:
return
self.fft.action.triggered.disconnect(self.toggle_fft)
self.log.action.triggered.disconnect(self.toggle_log)
self.transpose.action.triggered.disconnect(self.toggle_transpose)
self.right.action.triggered.disconnect(self.rotate_right)
self.left.action.triggered.disconnect(self.rotate_left)
self.reset.action.triggered.disconnect(self.reset_settings)

View File

@ -1,6 +1,5 @@
from __future__ import annotations
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
@ -15,12 +14,12 @@ from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbar import MaterialIconAction
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings
from bec_widgets.widgets.plots.motor_map.toolbar_bundles.motor_selection import (
MotorSelectionToolbarBundle,
from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import (
MotorSelectionAction,
)
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
logger = bec_logger.logger
@ -182,33 +181,60 @@ class MotorMap(PlotBase):
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plot
)
self._init_motor_map_toolbar()
self._add_motor_map_settings()
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_toolbar(self):
def _init_motor_map_toolbar(self):
"""
Initialize the toolbar for the motor map widget.
"""
self.motor_selection_bundle = MotorSelectionToolbarBundle(
bundle_id="motor_selection", target_widget=self
)
self.toolbar.add_bundle(self.motor_selection_bundle, target_widget=self)
super()._init_toolbar()
self.toolbar.widgets["reset_legend"].action.setVisible(False)
motor_selection = MotorSelectionAction(parent=self)
self.toolbar.add_action("motor_selection", motor_selection)
self.reset_legend_action = MaterialIconAction(
icon_name="history", tooltip="Reset the position of legend."
motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed)
motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed)
self.toolbar.components.get_action("reset_legend").action.setVisible(False)
reset_legend = MaterialIconAction(
icon_name="history",
tooltip="Reset the position of legend.",
checkable=False,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="motor_map_history",
action=self.reset_legend_action,
target_widget=self,
self.toolbar.components.add_safe("reset_motor_map_legend", reset_legend)
self.toolbar.get_bundle("roi").add_action("reset_motor_map_legend")
reset_legend.action.triggered.connect(self.reset_history)
settings_brightness = MaterialIconAction(
icon_name="settings_brightness",
tooltip="Show Motor Map Settings",
checkable=True,
parent=self,
)
self.reset_legend_action.action.triggered.connect(self.reset_history)
self.toolbar.components.add_safe("motor_map_settings", settings_brightness)
self.toolbar.get_bundle("axis_popup").add_action("motor_map_settings")
settings_brightness.action.triggered.connect(self.show_motor_map_settings)
bundles = ["motor_selection", "plot_export", "mouse_interaction", "roi"]
if self.ui_mode == UIMode.POPUP:
bundles.append("axis_popup")
self.toolbar.show_bundles(bundles)
@SafeSlot()
def on_motor_selection_changed(self, _):
action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection")
motor_x = action.motor_x.currentText()
motor_y = action.motor_y.currentText()
if motor_x != "" and motor_y != "":
if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name:
self.map(motor_x, motor_y)
def _add_motor_map_settings(self):
"""Add the motor map settings to the side panel."""
@ -221,32 +247,11 @@ class MotorMap(PlotBase):
title="Motor Map Settings",
)
def add_popups(self):
"""
Add popups to the ScatterWaveform widget.
"""
super().add_popups()
scatter_curve_setting_action = MaterialIconAction(
icon_name="settings_brightness",
tooltip="Show Motor Map Settings",
checkable=True,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle",
action_id="motor_map_settings",
action=scatter_curve_setting_action,
target_widget=self,
)
self.toolbar.widgets["motor_map_settings"].action.triggered.connect(
self.show_motor_map_settings
)
def show_motor_map_settings(self):
"""
Show the DAP summary popup.
"""
action = self.toolbar.widgets["motor_map_settings"].action
action = self.toolbar.components.get_action("motor_map_settings").action
if self.motor_map_settings is None or not self.motor_map_settings.isVisible():
motor_map_settings = MotorMapSettings(parent=self, target_widget=self, popup=True)
self.motor_map_settings = SettingsDialog(
@ -272,7 +277,7 @@ class MotorMap(PlotBase):
"""
self.motor_map_settings.deleteLater()
self.motor_map_settings = None
self.toolbar.widgets["motor_map_settings"].action.setChecked(False)
self.toolbar.components.get_action("motor_map_settings").action.setChecked(False)
################################################################################
# Widget Specific Properties
@ -766,20 +771,21 @@ class MotorMap(PlotBase):
"""
Sync the motor map selection toolbar with the current motor map.
"""
if self.motor_selection_bundle is not None:
motor_x = self.motor_selection_bundle.motor_x.currentText()
motor_y = self.motor_selection_bundle.motor_y.currentText()
motor_selection = self.toolbar.components.get_action("motor_selection")
if motor_x != self.config.x_motor.name:
self.motor_selection_bundle.motor_x.blockSignals(True)
self.motor_selection_bundle.motor_x.set_device(self.config.x_motor.name)
self.motor_selection_bundle.motor_x.check_validity(self.config.x_motor.name)
self.motor_selection_bundle.motor_x.blockSignals(False)
if motor_y != self.config.y_motor.name:
self.motor_selection_bundle.motor_y.blockSignals(True)
self.motor_selection_bundle.motor_y.set_device(self.config.y_motor.name)
self.motor_selection_bundle.motor_y.check_validity(self.config.y_motor.name)
self.motor_selection_bundle.motor_y.blockSignals(False)
motor_x = motor_selection.motor_x.currentText()
motor_y = motor_selection.motor_y.currentText()
if motor_x != self.config.x_motor.name:
motor_selection.motor_x.blockSignals(True)
motor_selection.motor_x.set_device(self.config.x_motor.name)
motor_selection.motor_x.check_validity(self.config.x_motor.name)
motor_selection.motor_x.blockSignals(False)
if motor_y != self.config.y_motor.name:
motor_selection.motor_y.blockSignals(True)
motor_selection.motor_y.set_device(self.config.y_motor.name)
motor_selection.motor_y.check_validity(self.config.y_motor.name)
motor_selection.motor_y.blockSignals(False)
################################################################################
# Export Methods
@ -795,10 +801,6 @@ class MotorMap(PlotBase):
data = {"x": self._buffer["x"], "y": self._buffer["y"]}
return data
def cleanup(self):
self.motor_selection_bundle.cleanup()
super().cleanup()
class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):

View File

@ -1,70 +0,0 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
class NoCheckDelegate(QStyledItemDelegate):
"""To reduce space in combo boxes by removing the checkmark."""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
# Remove any check indicator
option.checkState = Qt.Unchecked
class MotorSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that selects motors.
"""
def __init__(self, bundle_id="motor_selection", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Motor X
self.motor_x = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_x.addItem("", None)
self.motor_x.setCurrentText("")
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
# Motor X
self.motor_y = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_y.addItem("", None)
self.motor_y.setCurrentText("")
self.motor_y.setToolTip("Select Motor Y")
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
self.add_action("motor_x", WidgetAction(widget=self.motor_x, adjust_size=False))
self.add_action("motor_y", WidgetAction(widget=self.motor_y, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
self.motor_x.currentTextChanged.connect(lambda: self.connect_motors())
self.motor_y.currentTextChanged.connect(lambda: self.connect_motors())
@SafeSlot()
def connect_motors(self):
motor_x = self.motor_x.currentText()
motor_y = self.motor_y.currentText()
if motor_x != "" and motor_y != "":
if (
motor_x != self.target_widget.config.x_motor.name
or motor_y != self.target_widget.config.y_motor.name
):
self.target_widget.map(motor_x, motor_y)
def cleanup(self):
self.motor_x.close()
self.motor_x.deleteLater()
self.motor_y.close()
self.motor_y.deleteLater()

View File

@ -0,0 +1,51 @@
from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
class MotorSelectionAction(ToolBarAction):
def __init__(self, parent=None):
super().__init__(icon_path=None, tooltip=None, checkable=False)
self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
self.motor_x.addItem("", None)
self.motor_x.setCurrentText("")
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
self.motor_y.addItem("", None)
self.motor_y.setCurrentText("")
self.motor_y.setToolTip("Select Motor Y")
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
self.container = QWidget(parent)
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.motor_x)
layout.addWidget(self.motor_y)
self.container.setLayout(layout)
self.action = self.container
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the widget to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
toolbar.addWidget(self.container)
def cleanup(self):
"""
Cleans up the action, if necessary.
"""
self.motor_x.close()
self.motor_x.deleteLater()
self.motor_y.close()
self.motor_y.deleteLater()
self.container.close()
self.container.deleteLater()

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from collections import deque
from typing import TYPE_CHECKING, cast
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
@ -12,13 +13,15 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.plots.multi_waveform.settings.control_panel import (
MultiWaveformControlPanel,
)
from bec_widgets.widgets.plots.multi_waveform.toolbar_bundles.monitor_selection import (
MultiWaveformSelectionToolbarBundle,
from bec_widgets.widgets.plots.multi_waveform.toolbar_components.monitor_selection import (
monitor_selection_bundle,
)
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
logger = bec_logger.logger
@ -141,33 +144,54 @@ class MultiWaveform(PlotBase):
self.visible_curves = []
self.number_of_visible_curves = 0
self._init_control_panel()
self._init_multiwaveform_toolbar()
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_toolbar(self):
self.monitor_selection_bundle = MultiWaveformSelectionToolbarBundle(
bundle_id="motor_selection", target_widget=self
def _init_multiwaveform_toolbar(self):
self.toolbar.add_bundle(
monitor_selection_bundle(self.toolbar.components, target_widget=self)
)
self.toolbar.add_bundle(self.monitor_selection_bundle, target_widget=self)
super()._init_toolbar()
self.toolbar.widgets["reset_legend"].action.setVisible(False)
self.toolbar.toggle_action_visibility("reset_legend", visible=False)
combobox = self.toolbar.components.get_action("monitor_selection").widget
combobox.currentTextChanged.connect(self.connect_monitor)
cmap = self.toolbar.components.get_action("color_map").widget
cmap.colormap_changed_signal.connect(self.change_colormap)
bundles = self.toolbar.shown_bundles
bundles.insert(0, "monitor_selection")
self.toolbar.show_bundles(bundles)
self._init_control_panel()
def _init_control_panel(self):
self.control_panel = SidePanel(self, orientation="top", panel_max_width=90)
self.layout_manager.add_widget_relative(
self.control_panel, self.round_plot_widget, "bottom"
)
control_panel = SidePanel(self, orientation="top", panel_max_width=90)
self.layout_manager.add_widget_relative(control_panel, self.round_plot_widget, "bottom")
self.controls = MultiWaveformControlPanel(parent=self, target_widget=self)
self.control_panel.add_menu(
control_panel.add_menu(
action_id="control",
icon_name="tune",
tooltip="Show Control panel",
widget=self.controls,
title=None,
)
self.control_panel.toolbar.widgets["control"].action.trigger()
control_panel.toolbar.components.get_action("control").action.trigger()
@SafeSlot()
def connect_monitor(self, _):
combobox = self.toolbar.components.get_action("monitor_selection").widget
monitor = combobox.currentText()
if monitor != "":
if monitor != self.config.monitor:
self.config.monitor = monitor
@SafeSlot(str)
def change_colormap(self, colormap: str):
self.color_palette = colormap
################################################################################
# Widget Specific Properties
@ -488,23 +512,30 @@ class MultiWaveform(PlotBase):
"""
Sync the motor map selection toolbar with the current motor map.
"""
if self.monitor_selection_bundle is not None:
monitor = self.monitor_selection_bundle.monitor.currentText()
color_palette = self.monitor_selection_bundle.colormap_widget.colormap
if monitor != self.config.monitor:
self.monitor_selection_bundle.monitor.blockSignals(True)
self.monitor_selection_bundle.monitor.set_device(self.config.monitor)
self.monitor_selection_bundle.monitor.check_validity(self.config.monitor)
self.monitor_selection_bundle.monitor.blockSignals(False)
combobox_widget: DeviceComboBox = cast(
DeviceComboBox, self.toolbar.components.get_action("monitor_selection").widget
)
cmap_widget: BECColorMapWidget = cast(
BECColorMapWidget, self.toolbar.components.get_action("color_map").widget
)
if color_palette != self.config.color_palette:
self.monitor_selection_bundle.colormap_widget.blockSignals(True)
self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
self.monitor_selection_bundle.colormap_widget.blockSignals(False)
monitor = combobox_widget.currentText()
color_palette = cmap_widget.colormap
if monitor != self.config.monitor:
combobox_widget.setCurrentText(monitor)
combobox_widget.blockSignals(True)
combobox_widget.set_device(self.config.monitor)
combobox_widget.check_validity(self.config.monitor)
combobox_widget.blockSignals(False)
if color_palette != self.config.color_palette:
cmap_widget.blockSignals(True)
cmap_widget.colormap = self.config.color_palette
cmap_widget.blockSignals(False)
def cleanup(self):
self._disconnect_monitor()
self.clear_curves()
self.monitor_selection_bundle.cleanup()
super().cleanup()

View File

@ -1,9 +1,11 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QStyledItemDelegate
from qtpy.QtWidgets import QStyledItemDelegate, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarComponents
from bec_widgets.utils.toolbars.toolbar import ToolbarBundle
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
@ -18,6 +20,37 @@ class NoCheckDelegate(QStyledItemDelegate):
option.checkState = Qt.Unchecked
def monitor_selection_bundle(
components: ToolbarComponents, target_widget: QWidget
) -> ToolbarBundle:
"""
Creates a monitor selection toolbar bundle.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The monitor selection toolbar bundle.
"""
components.add_safe(
"monitor_selection",
DeviceComboBoxAction(
target_widget=target_widget,
device_filter=[BECDeviceFilter.DEVICE],
readout_priority_filter=ReadoutPriority.ASYNC,
add_empty_item=True,
no_check_delegate=True,
),
)
components.add_safe(
"color_map", WidgetAction(widget=BECColorMapWidget(cmap="plasma"), adjust_size=False)
)
bundle = ToolbarBundle("monitor_selection", components)
bundle.add_action("monitor_selection")
bundle.add_action("color_map")
return bundle
class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that selects motors.

View File

@ -14,17 +14,25 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar, ToolbarBundle
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
from bec_widgets.widgets.plots.toolbar_bundles.mouse_interactions import (
MouseInteractionToolbarBundle,
from bec_widgets.widgets.plots.toolbar_components.axis_settings_popup import (
AxisSettingsPopupConnection,
axis_popup_bundle,
)
from bec_widgets.widgets.plots.toolbar_bundles.plot_export import PlotExportBundle
from bec_widgets.widgets.plots.toolbar_bundles.roi_bundle import ROIBundle
from bec_widgets.widgets.plots.toolbar_components.mouse_interactions import (
MouseInteractionConnection,
mouse_interaction_bundle,
)
from bec_widgets.widgets.plots.toolbar_components.plot_export import (
PlotExportConnection,
plot_export_bundle,
)
from bec_widgets.widgets.plots.toolbar_components.roi import RoiConnection, roi_bundle
logger = bec_logger.logger
@ -102,8 +110,6 @@ class PlotBase(BECWidget, QWidget):
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget.addItem(self.plot_item)
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self._init_toolbar()
# PlotItem Addons
self.plot_item.addLegend()
@ -122,6 +128,9 @@ class PlotBase(BECWidget, QWidget):
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self._init_toolbar()
self._init_ui()
self._connect_to_theme_change()
@ -146,36 +155,33 @@ class PlotBase(BECWidget, QWidget):
self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
def _init_toolbar(self):
self.popup_bundle = None
self.performance_bundle = ToolbarBundle("performance")
self.plot_export_bundle = PlotExportBundle("plot_export", target_widget=self)
self.mouse_bundle = MouseInteractionToolbarBundle("mouse_interaction", target_widget=self)
# self.state_export_bundle = SaveStateBundle("state_export", target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
self.roi_bundle = ROIBundle("roi", target_widget=self)
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
self.toolbar.add_bundle(mouse_interaction_bundle(self.toolbar.components))
self.toolbar.add_bundle(roi_bundle(self.toolbar.components))
self.toolbar.add_bundle(axis_popup_bundle(self.toolbar.components))
# Add elements to toolbar
self.toolbar.add_bundle(self.plot_export_bundle, target_widget=self)
# self.toolbar.add_bundle(self.state_export_bundle, target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
self.toolbar.add_bundle(self.mouse_bundle, target_widget=self)
self.toolbar.add_bundle(self.roi_bundle, target_widget=self)
self.performance_bundle.add_action(
"fps_monitor",
MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=self
),
self.toolbar.connect_bundle(
"plot_base", PlotExportConnection(self.toolbar.components, self)
)
self.toolbar.add_bundle(self.performance_bundle, target_widget=self)
self.toolbar.widgets["fps_monitor"].action.toggled.connect(
lambda checked: setattr(self, "enable_fps_monitor", checked)
self.toolbar.connect_bundle(
"plot_base", PerformanceConnection(self.toolbar.components, self)
)
self.toolbar.connect_bundle(
"plot_base", MouseInteractionConnection(self.toolbar.components, self)
)
self.toolbar.connect_bundle("plot_base", RoiConnection(self.toolbar.components, self))
self.toolbar.connect_bundle(
"plot_base", AxisSettingsPopupConnection(self.toolbar.components, self)
)
# hide some options by default
self.toolbar.toggle_action_visibility("fps_monitor", False)
# Get default viewbox state
self.mouse_bundle.get_viewbox_mode()
self.toolbar.show_bundles(
["plot_export", "mouse_interaction", "roi", "performance", "axis_popup"]
)
def add_side_menus(self):
"""Adds multiple menus to the side panel."""
@ -192,45 +198,6 @@ class PlotBase(BECWidget, QWidget):
except ValueError:
return
def add_popups(self):
"""
Add popups to the toolbar.
"""
self.popup_bundle = ToolbarBundle("popup_bundle")
settings = MaterialIconAction(
icon_name="settings", tooltip="Show Axis Settings", checkable=True, parent=self
)
self.popup_bundle.add_action("axis", settings)
self.toolbar.add_bundle(self.popup_bundle, target_widget=self)
self.toolbar.widgets["axis"].action.triggered.connect(self.show_axis_settings_popup)
def show_axis_settings_popup(self):
"""
Show the axis settings dialog.
"""
settings_action = self.toolbar.widgets["axis"].action
if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible():
axis_setting = AxisSettings(parent=self, target_widget=self, popup=True)
self.axis_settings_dialog = SettingsDialog(
self, settings_widget=axis_setting, window_title="Axis Settings", modal=False
)
# When the dialog is closed, update the toolbar icon and clear the reference
self.axis_settings_dialog.finished.connect(self._axis_settings_closed)
self.axis_settings_dialog.show()
settings_action.setChecked(True)
else:
# If already open, bring it to the front
self.axis_settings_dialog.raise_()
self.axis_settings_dialog.activateWindow()
settings_action.setChecked(True) # keep it toggled
def _axis_settings_closed(self):
"""
Slot for when the axis settings dialog is closed.
"""
self.axis_settings_dialog = None
self.toolbar.widgets["axis"].action.setChecked(False)
def reset_legend(self):
"""In the case that the legend is not visible, reset it to be visible to top left corner"""
self.plot_item.legend.autoAnchor(50)
@ -257,22 +224,23 @@ class PlotBase(BECWidget, QWidget):
raise ValueError("ui_mode must be an instance of UIMode")
self._ui_mode = mode
# First, clear both UI elements:
if self.popup_bundle is not None:
for action_id in self.toolbar.bundles["popup_bundle"]:
self.toolbar.widgets[action_id].action.setVisible(False)
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
self.axis_settings_dialog.close()
self.side_panel.hide()
# Now, apply the new mode:
if mode == UIMode.POPUP:
if self.popup_bundle is None:
self.add_popups()
else:
for action_id in self.toolbar.bundles["popup_bundle"]:
self.toolbar.widgets[action_id].action.setVisible(True)
shown_bundles = self.toolbar.shown_bundles
if "axis_popup" not in shown_bundles:
shown_bundles.append("axis_popup")
self.toolbar.show_bundles(shown_bundles)
self.side_panel.hide()
elif mode == UIMode.SIDE:
shown_bundles = self.toolbar.shown_bundles
if "axis_popup" in shown_bundles:
shown_bundles.remove("axis_popup")
self.toolbar.show_bundles(shown_bundles)
pb_connection = self.toolbar.bundles["axis_popup"].get_connection("plot_base")
if pb_connection.axis_settings_dialog is not None:
pb_connection.axis_settings_dialog.close()
pb_connection.axis_settings_dialog = None
self.add_side_menus()
self.side_panel.show()
@ -1049,6 +1017,7 @@ class PlotBase(BECWidget, QWidget):
self.axis_settings_dialog = None
self.cleanup_pyqtgraph()
self.round_plot_widget.close()
self.toolbar.cleanup()
super().cleanup()
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):
@ -1087,8 +1056,12 @@ if __name__ == "__main__": # pragma: no cover:
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
app = QApplication(sys.argv)
window = PlotBase()
window.show()
launch_window = BECMainWindow()
pb = PlotBase(popups=False)
launch_window.setCentralWidget(pb)
launch_window.show()
sys.exit(app.exec_())

View File

@ -13,7 +13,7 @@ from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbar import MaterialIconAction
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import (
ScatterCurve,
@ -131,8 +131,8 @@ class ScatterWaveform(PlotBase):
self.proxy_update_sync = pg.SignalProxy(
self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
)
if self.ui_mode == UIMode.SIDE:
self._init_scatter_curve_settings()
self._init_scatter_curve_settings()
self.update_with_scan_history(-1)
################################################################################
@ -143,44 +143,40 @@ class ScatterWaveform(PlotBase):
"""
Initialize the scatter curve settings menu.
"""
if self.ui_mode == UIMode.SIDE:
self.scatter_curve_settings = ScatterCurveSettings(
parent=self, target_widget=self, popup=False
)
self.side_panel.add_menu(
action_id="scatter_curve",
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
widget=self.scatter_curve_settings,
title="Scatter Curve Settings",
)
else:
scatter_curve_action = MaterialIconAction(
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
checkable=True,
parent=self,
)
self.toolbar.components.add_safe("scatter_waveform_settings", scatter_curve_action)
self.toolbar.get_bundle("axis_popup").add_action("scatter_waveform_settings")
scatter_curve_action.action.triggered.connect(self.show_scatter_curve_settings)
self.scatter_curve_settings = ScatterCurveSettings(
parent=self, target_widget=self, popup=False
)
self.side_panel.add_menu(
action_id="scatter_curve",
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
widget=self.scatter_curve_settings,
title="Scatter Curve Settings",
)
def add_popups(self):
"""
Add popups to the ScatterWaveform widget.
"""
super().add_popups()
scatter_curve_setting_action = MaterialIconAction(
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
checkable=True,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle",
action_id="scatter_waveform_settings",
action=scatter_curve_setting_action,
target_widget=self,
)
self.toolbar.widgets["scatter_waveform_settings"].action.triggered.connect(
self.show_scatter_curve_settings
)
shown_bundles = self.toolbar.shown_bundles
if "performance" in shown_bundles:
shown_bundles.remove("performance")
self.toolbar.show_bundles(shown_bundles)
def show_scatter_curve_settings(self):
"""
Show the scatter curve settings dialog.
"""
scatter_settings_action = self.toolbar.widgets["scatter_waveform_settings"].action
scatter_settings_action = self.toolbar.components.get_action(
"scatter_waveform_settings"
).action
if self.scatter_dialog is None or not self.scatter_dialog.isVisible():
scatter_settings = ScatterCurveSettings(parent=self, target_widget=self, popup=True)
self.scatter_dialog = SettingsDialog(
@ -205,7 +201,7 @@ class ScatterWaveform(PlotBase):
Slot for when the scatter curve settings dialog is closed.
"""
self.scatter_dialog = None
self.toolbar.widgets["scatter_waveform_settings"].action.setChecked(False)
self.toolbar.components.get_action("scatter_waveform_settings").action.setChecked(False)
################################################################################
# Widget Specific Properties

View File

@ -1,108 +0,0 @@
import pyqtgraph as pg
from qtpy.QtCore import QTimer
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle
class MouseInteractionToolbarBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls mouse interactions on a plot.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
self.mouse_mode = None
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
drag = MaterialIconAction(
icon_name="drag_pan",
tooltip="Drag Mouse Mode",
checkable=True,
parent=self.target_widget, # or any valid parent
)
rect = MaterialIconAction(
icon_name="frame_inspect",
tooltip="Rectangle Zoom Mode",
checkable=True,
parent=self.target_widget,
)
auto = MaterialIconAction(
icon_name="open_in_full",
tooltip="Autorange Plot",
checkable=False,
parent=self.target_widget,
)
self.switch_mouse_action = SwitchableToolBarAction(
actions={"drag_mode": drag, "rectangle_mode": rect},
initial_action="drag_mode",
tooltip="Mouse Modes",
checkable=True,
parent=self.target_widget,
)
# Add them to the bundle
self.add_action("switch_mouse", self.switch_mouse_action)
self.add_action("auto_range", auto)
# Immediately connect signals
drag.action.toggled.connect(self.enable_mouse_pan_mode)
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
auto.action.triggered.connect(self.autorange_plot)
def get_viewbox_mode(self):
"""
Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action.
"""
if self.target_widget:
viewbox = self.target_widget.plot_item.getViewBox()
if viewbox.getState()["mouseMode"] == 3:
self.switch_mouse_action.set_default_action("drag_mode")
self.switch_mouse_action.main_button.setChecked(True)
self.mouse_mode = "PanMode"
elif viewbox.getState()["mouseMode"] == 1:
self.switch_mouse_action.set_default_action("rectangle_mode")
self.switch_mouse_action.main_button.setChecked(True)
self.mouse_mode = "RectMode"
@SafeSlot(bool)
def enable_mouse_rectangle_mode(self, checked: bool):
"""
Enable the rectangle zoom mode on the plot widget.
"""
if self.mouse_mode == "RectMode":
self.switch_mouse_action.main_button.setChecked(True)
return
self.actions["switch_mouse"].actions["drag_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
self.mouse_mode = "RectMode"
@SafeSlot(bool)
def enable_mouse_pan_mode(self, checked: bool):
"""
Enable the pan mode on the plot widget.
"""
if self.mouse_mode == "PanMode":
self.switch_mouse_action.main_button.setChecked(True)
return
self.actions["switch_mouse"].actions["rectangle_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
self.mouse_mode = "PanMode"
@SafeSlot()
def autorange_plot(self):
"""
Enable autorange on the plot widget.
"""
if self.target_widget:
self.target_widget.auto_range_x = True
self.target_widget.auto_range_y = True

View File

@ -1,81 +0,0 @@
import traceback
from pyqtgraph.exporters import MatplotlibExporter
from bec_widgets.utils.error_popups import SafeSlot, WarningPopupUtility
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle
class PlotExportBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls exporting a plot.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
save = MaterialIconAction(
icon_name="save", tooltip="Open Export Dialog", parent=self.target_widget
)
matplotlib = MaterialIconAction(
icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=self.target_widget
)
switch_export_action = SwitchableToolBarAction(
actions={"save": save, "matplotlib": matplotlib},
initial_action="save",
tooltip="Switchable Action",
checkable=False,
parent=self,
)
# Add them to the bundle
self.add_action("export_switch", switch_export_action)
# Immediately connect signals
save.action.triggered.connect(self.export_dialog)
matplotlib.action.triggered.connect(self.matplotlib_dialog)
@SafeSlot()
def export_dialog(self):
"""
Open the export dialog for the plot widget.
"""
if self.target_widget:
scene = self.target_widget.plot_item.scene()
scene.contextMenuItem = self.target_widget.plot_item
scene.showExportDialog()
@SafeSlot()
def matplotlib_dialog(self):
"""
Export the plot widget to Matplotlib.
"""
if self.target_widget:
try:
import matplotlib as mpl
MatplotlibExporter(self.target_widget.plot_item).export()
except ModuleNotFoundError:
warning_util = WarningPopupUtility()
warning_util.show_warning(
title="Matplotlib not installed",
message="Matplotlib is required for this feature.",
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
)
return
except TypeError:
warning_util = WarningPopupUtility()
error_msg = traceback.format_exc()
warning_util.show_warning(
title="Matplotlib TypeError",
message="Matplotlib exporter could not resolve the plot item.",
detailed_text=error_msg,
)
return

View File

@ -1,31 +0,0 @@
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
class ROIBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls crosshair and ROI interaction.
"""
def __init__(self, bundle_id="roi", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
)
reset_legend = MaterialIconAction(
icon_name="restart_alt", tooltip="Reset the position of legend.", checkable=False
)
# Add them to the bundle
self.add_action("crosshair", crosshair)
self.add_action("reset_legend", reset_legend)
# Immediately connect signals
crosshair.action.toggled.connect(self.target_widget.toggle_crosshair)
reset_legend.action.triggered.connect(self.target_widget.reset_legend)

View File

@ -1,48 +0,0 @@
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
class SaveStateBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls saving the state of the widget.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
save_state = MaterialIconAction(
icon_name="download", tooltip="Save Widget State", parent=self.target_widget
)
load_state = MaterialIconAction(
icon_name="upload", tooltip="Load Widget State", parent=self.target_widget
)
# Add them to the bundle
self.add_action("save", save_state)
self.add_action("matplotlib", load_state)
# Immediately connect signals
save_state.action.triggered.connect(self.save_state_dialog)
load_state.action.triggered.connect(self.load_state_dialog)
@SafeSlot()
def save_state_dialog(self):
"""
Open the export dialog to save a state of the widget.
"""
if self.target_widget:
self.target_widget.state_manager.save_state()
@SafeSlot()
def load_state_dialog(self):
"""
Load a saved state of the widget.
"""
if self.target_widget:
self.target_widget.state_manager.load_state()

View File

@ -0,0 +1,94 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
def axis_popup_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates an axis popup toolbar bundle.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The axis popup toolbar bundle.
"""
components.add_safe(
"axis_settings_popup",
MaterialIconAction(
icon_name="settings",
tooltip="Show Axis Settings",
checkable=True,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("axis_popup", components)
bundle.add_action("axis_settings_popup")
return bundle
class AxisSettingsPopupConnection(BundleConnection):
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "axis_popup"
self.components = components
self.target_widget = target_widget
self.axis_settings_dialog = None
self._connected = False
super().__init__()
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action_reference("axis_settings_popup")().action.triggered.connect(
self.show_axis_settings_popup
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action_reference("axis_settings_popup")().action.triggered.disconnect(
self.show_axis_settings_popup
)
def show_axis_settings_popup(self):
"""
Show the axis settings dialog.
"""
settings_action = self.components.get_action_reference("axis_settings_popup")().action
if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible():
axis_setting = AxisSettings(
parent=self.target_widget, target_widget=self.target_widget, popup=True
)
self.axis_settings_dialog = SettingsDialog(
self.target_widget,
settings_widget=axis_setting,
window_title="Axis Settings",
modal=False,
)
# When the dialog is closed, update the toolbar icon and clear the reference
self.axis_settings_dialog.finished.connect(self._axis_settings_closed)
self.axis_settings_dialog.show()
settings_action.setChecked(True)
else:
# If already open, bring it to the front
self.axis_settings_dialog.raise_()
self.axis_settings_dialog.activateWindow()
settings_action.setChecked(True) # keep it toggled
def _axis_settings_closed(self):
"""
Slot for when the axis settings dialog is closed.
"""
self.axis_settings_dialog = None
self.components.get_action_reference("axis_settings_popup")().action.setChecked(False)

View File

@ -0,0 +1,169 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pyqtgraph as pg
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.connections import BundleConnection
if TYPE_CHECKING:
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
def mouse_interaction_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a mouse interaction toolbar bundle.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The mouse interaction toolbar bundle.
"""
components.add_safe(
"mouse_drag",
MaterialIconAction(
icon_name="drag_pan",
tooltip="Drag Mouse Mode",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"mouse_rect",
MaterialIconAction(
icon_name="frame_inspect",
tooltip="Rectangle Zoom Mode",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"auto_range",
MaterialIconAction(
icon_name="open_in_full",
tooltip="Autorange Plot",
checkable=False,
parent=components.toolbar,
),
)
components.add_safe(
"switch_mouse_mode",
SwitchableToolBarAction(
actions={
"drag_mode": components.get_action_reference("mouse_drag")(),
"rectangle_mode": components.get_action_reference("mouse_rect")(),
},
initial_action="drag_mode",
tooltip="Mouse Modes",
checkable=True,
parent=components.toolbar,
default_state_checked=True,
),
)
bundle = ToolbarBundle("mouse_interaction", components)
bundle.add_action("switch_mouse_mode")
bundle.add_action("auto_range")
return bundle
class MouseInteractionConnection(BundleConnection):
"""
Connection class for mouse interaction toolbar bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "mouse_interaction"
self.components = components
self.target_widget = target_widget
self.mouse_mode = None
if (
not hasattr(self.target_widget, "plot_item")
or not hasattr(self.target_widget, "auto_range_x")
or not hasattr(self.target_widget, "auto_range_y")
):
raise AttributeError(
"Target widget must implement required methods for mouse interactions."
)
super().__init__()
self._connected = False # Track if the connection has been made
def connect(self):
self._connected = True
drag = self.components.get_action_reference("mouse_drag")()
rect = self.components.get_action_reference("mouse_rect")()
auto = self.components.get_action_reference("auto_range")()
drag.action.toggled.connect(self.enable_mouse_pan_mode)
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
auto.action.triggered.connect(self.autorange_plot)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
drag = self.components.get_action_reference("mouse_drag")()
rect = self.components.get_action_reference("mouse_rect")()
auto = self.components.get_action_reference("auto_range")()
drag.action.toggled.disconnect(self.enable_mouse_pan_mode)
rect.action.toggled.disconnect(self.enable_mouse_rectangle_mode)
auto.action.triggered.disconnect(self.autorange_plot)
def get_viewbox_mode(self):
"""
Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action.
"""
if self.target_widget:
viewbox = self.target_widget.plot_item.getViewBox()
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
if viewbox.getState()["mouseMode"] == 3:
switch_mouse_action.set_default_action("drag_mode")
switch_mouse_action.main_button.setChecked(True)
self.mouse_mode = "PanMode"
elif viewbox.getState()["mouseMode"] == 1:
switch_mouse_action.set_default_action("rectangle_mode")
switch_mouse_action.main_button.setChecked(True)
self.mouse_mode = "RectMode"
@SafeSlot(bool)
def enable_mouse_rectangle_mode(self, checked: bool):
"""
Enable the rectangle zoom mode on the plot widget.
"""
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
if self.mouse_mode == "RectMode":
switch_mouse_action.main_button.setChecked(True)
return
drag_mode = self.components.get_action_reference("mouse_drag")()
drag_mode.action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
self.mouse_mode = "RectMode"
@SafeSlot(bool)
def enable_mouse_pan_mode(self, checked: bool):
"""
Enable the pan mode on the plot widget.
"""
if self.mouse_mode == "PanMode":
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
switch_mouse_action.main_button.setChecked(True)
return
rect_mode = self.components.get_action_reference("mouse_rect")()
rect_mode.action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
self.mouse_mode = "PanMode"
@SafeSlot()
def autorange_plot(self):
"""
Enable autorange on the plot widget.
"""
if self.target_widget:
self.target_widget.auto_range_x = True
self.target_widget.auto_range_y = True

View File

@ -0,0 +1,123 @@
from __future__ import annotations
import traceback
from bec_widgets.utils.error_popups import SafeSlot, WarningPopupUtility
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
def plot_export_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a plot export toolbar bundle.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The plot export toolbar bundle.
"""
components.add_safe(
"save",
MaterialIconAction(
icon_name="save", tooltip="Open Export Dialog", parent=components.toolbar
),
)
components.add_safe(
"matplotlib",
MaterialIconAction(
icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=components.toolbar
),
)
components.add_safe(
"export_switch",
SwitchableToolBarAction(
actions={
"save": components.get_action_reference("save")(),
"matplotlib": components.get_action_reference("matplotlib")(),
},
initial_action="save",
tooltip="Export Plot",
checkable=False,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("plot_export", components)
bundle.add_action("export_switch")
return bundle
def plot_export_connection(components: ToolbarComponents, target_widget=None):
"""
Connects the plot export actions to the target widget.
Args:
components (ToolbarComponents): The components to be connected.
target_widget: The widget to which the actions will be connected.
"""
class PlotExportConnection(BundleConnection):
def __init__(self, components: ToolbarComponents, target_widget):
super().__init__()
self.bundle_name = "plot_export"
self.components = components
self.target_widget = target_widget
self._connected = False # Track if the connection has been made
def connect(self):
self._connected = True
# Connect the actions to the target widget
self.components.get_action_reference("save")().action.triggered.connect(self.export_dialog)
self.components.get_action_reference("matplotlib")().action.triggered.connect(
self.matplotlib_dialog
)
def disconnect(self):
if not self._connected:
return
# Disconnect the actions from the target widget
self.components.get_action_reference("save")().action.triggered.disconnect(
self.export_dialog
)
self.components.get_action_reference("matplotlib")().action.triggered.disconnect(
self.matplotlib_dialog
)
@SafeSlot()
def export_dialog(self):
"""
Open the export dialog for the plot widget.
"""
if self.target_widget:
scene = self.target_widget.plot_item.scene()
scene.contextMenuItem = self.target_widget.plot_item
scene.showExportDialog()
@SafeSlot()
def matplotlib_dialog(self):
"""
Export the plot widget to Matplotlib.
"""
if self.target_widget:
try:
import matplotlib as mpl
MatplotlibExporter(self.target_widget.plot_item).export()
except ModuleNotFoundError:
warning_util = WarningPopupUtility()
warning_util.show_warning(
title="Matplotlib not installed",
message="Matplotlib is required for this feature.",
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
)
return
except TypeError:
warning_util = WarningPopupUtility()
error_msg = traceback.format_exc()
warning_util.show_warning(
title="Matplotlib TypeError",
message="Matplotlib exporter could not resolve the plot item.",
detailed_text=error_msg,
)
return

View File

@ -0,0 +1,79 @@
from __future__ import annotations
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
def roi_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for ROI and crosshair interaction.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The ROI toolbar bundle.
"""
components.add_safe(
"crosshair",
MaterialIconAction(
icon_name="point_scan",
tooltip="Show Crosshair",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"reset_legend",
MaterialIconAction(
icon_name="restart_alt",
tooltip="Reset the position of legend.",
checkable=False,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("roi", components)
bundle.add_action("crosshair")
bundle.add_action("reset_legend")
return bundle
class RoiConnection(BundleConnection):
"""
Connection class for the ROI toolbar bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "roi"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "toggle_crosshair") or not hasattr(
self.target_widget, "reset_legend"
):
raise AttributeError(
"Target widget must implement 'toggle_crosshair' and 'reset_legend'."
)
super().__init__()
self._connected = False
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action_reference("crosshair")().action.toggled.connect(
self.target_widget.toggle_crosshair
)
self.components.get_action_reference("reset_legend")().action.triggered.connect(
self.target_widget.reset_legend
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action_reference("crosshair")().action.toggled.disconnect(
self.target_widget.toggle_crosshair
)
self.components.get_action_reference("reset_legend")().action.triggered.disconnect(
self.target_widget.reset_legend
)

View File

@ -25,7 +25,9 @@ from bec_widgets import SafeSlot
from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
@ -379,43 +381,67 @@ class CurveTree(BECWidget, QWidget):
def _init_toolbar(self):
"""Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize."""
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
add = MaterialIconAction(
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self.toolbar.components.add_safe(
"add",
MaterialIconAction(
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
),
)
expand = MaterialIconAction(
icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self
self.toolbar.components.add_safe(
"expand",
MaterialIconAction(
icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self
),
)
collapse = MaterialIconAction(
icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self
self.toolbar.components.add_safe(
"collapse",
MaterialIconAction(
icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self
),
)
self.toolbar.add_action("add", add, self)
self.toolbar.add_action("expand_all", expand, self)
self.toolbar.add_action("collapse_all", collapse, self)
bundle = ToolbarBundle("curve_tree", self.toolbar.components)
bundle.add_action("add")
bundle.add_action("expand")
bundle.add_action("collapse")
self.toolbar.add_bundle(bundle)
# Add colormap widget (not updating waveform's color_palette until Send is pressed)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
bundle.add_action("spacer")
# Renormalize colors button
renorm_action = MaterialIconAction(
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
self.toolbar.components.add_safe(
"renormalize_colors",
MaterialIconAction(
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
),
)
self.toolbar.add_action("renormalize_colors", renorm_action, self)
bundle.add_action("renormalize_colors")
renorm_action = self.toolbar.components.get_action("renormalize_colors")
renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors())
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "plasma")
self.toolbar.addWidget(self.colormap_widget)
self.toolbar.components.add_safe(
"colormap_widget", WidgetAction(widget=self.colormap_widget)
)
bundle.add_action("colormap_widget")
self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed)
add = self.toolbar.components.get_action("add")
expand = self.toolbar.components.get_action("expand")
collapse = self.toolbar.components.get_action("collapse")
add.action.triggered.connect(lambda checked: self.add_new_curve())
expand.action.triggered.connect(lambda checked: self.expand_all_daps())
collapse.action.triggered.connect(lambda checked: self.collapse_all_daps())
self.layout.addWidget(self.toolbar)
self.toolbar.show_bundles(["curve_tree"])
def _init_tree(self):
"""Initialize the QTreeWidget with 7 columns and compact widths."""
self.tree = QTreeWidget()

View File

@ -1,7 +1,7 @@
from __future__ import annotations
import json
from typing import Any, Literal
from typing import Literal
import lmfit
import numpy as np
@ -29,7 +29,7 @@ from bec_widgets.utils.colors import Colors, set_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbar import MaterialIconAction
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
@ -182,6 +182,7 @@ class Waveform(PlotBase):
self._init_roi_manager()
self.dap_summary = None
self.dap_summary_dialog = None
self._add_fit_parameters_popup()
self._enable_roi_toolbar_action(False) # default state where are no dap curves
self._init_curve_dialog()
self.curve_settings_dialog = None
@ -214,6 +215,8 @@ class Waveform(PlotBase):
# To fix the ViewAll action with clipToView activated
self._connect_viewbox_menu_actions()
self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
def _connect_viewbox_menu_actions(self):
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
menu = self.plot_item.vb.menu
@ -247,21 +250,21 @@ class Waveform(PlotBase):
super().add_side_menus()
self._add_dap_summary_side_menu()
def add_popups(self):
def _add_fit_parameters_popup(self):
"""
Add popups to the Waveform widget.
"""
super().add_popups()
LMFitDialog_action = MaterialIconAction(
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
self.toolbar.components.add_safe(
"fit_params",
MaterialIconAction(
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
),
)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle",
action_id="fit_params",
action=LMFitDialog_action,
target_widget=self,
self.toolbar.get_bundle("axis_popup").add_action("fit_params")
self.toolbar.components.get_action("fit_params").action.triggered.connect(
self.show_dap_summary_popup
)
self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_dap_summary_popup)
@SafeSlot()
def _reset_view(self):
@ -290,14 +293,17 @@ class Waveform(PlotBase):
Initialize the ROI manager for the Waveform widget.
"""
# Add toolbar icon
roi = MaterialIconAction(
icon_name="align_justify_space_between",
tooltip="Add ROI region for DAP",
checkable=True,
)
self.toolbar.add_action_to_bundle(
bundle_id="roi", action_id="roi_linear", action=roi, target_widget=self
self.toolbar.components.add_safe(
"roi_linear",
MaterialIconAction(
icon_name="align_justify_space_between",
tooltip="Add ROI region for DAP",
checkable=True,
parent=self,
),
)
self.toolbar.get_bundle("roi").add_action("roi_linear")
self._roi_manager = WaveformROIManager(self.plot_item, parent=self)
# Connect manager signals -> forward them via Waveform's own signals
@ -307,23 +313,30 @@ class Waveform(PlotBase):
# Example: connect ROI changed to re-request DAP
self.roi_changed.connect(self._on_roi_changed_for_dap)
self._roi_manager.roi_active.connect(self.request_dap_update)
self.toolbar.widgets["roi_linear"].action.toggled.connect(self._roi_manager.toggle_roi)
self.toolbar.components.get_action("roi_linear").action.toggled.connect(
self._roi_manager.toggle_roi
)
def _init_curve_dialog(self):
"""
Initializes the Curve dialog within the toolbar.
"""
curve_settings = MaterialIconAction(
icon_name="timeline", tooltip="Show Curve dialog.", checkable=True
self.toolbar.components.add_safe(
"curve",
MaterialIconAction(
icon_name="timeline", tooltip="Show Curve dialog.", checkable=True, parent=self
),
)
self.toolbar.get_bundle("axis_popup").add_action("curve")
self.toolbar.components.get_action("curve").action.triggered.connect(
self.show_curve_settings_popup
)
self.toolbar.add_action("curve", curve_settings, target_widget=self)
self.toolbar.widgets["curve"].action.triggered.connect(self.show_curve_settings_popup)
def show_curve_settings_popup(self):
"""
Displays the curve settings popup to allow users to modify curve-related configurations.
"""
curve_action = self.toolbar.widgets["curve"].action
curve_action = self.toolbar.components.get_action("curve").action
if self.curve_settings_dialog is None or not self.curve_settings_dialog.isVisible():
curve_setting = CurveSetting(parent=self, target_widget=self)
@ -347,7 +360,7 @@ class Waveform(PlotBase):
self.curve_settings_dialog.close()
self.curve_settings_dialog.deleteLater()
self.curve_settings_dialog = None
self.toolbar.widgets["curve"].action.setChecked(False)
self.toolbar.components.get_action("curve").action.setChecked(False)
@property
def roi_region(self) -> tuple[float, float] | None:
@ -394,9 +407,9 @@ class Waveform(PlotBase):
Args:
enable(bool): Enable or disable the ROI toolbar action.
"""
self.toolbar.widgets["roi_linear"].action.setEnabled(enable)
self.toolbar.components.get_action("roi_linear").action.setEnabled(enable)
if enable is False:
self.toolbar.widgets["roi_linear"].action.setChecked(False)
self.toolbar.components.get_action("roi_linear").action.setChecked(False)
self._roi_manager.toggle_roi(False)
################################################################################
@ -420,7 +433,7 @@ class Waveform(PlotBase):
"""
Show the DAP summary popup.
"""
fit_action = self.toolbar.widgets["fit_params"].action
fit_action = self.toolbar.components.get_action("fit_params").action
if self.dap_summary_dialog is None or not self.dap_summary_dialog.isVisible():
self.dap_summary = LMFitDialog(parent=self)
self.dap_summary_dialog = QDialog(modal=False)
@ -446,7 +459,7 @@ class Waveform(PlotBase):
self.dap_summary.deleteLater()
self.dap_summary_dialog.deleteLater()
self.dap_summary_dialog = None
self.toolbar.widgets["fit_params"].action.setChecked(False)
self.toolbar.components.get_action("fit_params").action.setChecked(False)
def _get_dap_from_target_widget(self) -> None:
"""Get the DAP data from the target widget and update the DAP dialog manually on creation."""

View File

@ -9,7 +9,9 @@ from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem,
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.toolbar import ModularToolBar, SeparatorAction, WidgetAction
from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
@ -76,17 +78,26 @@ class BECQueue(BECWidget, CompactPopupWidget):
"""
widget_label = QLabel(text="Live Queue", parent=self)
widget_label.setStyleSheet("font-weight: bold;")
self.toolbar = ModularToolBar(
parent=self,
actions={
"widget_label": WidgetAction(widget=widget_label),
"separator_1": SeparatorAction(),
"resume": WidgetAction(widget=ResumeButton(parent=self, toolbar=False)),
"stop": WidgetAction(widget=StopButton(parent=self, toolbar=False)),
"reset": WidgetAction(widget=ResetButton(parent=self, toolbar=False)),
},
target_widget=self,
self.toolbar = ModularToolBar(parent=self)
self.toolbar.components.add_safe("widget_label", WidgetAction(widget=widget_label))
bundle = ToolbarBundle("queue_label", self.toolbar.components)
bundle.add_action("widget_label")
self.toolbar.add_bundle(bundle)
self.toolbar.add_action(
"resume", WidgetAction(widget=ResumeButton(parent=self, toolbar=True))
)
self.toolbar.add_action("stop", WidgetAction(widget=StopButton(parent=self, toolbar=True)))
self.toolbar.add_action(
"reset", WidgetAction(widget=ResetButton(parent=self, toolbar=True))
)
control_bundle = ToolbarBundle("control", self.toolbar.components)
control_bundle.add_action("resume")
control_bundle.add_action("stop")
control_bundle.add_action("reset")
self.toolbar.add_bundle(control_bundle)
self.toolbar.show_bundles(["queue_label", "control"])
self.addWidget(self.toolbar)

View File

@ -97,13 +97,15 @@ def test_new_dock_raises_for_invalid_name(bec_dock_area):
# Toolbar Actions
###################################
def test_toolbar_add_plot_waveform(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["waveform"].trigger()
bec_dock_area.toolbar.components.get_action("menu_plots").actions["waveform"].action.trigger()
assert "waveform_0" in bec_dock_area.panels
assert bec_dock_area.panels["waveform_0"].widgets[0].config.widget_class == "Waveform"
def test_toolbar_add_plot_scatter_waveform(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].trigger()
bec_dock_area.toolbar.components.get_action("menu_plots").actions[
"scatter_waveform"
].action.trigger()
assert "scatter_waveform_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["scatter_waveform_0"].widgets[0].config.widget_class
@ -112,19 +114,22 @@ def test_toolbar_add_plot_scatter_waveform(bec_dock_area):
def test_toolbar_add_plot_image(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["image"].trigger()
bec_dock_area.toolbar.components.get_action("menu_plots").actions["image"].action.trigger()
assert "image_0" in bec_dock_area.panels
assert bec_dock_area.panels["image_0"].widgets[0].config.widget_class == "Image"
def test_toolbar_add_plot_motor_map(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["motor_map"].trigger()
bec_dock_area.toolbar.components.get_action("menu_plots").actions["motor_map"].action.trigger()
assert "motor_map_0" in bec_dock_area.panels
assert bec_dock_area.panels["motor_map_0"].widgets[0].config.widget_class == "MotorMap"
def test_toolbar_add_multi_waveform(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["multi_waveform"].trigger()
bec_dock_area.toolbar.components.get_action("menu_plots").actions[
"multi_waveform"
].action.trigger()
# Check if the MultiWaveform panel is created
assert "multi_waveform_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["multi_waveform_0"].widgets[0].config.widget_class == "MultiWaveform"
@ -132,7 +137,9 @@ def test_toolbar_add_multi_waveform(bec_dock_area):
def test_toolbar_add_device_positioner_box(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_devices"].widgets["positioner_box"].trigger()
bec_dock_area.toolbar.components.get_action("menu_devices").actions[
"positioner_box"
].action.trigger()
assert "positioner_box_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["positioner_box_0"].widgets[0].config.widget_class == "PositionerBox"
@ -143,19 +150,21 @@ def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full):
bec_dock_area.client.connector.set_and_publish(
MessageEndpoints.scan_queue_status(), bec_queue_msg_full
)
bec_dock_area.toolbar.widgets["menu_utils"].widgets["queue"].trigger()
bec_dock_area.toolbar.components.get_action("menu_utils").actions["queue"].action.trigger()
assert "bec_queue_0" in bec_dock_area.panels
assert bec_dock_area.panels["bec_queue_0"].widgets[0].config.widget_class == "BECQueue"
def test_toolbar_add_utils_status(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_utils"].widgets["status"].trigger()
bec_dock_area.toolbar.components.get_action("menu_utils").actions["status"].action.trigger()
assert "bec_status_box_0" in bec_dock_area.panels
assert bec_dock_area.panels["bec_status_box_0"].widgets[0].config.widget_class == "BECStatusBox"
def test_toolbar_add_utils_progress_bar(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_utils"].widgets["progress_bar"].trigger()
bec_dock_area.toolbar.components.get_action("menu_utils").actions[
"progress_bar"
].action.trigger()
assert "ring_progress_bar_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["ring_progress_bar_0"].widgets[0].config.widget_class

View File

@ -157,10 +157,10 @@ def test_curve_tree_init(curve_tree_fixture):
assert curve_tree.color_palette == "plasma"
assert curve_tree.tree.columnCount() == 7
assert "add" in curve_tree.toolbar.widgets
assert "expand_all" in curve_tree.toolbar.widgets
assert "collapse_all" in curve_tree.toolbar.widgets
assert "renormalize_colors" in curve_tree.toolbar.widgets
assert curve_tree.toolbar.components.exists("add")
assert curve_tree.toolbar.components.exists("expand")
assert curve_tree.toolbar.components.exists("collapse")
assert curve_tree.toolbar.components.exists("renormalize_colors")
def test_add_new_curve(curve_tree_fixture):

View File

@ -39,9 +39,11 @@ def test_initialization(roi_tree, image_widget):
assert len(roi_tree.tree.findItems("", Qt.MatchContains)) == 0 # Empty tree initially
# Check toolbar actions
assert hasattr(roi_tree, "add_rect_action")
assert hasattr(roi_tree, "add_circle_action")
assert hasattr(roi_tree, "expand_toggle")
assert roi_tree.toolbar.components.get_action("roi_rectangle")
assert roi_tree.toolbar.components.get_action("roi_circle")
assert roi_tree.toolbar.components.get_action("roi_ellipse")
assert roi_tree.toolbar.components.get_action("expand_toggle")
assert roi_tree.toolbar.components.get_action("lock_unlock_all")
# Check tree view setup
assert roi_tree.tree.columnCount() == 3
@ -216,23 +218,25 @@ def test_draw_mode_toggle(roi_tree, qtbot):
assert roi_tree._roi_draw_mode is None
# Toggle rect mode on
roi_tree.add_rect_action.action.toggle()
rect_action = roi_tree.toolbar.components.get_action("roi_rectangle").action
circle_action = roi_tree.toolbar.components.get_action("roi_circle").action
rect_action.toggle()
assert roi_tree._roi_draw_mode == "rect"
assert roi_tree.add_rect_action.action.isChecked()
assert not roi_tree.add_circle_action.action.isChecked()
assert rect_action.isChecked()
assert not circle_action.isChecked()
# Toggle circle mode on (should turn off rect mode)
roi_tree.add_circle_action.action.toggle()
circle_action.toggle()
qtbot.wait(200)
assert roi_tree._roi_draw_mode == "circle"
assert not roi_tree.add_rect_action.action.isChecked()
assert roi_tree.add_circle_action.action.isChecked()
assert not rect_action.isChecked()
assert circle_action.isChecked()
# Toggle circle mode off
roi_tree.add_circle_action.action.toggle()
circle_action.toggle()
assert roi_tree._roi_draw_mode is None
assert not roi_tree.add_rect_action.action.isChecked()
assert not roi_tree.add_circle_action.action.isChecked()
assert not circle_action.isChecked()
assert not rect_action.isChecked()
def test_add_roi_from_toolbar(qtbot, mocked_client):
@ -250,7 +254,7 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
# Test rectangle ROI creation
# 1. Activate rectangle drawing mode
roi_tree.add_rect_action.action.setChecked(True)
roi_tree.toolbar.components.get_action("roi_rectangle").action.setChecked(True)
assert roi_tree._roi_draw_mode == "rect"
# Get plot widget and view
@ -294,8 +298,8 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
# Test circle ROI creation
# Reset ROI draw mode
roi_tree.add_rect_action.action.setChecked(False)
roi_tree.add_circle_action.action.setChecked(True)
roi_tree.toolbar.components.get_action("roi_rectangle").action.setChecked(False)
roi_tree.toolbar.components.get_action("roi_circle").action.setChecked(True)
assert roi_tree._roi_draw_mode == "circle"
# Define new positions for circle ROI

View File

@ -242,10 +242,11 @@ def test_image_data_update_1d(qtbot, mocked_client):
def test_toolbar_actions_presence(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
assert "autorange_image" in bec_image_view.toolbar.widgets
assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"]
assert "processing" in bec_image_view.toolbar.bundles
assert "selection" in bec_image_view.toolbar.bundles
assert bec_image_view.toolbar.components.exists("image_autorange")
assert bec_image_view.toolbar.components.exists("lock_aspect_ratio")
assert bec_image_view.toolbar.components.exists("image_processing_fft")
assert bec_image_view.toolbar.components.exists("image_device_combo")
assert bec_image_view.toolbar.components.exists("image_dim_combo")
def test_image_processing_fft_toggle(qtbot, mocked_client):
@ -304,8 +305,8 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
def test_setup_image_from_toolbar(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.selection_bundle.device_combo_box.setCurrentText("eiger")
bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d")
bec_image_view.device_combo_box.setCurrentText("eiger")
bec_image_view.dim_combo_box.setCurrentText("2d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.subscriptions["main"].source == "device_monitor_2d"
@ -318,17 +319,17 @@ def test_image_actions_interactions(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.autorange = False # Change the initial state to False
bec_image_view.autorange_mean_action.action.trigger()
bec_image_view.toolbar.components.get_action("image_autorange_mean").action.trigger()
assert bec_image_view.autorange is True
assert bec_image_view.main_image.autorange is True
assert bec_image_view.autorange_mode == "mean"
bec_image_view.autorange_max_action.action.trigger()
bec_image_view.toolbar.components.get_action("image_autorange_max").action.trigger()
assert bec_image_view.autorange is True
assert bec_image_view.main_image.autorange is True
assert bec_image_view.autorange_mode == "max"
bec_image_view.toolbar.widgets["lock_aspect_ratio"].action.trigger()
bec_image_view.toolbar.components.get_action("lock_aspect_ratio").action.trigger()
assert bec_image_view.lock_aspect_ratio is False
assert bool(bec_image_view.plot_item.getViewBox().state["aspectLocked"]) is False
@ -336,7 +337,7 @@ def test_image_actions_interactions(qtbot, mocked_client):
def test_image_toggle_action_fft(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.processing_bundle.fft.action.trigger()
bec_image_view.toolbar.components.get_action("image_processing_fft").action.trigger()
assert bec_image_view.fft is True
assert bec_image_view.main_image.fft is True
@ -346,7 +347,7 @@ def test_image_toggle_action_fft(qtbot, mocked_client):
def test_image_toggle_action_log(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.processing_bundle.log.action.trigger()
bec_image_view.toolbar.components.get_action("image_processing_log").action.trigger()
assert bec_image_view.log is True
assert bec_image_view.main_image.log is True
@ -356,7 +357,7 @@ def test_image_toggle_action_log(qtbot, mocked_client):
def test_image_toggle_action_transpose(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.processing_bundle.transpose.action.trigger()
bec_image_view.toolbar.components.get_action("image_processing_transpose").action.trigger()
assert bec_image_view.transpose is True
assert bec_image_view.main_image.transpose is True
@ -366,7 +367,7 @@ def test_image_toggle_action_transpose(qtbot, mocked_client):
def test_image_toggle_action_rotate_right(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.processing_bundle.right.action.trigger()
bec_image_view.toolbar.components.get_action("image_processing_rotate_right").action.trigger()
assert bec_image_view.num_rotation_90 == 3
assert bec_image_view.main_image.num_rotation_90 == 3
@ -376,7 +377,7 @@ def test_image_toggle_action_rotate_right(qtbot, mocked_client):
def test_image_toggle_action_rotate_left(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.processing_bundle.left.action.trigger()
bec_image_view.toolbar.components.get_action("image_processing_rotate_left").action.trigger()
assert bec_image_view.num_rotation_90 == 1
assert bec_image_view.main_image.num_rotation_90 == 1
@ -392,7 +393,7 @@ def test_image_toggle_action_reset(qtbot, mocked_client):
bec_image_view.transpose = True
bec_image_view.num_rotation_90 = 2
bec_image_view.processing_bundle.reset.action.trigger()
bec_image_view.toolbar.components.get_action("image_processing_reset").action.trigger()
assert bec_image_view.num_rotation_90 == 0
assert bec_image_view.main_image.num_rotation_90 == 0
@ -473,8 +474,8 @@ def test_show_roi_manager_popup(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client, popups=True)
# ROI-manager toggle is exposed via the toolbar.
assert "roi_mgr" in view.toolbar.widgets
roi_action = view.toolbar.widgets["roi_mgr"].action
assert view.toolbar.components.exists("roi_mgr")
roi_action = view.toolbar.components.get_action("roi_mgr").action
assert roi_action.isChecked() is False, "Should start unchecked"
# Open the popup.
@ -497,10 +498,10 @@ def test_show_roi_manager_popup(qtbot, mocked_client):
def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
"""
Verify that enabling the ROIcrosshair shows ROI panels and disabling hides them.
Verify that enabling the ROI-crosshair shows ROI panels and disabling hides them.
"""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
# Initially panels should be hidden
assert bec_image_view.side_panel_x.panel_height == 0
@ -548,7 +549,7 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
bec_image_view.on_image_update_2d({"data": test_data}, {})
# Activate ROI crosshair
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
@ -579,11 +580,10 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
def test_monitor_selection_reverse_device_items(qtbot, mocked_client):
"""
Verify that _reverse_device_items correctly reverses the order of items in the
device combobox while preserving the current selection.
device combobox while preserving the current selection.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
combo = bundle.device_combo_box
combo = view.device_combo_box
# Replace existing items with a deterministic list
combo.clear()
@ -593,7 +593,7 @@ def test_monitor_selection_reverse_device_items(qtbot, mocked_client):
combo.setCurrentText("samy")
# Reverse the items
bundle._reverse_device_items()
view._reverse_device_items()
# Order should be reversed and selection preserved
assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"]
@ -606,7 +606,6 @@ def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkey
with the correct userData.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Provide a deterministic fake device_manager with get_bec_signals
class _FakeDM:
@ -618,27 +617,26 @@ def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkey
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
initial_count = bundle.device_combo_box.count()
initial_count = view.device_combo_box.count()
bundle._populate_preview_signals()
view._populate_preview_signals()
# Two new entries should have been added
assert bundle.device_combo_box.count() == initial_count + 2
assert view.device_combo_box.count() == initial_count + 2
# The first newly added item should carry tuple userData describing the device/signal
data = bundle.device_combo_box.itemData(initial_count)
data = view.device_combo_box.itemData(initial_count)
assert isinstance(data, tuple) and data[0] == "eiger"
def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch):
"""
Verify that _adjust_and_connect performs the full setup:
fills the combobox with preview signals,
reverses their order,
and resets the currentText to an empty string.
Verify that _adjust_and_connect performs the full set-up:
- fills the combobox with preview signals,
- reverses their order,
- and resets the currentText to an empty string.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Deterministic fake device_manager
class _FakeDM:
@ -647,14 +645,14 @@ def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch)
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
combo = bundle.device_combo_box
combo = view.device_combo_box
# Start from a clean state
combo.clear()
combo.addItem("", None)
combo.setCurrentText("")
# Execute the method under test
bundle._adjust_and_connect()
view._adjust_and_connect()
# Expect exactly two items: preview label followed by the empty default
assert combo.count() == 2

View File

@ -5,19 +5,18 @@ from qtpy.QtCore import QPoint, Qt
from qtpy.QtGui import QContextMenuEvent
from qtpy.QtWidgets import QComboBox, QLabel, QMenu, QStyle, QToolButton, QWidget
from bec_widgets.utils.toolbar import (
from bec_widgets.utils.toolbars.actions import (
DeviceSelectionAction,
ExpandableMenuAction,
IconAction,
LongPressToolButton,
MaterialIconAction,
ModularToolBar,
QtIconAction,
SeparatorAction,
SwitchableToolBarAction,
ToolbarBundle,
WidgetAction,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
@pytest.fixture
@ -34,14 +33,12 @@ def toolbar_fixture(qtbot, request, dummy_widget):
"""Parametrized fixture to create a ModularToolBar with different orientations."""
orientation: Literal["horizontal", "vertical"] = request.param
toolbar = ModularToolBar(
target_widget=dummy_widget,
orientation=orientation,
background_color="rgba(255, 255, 255, 255)", # White background for testing
)
qtbot.addWidget(toolbar)
qtbot.waitExposed(toolbar)
yield toolbar
toolbar.close()
@pytest.fixture
@ -50,12 +47,6 @@ def separator_action():
return SeparatorAction()
@pytest.fixture
def icon_action():
"""Fixture to create an IconAction."""
return IconAction(icon_path="assets/BEC-Icon.png", tooltip="Test Icon Action", checkable=True)
@pytest.fixture
def material_icon_action():
"""Fixture to create a MaterialIconAction."""
@ -64,6 +55,14 @@ def material_icon_action():
)
@pytest.fixture
def material_icon_action_2():
"""Fixture to create another MaterialIconAction."""
return MaterialIconAction(
icon_name="home", tooltip="Test Material Icon Action 2", checkable=False
)
@pytest.fixture
def qt_icon_action():
"""Fixture to create a QtIconAction."""
@ -121,7 +120,7 @@ def test_initialization(toolbar_fixture):
else:
pytest.fail("Toolbar orientation is neither horizontal nor vertical.")
assert toolbar.background_color == "rgba(255, 255, 255, 255)"
assert toolbar.widgets == {}
assert len(toolbar.components._components) == 1 # only the separator
assert not toolbar.isMovable()
assert not toolbar.isFloatable()
@ -152,80 +151,60 @@ def test_set_orientation(toolbar_fixture, qtbot, dummy_widget):
assert toolbar.orientation() == Qt.Vertical
def test_add_action(
toolbar_fixture,
icon_action,
separator_action,
material_icon_action,
qt_icon_action,
dummy_widget,
):
"""Test adding different types of actions to the toolbar."""
def test_add_action(toolbar_fixture, material_icon_action, qt_icon_action):
"""Test adding different types of actions to the toolbar components."""
toolbar = toolbar_fixture
# Add IconAction
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "icon_action" in toolbar.widgets
assert toolbar.widgets["icon_action"] == icon_action
assert icon_action.action in toolbar.actions()
# Add SeparatorAction
toolbar.add_action("separator_action", separator_action, dummy_widget)
assert "separator_action" in toolbar.widgets
assert toolbar.widgets["separator_action"] == separator_action
# Add MaterialIconAction
toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
assert "material_icon_action" in toolbar.widgets
assert toolbar.widgets["material_icon_action"] == material_icon_action
assert material_icon_action.action in toolbar.actions()
toolbar.add_action("material_icon_action", material_icon_action)
assert toolbar.components.exists("material_icon_action")
assert toolbar.components.get_action("material_icon_action") == material_icon_action
# Add QtIconAction
toolbar.add_action("qt_icon_action", qt_icon_action, dummy_widget)
assert "qt_icon_action" in toolbar.widgets
assert toolbar.widgets["qt_icon_action"] == qt_icon_action
assert qt_icon_action.action in toolbar.actions()
toolbar.add_action("qt_icon_action", qt_icon_action)
assert toolbar.components.exists("qt_icon_action")
assert toolbar.components.get_action("qt_icon_action") == qt_icon_action
def test_hide_show_action(toolbar_fixture, icon_action, qtbot, dummy_widget):
def test_hide_show_action(toolbar_fixture, qt_icon_action, qtbot):
"""Test hiding and showing actions on the toolbar."""
toolbar = toolbar_fixture
# Add an action
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert icon_action.action.isVisible()
toolbar.add_action("icon_action", qt_icon_action)
assert qt_icon_action.action.isVisible()
# Hide the action
toolbar.hide_action("icon_action")
qtbot.wait(100)
assert not icon_action.action.isVisible()
assert not qt_icon_action.action.isVisible()
# Show the action
toolbar.show_action("icon_action")
qtbot.wait(100)
assert icon_action.action.isVisible()
assert qt_icon_action.action.isVisible()
def test_add_duplicate_action(toolbar_fixture, icon_action, dummy_widget):
def test_add_duplicate_action(toolbar_fixture, qt_icon_action):
"""Test that adding an action with a duplicate action_id raises a ValueError."""
toolbar = toolbar_fixture
# Add an action
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "icon_action" in toolbar.widgets
toolbar.add_action("qt_icon_action", qt_icon_action)
assert toolbar.components.exists("qt_icon_action")
# Attempt to add another action with the same ID
with pytest.raises(ValueError) as excinfo:
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "Action with ID 'icon_action' already exists." in str(excinfo.value)
toolbar.add_action("qt_icon_action", qt_icon_action)
assert "Bundle with name 'qt_icon_action' already exists." in str(excinfo.value)
def test_update_material_icon_colors(toolbar_fixture, material_icon_action, dummy_widget):
def test_update_material_icon_colors(toolbar_fixture, material_icon_action):
"""Test updating the color of MaterialIconAction icons."""
toolbar = toolbar_fixture
# Add MaterialIconAction
toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
toolbar.add_action("material_icon_action", material_icon_action)
assert material_icon_action.action is not None
# Initial icon
@ -242,11 +221,12 @@ def test_update_material_icon_colors(toolbar_fixture, material_icon_action, dumm
assert initial_icon != updated_icon
def test_device_selection_action(toolbar_fixture, device_selection_action, dummy_widget):
def test_device_selection_action(toolbar_fixture, device_selection_action):
"""Test adding a DeviceSelectionAction to the toolbar."""
toolbar = toolbar_fixture
toolbar.add_action("device_selection", device_selection_action, dummy_widget)
assert "device_selection" in toolbar.widgets
toolbar.add_action("device_selection", device_selection_action)
assert toolbar.components.exists("device_selection")
toolbar.show_bundles(["device_selection"])
# DeviceSelectionAction adds a QWidget, so it should be present in the toolbar's widgets
# Check if the widget is added
widget = device_selection_action.device_combobox.parentWidget()
@ -256,11 +236,12 @@ def test_device_selection_action(toolbar_fixture, device_selection_action, dummy
assert label.text() == "Select Device:"
def test_widget_action(toolbar_fixture, widget_action, dummy_widget):
def test_widget_action(toolbar_fixture, widget_action):
"""Test adding a WidgetAction to the toolbar."""
toolbar = toolbar_fixture
toolbar.add_action("widget_action", widget_action, dummy_widget)
assert "widget_action" in toolbar.widgets
toolbar.add_action("widget_action", widget_action)
assert toolbar.components.exists("widget_action")
toolbar.show_bundles(["widget_action"])
# WidgetAction adds a QWidget to the toolbar
container = widget_action.widget.parentWidget()
assert container in toolbar.findChildren(QWidget)
@ -269,11 +250,12 @@ def test_widget_action(toolbar_fixture, widget_action, dummy_widget):
assert label.text() == "Sample Label:"
def test_expandable_menu_action(toolbar_fixture, expandable_menu_action, dummy_widget):
def test_expandable_menu_action(toolbar_fixture, expandable_menu_action):
"""Test adding an ExpandableMenuAction to the toolbar."""
toolbar = toolbar_fixture
toolbar.add_action("expandable_menu", expandable_menu_action, dummy_widget)
assert "expandable_menu" in toolbar.widgets
toolbar.add_action("expandable_menu", expandable_menu_action)
assert toolbar.components.exists("expandable_menu")
toolbar.show_bundles(["expandable_menu"])
# ExpandableMenuAction adds a QToolButton with a QMenu
# Find the QToolButton
tool_buttons = toolbar.findChildren(QToolButton)
@ -300,44 +282,47 @@ def test_update_material_icon_colors_no_material_actions(toolbar_fixture, dummy_
def test_hide_action_nonexistent(toolbar_fixture):
"""Test hiding an action that does not exist raises a ValueError."""
"""Test hiding an action that does not exist raises a KeyError."""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
with pytest.raises(KeyError) as excinfo:
toolbar.hide_action("nonexistent_action")
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
excinfo.match("Component with name 'nonexistent_action' does not exist.")
def test_show_action_nonexistent(toolbar_fixture):
"""Test showing an action that does not exist raises a ValueError."""
"""Test showing an action that does not exist raises a KeyError."""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
with pytest.raises(KeyError) as excinfo:
toolbar.show_action("nonexistent_action")
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
excinfo.match("Component with name 'nonexistent_action' does not exist.")
def test_add_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action):
def test_add_bundle(toolbar_fixture, material_icon_action):
"""Test adding a bundle of actions to the toolbar."""
toolbar = toolbar_fixture
bundle = ToolbarBundle(
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()
toolbar.add_action("material_icon_in_bundle", material_icon_action)
bundle = ToolbarBundle("test_bundle", toolbar.components)
bundle.add_action("material_icon_in_bundle")
toolbar.add_bundle(bundle)
assert toolbar.get_bundle("test_bundle")
assert toolbar.components.exists("material_icon_in_bundle")
toolbar.show_bundles(["test_bundle"])
assert material_icon_action.action in toolbar.actions()
def test_invalid_orientation(dummy_widget):
def test_invalid_orientation():
"""Test that an invalid orientation raises a ValueError."""
toolbar = ModularToolBar(target_widget=dummy_widget, orientation="horizontal")
with pytest.raises(ValueError):
toolbar.set_orientation("diagonal")
try:
toolbar = ModularToolBar(orientation="horizontal")
with pytest.raises(ValueError):
toolbar.set_orientation("diagonal")
finally:
toolbar.close()
toolbar.deleteLater()
def test_widget_action_calculate_minimum_width(qtbot):
@ -353,24 +338,26 @@ def test_widget_action_calculate_minimum_width(qtbot):
def test_add_action_to_bundle(toolbar_fixture, dummy_widget, material_icon_action):
# Create an initial bundle with one action
bundle = ToolbarBundle(
bundle_id="test_bundle", actions=[("initial_action", material_icon_action)]
)
toolbar_fixture.add_bundle(bundle, dummy_widget)
toolbar_fixture.add_action("initial_action", material_icon_action)
bundle = ToolbarBundle("test_bundle", toolbar_fixture.components)
bundle.add_action("initial_action")
toolbar_fixture.add_bundle(bundle)
# Create a new action to add to the existing bundle
new_action = MaterialIconAction(
icon_name="counter_1", tooltip="New Action", checkable=True, parent=dummy_widget
)
toolbar_fixture.add_action_to_bundle("test_bundle", "new_action", new_action, dummy_widget)
toolbar_fixture.components.add_safe("new_action", new_action)
toolbar_fixture.get_bundle("test_bundle").add_action("new_action")
toolbar_fixture.show_bundles(["test_bundle"])
# Verify the new action is registered in the toolbar's widgets
assert "new_action" in toolbar_fixture.widgets
assert toolbar_fixture.widgets["new_action"] == new_action
assert toolbar_fixture.components.exists("new_action")
assert toolbar_fixture.components.get_action("new_action") == new_action
# Verify the new action is included in the bundle tracking
assert "new_action" in toolbar_fixture.bundles["test_bundle"]
assert toolbar_fixture.bundles["test_bundle"][-1] == "new_action"
assert toolbar_fixture.bundles["test_bundle"].bundle_actions["new_action"]() == new_action
# Verify the new action's QAction is present in the toolbar's action list
actions_list = toolbar_fixture.actions()
@ -384,7 +371,7 @@ def test_add_action_to_bundle(toolbar_fixture, dummy_widget, material_icon_actio
def test_context_menu_contains_added_actions(
toolbar_fixture, icon_action, material_icon_action, dummy_widget, monkeypatch
toolbar_fixture, material_icon_action, material_icon_action_2, monkeypatch
):
"""
Test that the toolbar's context menu lists all added toolbar actions.
@ -392,9 +379,13 @@ def test_context_menu_contains_added_actions(
toolbar = toolbar_fixture
# Add two different actions
toolbar.add_action("icon_action", icon_action, dummy_widget)
toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
toolbar.components.add_safe("material_icon_action", material_icon_action)
toolbar.components.add_safe("material_icon_action_2", material_icon_action_2)
bundle = toolbar.new_bundle("test_bundle")
bundle.add_action("material_icon_action")
bundle.add_action("material_icon_action_2")
toolbar.show_bundles(["test_bundle"])
# Mock the QMenu.exec_ method to prevent the context menu from being displayed and block CI pipeline
monkeypatch.setattr(QMenu, "exec_", lambda self, pos=None: None)
event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
@ -404,23 +395,26 @@ def test_context_menu_contains_added_actions(
assert len(menus) > 0
menu = menus[-1]
menu_action_texts = [action.text() for action in menu.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
)
tooltips = [
action.action.tooltip
for action in toolbar.components._components.values()
if not isinstance(action.action, SeparatorAction)
]
menu_actions_tooltips = [
action.toolTip() for action in menu.actions() if action.toolTip() != ""
]
assert menu_action_texts == tooltips
def test_context_menu_toggle_action_visibility(
toolbar_fixture, icon_action, dummy_widget, monkeypatch
):
def test_context_menu_toggle_action_visibility(toolbar_fixture, material_icon_action, monkeypatch):
"""
Test that toggling action visibility works correctly through the toolbar's context menu.
"""
toolbar = toolbar_fixture
# Add an action
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert icon_action.action.isVisible()
toolbar.add_action("material_icon_action", material_icon_action)
toolbar.show_bundles(["material_icon_action"])
assert material_icon_action.action.isVisible()
# Manually trigger the context menu event
monkeypatch.setattr(QMenu, "exec_", lambda self, pos=None: None)
@ -433,7 +427,7 @@ def test_context_menu_toggle_action_visibility(
menu = menus[-1]
# Locate the QAction in the menu
matching_actions = [m for m in menu.actions() if m.text() == icon_action.tooltip]
matching_actions = [m for m in menu.actions() if m.text() == material_icon_action.tooltip]
assert len(matching_actions) == 1
action_in_menu = matching_actions[0]
@ -441,23 +435,24 @@ def test_context_menu_toggle_action_visibility(
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()
assert not material_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()
assert material_icon_action.action.isVisible()
def test_switchable_toolbar_action_add(toolbar_fixture, dummy_widget, switchable_toolbar_action):
def test_switchable_toolbar_action_add(toolbar_fixture, switchable_toolbar_action):
"""Test that a switchable toolbar action can be added to the toolbar correctly."""
toolbar = toolbar_fixture
toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget)
toolbar.add_action("switch_action", switchable_toolbar_action)
toolbar.show_bundles(["switch_action"])
# Verify the action was added correctly
assert "switch_action" in toolbar.widgets
assert toolbar.widgets["switch_action"] == switchable_toolbar_action
assert toolbar.components.exists("switch_action")
assert toolbar.components.get_action("switch_action") == switchable_toolbar_action
# Verify the button is present and is the correct type
button = switchable_toolbar_action.main_button
@ -468,11 +463,10 @@ def test_switchable_toolbar_action_add(toolbar_fixture, dummy_widget, switchable
assert button.toolTip() == "Action 1"
def test_switchable_toolbar_action_switching(
toolbar_fixture, dummy_widget, switchable_toolbar_action, qtbot
):
def test_switchable_toolbar_action_switching(toolbar_fixture, switchable_toolbar_action, qtbot):
toolbar = toolbar_fixture
toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget)
toolbar.add_action("switch_action", switchable_toolbar_action)
toolbar.show_bundles(["switch_action"])
# Verify initial state is set to action1
assert switchable_toolbar_action.current_key == "action1"
assert switchable_toolbar_action.main_button.toolTip() == "Action 1"
@ -494,9 +488,10 @@ def test_switchable_toolbar_action_switching(
assert switchable_toolbar_action.main_button.toolTip() == "Action 2"
def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_action, qtbot):
def test_long_pressbutton(toolbar_fixture, switchable_toolbar_action, qtbot):
toolbar = toolbar_fixture
toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget)
toolbar.add_action("switch_action", switchable_toolbar_action)
toolbar.show_bundles(["switch_action"])
# Verify the button is a LongPressToolButton
button = switchable_toolbar_action.main_button
@ -521,92 +516,73 @@ def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_acti
# Additional tests for action/bundle removal
def test_remove_standalone_action(toolbar_fixture, icon_action, dummy_widget):
def test_remove_standalone_action(toolbar_fixture, material_icon_action):
"""
Ensure that a standalone action is fully removed and no longer accessible.
"""
toolbar = toolbar_fixture
# Add the action and check it is present
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "icon_action" in toolbar.widgets
assert icon_action.action in toolbar.actions()
toolbar.add_action("icon_action", material_icon_action)
assert toolbar.components.exists("icon_action")
toolbar.show_bundles(["icon_action"])
assert material_icon_action.action in toolbar.actions()
# Now remove it
toolbar.remove_action("icon_action")
toolbar.components.remove_action("icon_action")
# Action bookkeeping
assert "icon_action" not in toolbar.widgets
assert not toolbar.components.exists("icon_action")
# QAction list
assert icon_action.action not in toolbar.actions()
assert material_icon_action.action not in toolbar.actions()
# Attempting to hide / show it should raise
with pytest.raises(ValueError):
with pytest.raises(KeyError):
toolbar.hide_action("icon_action")
with pytest.raises(ValueError):
with pytest.raises(KeyError):
toolbar.show_action("icon_action")
def test_remove_action_from_bundle(
toolbar_fixture, dummy_widget, icon_action, material_icon_action
):
def test_remove_action_from_bundle(toolbar_fixture, material_icon_action, material_icon_action_2):
"""
Remove a single action that is part of a bundle and verify cleanup.
Remove a single action that is part of a bundle. This should not remove the action
from the toolbar's components, but only from the bundle tracking.
"""
toolbar = toolbar_fixture
bundle = ToolbarBundle(
bundle_id="test_bundle",
actions=[("icon_action", icon_action), ("material_action", material_icon_action)],
)
toolbar.add_bundle(bundle, dummy_widget)
bundle = toolbar.new_bundle("test_bundle")
# Add two actions to the bundle
toolbar.components.add_safe("material_action", material_icon_action)
toolbar.components.add_safe("material_action_2", material_icon_action_2)
bundle.add_action("material_action")
bundle.add_action("material_action_2")
toolbar.show_bundles(["test_bundle"])
# Initial assertions
assert "test_bundle" in toolbar.bundles
assert "icon_action" in toolbar.widgets
assert "material_action" in toolbar.widgets
assert toolbar.components.exists("material_action")
assert toolbar.components.exists("material_action_2")
# Remove one action from the bundle
toolbar.remove_action("icon_action")
toolbar.get_bundle("test_bundle").remove_action("material_action")
# icon_action should be fully gone
assert "icon_action" not in toolbar.widgets
assert icon_action.action not in toolbar.actions()
# Bundle tracking should be updated
assert "icon_action" not in toolbar.bundles["test_bundle"]
# The other action must still exist
assert "material_action" in toolbar.widgets
assert material_icon_action.action in toolbar.actions()
# The bundle should still exist
assert "test_bundle" in toolbar.bundles
# The removed action should still exist in the components
assert toolbar.components.exists("material_action")
# The removed action should not be in the bundle anymore
assert "material_action" not in toolbar.bundles["test_bundle"].bundle_actions
def test_remove_last_action_from_bundle_removes_bundle(toolbar_fixture, dummy_widget, icon_action):
"""
Removing the final action from a bundle should delete the bundle entry itself.
"""
def test_remove_entire_bundle(toolbar_fixture, material_icon_action, material_icon_action_2):
toolbar = toolbar_fixture
bundle = ToolbarBundle(bundle_id="single_action_bundle", actions=[("only_action", icon_action)])
toolbar.add_bundle(bundle, dummy_widget)
# Sanity check
assert "single_action_bundle" in toolbar.bundles
assert "only_action" in toolbar.widgets
# Remove the sole action
toolbar.remove_action("only_action")
# Bundle should be gone
assert "single_action_bundle" not in toolbar.bundles
# QAction removed
assert icon_action.action not in toolbar.actions()
def test_remove_entire_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action):
"""
Ensure that removing a bundle deletes all its actions and separators.
"""
toolbar = toolbar_fixture
bundle = ToolbarBundle(
bundle_id="to_remove",
actions=[("icon_action", icon_action), ("material_action", material_icon_action)],
)
toolbar.add_bundle(bundle, dummy_widget)
toolbar.components.add_safe("material_action", material_icon_action)
toolbar.components.add_safe("material_action_2", material_icon_action_2)
# Create a bundle with two actions
bundle = toolbar.new_bundle("to_remove")
bundle.add_action("material_action")
bundle.add_action("material_action_2")
# Confirm bundle presence
assert "to_remove" in toolbar.bundles
@ -616,58 +592,23 @@ def test_remove_entire_bundle(toolbar_fixture, dummy_widget, icon_action, materi
# Bundle mapping gone
assert "to_remove" not in toolbar.bundles
# All actions gone
for aid, act in [("icon_action", icon_action), ("material_action", material_icon_action)]:
assert aid not in toolbar.widgets
assert act.action not in toolbar.actions()
def test_trigger_removed_action_raises(toolbar_fixture, icon_action, dummy_widget, qtbot):
"""
Add an action, connect a mock slot, then remove the action and verify that
attempting to trigger it afterwards raises RuntimeError (since the underlying
QAction has been deleted).
"""
toolbar = toolbar_fixture
# Add the action and connect a mock slot
toolbar.add_action("icon_action", icon_action, dummy_widget)
called = []
def mock_slot():
called.append(True)
icon_action.action.triggered.connect(mock_slot)
# Trigger once to confirm connection works
icon_action.action.trigger()
assert called == [True]
# Now remove the action
toolbar.remove_action("icon_action")
# Allow deleteLater event to process
qtbot.wait(50)
# The underlying C++ object should be deleted; triggering should raise
with pytest.raises(RuntimeError):
icon_action.action.trigger()
def test_remove_nonexistent_action(toolbar_fixture):
"""
Attempting to remove an action that does not exist should raise ValueError.
Attempting to remove an action that does not exist should raise KeyError.
"""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
toolbar.remove_action("nonexistent_action")
with pytest.raises(KeyError) as excinfo:
toolbar.components.remove_action("nonexistent_action")
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
def test_remove_nonexistent_bundle(toolbar_fixture):
"""
Attempting to remove a bundle that does not exist should raise ValueError.
Attempting to remove a bundle that does not exist should raise KeyError.
"""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
with pytest.raises(KeyError) as excinfo:
toolbar.remove_bundle("nonexistent_bundle")
assert "Bundle 'nonexistent_bundle' does not exist." in str(excinfo.value)
excinfo.match("Bundle with name 'nonexistent_bundle' does not exist.")

View File

@ -272,16 +272,15 @@ def test_motor_map_toolbar_selection(qtbot, mocked_client):
mm = create_widget(qtbot, MotorMap, client=mocked_client)
# Verify toolbar bundle was created during initialization
assert hasattr(mm, "motor_selection_bundle")
assert mm.motor_selection_bundle is not None
motor_selection = mm.toolbar.components.get_action("motor_selection")
mm.motor_selection_bundle.motor_x.setCurrentText("samx")
mm.motor_selection_bundle.motor_y.setCurrentText("samy")
motor_selection.motor_x.setCurrentText("samx")
motor_selection.motor_y.setCurrentText("samy")
assert mm.config.x_motor.name == "samx"
assert mm.config.y_motor.name == "samy"
mm.motor_selection_bundle.motor_y.setCurrentText("samz")
motor_selection.motor_y.setCurrentText("samz")
assert mm.config.x_motor.name == "samx"
assert mm.config.y_motor.name == "samz"
@ -291,9 +290,9 @@ def test_motor_map_settings_dialog(qtbot, mocked_client):
"""Test the settings dialog for the motor map."""
mm = create_widget(qtbot, MotorMap, client=mocked_client, popups=True)
assert "popup_bundle" in mm.toolbar.bundles
for action_id in mm.toolbar.bundles["popup_bundle"]:
assert mm.toolbar.widgets[action_id].action.isVisible() is True
assert "axis_popup" in mm.toolbar.bundles
for action_ref in mm.toolbar.bundles["axis_popup"].bundle_actions.values():
assert action_ref().action.isVisible()
# set properties to be fetched by dialog
mm.map(x_name="samx", y_name="samy")

View File

@ -244,15 +244,14 @@ def test_selection_toolbar_updates_widget(qtbot, mocked_client):
updates the widget properties.
"""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
toolbar = mw.monitor_selection_bundle
monitor_combo = toolbar.monitor
colormap_widget = toolbar.colormap_widget
monitor_selection_action = mw.toolbar.components.get_action("monitor_selection")
cmap_action = mw.toolbar.components.get_action("color_map")
monitor_combo.addItem("waveform1d")
monitor_combo.setCurrentText("waveform1d")
monitor_selection_action.combobox.addItem("waveform1d")
monitor_selection_action.combobox.setCurrentText("waveform1d")
assert mw.monitor == "waveform1d"
colormap_widget.colormap = "viridis"
cmap_action.widget.colormap = "viridis"
assert mw.color_palette == "viridis"
@ -290,11 +289,10 @@ def test_control_panel_opacity_slider_spinbox(qtbot, mocked_client):
def test_control_panel_highlight_slider_spinbox(qtbot, mocked_client):
"""
Test that the slider and spinbox for curve highlighting update
the widgets highlighted_index property, and are disabled if
the widget's highlighted_index property, and are disabled if
highlight_last_curve is True.
"""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
slider_index = mw.controls.ui.highlighted_index
spinbox_index = mw.controls.ui.spinbox_index
checkbox_highlight_last = mw.controls.ui.highlight_last_curve

View File

@ -265,54 +265,56 @@ def test_ui_mode_popup(qtbot, mocked_client):
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.ui_mode = UIMode.POPUP
# The popup bundle should be created and its actions made visible.
assert "popup_bundle" in pb.toolbar.bundles
for action_id in pb.toolbar.bundles["popup_bundle"]:
assert pb.toolbar.widgets[action_id].action.isVisible() is True
assert "axis_popup" in pb.toolbar.bundles
for action_ref in pb.toolbar.bundles["axis_popup"].bundle_actions.values():
assert action_ref().action.isVisible() is True
# The side panel should be hidden.
assert not pb.side_panel.isVisible()
def test_ui_mode_side(qtbot, mocked_client):
"""
Test that setting ui_mode to SIDE shows the side panel and ensures any popup actions
are hidden.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.ui_mode = UIMode.SIDE
# If a popup bundle exists, its actions should be hidden.
if "popup_bundle" in pb.toolbar.bundles:
for action_id in pb.toolbar.bundles["popup_bundle"]:
assert pb.toolbar.widgets[action_id].action.isVisible() is False
# Side panels are not properly implemented yet. Once the logic is fixed, we can re-enable this test.
# See issue #742
# def test_ui_mode_side(qtbot, mocked_client):
# """
# Test that setting ui_mode to SIDE shows the side panel and ensures any popup actions
# are hidden.
# """
# pb = create_widget(qtbot, PlotBase, client=mocked_client)
# pb.ui_mode = UIMode.SIDE
# # If a popup bundle exists, its actions should be hidden.
# if "axis_popup" in pb.toolbar.bundles:
# for action_ref in pb.toolbar.bundles["axis_popup"].bundle_actions.values():
# assert action_ref().action.isVisible() is False
def test_enable_popups_property(qtbot, mocked_client):
"""
Test the enable_popups property: when enabled, ui_mode should be POPUP,
and when disabled, ui_mode should change to NONE.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.enable_popups = True
assert pb.ui_mode == UIMode.POPUP
# The popup bundle actions should be visible.
assert "popup_bundle" in pb.toolbar.bundles
for action_id in pb.toolbar.bundles["popup_bundle"]:
assert pb.toolbar.widgets[action_id].action.isVisible() is True
# def test_enable_popups_property(qtbot, mocked_client):
# """
# Test the enable_popups property: when enabled, ui_mode should be POPUP,
# and when disabled, ui_mode should change to NONE.
# """
# pb = create_widget(qtbot, PlotBase, client=mocked_client)
# pb.enable_popups = True
# assert pb.ui_mode == UIMode.POPUP
# # The popup bundle actions should be visible.
# assert "popup_bundle" in pb.toolbar.bundles
# for action_id in pb.toolbar.bundles["popup_bundle"]:
# assert pb.toolbar.widgets[action_id].action.isVisible() is True
pb.enable_popups = False
assert pb.ui_mode == UIMode.NONE
# pb.enable_popups = False
# assert pb.ui_mode == UIMode.NONE
def test_enable_side_panel_property(qtbot, mocked_client):
"""
Test the enable_side_panel property: when enabled, ui_mode should be SIDE,
and when disabled, ui_mode should change to NONE.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.enable_side_panel = True
assert pb.ui_mode == UIMode.SIDE
# def test_enable_side_panel_property(qtbot, mocked_client):
# """
# Test the enable_side_panel property: when enabled, ui_mode should be SIDE,
# and when disabled, ui_mode should change to NONE.
# """
# pb = create_widget(qtbot, PlotBase, client=mocked_client)
# pb.enable_side_panel = True
# assert pb.ui_mode == UIMode.SIDE
pb.enable_side_panel = False
assert pb.ui_mode == UIMode.NONE
# pb.enable_side_panel = False
# assert pb.ui_mode == UIMode.NONE
def test_switching_between_popup_and_side_panel_closes_dialog(qtbot, mocked_client):
@ -323,18 +325,19 @@ def test_switching_between_popup_and_side_panel_closes_dialog(qtbot, mocked_clie
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.ui_mode = UIMode.POPUP
# Open the axis settings popup.
pb.show_axis_settings_popup()
pb_connection = pb.toolbar.bundles["axis_popup"].get_connection("plot_base")
pb_connection.show_axis_settings_popup()
qtbot.wait(100)
# The dialog should now exist and be visible.
assert pb.axis_settings_dialog is not None
assert pb.axis_settings_dialog.isVisible() is True
assert pb_connection.axis_settings_dialog is not None
assert pb_connection.axis_settings_dialog.isVisible() is True
# Switch to side panel mode.
pb.ui_mode = UIMode.SIDE
qtbot.wait(100)
# The axis settings dialog should be closed (and reference cleared).
qtbot.waitUntil(lambda: pb.axis_settings_dialog is None, timeout=5000)
qtbot.waitUntil(lambda: pb_connection.axis_settings_dialog is None, timeout=5000)
def test_enable_fps_monitor_property(qtbot, mocked_client):

View File

@ -136,7 +136,7 @@ def test_add_menu(side_panel_fixture, menu_widget, qtbot):
assert panel.stack_widget.count() == initial_count + 1
# Verify the action is added to the toolbar
action = panel.toolbar.widgets.get("test_action")
action = panel.toolbar.components.get_action("test_action")
assert action is not None
assert action.tooltip == "Test Tooltip"
assert action.action in panel.toolbar.actions()
@ -155,7 +155,7 @@ def test_toggle_action_show_panel(side_panel_fixture, menu_widget, qtbot):
)
qtbot.wait(100)
action = panel.toolbar.widgets.get("toggle_action")
action = panel.toolbar.components.get_action("toggle_action")
assert action is not None
# Initially, panel should be hidden
@ -199,8 +199,8 @@ def test_switch_actions(side_panel_fixture, menu_widget, qtbot):
)
qtbot.wait(100)
action1 = panel.toolbar.widgets.get("action1")
action2 = panel.toolbar.widgets.get("action2")
action1 = panel.toolbar.components.get_action("action1")
action2 = panel.toolbar.components.get_action("action2")
assert action1 is not None
assert action2 is not None
@ -241,7 +241,7 @@ def test_multiple_add_menu(side_panel_fixture, menu_widget, qtbot):
)
qtbot.wait(100)
assert panel.stack_widget.count() == initial_count + i + 1
action = panel.toolbar.widgets.get(f"action{i}")
action = panel.toolbar.components.get_action(f"action{i}")
assert action is not None
assert action.tooltip == f"Tooltip{i}"
assert action.action in panel.toolbar.actions()
@ -360,7 +360,7 @@ def test_add_multiple_menus(side_panel_fixture, menu_widget, qtbot):
)
qtbot.wait(100)
assert panel.stack_widget.count() == initial_count + i + 1
action = panel.toolbar.widgets.get(f"action{i}")
action = panel.toolbar.components.get_action(f"action{i}")
assert action is not None
assert action.tooltip == f"Tooltip{i}"
assert action.action in panel.toolbar.actions()

View File

@ -797,7 +797,7 @@ def test_show_curve_settings_popup(qtbot, mocked_client):
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
curve_action = wf.toolbar.widgets["curve"].action
curve_action = wf.toolbar.components.get_action("curve").action
assert not curve_action.isChecked(), "Should start unchecked"
wf.show_curve_settings_popup()
@ -807,8 +807,9 @@ def test_show_curve_settings_popup(qtbot, mocked_client):
assert curve_action.isChecked()
# add a new row to the curve tree
wf.curve_settings_dialog.widget.curve_manager.toolbar.widgets["add"].action.trigger()
wf.curve_settings_dialog.widget.curve_manager.toolbar.widgets["add"].action.trigger()
add_action = wf.curve_settings_dialog.widget.curve_manager.toolbar.components.get_action("add")
add_action.action.trigger()
add_action.action.trigger()
qtbot.wait(100)
# Check that the new row is added
assert wf.curve_settings_dialog.widget.curve_manager.tree.model().rowCount() == 2
@ -824,9 +825,9 @@ def test_show_dap_summary_popup(qtbot, mocked_client):
"""
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=True)
assert "fit_params" in wf.toolbar.widgets
assert wf.toolbar.components.exists("fit_params")
fit_action = wf.toolbar.widgets["fit_params"].action
fit_action = wf.toolbar.components.get_action("fit_params").action
assert fit_action.isChecked() is False
wf.show_dap_summary_popup()